From 083fefdd07bf139fd45d6b26dfe73ab2ecc52385 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Fri, 5 Aug 2022 23:49:48 +0300 Subject: [PATCH 01/47] Create Native Menu Bar with PyQt to open Search, Config webpages - Run FastAPI server in a separate thread. - This allows starting both the server and gui in parallel - Create System Tray for Khoj - Contains menu items that open search or config pages in browser - Rearrange code to have only the code required to start Backend and GUI in the run() method - Move the backend setup code into a separate method --- setup.py | 1 + src/main.py | 79 +++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/setup.py b/setup.py index eca9ce4d..8646e6cb 100644 --- a/setup.py +++ b/setup.py @@ -39,6 +39,7 @@ setup( "pillow >= 9.0.1", "aiofiles == 0.8.0", "dateparser == 1.1.1", + "pyqt6 == 6.3.1", ], include_package_data=True, entry_points={"console_scripts": ["khoj = src.main:run"]}, diff --git a/src/main.py b/src/main.py index f2bc7db9..d93856d6 100644 --- a/src/main.py +++ b/src/main.py @@ -4,6 +4,7 @@ import time from typing import Optional from pathlib import Path from functools import lru_cache +import webbrowser # External Packages import uvicorn @@ -12,6 +13,7 @@ from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, FileResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from PyQt6 import QtCore, QtGui, QtWidgets # Internal Packages from src.search_type import image_search, text_search @@ -285,7 +287,7 @@ def shutdown_event(): print('INFO:\tConversation logs saved to disk.') -def run(): +def setup_server(): # Load config from CLI args = cli(sys.argv[1:]) @@ -312,11 +314,78 @@ def run(): global processor_config processor_config = initialize_processor(args.config) + return args.host, args.port, args.socket + + +def run(): + # Setup Application Server + host, port, socket = setup_server() + + # Setup GUI + gui = QtWidgets.QApplication([]) + gui.setQuitOnLastWindowClosed(False) + tray = create_system_tray() + # Start Application Server - if args.socket: - uvicorn.run(app, proxy_headers=True, uds=args.socket) - else: - uvicorn.run(app, host=args.host, port=args.port) + server = ServerThread(app, host, port, socket) + server.start() + gui.aboutToQuit.connect(server.terminate) + + # Start the GUI + tray.show() + gui.exec() + + +class ServerThread(QtCore.QThread): + def __init__(self, app, host=None, port=None, socket=None): + super(ServerThread, self).__init__() + self.app = app + self.host = host + self.port = port + self.socket = socket + + def __del__(self): + self.wait() + + def run(self): + if self.socket: + uvicorn.run(app, proxy_headers=True, uds=self.socket) + else: + uvicorn.run(app, host=self.host, port=self.port) + + +def create_system_tray(): + """Create System Tray with Menu + Menu Actions should contain + 1. option to open search page at localhost:8000/ + 2. option to open config page at localhost:8000/config + 3. to quit + """ + + # Create the system tray with icon + icon_path = web_directory / 'assets/icons/favicon-144x144.png' + icon = QtGui.QIcon(f'{icon_path.absolute()}') + tray = QtWidgets.QSystemTrayIcon(icon) + tray.setVisible(True) + + # Create the menu and menu actions + menu = QtWidgets.QMenu() + menu_actions = [ + ('Search', lambda: webbrowser.open('http://localhost:8000/')), + ('Configure', lambda: webbrowser.open('http://localhost:8000/config')), + ('Quit', quit), + ] + + # Add the menu actions to the menu + for action_text, action_function in menu_actions: + menu_action = QtGui.QAction(action_text, menu) + menu_action.triggered.connect(action_function) + menu.addAction(menu_action) + + # Add the menu to the system tray + tray.setContextMenu(menu) + + return tray if __name__ == '__main__': From b04c84721b76057292d1594bf14e16dfc7914f37 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Sat, 6 Aug 2022 02:37:52 +0300 Subject: [PATCH 02/47] Extract configure and routers from main.py into separate modules - Main.py was becoming too big to manage. It had both controllers/routers and component configurations (search, processors) in it - Now that the native app GUI code is also getting added to the main path, good time to split/modularize/clean main.py - Put global state into a separate file to share across modules --- src/configure.py | 95 ++++++++++++ src/main.py | 319 ++--------------------------------------- src/router.py | 208 +++++++++++++++++++++++++++ src/utils/constants.py | 20 ++- 4 files changed, 333 insertions(+), 309 deletions(-) create mode 100644 src/configure.py create mode 100644 src/router.py diff --git a/src/configure.py b/src/configure.py new file mode 100644 index 00000000..564b7280 --- /dev/null +++ b/src/configure.py @@ -0,0 +1,95 @@ +# Standard Packages +import sys + +# External Packages +import torch + +# Internal Packages +from src.processor.ledger.beancount_to_jsonl import beancount_to_jsonl +from src.processor.markdown.markdown_to_jsonl import markdown_to_jsonl +from src.processor.org_mode.org_to_jsonl import org_to_jsonl +from src.search_type import image_search, text_search +from src.utils.config import SearchType, SearchModels, ProcessorConfigModel, ConversationProcessorConfigModel +from src.utils.cli import cli +from src.utils import constants +from src.utils.helpers import get_absolute_path +from src.utils.rawconfig import FullConfig + + +def initialize_server(cmd_args): + # Load config from CLI + args = cli(cmd_args) + + # Stores the file path to the config file. + constants.config_file = args.config_file + + # Store the raw config data. + constants.config = args.config + + # Store the verbose flag + constants.verbose = args.verbose + + # Initialize the search model from Config + constants.model = initialize_search(constants.model, args.config, args.regenerate, device=constants.device, verbose=constants.verbose) + + # Initialize Processor from Config + constants.processor_config = initialize_processor(args.config, verbose=constants.verbose) + + return args.host, args.port, args.socket + + +def initialize_search(model: SearchModels, config: FullConfig, regenerate: bool, t: SearchType = None, device=torch.device("cpu"), verbose: int = 0): + # Initialize Org Notes Search + if (t == SearchType.Org or t == None) and config.content_type.org: + # Extract Entries, Generate Notes Embeddings + model.orgmode_search = text_search.setup(org_to_jsonl, config.content_type.org, search_config=config.search_type.asymmetric, regenerate=regenerate, device=device, verbose=verbose) + + # Initialize Org Music Search + if (t == SearchType.Music or t == None) and config.content_type.music: + # Extract Entries, Generate Music Embeddings + model.music_search = text_search.setup(org_to_jsonl, config.content_type.music, search_config=config.search_type.asymmetric, regenerate=regenerate, device=device, verbose=verbose) + + # Initialize Markdown Search + if (t == SearchType.Markdown or t == None) and config.content_type.markdown: + # Extract Entries, Generate Markdown Embeddings + model.markdown_search = text_search.setup(markdown_to_jsonl, config.content_type.markdown, search_config=config.search_type.asymmetric, regenerate=regenerate, device=device, verbose=verbose) + + # Initialize Ledger Search + if (t == SearchType.Ledger or t == None) and config.content_type.ledger: + # Extract Entries, Generate Ledger Embeddings + model.ledger_search = text_search.setup(beancount_to_jsonl, config.content_type.ledger, search_config=config.search_type.symmetric, regenerate=regenerate, verbose=verbose) + + # Initialize Image Search + if (t == SearchType.Image or t == None) and config.content_type.image: + # Extract Entries, Generate Image Embeddings + model.image_search = image_search.setup(config.content_type.image, search_config=config.search_type.image, regenerate=regenerate, verbose=verbose) + + return model + + +def initialize_processor(config: FullConfig, verbose: int): + if not config.processor: + return + + processor_config = ProcessorConfigModel() + + # Initialize Conversation Processor + processor_config.conversation = ConversationProcessorConfigModel(config.processor.conversation, verbose) + + conversation_logfile = processor_config.conversation.conversation_logfile + if processor_config.conversation.verbose: + print('INFO:\tLoading conversation logs from disk...') + + if conversation_logfile.expanduser().absolute().is_file(): + # Load Metadata Logs from Conversation Logfile + with open(get_absolute_path(conversation_logfile), 'r') as f: + processor_config.conversation.meta_log = json.load(f) + + print('INFO:\tConversation logs loaded from disk.') + else: + # Initialize Conversation Logs + processor_config.conversation.meta_log = {} + processor_config.conversation.chat_session = "" + + return processor_config + diff --git a/src/main.py b/src/main.py index d93856d6..1d4c5ad7 100644 --- a/src/main.py +++ b/src/main.py @@ -1,325 +1,28 @@ # Standard Packages -import sys, json, yaml -import time -from typing import Optional -from pathlib import Path -from functools import lru_cache +import sys import webbrowser # External Packages import uvicorn -import torch -from fastapi import FastAPI, Request -from fastapi.responses import HTMLResponse, FileResponse +from fastapi import FastAPI from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates from PyQt6 import QtCore, QtGui, QtWidgets # Internal Packages -from src.search_type import image_search, text_search -from src.processor.org_mode.org_to_jsonl import org_to_jsonl -from src.processor.ledger.beancount_to_jsonl import beancount_to_jsonl -from src.processor.markdown.markdown_to_jsonl import markdown_to_jsonl -from src.utils.helpers import get_absolute_path, get_from_dict -from src.utils.cli import cli -from src.utils.config import SearchType, SearchModels, ProcessorConfigModel, ConversationProcessorConfigModel -from src.utils.rawconfig import FullConfig -from src.processor.conversation.gpt import converse, extract_search_type, message_to_log, message_to_prompt, understand, summarize -from src.search_filter.explicit_filter import ExplicitFilter -from src.search_filter.date_filter import DateFilter +from src.utils import constants +from src.configure import initialize_server +from src.router import router -# Application Global State -config = FullConfig() -model = SearchModels() -processor_config = ProcessorConfigModel() -config_file = "" -verbose = 0 + +# Initialize the Application Server app = FastAPI() -this_directory = Path(__file__).parent -web_directory = this_directory / 'interface/web/' - -app.mount("/static", StaticFiles(directory=web_directory), name="static") -templates = Jinja2Templates(directory=web_directory) - - -# Controllers -@app.get("/", response_class=FileResponse) -def index(): - return FileResponse(web_directory / "index.html") - -@app.get('/config', response_class=HTMLResponse) -def config(request: Request): - return templates.TemplateResponse("config.html", context={'request': request}) - -@app.get('/config/data', response_model=FullConfig) -def config_data(): - return config - -@app.post('/config/data') -async def config_data(updated_config: FullConfig): - global config - config = updated_config - with open(config_file, 'w') as outfile: - yaml.dump(yaml.safe_load(config.json(by_alias=True)), outfile) - outfile.close() - return config - -@app.get('/search') -@lru_cache(maxsize=100) -def search(q: str, n: Optional[int] = 5, t: Optional[SearchType] = None, r: Optional[bool] = False): - if q is None or q == '': - print(f'No query param (q) passed in API call to initiate search') - return {} - - device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu") - user_query = q - results_count = n - results = {} - - if (t == SearchType.Org or t == None) and model.orgmode_search: - # query org-mode notes - query_start = time.time() - hits, entries = text_search.query(user_query, model.orgmode_search, rank_results=r, device=device, filters=[DateFilter(), ExplicitFilter()], verbose=verbose) - query_end = time.time() - - # collate and return results - collate_start = time.time() - results = text_search.collate_results(hits, entries, results_count) - collate_end = time.time() - - if (t == SearchType.Music or t == None) and model.music_search: - # query music library - query_start = time.time() - hits, entries = text_search.query(user_query, model.music_search, rank_results=r, device=device, filters=[DateFilter(), ExplicitFilter()], verbose=verbose) - query_end = time.time() - - # collate and return results - collate_start = time.time() - results = text_search.collate_results(hits, entries, results_count) - collate_end = time.time() - - if (t == SearchType.Markdown or t == None) and model.orgmode_search: - # query markdown files - query_start = time.time() - hits, entries = text_search.query(user_query, model.markdown_search, rank_results=r, device=device, filters=[ExplicitFilter(), DateFilter()], verbose=verbose) - query_end = time.time() - - # collate and return results - collate_start = time.time() - results = text_search.collate_results(hits, entries, results_count) - collate_end = time.time() - - if (t == SearchType.Ledger or t == None) and model.ledger_search: - # query transactions - query_start = time.time() - hits, entries = text_search.query(user_query, model.ledger_search, rank_results=r, device=device, filters=[ExplicitFilter(), DateFilter()], verbose=verbose) - query_end = time.time() - - # collate and return results - collate_start = time.time() - results = text_search.collate_results(hits, entries, results_count) - collate_end = time.time() - - if (t == SearchType.Image or t == None) and model.image_search: - # query images - query_start = time.time() - hits = image_search.query(user_query, results_count, model.image_search) - output_directory = web_directory / 'images' - query_end = time.time() - - # collate and return results - collate_start = time.time() - results = image_search.collate_results( - hits, - image_names=model.image_search.image_names, - output_directory=output_directory, - image_files_url='/static/images', - count=results_count) - collate_end = time.time() - - if verbose > 1: - print(f"Query took {query_end - query_start:.3f} seconds") - print(f"Collating results took {collate_end - collate_start:.3f} seconds") - - return results - - -@app.get('/reload') -def reload(t: Optional[SearchType] = None): - global model - device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu") - model = initialize_search(config, regenerate=False, t=t, device=device) - return {'status': 'ok', 'message': 'reload completed'} - - -@app.get('/regenerate') -def regenerate(t: Optional[SearchType] = None): - global model - device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu") - model = initialize_search(config, regenerate=True, t=t, device=device) - return {'status': 'ok', 'message': 'regeneration completed'} - - -@app.get('/beta/search') -def search_beta(q: str, n: Optional[int] = 1): - # Extract Search Type using GPT - metadata = extract_search_type(q, api_key=processor_config.conversation.openai_api_key, verbose=verbose) - search_type = get_from_dict(metadata, "search-type") - - # Search - search_results = search(q, n=n, t=SearchType(search_type)) - - # Return response - return {'status': 'ok', 'result': search_results, 'type': search_type} - - -@app.get('/chat') -def chat(q: str): - # Load Conversation History - chat_session = processor_config.conversation.chat_session - meta_log = processor_config.conversation.meta_log - - # Converse with OpenAI GPT - metadata = understand(q, api_key=processor_config.conversation.openai_api_key, verbose=verbose) - if verbose > 1: - print(f'Understood: {get_from_dict(metadata, "intent")}') - - if get_from_dict(metadata, "intent", "memory-type") == "notes": - query = get_from_dict(metadata, "intent", "query") - result_list = search(query, n=1, t=SearchType.Org) - collated_result = "\n".join([item["entry"] for item in result_list]) - if verbose > 1: - print(f'Semantically Similar Notes:\n{collated_result}') - gpt_response = summarize(collated_result, summary_type="notes", user_query=q, api_key=processor_config.conversation.openai_api_key) - else: - gpt_response = converse(q, chat_session, api_key=processor_config.conversation.openai_api_key) - - # Update Conversation History - processor_config.conversation.chat_session = message_to_prompt(q, chat_session, gpt_message=gpt_response) - processor_config.conversation.meta_log['chat'] = message_to_log(q, metadata, gpt_response, meta_log.get('chat', [])) - - return {'status': 'ok', 'response': gpt_response} - - -def initialize_search(config: FullConfig, regenerate: bool, t: SearchType = None, device=torch.device("cpu")): - # Initialize Org Notes Search - if (t == SearchType.Org or t == None) and config.content_type.org: - # Extract Entries, Generate Notes Embeddings - model.orgmode_search = text_search.setup(org_to_jsonl, config.content_type.org, search_config=config.search_type.asymmetric, regenerate=regenerate, device=device, verbose=verbose) - - # Initialize Org Music Search - if (t == SearchType.Music or t == None) and config.content_type.music: - # Extract Entries, Generate Music Embeddings - model.music_search = text_search.setup(org_to_jsonl, config.content_type.music, search_config=config.search_type.asymmetric, regenerate=regenerate, device=device, verbose=verbose) - - # Initialize Markdown Search - if (t == SearchType.Markdown or t == None) and config.content_type.markdown: - # Extract Entries, Generate Markdown Embeddings - model.markdown_search = text_search.setup(markdown_to_jsonl, config.content_type.markdown, search_config=config.search_type.asymmetric, regenerate=regenerate, device=device, verbose=verbose) - - # Initialize Ledger Search - if (t == SearchType.Ledger or t == None) and config.content_type.ledger: - # Extract Entries, Generate Ledger Embeddings - model.ledger_search = text_search.setup(beancount_to_jsonl, config.content_type.ledger, search_config=config.search_type.symmetric, regenerate=regenerate, verbose=verbose) - - # Initialize Image Search - if (t == SearchType.Image or t == None) and config.content_type.image: - # Extract Entries, Generate Image Embeddings - model.image_search = image_search.setup(config.content_type.image, search_config=config.search_type.image, regenerate=regenerate, verbose=verbose) - - return model - - -def initialize_processor(config: FullConfig): - if not config.processor: - return - - processor_config = ProcessorConfigModel() - - # Initialize Conversation Processor - processor_config.conversation = ConversationProcessorConfigModel(config.processor.conversation, verbose) - - conversation_logfile = processor_config.conversation.conversation_logfile - if processor_config.conversation.verbose: - print('INFO:\tLoading conversation logs from disk...') - - if conversation_logfile.expanduser().absolute().is_file(): - # Load Metadata Logs from Conversation Logfile - with open(get_absolute_path(conversation_logfile), 'r') as f: - processor_config.conversation.meta_log = json.load(f) - - print('INFO:\tConversation logs loaded from disk.') - else: - # Initialize Conversation Logs - processor_config.conversation.meta_log = {} - processor_config.conversation.chat_session = "" - - return processor_config - - -@app.on_event('shutdown') -def shutdown_event(): - # No need to create empty log file - if not (processor_config and processor_config.conversation and processor_config.conversation.meta_log): - return - elif processor_config.conversation.verbose: - print('INFO:\tSaving conversation logs to disk...') - - # Summarize Conversation Logs for this Session - chat_session = processor_config.conversation.chat_session - openai_api_key = processor_config.conversation.openai_api_key - conversation_log = processor_config.conversation.meta_log - session = { - "summary": summarize(chat_session, summary_type="chat", api_key=openai_api_key), - "session-start": conversation_log.get("session", [{"session-end": 0}])[-1]["session-end"], - "session-end": len(conversation_log["chat"]) - } - if 'session' in conversation_log: - conversation_log['session'].append(session) - else: - conversation_log['session'] = [session] - - # Save Conversation Metadata Logs to Disk - conversation_logfile = get_absolute_path(processor_config.conversation.conversation_logfile) - with open(conversation_logfile, "w+", encoding='utf-8') as logfile: - json.dump(conversation_log, logfile) - - print('INFO:\tConversation logs saved to disk.') - - -def setup_server(): - # Load config from CLI - args = cli(sys.argv[1:]) - - # Stores the file path to the config file. - global config_file - config_file = args.config_file - - # Store the raw config data. - global config - config = args.config - - # Store the verbose flag - global verbose - verbose = args.verbose - - # Set device to GPU if available - device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu") - - # Initialize the search model from Config - global model - model = initialize_search(args.config, args.regenerate, device=device) - - # Initialize Processor from Config - global processor_config - processor_config = initialize_processor(args.config) - - return args.host, args.port, args.socket +app.mount("/static", StaticFiles(directory=constants.web_directory), name="static") +app.include_router(router) def run(): # Setup Application Server - host, port, socket = setup_server() + host, port, socket = initialize_server(sys.argv[1:]) # Setup GUI gui = QtWidgets.QApplication([]) @@ -363,7 +66,7 @@ def create_system_tray(): """ # Create the system tray with icon - icon_path = web_directory / 'assets/icons/favicon-144x144.png' + icon_path = constants.web_directory / 'assets/icons/favicon-144x144.png' icon = QtGui.QIcon(f'{icon_path.absolute()}') tray = QtWidgets.QSystemTrayIcon(icon) tray.setVisible(True) diff --git a/src/router.py b/src/router.py new file mode 100644 index 00000000..28880eb7 --- /dev/null +++ b/src/router.py @@ -0,0 +1,208 @@ +# Standard Packages +import yaml +import json +import time +from typing import Optional +from functools import lru_cache + +# External Packages +from fastapi import APIRouter +from fastapi import Request +from fastapi.responses import HTMLResponse, FileResponse +from fastapi.templating import Jinja2Templates + +# Internal Packages +from src.configure import initialize_search +from src.search_type import image_search, text_search +from src.processor.conversation.gpt import converse, extract_search_type, message_to_log, message_to_prompt, understand, summarize +from src.search_filter.explicit_filter import ExplicitFilter +from src.search_filter.date_filter import DateFilter +from src.utils.rawconfig import FullConfig +from src.utils.config import SearchType +from src.utils.helpers import get_absolute_path, get_from_dict +from src.utils import constants + +router = APIRouter() + +templates = Jinja2Templates(directory=constants.web_directory) + +@router.get("/", response_class=FileResponse) +def index(): + return FileResponse(constants.web_directory / "index.html") + +@router.get('/config', response_class=HTMLResponse) +def config_page(request: Request): + return templates.TemplateResponse("config.html", context={'request': request}) + +@router.get('/config/data', response_model=FullConfig) +def config_data(): + return constants.config + +@router.post('/config/data') +async def config_data(updated_config: FullConfig): + constants.config = updated_config + with open(constants.config_file, 'w') as outfile: + yaml.dump(yaml.safe_load(constants.config.json(by_alias=True)), outfile) + outfile.close() + return constants.config + +@router.get('/search') +@lru_cache(maxsize=100) +def search(q: str, n: Optional[int] = 5, t: Optional[SearchType] = None, r: Optional[bool] = False): + if q is None or q == '': + print(f'No query param (q) passed in API call to initiate search') + return {} + + user_query = q + results_count = n + results = {} + + if (t == SearchType.Org or t == None) and constants.model.orgmode_search: + # query org-mode notes + query_start = time.time() + hits, entries = text_search.query(user_query, constants.model.orgmode_search, rank_results=r, device=constants.device, filters=[DateFilter(), ExplicitFilter()], verbose=constants.verbose) + query_end = time.time() + + # collate and return results + collate_start = time.time() + results = text_search.collate_results(hits, entries, results_count) + collate_end = time.time() + + if (t == SearchType.Music or t == None) and constants.model.music_search: + # query music library + query_start = time.time() + hits, entries = text_search.query(user_query, constants.model.music_search, rank_results=r, device=constants.device, filters=[DateFilter(), ExplicitFilter()], verbose=constants.verbose) + query_end = time.time() + + # collate and return results + collate_start = time.time() + results = text_search.collate_results(hits, entries, results_count) + collate_end = time.time() + + if (t == SearchType.Markdown or t == None) and constants.model.orgmode_search: + # query markdown files + query_start = time.time() + hits, entries = text_search.query(user_query, constants.model.markdown_search, rank_results=r, device=constants.device, filters=[ExplicitFilter(), DateFilter()], verbose=constants.verbose) + query_end = time.time() + + # collate and return results + collate_start = time.time() + results = text_search.collate_results(hits, entries, results_count) + collate_end = time.time() + + if (t == SearchType.Ledger or t == None) and constants.model.ledger_search: + # query transactions + query_start = time.time() + hits, entries = text_search.query(user_query, constants.model.ledger_search, rank_results=r, device=constants.device, filters=[ExplicitFilter(), DateFilter()], verbose=constants.verbose) + query_end = time.time() + + # collate and return results + collate_start = time.time() + results = text_search.collate_results(hits, entries, results_count) + collate_end = time.time() + + if (t == SearchType.Image or t == None) and constants.model.image_search: + # query images + query_start = time.time() + hits = image_search.query(user_query, results_count, constants.model.image_search) + output_directory = constants.web_directory / 'images' + query_end = time.time() + + # collate and return results + collate_start = time.time() + results = image_search.collate_results( + hits, + image_names=constants.model.image_search.image_names, + output_directory=output_directory, + image_files_url='/static/images', + count=results_count) + collate_end = time.time() + + if constants.verbose > 1: + print(f"Query took {query_end - query_start:.3f} seconds") + print(f"Collating results took {collate_end - collate_start:.3f} seconds") + + return results + + +@router.get('/reload') +def reload(t: Optional[SearchType] = None): + constants.model = initialize_search(constants.model, constants.config, regenerate=False, t=t, device=constants.device) + return {'status': 'ok', 'message': 'reload completed'} + + +@router.get('/regenerate') +def regenerate(t: Optional[SearchType] = None): + constants.model = initialize_search(constants.model, constants.config, regenerate=True, t=t, device=constants.device) + return {'status': 'ok', 'message': 'regeneration completed'} + + +@router.get('/beta/search') +def search_beta(q: str, n: Optional[int] = 1): + # Extract Search Type using GPT + metadata = extract_search_type(q, api_key=constants.processor_config.conversation.openai_api_key, verbose=constants.verbose) + search_type = get_from_dict(metadata, "search-type") + + # Search + search_results = search(q, n=n, t=SearchType(search_type)) + + # Return response + return {'status': 'ok', 'result': search_results, 'type': search_type} + + +@router.get('/chat') +def chat(q: str): + # Load Conversation History + chat_session = constants.processor_config.conversation.chat_session + meta_log = constants.processor_config.conversation.meta_log + + # Converse with OpenAI GPT + metadata = understand(q, api_key=constants.processor_config.conversation.openai_api_key, verbose=constants.verbose) + if constants.verbose > 1: + print(f'Understood: {get_from_dict(metadata, "intent")}') + + if get_from_dict(metadata, "intent", "memory-type") == "notes": + query = get_from_dict(metadata, "intent", "query") + result_list = search(query, n=1, t=SearchType.Org) + collated_result = "\n".join([item["entry"] for item in result_list]) + if constants.verbose > 1: + print(f'Semantically Similar Notes:\n{collated_result}') + gpt_response = summarize(collated_result, summary_type="notes", user_query=q, api_key=constants.processor_config.conversation.openai_api_key) + else: + gpt_response = converse(q, chat_session, api_key=constants.processor_config.conversation.openai_api_key) + + # Update Conversation History + constants.processor_config.conversation.chat_session = message_to_prompt(q, chat_session, gpt_message=gpt_response) + constants.processor_config.conversation.meta_log['chat'] = message_to_log(q, metadata, gpt_response, meta_log.get('chat', [])) + + return {'status': 'ok', 'response': gpt_response} + + +@router.on_event('shutdown') +def shutdown_event(): + # No need to create empty log file + if not (constants.processor_config and constants.processor_config.conversation and constants.processor_config.conversation.meta_log): + return + elif constants.processor_config.conversation.verbose: + print('INFO:\tSaving conversation logs to disk...') + + # Summarize Conversation Logs for this Session + chat_session = constants.processor_config.conversation.chat_session + openai_api_key = constants.processor_config.conversation.openai_api_key + conversation_log = constants.processor_config.conversation.meta_log + session = { + "summary": summarize(chat_session, summary_type="chat", api_key=openai_api_key), + "session-start": conversation_log.get("session", [{"session-end": 0}])[-1]["session-end"], + "session-end": len(conversation_log["chat"]) + } + if 'session' in conversation_log: + conversation_log['session'].append(session) + else: + conversation_log['session'] = [session] + + # Save Conversation Metadata Logs to Disk + conversation_logfile = get_absolute_path(constants.processor_config.conversation.conversation_logfile) + with open(conversation_logfile, "w+", encoding='utf-8') as logfile: + json.dump(conversation_log, logfile) + + print('INFO:\tConversation logs saved to disk.') diff --git a/src/utils/constants.py b/src/utils/constants.py index fb0ca717..be5d7464 100644 --- a/src/utils/constants.py +++ b/src/utils/constants.py @@ -1 +1,19 @@ -empty_escape_sequences = r'\n|\r\t ' \ No newline at end of file +# External Packages +import torch +from pathlib import Path + +# Internal Packages +from src.utils.config import SearchModels, ProcessorConfigModel +from src.utils.rawconfig import FullConfig + +# Application Global State +config = FullConfig() +model = SearchModels() +processor_config = ProcessorConfigModel() +config_file: Path = "" +verbose: int = 0 +device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu") # Set device to GPU if available + +# Other Constants +web_directory = Path(__file__).parent.parent / 'interface/web/' +empty_escape_sequences = r'\n|\r\t ' From bc423d8f769a94f3010f662b6b5dfbc23c5bc0cc Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Sat, 6 Aug 2022 02:47:52 +0300 Subject: [PATCH 03/47] Disable image search in tests. Import global state from constants module - Upstream issues causing load of image search model to fail. Disable tests related to image search for now --- tests/conftest.py | 28 ++++++++++++++-------------- tests/test_asymmetric_search.py | 2 +- tests/test_client.py | 7 ++++--- tests/test_image_search.py | 3 ++- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 742006d2..68f12634 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ import torch from src.search_type import image_search, text_search from src.utils.rawconfig import ContentConfig, TextContentConfig, ImageContentConfig, SearchConfig, TextSearchConfig, ImageSearchConfig from src.processor.org_mode.org_to_jsonl import org_to_jsonl +from src.utils import constants @pytest.fixture(scope='session') @@ -37,17 +38,16 @@ def search_config(tmp_path_factory): @pytest.fixture(scope='session') def model_dir(search_config): model_dir = search_config.asymmetric.model_directory - device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu") # Generate Image Embeddings from Test Images - content_config = ContentConfig() - content_config.image = ImageContentConfig( - input_directories = ['tests/data/images'], - embeddings_file = model_dir.joinpath('image_embeddings.pt'), - batch_size = 10, - use_xmp_metadata = False) + # content_config = ContentConfig() + # content_config.image = ImageContentConfig( + # input_directories = ['tests/data/images'], + # embeddings_file = model_dir.joinpath('image_embeddings.pt'), + # batch_size = 10, + # use_xmp_metadata = False) - image_search.setup(content_config.image, search_config.image, regenerate=False, verbose=True) + # image_search.setup(content_config.image, search_config.image, regenerate=False, verbose=True) # Generate Notes Embeddings from Test Notes content_config.org = TextContentConfig( @@ -56,7 +56,7 @@ def model_dir(search_config): compressed_jsonl = model_dir.joinpath('notes.jsonl.gz'), embeddings_file = model_dir.joinpath('note_embeddings.pt')) - text_search.setup(org_to_jsonl, content_config.org, search_config.asymmetric, regenerate=False, device=device, verbose=True) + text_search.setup(org_to_jsonl, content_config.org, search_config.asymmetric, regenerate=False, device=constants.device, verbose=True) return model_dir @@ -70,10 +70,10 @@ def content_config(model_dir): compressed_jsonl = model_dir.joinpath('notes.jsonl.gz'), embeddings_file = model_dir.joinpath('note_embeddings.pt')) - content_config.image = ImageContentConfig( - input_directories = ['tests/data/images'], - embeddings_file = model_dir.joinpath('image_embeddings.pt'), - batch_size = 10, - use_xmp_metadata = False) + # content_config.image = ImageContentConfig( + # input_directories = ['tests/data/images'], + # embeddings_file = model_dir.joinpath('image_embeddings.pt'), + # batch_size = 10, + # use_xmp_metadata = False) return content_config \ No newline at end of file diff --git a/tests/test_asymmetric_search.py b/tests/test_asymmetric_search.py index 135f9680..cf56b449 100644 --- a/tests/test_asymmetric_search.py +++ b/tests/test_asymmetric_search.py @@ -2,7 +2,7 @@ from pathlib import Path # Internal Packages -from src.main import model +from src.utils.constants import model from src.search_type import text_search from src.utils.rawconfig import ContentConfig, SearchConfig from src.processor.org_mode.org_to_jsonl import org_to_jsonl diff --git a/tests/test_client.py b/tests/test_client.py index 04d26a80..779cc8ab 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -7,7 +7,8 @@ from fastapi.testclient import TestClient import pytest # Internal Packages -from src.main import app, model, config +from src.main import app +from src.utils.constants import model, config from src.search_type import text_search, image_search from src.utils.rawconfig import ContentConfig, SearchConfig from src.processor.org_mode import org_to_jsonl @@ -37,7 +38,7 @@ def test_search_with_valid_content_type(content_config: ContentConfig, search_co config.search_type = search_config # config.content_type.image = search_config.image - for content_type in ["org", "markdown", "ledger", "music", "image"]: + for content_type in ["org", "markdown", "ledger", "music"]: # Act response = client.get(f"/search?q=random&t={content_type}") # Assert @@ -59,7 +60,7 @@ def test_reload_with_valid_content_type(content_config: ContentConfig, search_co config.content_type = content_config config.search_type = search_config - for content_type in ["org", "markdown", "ledger", "music", "image"]: + for content_type in ["org", "markdown", "ledger", "music"]: # Act response = client.get(f"/reload?t={content_type}") # Assert diff --git a/tests/test_image_search.py b/tests/test_image_search.py index 0b4953c8..21b72937 100644 --- a/tests/test_image_search.py +++ b/tests/test_image_search.py @@ -6,7 +6,7 @@ from PIL import Image import pytest # Internal Packages -from src.main import model, web_directory +from src.utils.constants import model, web_directory from src.search_type import image_search from src.utils.helpers import resolve_absolute_path from src.utils.rawconfig import ContentConfig, SearchConfig @@ -14,6 +14,7 @@ from src.utils.rawconfig import ContentConfig, SearchConfig # Test # ---------------------------------------------------------------------------------------------------- +@pytest.mark.skip(reason="upstream issues in loading image search model. disabled for now") def test_image_search_setup(content_config: ContentConfig, search_config: SearchConfig): # Act # Regenerate image search embeddings during image setup From 7b04978f52a03eaa9fff316b50c37307a7cefb50 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Sat, 6 Aug 2022 03:05:35 +0300 Subject: [PATCH 04/47] Put global state variables into separate state module - Variables storing app, device state aren't constants. Do not mix with actual constants like empty_escape_sequence, web_directory --- src/configure.py | 12 +++--- src/main.py | 2 +- src/router.py | 72 ++++++++++++++++----------------- src/utils/constants.py | 15 ------- src/utils/state.py | 15 +++++++ tests/conftest.py | 5 +-- tests/test_asymmetric_search.py | 2 +- tests/test_client.py | 2 +- tests/test_image_search.py | 3 +- 9 files changed, 64 insertions(+), 64 deletions(-) create mode 100644 src/utils/state.py diff --git a/src/configure.py b/src/configure.py index 564b7280..8c42fbd8 100644 --- a/src/configure.py +++ b/src/configure.py @@ -11,7 +11,7 @@ from src.processor.org_mode.org_to_jsonl import org_to_jsonl from src.search_type import image_search, text_search from src.utils.config import SearchType, SearchModels, ProcessorConfigModel, ConversationProcessorConfigModel from src.utils.cli import cli -from src.utils import constants +from src.utils import state from src.utils.helpers import get_absolute_path from src.utils.rawconfig import FullConfig @@ -21,19 +21,19 @@ def initialize_server(cmd_args): args = cli(cmd_args) # Stores the file path to the config file. - constants.config_file = args.config_file + state.config_file = args.config_file # Store the raw config data. - constants.config = args.config + state.config = args.config # Store the verbose flag - constants.verbose = args.verbose + state.verbose = args.verbose # Initialize the search model from Config - constants.model = initialize_search(constants.model, args.config, args.regenerate, device=constants.device, verbose=constants.verbose) + state.model = initialize_search(state.model, args.config, args.regenerate, device=state.device, verbose=state.verbose) # Initialize Processor from Config - constants.processor_config = initialize_processor(args.config, verbose=constants.verbose) + state.processor_config = initialize_processor(args.config, verbose=state.verbose) return args.host, args.port, args.socket diff --git a/src/main.py b/src/main.py index 1d4c5ad7..76f68089 100644 --- a/src/main.py +++ b/src/main.py @@ -9,9 +9,9 @@ from fastapi.staticfiles import StaticFiles from PyQt6 import QtCore, QtGui, QtWidgets # Internal Packages -from src.utils import constants from src.configure import initialize_server from src.router import router +from src.utils import constants # Initialize the Application Server diff --git a/src/router.py b/src/router.py index 28880eb7..c5bb93e6 100644 --- a/src/router.py +++ b/src/router.py @@ -20,7 +20,7 @@ from src.search_filter.date_filter import DateFilter from src.utils.rawconfig import FullConfig from src.utils.config import SearchType from src.utils.helpers import get_absolute_path, get_from_dict -from src.utils import constants +from src.utils import state, constants router = APIRouter() @@ -36,15 +36,15 @@ def config_page(request: Request): @router.get('/config/data', response_model=FullConfig) def config_data(): - return constants.config + return state.config @router.post('/config/data') async def config_data(updated_config: FullConfig): - constants.config = updated_config - with open(constants.config_file, 'w') as outfile: - yaml.dump(yaml.safe_load(constants.config.json(by_alias=True)), outfile) + state.config = updated_config + with open(state.config_file, 'w') as outfile: + yaml.dump(yaml.safe_load(state.config.json(by_alias=True)), outfile) outfile.close() - return constants.config + return state.config @router.get('/search') @lru_cache(maxsize=100) @@ -57,10 +57,10 @@ def search(q: str, n: Optional[int] = 5, t: Optional[SearchType] = None, r: Opti results_count = n results = {} - if (t == SearchType.Org or t == None) and constants.model.orgmode_search: + if (t == SearchType.Org or t == None) and state.model.orgmode_search: # query org-mode notes query_start = time.time() - hits, entries = text_search.query(user_query, constants.model.orgmode_search, rank_results=r, device=constants.device, filters=[DateFilter(), ExplicitFilter()], verbose=constants.verbose) + hits, entries = text_search.query(user_query, state.model.orgmode_search, rank_results=r, device=state.device, filters=[DateFilter(), ExplicitFilter()], verbose=state.verbose) query_end = time.time() # collate and return results @@ -68,10 +68,10 @@ def search(q: str, n: Optional[int] = 5, t: Optional[SearchType] = None, r: Opti results = text_search.collate_results(hits, entries, results_count) collate_end = time.time() - if (t == SearchType.Music or t == None) and constants.model.music_search: + if (t == SearchType.Music or t == None) and state.model.music_search: # query music library query_start = time.time() - hits, entries = text_search.query(user_query, constants.model.music_search, rank_results=r, device=constants.device, filters=[DateFilter(), ExplicitFilter()], verbose=constants.verbose) + hits, entries = text_search.query(user_query, state.model.music_search, rank_results=r, device=state.device, filters=[DateFilter(), ExplicitFilter()], verbose=state.verbose) query_end = time.time() # collate and return results @@ -79,10 +79,10 @@ def search(q: str, n: Optional[int] = 5, t: Optional[SearchType] = None, r: Opti results = text_search.collate_results(hits, entries, results_count) collate_end = time.time() - if (t == SearchType.Markdown or t == None) and constants.model.orgmode_search: + if (t == SearchType.Markdown or t == None) and state.model.orgmode_search: # query markdown files query_start = time.time() - hits, entries = text_search.query(user_query, constants.model.markdown_search, rank_results=r, device=constants.device, filters=[ExplicitFilter(), DateFilter()], verbose=constants.verbose) + hits, entries = text_search.query(user_query, state.model.markdown_search, rank_results=r, device=state.device, filters=[ExplicitFilter(), DateFilter()], verbose=state.verbose) query_end = time.time() # collate and return results @@ -90,10 +90,10 @@ def search(q: str, n: Optional[int] = 5, t: Optional[SearchType] = None, r: Opti results = text_search.collate_results(hits, entries, results_count) collate_end = time.time() - if (t == SearchType.Ledger or t == None) and constants.model.ledger_search: + if (t == SearchType.Ledger or t == None) and state.model.ledger_search: # query transactions query_start = time.time() - hits, entries = text_search.query(user_query, constants.model.ledger_search, rank_results=r, device=constants.device, filters=[ExplicitFilter(), DateFilter()], verbose=constants.verbose) + hits, entries = text_search.query(user_query, state.model.ledger_search, rank_results=r, device=state.device, filters=[ExplicitFilter(), DateFilter()], verbose=state.verbose) query_end = time.time() # collate and return results @@ -101,10 +101,10 @@ def search(q: str, n: Optional[int] = 5, t: Optional[SearchType] = None, r: Opti results = text_search.collate_results(hits, entries, results_count) collate_end = time.time() - if (t == SearchType.Image or t == None) and constants.model.image_search: + if (t == SearchType.Image or t == None) and state.model.image_search: # query images query_start = time.time() - hits = image_search.query(user_query, results_count, constants.model.image_search) + hits = image_search.query(user_query, results_count, state.model.image_search) output_directory = constants.web_directory / 'images' query_end = time.time() @@ -112,13 +112,13 @@ def search(q: str, n: Optional[int] = 5, t: Optional[SearchType] = None, r: Opti collate_start = time.time() results = image_search.collate_results( hits, - image_names=constants.model.image_search.image_names, + image_names=state.model.image_search.image_names, output_directory=output_directory, image_files_url='/static/images', count=results_count) collate_end = time.time() - if constants.verbose > 1: + if state.verbose > 1: print(f"Query took {query_end - query_start:.3f} seconds") print(f"Collating results took {collate_end - collate_start:.3f} seconds") @@ -127,20 +127,20 @@ def search(q: str, n: Optional[int] = 5, t: Optional[SearchType] = None, r: Opti @router.get('/reload') def reload(t: Optional[SearchType] = None): - constants.model = initialize_search(constants.model, constants.config, regenerate=False, t=t, device=constants.device) + state.model = initialize_search(state.model, state.config, regenerate=False, t=t, device=state.device) return {'status': 'ok', 'message': 'reload completed'} @router.get('/regenerate') def regenerate(t: Optional[SearchType] = None): - constants.model = initialize_search(constants.model, constants.config, regenerate=True, t=t, device=constants.device) + state.model = initialize_search(state.model, state.config, regenerate=True, t=t, device=state.device) return {'status': 'ok', 'message': 'regeneration completed'} @router.get('/beta/search') def search_beta(q: str, n: Optional[int] = 1): # Extract Search Type using GPT - metadata = extract_search_type(q, api_key=constants.processor_config.conversation.openai_api_key, verbose=constants.verbose) + metadata = extract_search_type(q, api_key=state.processor_config.conversation.openai_api_key, verbose=state.verbose) search_type = get_from_dict(metadata, "search-type") # Search @@ -153,27 +153,27 @@ def search_beta(q: str, n: Optional[int] = 1): @router.get('/chat') def chat(q: str): # Load Conversation History - chat_session = constants.processor_config.conversation.chat_session - meta_log = constants.processor_config.conversation.meta_log + chat_session = state.processor_config.conversation.chat_session + meta_log = state.processor_config.conversation.meta_log # Converse with OpenAI GPT - metadata = understand(q, api_key=constants.processor_config.conversation.openai_api_key, verbose=constants.verbose) - if constants.verbose > 1: + metadata = understand(q, api_key=state.processor_config.conversation.openai_api_key, verbose=state.verbose) + if state.verbose > 1: print(f'Understood: {get_from_dict(metadata, "intent")}') if get_from_dict(metadata, "intent", "memory-type") == "notes": query = get_from_dict(metadata, "intent", "query") result_list = search(query, n=1, t=SearchType.Org) collated_result = "\n".join([item["entry"] for item in result_list]) - if constants.verbose > 1: + if state.verbose > 1: print(f'Semantically Similar Notes:\n{collated_result}') - gpt_response = summarize(collated_result, summary_type="notes", user_query=q, api_key=constants.processor_config.conversation.openai_api_key) + gpt_response = summarize(collated_result, summary_type="notes", user_query=q, api_key=state.processor_config.conversation.openai_api_key) else: - gpt_response = converse(q, chat_session, api_key=constants.processor_config.conversation.openai_api_key) + gpt_response = converse(q, chat_session, api_key=state.processor_config.conversation.openai_api_key) # Update Conversation History - constants.processor_config.conversation.chat_session = message_to_prompt(q, chat_session, gpt_message=gpt_response) - constants.processor_config.conversation.meta_log['chat'] = message_to_log(q, metadata, gpt_response, meta_log.get('chat', [])) + state.processor_config.conversation.chat_session = message_to_prompt(q, chat_session, gpt_message=gpt_response) + state.processor_config.conversation.meta_log['chat'] = message_to_log(q, metadata, gpt_response, meta_log.get('chat', [])) return {'status': 'ok', 'response': gpt_response} @@ -181,15 +181,15 @@ def chat(q: str): @router.on_event('shutdown') def shutdown_event(): # No need to create empty log file - if not (constants.processor_config and constants.processor_config.conversation and constants.processor_config.conversation.meta_log): + if not (state.processor_config and state.processor_config.conversation and state.processor_config.conversation.meta_log): return - elif constants.processor_config.conversation.verbose: + elif state.processor_config.conversation.verbose: print('INFO:\tSaving conversation logs to disk...') # Summarize Conversation Logs for this Session - chat_session = constants.processor_config.conversation.chat_session - openai_api_key = constants.processor_config.conversation.openai_api_key - conversation_log = constants.processor_config.conversation.meta_log + chat_session = state.processor_config.conversation.chat_session + openai_api_key = state.processor_config.conversation.openai_api_key + conversation_log = state.processor_config.conversation.meta_log session = { "summary": summarize(chat_session, summary_type="chat", api_key=openai_api_key), "session-start": conversation_log.get("session", [{"session-end": 0}])[-1]["session-end"], @@ -201,7 +201,7 @@ def shutdown_event(): conversation_log['session'] = [session] # Save Conversation Metadata Logs to Disk - conversation_logfile = get_absolute_path(constants.processor_config.conversation.conversation_logfile) + conversation_logfile = get_absolute_path(state.processor_config.conversation.conversation_logfile) with open(conversation_logfile, "w+", encoding='utf-8') as logfile: json.dump(conversation_log, logfile) diff --git a/src/utils/constants.py b/src/utils/constants.py index be5d7464..bfb307f0 100644 --- a/src/utils/constants.py +++ b/src/utils/constants.py @@ -1,19 +1,4 @@ -# External Packages -import torch from pathlib import Path -# Internal Packages -from src.utils.config import SearchModels, ProcessorConfigModel -from src.utils.rawconfig import FullConfig - -# Application Global State -config = FullConfig() -model = SearchModels() -processor_config = ProcessorConfigModel() -config_file: Path = "" -verbose: int = 0 -device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu") # Set device to GPU if available - -# Other Constants web_directory = Path(__file__).parent.parent / 'interface/web/' empty_escape_sequences = r'\n|\r\t ' diff --git a/src/utils/state.py b/src/utils/state.py new file mode 100644 index 00000000..964fa458 --- /dev/null +++ b/src/utils/state.py @@ -0,0 +1,15 @@ +# External Packages +import torch +from pathlib import Path + +# Internal Packages +from src.utils.config import SearchModels, ProcessorConfigModel +from src.utils.rawconfig import FullConfig + +# Application Global State +config = FullConfig() +model = SearchModels() +processor_config = ProcessorConfigModel() +config_file: Path = "" +verbose: int = 0 +device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu") # Set device to GPU if available \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 68f12634..56610d45 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,12 +1,11 @@ # Standard Packages import pytest -import torch # Internal Packages from src.search_type import image_search, text_search from src.utils.rawconfig import ContentConfig, TextContentConfig, ImageContentConfig, SearchConfig, TextSearchConfig, ImageSearchConfig from src.processor.org_mode.org_to_jsonl import org_to_jsonl -from src.utils import constants +from src.utils import state @pytest.fixture(scope='session') @@ -56,7 +55,7 @@ def model_dir(search_config): compressed_jsonl = model_dir.joinpath('notes.jsonl.gz'), embeddings_file = model_dir.joinpath('note_embeddings.pt')) - text_search.setup(org_to_jsonl, content_config.org, search_config.asymmetric, regenerate=False, device=constants.device, verbose=True) + text_search.setup(org_to_jsonl, content_config.org, search_config.asymmetric, regenerate=False, device=state.device, verbose=True) return model_dir diff --git a/tests/test_asymmetric_search.py b/tests/test_asymmetric_search.py index cf56b449..39fed92e 100644 --- a/tests/test_asymmetric_search.py +++ b/tests/test_asymmetric_search.py @@ -2,7 +2,7 @@ from pathlib import Path # Internal Packages -from src.utils.constants import model +from src.utils.state import model from src.search_type import text_search from src.utils.rawconfig import ContentConfig, SearchConfig from src.processor.org_mode.org_to_jsonl import org_to_jsonl diff --git a/tests/test_client.py b/tests/test_client.py index 779cc8ab..85aad8d7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,7 +8,7 @@ import pytest # Internal Packages from src.main import app -from src.utils.constants import model, config +from src.utils.state import model, config from src.search_type import text_search, image_search from src.utils.rawconfig import ContentConfig, SearchConfig from src.processor.org_mode import org_to_jsonl diff --git a/tests/test_image_search.py b/tests/test_image_search.py index 21b72937..4eb52048 100644 --- a/tests/test_image_search.py +++ b/tests/test_image_search.py @@ -6,7 +6,8 @@ from PIL import Image import pytest # Internal Packages -from src.utils.constants import model, web_directory +from src.utils.state import model +from src.utils.constants import web_directory from src.search_type import image_search from src.utils.helpers import resolve_absolute_path from src.utils.rawconfig import ContentConfig, SearchConfig From c5bf051a2958db5b85e260fda0d22ac4e7cc628e Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Sat, 6 Aug 2022 03:20:04 +0300 Subject: [PATCH 05/47] Rename initialize_{search,processor,server} to configure_{search,procesor,server} - Search is being reconfigured multiple times in /regenerate and n/reload. More appropriate name is configure_ rather than initialize_ for it - Standardize name of methods under configure.py --- src/configure.py | 13 +++++-------- src/main.py | 4 ++-- src/router.py | 6 +++--- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/configure.py b/src/configure.py index 8c42fbd8..68a872cc 100644 --- a/src/configure.py +++ b/src/configure.py @@ -1,6 +1,3 @@ -# Standard Packages -import sys - # External Packages import torch @@ -16,7 +13,7 @@ from src.utils.helpers import get_absolute_path from src.utils.rawconfig import FullConfig -def initialize_server(cmd_args): +def configure_server(cmd_args): # Load config from CLI args = cli(cmd_args) @@ -30,15 +27,15 @@ def initialize_server(cmd_args): state.verbose = args.verbose # Initialize the search model from Config - state.model = initialize_search(state.model, args.config, args.regenerate, device=state.device, verbose=state.verbose) + state.model = configure_search(state.model, args.config, args.regenerate, device=state.device, verbose=state.verbose) # Initialize Processor from Config - state.processor_config = initialize_processor(args.config, verbose=state.verbose) + state.processor_config = configure_processor(args.config, verbose=state.verbose) return args.host, args.port, args.socket -def initialize_search(model: SearchModels, config: FullConfig, regenerate: bool, t: SearchType = None, device=torch.device("cpu"), verbose: int = 0): +def configure_search(model: SearchModels, config: FullConfig, regenerate: bool, t: SearchType = None, device=torch.device("cpu"), verbose: int = 0): # Initialize Org Notes Search if (t == SearchType.Org or t == None) and config.content_type.org: # Extract Entries, Generate Notes Embeddings @@ -67,7 +64,7 @@ def initialize_search(model: SearchModels, config: FullConfig, regenerate: bool, return model -def initialize_processor(config: FullConfig, verbose: int): +def configure_processor(config: FullConfig, verbose: int): if not config.processor: return diff --git a/src/main.py b/src/main.py index 76f68089..8602d205 100644 --- a/src/main.py +++ b/src/main.py @@ -9,7 +9,7 @@ from fastapi.staticfiles import StaticFiles from PyQt6 import QtCore, QtGui, QtWidgets # Internal Packages -from src.configure import initialize_server +from src.configure import configure_server from src.router import router from src.utils import constants @@ -22,7 +22,7 @@ app.include_router(router) def run(): # Setup Application Server - host, port, socket = initialize_server(sys.argv[1:]) + host, port, socket = configure_server(sys.argv[1:]) # Setup GUI gui = QtWidgets.QApplication([]) diff --git a/src/router.py b/src/router.py index c5bb93e6..61f27686 100644 --- a/src/router.py +++ b/src/router.py @@ -12,7 +12,7 @@ from fastapi.responses import HTMLResponse, FileResponse from fastapi.templating import Jinja2Templates # Internal Packages -from src.configure import initialize_search +from src.configure import configure_search from src.search_type import image_search, text_search from src.processor.conversation.gpt import converse, extract_search_type, message_to_log, message_to_prompt, understand, summarize from src.search_filter.explicit_filter import ExplicitFilter @@ -127,13 +127,13 @@ def search(q: str, n: Optional[int] = 5, t: Optional[SearchType] = None, r: Opti @router.get('/reload') def reload(t: Optional[SearchType] = None): - state.model = initialize_search(state.model, state.config, regenerate=False, t=t, device=state.device) + state.model = configure_search(state.model, state.config, regenerate=False, t=t, device=state.device) return {'status': 'ok', 'message': 'reload completed'} @router.get('/regenerate') def regenerate(t: Optional[SearchType] = None): - state.model = initialize_search(state.model, state.config, regenerate=True, t=t, device=state.device) + state.model = configure_search(state.model, state.config, regenerate=True, t=t, device=state.device) return {'status': 'ok', 'message': 'regeneration completed'} From eacd95bebd6ea691dc203998867a0391ca559e87 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Sat, 6 Aug 2022 15:18:28 +0300 Subject: [PATCH 06/47] Start Creating Native Configure Page using PyQt --- src/main.py | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/main.py b/src/main.py index 8602d205..f903bc62 100644 --- a/src/main.py +++ b/src/main.py @@ -6,7 +6,8 @@ import webbrowser import uvicorn from fastapi import FastAPI from fastapi.staticfiles import StaticFiles -from PyQt6 import QtCore, QtGui, QtWidgets +from PyQt6 import QtGui, QtWidgets +from PyQt6.QtCore import Qt, QThread # Internal Packages from src.configure import configure_server @@ -28,6 +29,7 @@ def run(): gui = QtWidgets.QApplication([]) gui.setQuitOnLastWindowClosed(False) tray = create_system_tray() + window = ConfigureWindow() # Start Application Server server = ServerThread(app, host, port, socket) @@ -35,11 +37,12 @@ def run(): gui.aboutToQuit.connect(server.terminate) # Start the GUI + window.show() tray.show() gui.exec() -class ServerThread(QtCore.QThread): +class ServerThread(QThread): def __init__(self, app, host=None, port=None, socket=None): super(ServerThread, self).__init__() self.app = app @@ -57,6 +60,48 @@ class ServerThread(QtCore.QThread): uvicorn.run(app, host=self.host, port=self.port) +# Subclass QMainWindow to customize your application's main window +class ConfigureWindow(QtWidgets.QMainWindow): + def __init__(self): + super().__init__() + + self.setWindowTitle("Khoj - Configure") + + self.layout = QtWidgets.QVBoxLayout() + + enable_orgmode_search = QtWidgets.QCheckBox("Enable Search on Org-Mode Files") + enable_orgmode_search.stateChanged.connect(self.show_orgmode_search_options) + self.layout.addWidget(enable_orgmode_search) + + enable_ledger_search = QtWidgets.QCheckBox("Enable Search on Beancount Files") + enable_ledger_search.stateChanged.connect(self.show_ledger_search_options) + self.layout.addWidget(enable_ledger_search) + + # Set the central widget of the Window. Widget will expand + # to take up all the space in the window by default. + # Create Widget for Setting Directory with Org-Mode Files + self.config_window = QtWidgets.QWidget() + self.config_window.setLayout(self.layout) + + self.setCentralWidget(self.config_window) + + def show_orgmode_search_options(self, s): + if Qt.CheckState(s) == Qt.CheckState.Checked: + self.config_window.layout().addWidget(QtWidgets.QLabel("Search Org-Mode Files")) + self.config_window.layout().addWidget(QtWidgets.QLineEdit()) + else: + self.config_window.layout().removeWidget(self.config_window.layout().itemAt(2).widget()) + self.config_window.layout().removeWidget(self.config_window.layout().itemAt(2).widget()) + + def show_ledger_search_options(self, s): + if Qt.CheckState(s) == Qt.CheckState.Checked: + self.config_window.layout().addWidget(QtWidgets.QLabel("Search Ledger Files")) + self.config_window.layout().addWidget(QtWidgets.QLineEdit()) + else: + self.config_window.layout().removeWidget(self.config_window.layout().itemAt(2).widget()) + self.config_window.layout().removeWidget(self.config_window.layout().itemAt(2).widget()) + + def create_system_tray(): """Create System Tray with Menu Menu Actions should contain @@ -90,6 +135,15 @@ def create_system_tray(): return tray +def create_window_to_configure_khoj(): + """Create Window to Configure Khoj + Allow user to + 1. Enable/Disable search on 1. org-mode, 2. markdown, 3. beancount or 4. image content types + 2. Configure the server host and port + 3. Save the configuration to khoj.yml and start the server + """ + + if __name__ == '__main__': run() \ No newline at end of file From ef009323e748ceddded9b77ccb0de1836517cadc Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Mon, 8 Aug 2022 21:42:36 +0300 Subject: [PATCH 07/47] Use sys.exit to quit via system tray. Fix pip install cmd in Readme --- Readme.md | 2 +- src/main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Readme.md b/Readme.md index d7f199bb..da5ba39a 100644 --- a/Readme.md +++ b/Readme.md @@ -136,7 +136,7 @@ pip install --upgrade khoj-assistant ``` shell git clone https://github.com/debanjum/khoj && cd khoj python -m venv .venv && source .venv/bin/activate - pip install + pip install . ``` ##### 2. Configure - Set `input-files` or `input-filter` in each relevant `content-type` section of `khoj_sample.yml` diff --git a/src/main.py b/src/main.py index f903bc62..dc223f48 100644 --- a/src/main.py +++ b/src/main.py @@ -121,7 +121,7 @@ def create_system_tray(): menu_actions = [ ('Search', lambda: webbrowser.open('http://localhost:8000/')), ('Configure', lambda: webbrowser.open('http://localhost:8000/config')), - ('Quit', quit), + ('Quit', sys.exit), ] # Add the menu actions to the menu From c00fcb70f6763aa582de8d3db5e9705d0e572238 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Mon, 8 Aug 2022 22:31:42 +0300 Subject: [PATCH 08/47] Rmove unsupported Python versions from Classifiers list in setup.py --- setup.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/setup.py b/setup.py index 8646e6cb..5c3a5a00 100644 --- a/setup.py +++ b/setup.py @@ -48,9 +48,6 @@ setup( "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", From e5691f9d1d301b1a1379937234159d5f34506d4e Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Mon, 8 Aug 2022 22:32:29 +0300 Subject: [PATCH 09/47] PyInstaller Spec to Wrap Khoj into a Basic Native App - Verified functionality on MacOS - Add ICNS Icon to use as MacOS App Icon - Spec generated by PyInstaller: ```sh pyinstaller \ src/main.py \ --windowed \ --onefile \ --name "Khoj" \ --target-arch arm64 \ -i src/interface/web/assets/icons/favicon.icns \ --add-data "src/interface/web:src/interface/web" \ --copy-metadata tqdm \ --copy-metadata regex \ --copy-metadata requests \ --copy-metadata packaging \ --copy-metadata filelock \ --copy-metadata numpy \ --copy-metadata tokenizers ``` --- Khoj.spec | 61 ++++++++++++++++++++ src/interface/web/assets/icons/favicon.icns | Bin 0 -> 113060 bytes 2 files changed, 61 insertions(+) create mode 100644 Khoj.spec create mode 100644 src/interface/web/assets/icons/favicon.icns diff --git a/Khoj.spec b/Khoj.spec new file mode 100644 index 00000000..a2470734 --- /dev/null +++ b/Khoj.spec @@ -0,0 +1,61 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import copy_metadata + +datas = [('src/interface/web', 'src/interface/web')] +datas += copy_metadata('tqdm') +datas += copy_metadata('regex') +datas += copy_metadata('requests') +datas += copy_metadata('packaging') +datas += copy_metadata('filelock') +datas += copy_metadata('numpy') +datas += copy_metadata('tokenizers') + + +block_cipher = None + + +a = Analysis( + ['src/main.py'], + pathex=[], + binaries=[], + datas=datas, + hiddenimports=['huggingface_hub.repository'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='Khoj', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch='arm64', + codesign_identity=None, + entitlements_file=None, + icon='src/interface/web/assets/icons/favicon.icns', +) +app = BUNDLE( + exe, + name='Khoj.app', + icon='src/interface/web/assets/icons/favicon.icns', + bundle_identifier=None, +) diff --git a/src/interface/web/assets/icons/favicon.icns b/src/interface/web/assets/icons/favicon.icns new file mode 100644 index 0000000000000000000000000000000000000000..6e0c6c752ea8ad8a944033de72bed3a79af1dc7b GIT binary patch literal 113060 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 Date: Mon, 8 Aug 2022 23:49:26 +0300 Subject: [PATCH 10/47] Quit GUI via SysTray instead of sys.exit to cleanly terminate server --- src/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main.py b/src/main.py index dc223f48..cf5842f6 100644 --- a/src/main.py +++ b/src/main.py @@ -28,7 +28,7 @@ def run(): # Setup GUI gui = QtWidgets.QApplication([]) gui.setQuitOnLastWindowClosed(False) - tray = create_system_tray() + tray = create_system_tray(gui) window = ConfigureWindow() # Start Application Server @@ -102,7 +102,7 @@ class ConfigureWindow(QtWidgets.QMainWindow): self.config_window.layout().removeWidget(self.config_window.layout().itemAt(2).widget()) -def create_system_tray(): +def create_system_tray(gui: QtWidgets.QApplication): """Create System Tray with Menu Menu Actions should contain 1. option to open search page at localhost:8000/ @@ -121,7 +121,7 @@ def create_system_tray(): menu_actions = [ ('Search', lambda: webbrowser.open('http://localhost:8000/')), ('Configure', lambda: webbrowser.open('http://localhost:8000/config')), - ('Quit', sys.exit), + ('Quit', gui.quit), ] # Add the menu actions to the menu From 21af122447fca1a2e3fcdf6623c278b7ee2a0267 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Tue, 9 Aug 2022 16:53:07 +0300 Subject: [PATCH 11/47] Clean up unused methods, module imports. Add comments --- src/configure.py | 3 +-- src/main.py | 19 ++++++++----------- src/utils/cli.py | 2 +- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/configure.py b/src/configure.py index 68a872cc..87bad7aa 100644 --- a/src/configure.py +++ b/src/configure.py @@ -88,5 +88,4 @@ def configure_processor(config: FullConfig, verbose: int): processor_config.conversation.meta_log = {} processor_config.conversation.chat_session = "" - return processor_config - + return processor_config \ No newline at end of file diff --git a/src/main.py b/src/main.py index cf5842f6..02cda31e 100644 --- a/src/main.py +++ b/src/main.py @@ -60,8 +60,14 @@ class ServerThread(QThread): uvicorn.run(app, host=self.host, port=self.port) -# Subclass QMainWindow to customize your application's main window class ConfigureWindow(QtWidgets.QMainWindow): + """Create Window to Configure Khoj + Allow user to + 1. Enable/Disable search on 1. org-mode, 2. markdown, 3. beancount or 4. image content types + 2. Configure the server host and port + 3. Save the configuration to khoj.yml and start the server + """ + def __init__(self): super().__init__() @@ -135,15 +141,6 @@ def create_system_tray(gui: QtWidgets.QApplication): return tray -def create_window_to_configure_khoj(): - """Create Window to Configure Khoj - Allow user to - 1. Enable/Disable search on 1. org-mode, 2. markdown, 3. beancount or 4. image content types - 2. Configure the server host and port - 3. Save the configuration to khoj.yml and start the server - """ - - if __name__ == '__main__': - run() \ No newline at end of file + run() diff --git a/src/utils/cli.py b/src/utils/cli.py index 152c1805..a4d8ac2b 100644 --- a/src/utils/cli.py +++ b/src/utils/cli.py @@ -6,7 +6,7 @@ import pathlib import yaml # Internal Packages -from src.utils.helpers import is_none_or_empty, get_absolute_path, resolve_absolute_path, merge_dicts +from src.utils.helpers import get_absolute_path, resolve_absolute_path from src.utils.rawconfig import FullConfig def cli(args=None): From a588a8e21ffcb3471ad9be6aa6b1f3046de39136 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Tue, 9 Aug 2022 17:02:02 +0300 Subject: [PATCH 12/47] Make config_file an optional argument. It can be generated on FRE - Make config_file an optional arg. It defaults to default khoj config dir - Return args.config as None if no config_file explicitly passed by user - Parent can use args.config = None as signal to trigger first run experience --- src/utils/cli.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/utils/cli.py b/src/utils/cli.py index a4d8ac2b..e4d91e0f 100644 --- a/src/utils/cli.py +++ b/src/utils/cli.py @@ -9,10 +9,11 @@ import yaml from src.utils.helpers import get_absolute_path, resolve_absolute_path from src.utils.rawconfig import FullConfig + def cli(args=None): # Setup Argument Parser for the Commandline Interface parser = argparse.ArgumentParser(description="Start Khoj; A Natural Language Search Engine for your personal Notes, Transactions and Photos") - parser.add_argument('config_file', type=pathlib.Path, help="YAML file to configure Khoj") + parser.add_argument('--config-file', '-c', default='~/.khoj/khoj.yml', type=pathlib.Path, help="YAML file to configure Khoj") parser.add_argument('--regenerate', action='store_true', default=False, help="Regenerate model embeddings from source files. Default: false") parser.add_argument('--verbose', '-v', action='count', default=0, help="Show verbose conversion logs. Default: 0") parser.add_argument('--host', type=str, default='127.0.0.1', help="Host address of the server. Default: 127.0.0.1") @@ -22,14 +23,14 @@ def cli(args=None): args = parser.parse_args(args) if not resolve_absolute_path(args.config_file).exists(): - raise ValueError(f"Config file {args.config_file} does not exist") + args.config = None + else: + # Read Config from YML file + config_from_file = None + with open(get_absolute_path(args.config_file), 'r', encoding='utf-8') as config_file: + config_from_file = yaml.safe_load(config_file) - # Read Config from YML file - config_from_file = None - with open(get_absolute_path(args.config_file), 'r', encoding='utf-8') as config_file: - config_from_file = yaml.safe_load(config_file) - - # Parse, Validate Config in YML file - args.config = FullConfig.parse_obj(config_from_file) + # Parse, Validate Config in YML file + args.config = FullConfig.parse_obj(config_from_file) return args \ No newline at end of file From 027da719aae0812fcdfdb48201d08037ac78d866 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Tue, 9 Aug 2022 17:05:27 +0300 Subject: [PATCH 13/47] Open Configure Window on First Run or from System Tray - Trigger FRE if no config loaded. Open Configure Window automatically - Else user can manually open config window from App on System Tray --- src/configure.py | 6 +----- src/main.py | 33 ++++++++++++++++++++++++--------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/configure.py b/src/configure.py index 87bad7aa..f7cb49e1 100644 --- a/src/configure.py +++ b/src/configure.py @@ -7,16 +7,12 @@ from src.processor.markdown.markdown_to_jsonl import markdown_to_jsonl from src.processor.org_mode.org_to_jsonl import org_to_jsonl from src.search_type import image_search, text_search from src.utils.config import SearchType, SearchModels, ProcessorConfigModel, ConversationProcessorConfigModel -from src.utils.cli import cli from src.utils import state from src.utils.helpers import get_absolute_path from src.utils.rawconfig import FullConfig -def configure_server(cmd_args): - # Load config from CLI - args = cli(cmd_args) - +def configure_server(args): # Stores the file path to the config file. state.config_file = args.config_file diff --git a/src/main.py b/src/main.py index 02cda31e..b7060ccc 100644 --- a/src/main.py +++ b/src/main.py @@ -13,6 +13,7 @@ from PyQt6.QtCore import Qt, QThread from src.configure import configure_server from src.router import router from src.utils import constants +from src.utils.cli import cli # Initialize the Application Server @@ -22,14 +23,30 @@ app.include_router(router) def run(): - # Setup Application Server - host, port, socket = configure_server(sys.argv[1:]) - - # Setup GUI + # Setup Base GUI gui = QtWidgets.QApplication([]) gui.setQuitOnLastWindowClosed(False) - tray = create_system_tray(gui) window = ConfigureWindow() + tray = create_system_tray(gui, window) + tray.show() + + # Load config from CLI + args = cli(sys.argv[1:]) + + # Trigger First Run Experience, if required + if args.config is None: + window.show() + gui.exec() + + # Reload config after first run + args = cli(sys.argv[1:]) + # Quit if app still not configured + if args.config is None: + print('Exiting as Khoj is not configured. Configure the application to use it.') + sys.exit(1) + + # Setup Application Server + host, port, socket = configure_server(args) # Start Application Server server = ServerThread(app, host, port, socket) @@ -37,8 +54,6 @@ def run(): gui.aboutToQuit.connect(server.terminate) # Start the GUI - window.show() - tray.show() gui.exec() @@ -108,7 +123,7 @@ class ConfigureWindow(QtWidgets.QMainWindow): self.config_window.layout().removeWidget(self.config_window.layout().itemAt(2).widget()) -def create_system_tray(gui: QtWidgets.QApplication): +def create_system_tray(gui: QtWidgets.QApplication, window: QtWidgets.QMainWindow): """Create System Tray with Menu Menu Actions should contain 1. option to open search page at localhost:8000/ @@ -126,7 +141,7 @@ def create_system_tray(gui: QtWidgets.QApplication): menu = QtWidgets.QMenu() menu_actions = [ ('Search', lambda: webbrowser.open('http://localhost:8000/')), - ('Configure', lambda: webbrowser.open('http://localhost:8000/config')), + ('Configure', window.show), ('Quit', gui.quit), ] From 2c77caf06cf211e97af7ecfa648a6fda8276a156 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Tue, 9 Aug 2022 20:35:39 +0300 Subject: [PATCH 14/47] Group ledger, org setting widgets into child Qt widgets of config window --- src/main.py | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/src/main.py b/src/main.py index b7060ccc..eab575da 100644 --- a/src/main.py +++ b/src/main.py @@ -86,21 +86,30 @@ class ConfigureWindow(QtWidgets.QMainWindow): def __init__(self): super().__init__() + # Initialize Configure Window self.setWindowTitle("Khoj - Configure") - self.layout = QtWidgets.QVBoxLayout() - enable_orgmode_search = QtWidgets.QCheckBox("Enable Search on Org-Mode Files") - enable_orgmode_search.stateChanged.connect(self.show_orgmode_search_options) - self.layout.addWidget(enable_orgmode_search) + # Org Mode Settings + orgmode_settings = QtWidgets.QWidget() + self.orgmode_layout = QtWidgets.QVBoxLayout(orgmode_settings) + enable_orgmode_search = QtWidgets.QCheckBox( + "Search Org-Mode Files", + stateChanged = self.show_orgmode_search_options) + self.orgmode_layout.addWidget(enable_orgmode_search) + self.layout.addWidget(orgmode_settings) - enable_ledger_search = QtWidgets.QCheckBox("Enable Search on Beancount Files") - enable_ledger_search.stateChanged.connect(self.show_ledger_search_options) - self.layout.addWidget(enable_ledger_search) + # Ledger Settings + ledger_settings = QtWidgets.QWidget() + self.ledger_layout = QtWidgets.QVBoxLayout(ledger_settings) + enable_ledger_search = QtWidgets.QCheckBox( + "Search Beancount Files", + state_changed=self.show_ledger_search_options) + self.ledger_layout.addWidget(enable_ledger_search) + self.layout.addWidget(ledger_settings) # Set the central widget of the Window. Widget will expand # to take up all the space in the window by default. - # Create Widget for Setting Directory with Org-Mode Files self.config_window = QtWidgets.QWidget() self.config_window.setLayout(self.layout) @@ -108,19 +117,19 @@ class ConfigureWindow(QtWidgets.QMainWindow): def show_orgmode_search_options(self, s): if Qt.CheckState(s) == Qt.CheckState.Checked: - self.config_window.layout().addWidget(QtWidgets.QLabel("Search Org-Mode Files")) - self.config_window.layout().addWidget(QtWidgets.QLineEdit()) + self.orgmode_layout.layout().addWidget(QtWidgets.QLabel("Search Org-Mode Files")) + self.orgmode_layout.layout().addWidget(QtWidgets.QLineEdit()) else: - self.config_window.layout().removeWidget(self.config_window.layout().itemAt(2).widget()) - self.config_window.layout().removeWidget(self.config_window.layout().itemAt(2).widget()) + self.orgmode_layout.layout().removeWidget(self.orgmode_layout.layout().itemAt(1).widget()) + self.orgmode_layout.layout().removeWidget(self.orgmode_layout.layout().itemAt(1).widget()) def show_ledger_search_options(self, s): if Qt.CheckState(s) == Qt.CheckState.Checked: - self.config_window.layout().addWidget(QtWidgets.QLabel("Search Ledger Files")) - self.config_window.layout().addWidget(QtWidgets.QLineEdit()) + self.ledger_layout.layout().addWidget(QtWidgets.QLabel("Search Ledger Files")) + self.ledger_layout.layout().addWidget(QtWidgets.QLineEdit()) else: - self.config_window.layout().removeWidget(self.config_window.layout().itemAt(2).widget()) - self.config_window.layout().removeWidget(self.config_window.layout().itemAt(2).widget()) + self.ledger_layout.layout().removeWidget(self.ledger_layout.layout().itemAt(1).widget()) + self.ledger_layout.layout().removeWidget(self.ledger_layout.layout().itemAt(1).widget()) def create_system_tray(gui: QtWidgets.QApplication, window: QtWidgets.QMainWindow): From cd59982c9cccf01cd9d185f3ec87fdc590c87143 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Tue, 9 Aug 2022 20:39:49 +0300 Subject: [PATCH 15/47] Add Qt Button to save Khoj configuration in Khoj Configuration Window --- src/main.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main.py b/src/main.py index eab575da..a7c7a38a 100644 --- a/src/main.py +++ b/src/main.py @@ -108,6 +108,13 @@ class ConfigureWindow(QtWidgets.QMainWindow): self.ledger_layout.addWidget(enable_ledger_search) self.layout.addWidget(ledger_settings) + # Button to Save Settings + action_bar = QtWidgets.QWidget() + action_bar_layout = QtWidgets.QHBoxLayout(action_bar) + save_button = QtWidgets.QPushButton("Start", clicked=self.save_settings) + action_bar_layout.addWidget(save_button) + self.layout.addWidget(action_bar) + # Set the central widget of the Window. Widget will expand # to take up all the space in the window by default. self.config_window = QtWidgets.QWidget() @@ -115,6 +122,10 @@ class ConfigureWindow(QtWidgets.QMainWindow): self.setCentralWidget(self.config_window) + def save_settings(self, s): + # Save the settings to khoj.yml + pass + def show_orgmode_search_options(self, s): if Qt.CheckState(s) == Qt.CheckState.Checked: self.orgmode_layout.layout().addWidget(QtWidgets.QLabel("Search Org-Mode Files")) From 664713b24e66a825ae346eb5436bab8ebd794262 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Tue, 9 Aug 2022 22:12:29 +0300 Subject: [PATCH 16/47] Extract Qt GUI code from main.py into separate interface/desktop dir --- src/interface/desktop/__init__.py | 0 src/interface/desktop/configure_window.py | 70 ++++++++++++++ src/interface/desktop/system_tray.py | 43 +++++++++ src/main.py | 109 +--------------------- 4 files changed, 117 insertions(+), 105 deletions(-) create mode 100644 src/interface/desktop/__init__.py create mode 100644 src/interface/desktop/configure_window.py create mode 100644 src/interface/desktop/system_tray.py diff --git a/src/interface/desktop/__init__.py b/src/interface/desktop/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/interface/desktop/configure_window.py b/src/interface/desktop/configure_window.py new file mode 100644 index 00000000..d99cd0c3 --- /dev/null +++ b/src/interface/desktop/configure_window.py @@ -0,0 +1,70 @@ +# External Packages +from PyQt6 import QtWidgets +from PyQt6.QtCore import Qt + + +class ConfigureWindow(QtWidgets.QMainWindow): + """Create Window to Configure Khoj + Allow user to + 1. Enable/Disable search on 1. org-mode, 2. markdown, 3. beancount or 4. image content types + 2. Configure the server host and port + 3. Save the configuration to khoj.yml and start the server + """ + + def __init__(self): + super().__init__() + + # Initialize Configure Window + self.setWindowTitle("Khoj - Configure") + self.layout = QtWidgets.QVBoxLayout() + + # Org Mode Settings + orgmode_settings = QtWidgets.QWidget() + self.orgmode_layout = QtWidgets.QVBoxLayout(orgmode_settings) + enable_orgmode_search = QtWidgets.QCheckBox("Search Org-Mode Files") + enable_orgmode_search.stateChanged.connect(self.show_orgmode_search_options) + self.orgmode_layout.addWidget(enable_orgmode_search) + self.layout.addWidget(orgmode_settings) + + # Ledger Settings + ledger_settings = QtWidgets.QWidget() + self.ledger_layout = QtWidgets.QVBoxLayout(ledger_settings) + enable_ledger_search = QtWidgets.QCheckBox("Search Beancount Files") + enable_ledger_search.stateChanged.connect(self.show_ledger_search_options) + self.ledger_layout.addWidget(enable_ledger_search) + self.layout.addWidget(ledger_settings) + + # Button to Save Settings + action_bar = QtWidgets.QWidget() + action_bar_layout = QtWidgets.QHBoxLayout(action_bar) + save_button = QtWidgets.QPushButton("Start", clicked=self.save_settings) + action_bar_layout.addWidget(save_button) + self.layout.addWidget(action_bar) + + # Set the central widget of the Window. Widget will expand + # to take up all the space in the window by default. + self.config_window = QtWidgets.QWidget() + self.config_window.setLayout(self.layout) + + self.setCentralWidget(self.config_window) + + def save_settings(self, s): + # Save the settings to khoj.yml + pass + + def show_orgmode_search_options(self, s): + if Qt.CheckState(s) == Qt.CheckState.Checked: + self.orgmode_layout.layout().addWidget(QtWidgets.QLabel("Search Org-Mode Notes")) + self.orgmode_layout.layout().addWidget(QtWidgets.QLineEdit()) + else: + self.orgmode_layout.layout().removeWidget(self.orgmode_layout.layout().itemAt(1).widget()) + self.orgmode_layout.layout().removeWidget(self.orgmode_layout.layout().itemAt(1).widget()) + + def show_ledger_search_options(self, s): + if Qt.CheckState(s) == Qt.CheckState.Checked: + self.ledger_layout.layout().addWidget(QtWidgets.QLabel("Search Beancount Transactions")) + self.ledger_layout.layout().addWidget(QtWidgets.QLineEdit()) + else: + self.ledger_layout.layout().removeWidget(self.ledger_layout.layout().itemAt(1).widget()) + self.ledger_layout.layout().removeWidget(self.ledger_layout.layout().itemAt(1).widget()) + \ No newline at end of file diff --git a/src/interface/desktop/system_tray.py b/src/interface/desktop/system_tray.py new file mode 100644 index 00000000..52f45dd2 --- /dev/null +++ b/src/interface/desktop/system_tray.py @@ -0,0 +1,43 @@ +# Standard Packages +import webbrowser + +# External Packages +from PyQt6 import QtGui, QtWidgets + +# Internal Packages +from src.utils import constants + + +def create_system_tray(gui: QtWidgets.QApplication, window: QtWidgets.QMainWindow): + """Create System Tray with Menu + Menu Actions should contain + 1. option to open search page at localhost:8000/ + 2. option to open config page at localhost:8000/config + 3. to quit + """ + + # Create the system tray with icon + icon_path = constants.web_directory / 'assets/icons/favicon-144x144.png' + icon = QtGui.QIcon(f'{icon_path.absolute()}') + tray = QtWidgets.QSystemTrayIcon(icon) + tray.setVisible(True) + + # Create the menu and menu actions + menu = QtWidgets.QMenu() + menu_actions = [ + ('Search', lambda: webbrowser.open('http://localhost:8000/')), + ('Configure', window.show), + ('Quit', gui.quit), + ] + + # Add the menu actions to the menu + for action_text, action_function in menu_actions: + menu_action = QtGui.QAction(action_text, menu) + menu_action.triggered.connect(action_function) + menu.addAction(menu_action) + + # Add the menu to the system tray + tray.setContextMenu(menu) + + return tray + diff --git a/src/main.py b/src/main.py index a7c7a38a..780c9d61 100644 --- a/src/main.py +++ b/src/main.py @@ -1,19 +1,20 @@ # Standard Packages import sys -import webbrowser # External Packages import uvicorn from fastapi import FastAPI from fastapi.staticfiles import StaticFiles -from PyQt6 import QtGui, QtWidgets -from PyQt6.QtCore import Qt, QThread +from PyQt6 import QtWidgets +from PyQt6.QtCore import QThread # Internal Packages from src.configure import configure_server from src.router import router from src.utils import constants from src.utils.cli import cli +from src.interface.desktop.configure_window import ConfigureWindow +from src.interface.desktop.system_tray import create_system_tray # Initialize the Application Server @@ -75,107 +76,5 @@ class ServerThread(QThread): uvicorn.run(app, host=self.host, port=self.port) -class ConfigureWindow(QtWidgets.QMainWindow): - """Create Window to Configure Khoj - Allow user to - 1. Enable/Disable search on 1. org-mode, 2. markdown, 3. beancount or 4. image content types - 2. Configure the server host and port - 3. Save the configuration to khoj.yml and start the server - """ - - def __init__(self): - super().__init__() - - # Initialize Configure Window - self.setWindowTitle("Khoj - Configure") - self.layout = QtWidgets.QVBoxLayout() - - # Org Mode Settings - orgmode_settings = QtWidgets.QWidget() - self.orgmode_layout = QtWidgets.QVBoxLayout(orgmode_settings) - enable_orgmode_search = QtWidgets.QCheckBox( - "Search Org-Mode Files", - stateChanged = self.show_orgmode_search_options) - self.orgmode_layout.addWidget(enable_orgmode_search) - self.layout.addWidget(orgmode_settings) - - # Ledger Settings - ledger_settings = QtWidgets.QWidget() - self.ledger_layout = QtWidgets.QVBoxLayout(ledger_settings) - enable_ledger_search = QtWidgets.QCheckBox( - "Search Beancount Files", - state_changed=self.show_ledger_search_options) - self.ledger_layout.addWidget(enable_ledger_search) - self.layout.addWidget(ledger_settings) - - # Button to Save Settings - action_bar = QtWidgets.QWidget() - action_bar_layout = QtWidgets.QHBoxLayout(action_bar) - save_button = QtWidgets.QPushButton("Start", clicked=self.save_settings) - action_bar_layout.addWidget(save_button) - self.layout.addWidget(action_bar) - - # Set the central widget of the Window. Widget will expand - # to take up all the space in the window by default. - self.config_window = QtWidgets.QWidget() - self.config_window.setLayout(self.layout) - - self.setCentralWidget(self.config_window) - - def save_settings(self, s): - # Save the settings to khoj.yml - pass - - def show_orgmode_search_options(self, s): - if Qt.CheckState(s) == Qt.CheckState.Checked: - self.orgmode_layout.layout().addWidget(QtWidgets.QLabel("Search Org-Mode Files")) - self.orgmode_layout.layout().addWidget(QtWidgets.QLineEdit()) - else: - self.orgmode_layout.layout().removeWidget(self.orgmode_layout.layout().itemAt(1).widget()) - self.orgmode_layout.layout().removeWidget(self.orgmode_layout.layout().itemAt(1).widget()) - - def show_ledger_search_options(self, s): - if Qt.CheckState(s) == Qt.CheckState.Checked: - self.ledger_layout.layout().addWidget(QtWidgets.QLabel("Search Ledger Files")) - self.ledger_layout.layout().addWidget(QtWidgets.QLineEdit()) - else: - self.ledger_layout.layout().removeWidget(self.ledger_layout.layout().itemAt(1).widget()) - self.ledger_layout.layout().removeWidget(self.ledger_layout.layout().itemAt(1).widget()) - - -def create_system_tray(gui: QtWidgets.QApplication, window: QtWidgets.QMainWindow): - """Create System Tray with Menu - Menu Actions should contain - 1. option to open search page at localhost:8000/ - 2. option to open config page at localhost:8000/config - 3. to quit - """ - - # Create the system tray with icon - icon_path = constants.web_directory / 'assets/icons/favicon-144x144.png' - icon = QtGui.QIcon(f'{icon_path.absolute()}') - tray = QtWidgets.QSystemTrayIcon(icon) - tray.setVisible(True) - - # Create the menu and menu actions - menu = QtWidgets.QMenu() - menu_actions = [ - ('Search', lambda: webbrowser.open('http://localhost:8000/')), - ('Configure', window.show), - ('Quit', gui.quit), - ] - - # Add the menu actions to the menu - for action_text, action_function in menu_actions: - menu_action = QtGui.QAction(action_text, menu) - menu_action.triggered.connect(action_function) - menu.addAction(menu_action) - - # Add the menu to the system tray - tray.setContextMenu(menu) - - return tray - - if __name__ == '__main__': run() From c50ab7c3ad2371399caee7bd76bf636ae9e4e047 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Tue, 9 Aug 2022 22:36:41 +0300 Subject: [PATCH 17/47] Split config settings GUI into functions. Convert Config Window to Dialog --- src/interface/desktop/configure_window.py | 37 +++++++++++++---------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/src/interface/desktop/configure_window.py b/src/interface/desktop/configure_window.py index d99cd0c3..ba9e8ec1 100644 --- a/src/interface/desktop/configure_window.py +++ b/src/interface/desktop/configure_window.py @@ -3,7 +3,7 @@ from PyQt6 import QtWidgets from PyQt6.QtCore import Qt -class ConfigureWindow(QtWidgets.QMainWindow): +class ConfigureWindow(QtWidgets.QDialog): """Create Window to Configure Khoj Allow user to 1. Enable/Disable search on 1. org-mode, 2. markdown, 3. beancount or 4. image content types @@ -11,42 +11,47 @@ class ConfigureWindow(QtWidgets.QMainWindow): 3. Save the configuration to khoj.yml and start the server """ - def __init__(self): - super().__init__() + def __init__(self, parent=None): + super(ConfigureWindow, self).__init__(parent=parent) # Initialize Configure Window self.setWindowTitle("Khoj - Configure") - self.layout = QtWidgets.QVBoxLayout() - # Org Mode Settings + # Initialize Configure Window Layout + layout = QtWidgets.QVBoxLayout() + self.setLayout(layout) + + # Add Panels to Configure Window Layout + self.show_orgmode_settings(layout) + self.show_ledger_settings(layout) + self.show_action_bar(layout) + + def show_orgmode_settings(self, parent_layout): + "Add Org Mode Settings to the Configure Window" orgmode_settings = QtWidgets.QWidget() self.orgmode_layout = QtWidgets.QVBoxLayout(orgmode_settings) enable_orgmode_search = QtWidgets.QCheckBox("Search Org-Mode Files") enable_orgmode_search.stateChanged.connect(self.show_orgmode_search_options) self.orgmode_layout.addWidget(enable_orgmode_search) - self.layout.addWidget(orgmode_settings) + parent_layout.addWidget(orgmode_settings) - # Ledger Settings + def show_ledger_settings(self, parent_layout): + "Add Ledger Settings to the Configure Window" ledger_settings = QtWidgets.QWidget() self.ledger_layout = QtWidgets.QVBoxLayout(ledger_settings) enable_ledger_search = QtWidgets.QCheckBox("Search Beancount Files") enable_ledger_search.stateChanged.connect(self.show_ledger_search_options) self.ledger_layout.addWidget(enable_ledger_search) - self.layout.addWidget(ledger_settings) + parent_layout.addWidget(ledger_settings) + def show_action_bar(self, parent_layout): + "Add Action Bar to the Configure Window" # Button to Save Settings action_bar = QtWidgets.QWidget() action_bar_layout = QtWidgets.QHBoxLayout(action_bar) save_button = QtWidgets.QPushButton("Start", clicked=self.save_settings) action_bar_layout.addWidget(save_button) - self.layout.addWidget(action_bar) - - # Set the central widget of the Window. Widget will expand - # to take up all the space in the window by default. - self.config_window = QtWidgets.QWidget() - self.config_window.setLayout(self.layout) - - self.setCentralWidget(self.config_window) + parent_layout.addWidget(action_bar) def save_settings(self, s): # Save the settings to khoj.yml From 3c788f1d2912b54b38ed838f5dd677502b6c5077 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Tue, 9 Aug 2022 22:41:23 +0300 Subject: [PATCH 18/47] Rename configure window to more generic configure screen --- .../desktop/{configure_window.py => configure_screen.py} | 4 ++-- src/interface/desktop/system_tray.py | 6 +++--- src/main.py | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) rename src/interface/desktop/{configure_window.py => configure_screen.py} (96%) diff --git a/src/interface/desktop/configure_window.py b/src/interface/desktop/configure_screen.py similarity index 96% rename from src/interface/desktop/configure_window.py rename to src/interface/desktop/configure_screen.py index ba9e8ec1..0e46723b 100644 --- a/src/interface/desktop/configure_window.py +++ b/src/interface/desktop/configure_screen.py @@ -3,7 +3,7 @@ from PyQt6 import QtWidgets from PyQt6.QtCore import Qt -class ConfigureWindow(QtWidgets.QDialog): +class ConfigureScreen(QtWidgets.QDialog): """Create Window to Configure Khoj Allow user to 1. Enable/Disable search on 1. org-mode, 2. markdown, 3. beancount or 4. image content types @@ -12,7 +12,7 @@ class ConfigureWindow(QtWidgets.QDialog): """ def __init__(self, parent=None): - super(ConfigureWindow, self).__init__(parent=parent) + super(ConfigureScreen, self).__init__(parent=parent) # Initialize Configure Window self.setWindowTitle("Khoj - Configure") diff --git a/src/interface/desktop/system_tray.py b/src/interface/desktop/system_tray.py index 52f45dd2..ebc465d0 100644 --- a/src/interface/desktop/system_tray.py +++ b/src/interface/desktop/system_tray.py @@ -8,11 +8,11 @@ from PyQt6 import QtGui, QtWidgets from src.utils import constants -def create_system_tray(gui: QtWidgets.QApplication, window: QtWidgets.QMainWindow): +def create_system_tray(gui: QtWidgets.QApplication, configure_screen: QtWidgets.QDialog): """Create System Tray with Menu Menu Actions should contain 1. option to open search page at localhost:8000/ - 2. option to open config page at localhost:8000/config + 2. option to open config screen 3. to quit """ @@ -26,7 +26,7 @@ def create_system_tray(gui: QtWidgets.QApplication, window: QtWidgets.QMainWindo menu = QtWidgets.QMenu() menu_actions = [ ('Search', lambda: webbrowser.open('http://localhost:8000/')), - ('Configure', window.show), + ('Configure', configure_screen.show), ('Quit', gui.quit), ] diff --git a/src/main.py b/src/main.py index 780c9d61..b35ddbbe 100644 --- a/src/main.py +++ b/src/main.py @@ -13,7 +13,7 @@ from src.configure import configure_server from src.router import router from src.utils import constants from src.utils.cli import cli -from src.interface.desktop.configure_window import ConfigureWindow +from src.interface.desktop.configure_screen import ConfigureScreen from src.interface.desktop.system_tray import create_system_tray @@ -27,8 +27,8 @@ def run(): # Setup Base GUI gui = QtWidgets.QApplication([]) gui.setQuitOnLastWindowClosed(False) - window = ConfigureWindow() - tray = create_system_tray(gui, window) + configure_screen = ConfigureScreen() + tray = create_system_tray(gui, configure_screen) tray.show() # Load config from CLI @@ -36,7 +36,7 @@ def run(): # Trigger First Run Experience, if required if args.config is None: - window.show() + configure_screen.show() gui.exec() # Reload config after first run From 509d52e2cd085d398a38017a553cb093ab210e3b Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Tue, 9 Aug 2022 23:34:54 +0300 Subject: [PATCH 19/47] Toggle Editability instead of Visibility of Per Search Type Settings - Simplifies the configure screen layout and allows it to be of constant width - It was buggy, the configure screen would dynamically expand but not restore back to original size on disabling search type after enable --- src/interface/desktop/configure_screen.py | 51 ++++++++++++----------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index 0e46723b..2b80eb29 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -29,19 +29,37 @@ class ConfigureScreen(QtWidgets.QDialog): def show_orgmode_settings(self, parent_layout): "Add Org Mode Settings to the Configure Window" orgmode_settings = QtWidgets.QWidget() - self.orgmode_layout = QtWidgets.QVBoxLayout(orgmode_settings) - enable_orgmode_search = QtWidgets.QCheckBox("Search Org-Mode Files") - enable_orgmode_search.stateChanged.connect(self.show_orgmode_search_options) - self.orgmode_layout.addWidget(enable_orgmode_search) + orgmode_layout = QtWidgets.QVBoxLayout(orgmode_settings) + + enable_orgmode_search = QtWidgets.QCheckBox("Search Org-Mode Notes") + input_files_label = QtWidgets.QLabel("Org-Mode Files") + input_files = QtWidgets.QLineEdit() + input_files.setEnabled(enable_orgmode_search.isChecked()) + + enable_orgmode_search.stateChanged.connect(lambda _: input_files.setEnabled(enable_orgmode_search.isChecked())) + + orgmode_layout.addWidget(enable_orgmode_search) + orgmode_layout.addWidget(input_files_label) + orgmode_layout.addWidget(input_files) + parent_layout.addWidget(orgmode_settings) def show_ledger_settings(self, parent_layout): "Add Ledger Settings to the Configure Window" ledger_settings = QtWidgets.QWidget() - self.ledger_layout = QtWidgets.QVBoxLayout(ledger_settings) - enable_ledger_search = QtWidgets.QCheckBox("Search Beancount Files") - enable_ledger_search.stateChanged.connect(self.show_ledger_search_options) - self.ledger_layout.addWidget(enable_ledger_search) + ledger_layout = QtWidgets.QVBoxLayout(ledger_settings) + + enable_ledger_search = QtWidgets.QCheckBox("Search Beancount Transactions") + input_files_label = QtWidgets.QLabel("Beancount Files") + input_files = QtWidgets.QLineEdit() + input_files.setEnabled(enable_ledger_search.isChecked()) + + enable_ledger_search.stateChanged.connect(lambda _: input_files.setEnabled(enable_ledger_search.isChecked())) + + ledger_layout.addWidget(enable_ledger_search) + ledger_layout.addWidget(input_files_label) + ledger_layout.addWidget(input_files) + parent_layout.addWidget(ledger_settings) def show_action_bar(self, parent_layout): @@ -56,20 +74,3 @@ class ConfigureScreen(QtWidgets.QDialog): def save_settings(self, s): # Save the settings to khoj.yml pass - - def show_orgmode_search_options(self, s): - if Qt.CheckState(s) == Qt.CheckState.Checked: - self.orgmode_layout.layout().addWidget(QtWidgets.QLabel("Search Org-Mode Notes")) - self.orgmode_layout.layout().addWidget(QtWidgets.QLineEdit()) - else: - self.orgmode_layout.layout().removeWidget(self.orgmode_layout.layout().itemAt(1).widget()) - self.orgmode_layout.layout().removeWidget(self.orgmode_layout.layout().itemAt(1).widget()) - - def show_ledger_search_options(self, s): - if Qt.CheckState(s) == Qt.CheckState.Checked: - self.ledger_layout.layout().addWidget(QtWidgets.QLabel("Search Beancount Transactions")) - self.ledger_layout.layout().addWidget(QtWidgets.QLineEdit()) - else: - self.ledger_layout.layout().removeWidget(self.ledger_layout.layout().itemAt(1).widget()) - self.ledger_layout.layout().removeWidget(self.ledger_layout.layout().itemAt(1).widget()) - \ No newline at end of file From d74134e6cc818c1137b6f0b7eea6a267775e0811 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Tue, 9 Aug 2022 23:48:32 +0300 Subject: [PATCH 20/47] Reuse Single Method to Create Setting Panels for each Search Type --- src/interface/desktop/configure_screen.py | 47 ++++++++--------------- 1 file changed, 15 insertions(+), 32 deletions(-) diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index 2b80eb29..7de2a113 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -1,6 +1,5 @@ # External Packages from PyQt6 import QtWidgets -from PyQt6.QtCore import Qt class ConfigureScreen(QtWidgets.QDialog): @@ -21,53 +20,37 @@ class ConfigureScreen(QtWidgets.QDialog): layout = QtWidgets.QVBoxLayout() self.setLayout(layout) - # Add Panels to Configure Window Layout - self.show_orgmode_settings(layout) - self.show_ledger_settings(layout) - self.show_action_bar(layout) + # Add Settings Panels for each Search Type to Configure Window Layout + for search_type in ["Org-Mode", "Markdown", "Beancount", "Image"]: + self.add_settings_panel(search_type, layout) + self.add_action_panel(layout) - def show_orgmode_settings(self, parent_layout): - "Add Org Mode Settings to the Configure Window" + def add_settings_panel(self, search_type, parent_layout): + "Add Settings Panel for specified Search Type. Toggle Editable Search Types" orgmode_settings = QtWidgets.QWidget() orgmode_layout = QtWidgets.QVBoxLayout(orgmode_settings) - enable_orgmode_search = QtWidgets.QCheckBox("Search Org-Mode Notes") - input_files_label = QtWidgets.QLabel("Org-Mode Files") + enable_search_type = QtWidgets.QCheckBox(f"Search {search_type} Notes") + input_files_label = QtWidgets.QLabel(f"{search_type} Files") input_files = QtWidgets.QLineEdit() - input_files.setEnabled(enable_orgmode_search.isChecked()) + input_files.setEnabled(enable_search_type.isChecked()) - enable_orgmode_search.stateChanged.connect(lambda _: input_files.setEnabled(enable_orgmode_search.isChecked())) + enable_search_type.stateChanged.connect(lambda _: input_files.setEnabled(enable_search_type.isChecked())) - orgmode_layout.addWidget(enable_orgmode_search) + orgmode_layout.addWidget(enable_search_type) orgmode_layout.addWidget(input_files_label) orgmode_layout.addWidget(input_files) parent_layout.addWidget(orgmode_settings) - def show_ledger_settings(self, parent_layout): - "Add Ledger Settings to the Configure Window" - ledger_settings = QtWidgets.QWidget() - ledger_layout = QtWidgets.QVBoxLayout(ledger_settings) - - enable_ledger_search = QtWidgets.QCheckBox("Search Beancount Transactions") - input_files_label = QtWidgets.QLabel("Beancount Files") - input_files = QtWidgets.QLineEdit() - input_files.setEnabled(enable_ledger_search.isChecked()) - - enable_ledger_search.stateChanged.connect(lambda _: input_files.setEnabled(enable_ledger_search.isChecked())) - - ledger_layout.addWidget(enable_ledger_search) - ledger_layout.addWidget(input_files_label) - ledger_layout.addWidget(input_files) - - parent_layout.addWidget(ledger_settings) - - def show_action_bar(self, parent_layout): - "Add Action Bar to the Configure Window" + def add_action_panel(self, parent_layout): + "Add Action Panel" # Button to Save Settings action_bar = QtWidgets.QWidget() action_bar_layout = QtWidgets.QHBoxLayout(action_bar) + save_button = QtWidgets.QPushButton("Start", clicked=self.save_settings) + action_bar_layout.addWidget(save_button) parent_layout.addWidget(action_bar) From daef276fd1682e8186f39e8a740f9fb765bd4899 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Wed, 10 Aug 2022 03:23:37 +0300 Subject: [PATCH 21/47] Add files for each search type. Extract config on clicking start - Only allow adding files with appropriate file extension for each search type - e.g .org for org-mode search, directory for image search - Extract file paths added to config and enablement state of each search type - This extracted state will be used to populate the khoj.yml config file --- src/interface/desktop/configure_screen.py | 42 ++++++++----- src/interface/desktop/file_browser.py | 75 +++++++++++++++++++++++ 2 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 src/interface/desktop/file_browser.py diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index 7de2a113..e21b761a 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -1,5 +1,10 @@ # External Packages from PyQt6 import QtWidgets +from PyQt6.QtCore import Qt + +# Internal Packages +from src.utils.config import SearchType +from src.interface.desktop.file_browser import FileBrowser class ConfigureScreen(QtWidgets.QDialog): @@ -14,6 +19,7 @@ class ConfigureScreen(QtWidgets.QDialog): super(ConfigureScreen, self).__init__(parent=parent) # Initialize Configure Window + self.setWindowFlags(Qt.WindowType.WindowStaysOnTopHint) self.setWindowTitle("Khoj - Configure") # Initialize Configure Window Layout @@ -21,27 +27,27 @@ class ConfigureScreen(QtWidgets.QDialog): self.setLayout(layout) # Add Settings Panels for each Search Type to Configure Window Layout - for search_type in ["Org-Mode", "Markdown", "Beancount", "Image"]: - self.add_settings_panel(search_type, layout) + self.settings_panels = [] + for search_type in SearchType: + self.settings_panels += [self.add_settings_panel(search_type, layout)] self.add_action_panel(layout) - def add_settings_panel(self, search_type, parent_layout): + def add_settings_panel(self, search_type: SearchType, parent_layout): "Add Settings Panel for specified Search Type. Toggle Editable Search Types" - orgmode_settings = QtWidgets.QWidget() - orgmode_layout = QtWidgets.QVBoxLayout(orgmode_settings) + search_type_settings = QtWidgets.QWidget() + search_type_layout = QtWidgets.QVBoxLayout(search_type_settings) - enable_search_type = QtWidgets.QCheckBox(f"Search {search_type} Notes") - input_files_label = QtWidgets.QLabel(f"{search_type} Files") - input_files = QtWidgets.QLineEdit() + enable_search_type = QtWidgets.QCheckBox(f"Search {search_type.name}") + input_files = FileBrowser(f'{search_type.name} Files', search_type) input_files.setEnabled(enable_search_type.isChecked()) enable_search_type.stateChanged.connect(lambda _: input_files.setEnabled(enable_search_type.isChecked())) - orgmode_layout.addWidget(enable_search_type) - orgmode_layout.addWidget(input_files_label) - orgmode_layout.addWidget(input_files) + search_type_layout.addWidget(enable_search_type) + search_type_layout.addWidget(input_files) - parent_layout.addWidget(orgmode_settings) + parent_layout.addWidget(search_type_settings) + return search_type_settings def add_action_panel(self, parent_layout): "Add Action Panel" @@ -54,6 +60,14 @@ class ConfigureScreen(QtWidgets.QDialog): action_bar_layout.addWidget(save_button) parent_layout.addWidget(action_bar) - def save_settings(self, s): + def save_settings(self, _): # Save the settings to khoj.yml - pass + for settings_panel in self.settings_panels: + for child in settings_panel.children(): + if isinstance(child, QtWidgets.QCheckBox): + if child.isChecked(): + print(f"{child.text()} is enabled") + else: + print(f"{child.text()} is disabled") + elif isinstance(child, FileBrowser): + print(f"{child.search_type} files are {child.getPaths()}") diff --git a/src/interface/desktop/file_browser.py b/src/interface/desktop/file_browser.py new file mode 100644 index 00000000..a924a771 --- /dev/null +++ b/src/interface/desktop/file_browser.py @@ -0,0 +1,75 @@ +# External Packages +from PyQt6 import QtWidgets +from PyQt6.QtCore import QDir + +# Internal Packages +from src.utils.config import SearchType + +class FileBrowser(QtWidgets.QWidget): + def __init__(self, title, search_type: SearchType=None): + 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.filepaths = [] + + self.label = QtWidgets.QLabel() + self.label.setText(title) + self.label.setFixedWidth(95) + layout.addWidget(self.label) + + self.lineEdit = QtWidgets.QLineEdit(self) + self.lineEdit.setFixedWidth(180) + + layout.addWidget(self.lineEdit) + + self.button = QtWidgets.QPushButton('Add') + self.button.clicked.connect(self.getFile) + layout.addWidget(self.button) + layout.addStretch() + + def setMode(self, search_type): + self.search_type = search_type + + 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.Music: + return 'Org-Music Files (*.org)' + elif search_type == SearchType.Image: + return 'Images (*.jp[e]g)' + + def setDefaultDir(self, path): + self.dirpath = path + + def getFile(self): + self.filepaths = [] + if self.search_type == SearchType.Image: + self.filepaths.append(QtWidgets.QFileDialog.getExistingDirectory(self, caption='Choose Directory', + directory=self.dirpath)) + else: + self.filepaths.extend(QtWidgets.QFileDialog.getOpenFileNames(self, caption='Choose Files', + directory=self.dirpath, + filter=self.filter_name)[0]) + if len(self.filepaths) == 0: + return + elif len(self.filepaths) == 1: + self.lineEdit.setText(self.filepaths[0]) + else: + self.lineEdit.setText(",".join(self.filepaths)) + + def setLabelWidth(self, width): + self.label.setFixedWidth(width) + + def setlineEditWidth(self, width): + self.lineEdit.setFixedWidth(width) + + def getPaths(self): + return self.filepaths From d2c7b28172dc27917205a9a6f12a40861289c9fa Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Wed, 10 Aug 2022 20:07:00 +0300 Subject: [PATCH 22/47] Extract code to load config from YAML file into new utils.yaml module --- src/utils/cli.py | 15 +++------------ src/utils/yaml.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 12 deletions(-) create mode 100644 src/utils/yaml.py diff --git a/src/utils/cli.py b/src/utils/cli.py index e4d91e0f..b24cc1bf 100644 --- a/src/utils/cli.py +++ b/src/utils/cli.py @@ -2,12 +2,9 @@ import argparse import pathlib -# External Packages -import yaml - # Internal Packages -from src.utils.helpers import get_absolute_path, resolve_absolute_path -from src.utils.rawconfig import FullConfig +from src.utils.helpers import resolve_absolute_path +from src.utils.yaml import load_config_from_file def cli(args=None): @@ -25,12 +22,6 @@ def cli(args=None): if not resolve_absolute_path(args.config_file).exists(): args.config = None else: - # Read Config from YML file - config_from_file = None - with open(get_absolute_path(args.config_file), 'r', encoding='utf-8') as config_file: - config_from_file = yaml.safe_load(config_file) - - # Parse, Validate Config in YML file - args.config = FullConfig.parse_obj(config_from_file) + args.config = load_config_from_file(args.config_file) return args \ No newline at end of file diff --git a/src/utils/yaml.py b/src/utils/yaml.py new file mode 100644 index 00000000..223f99fd --- /dev/null +++ b/src/utils/yaml.py @@ -0,0 +1,15 @@ +# External Packages +import yaml + +# Internal Packages +from src.utils.helpers import get_absolute_path +from src.utils.rawconfig import FullConfig + +def load_config_from_file(yaml_config_file): + # Read Config from YML file + config_from_file = None + with open(get_absolute_path(yaml_config_file), 'r', encoding='utf-8') as config_file: + config_from_file = yaml.safe_load(config_file) + + # Parse, Validate Config in YML file + return FullConfig.parse_obj(config_from_file) From 328cc00439bd54f587fcf700a632cb8f3942ece0 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Wed, 10 Aug 2022 20:08:19 +0300 Subject: [PATCH 23/47] Create global constant to store app root directory --- src/utils/constants.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/constants.py b/src/utils/constants.py index bfb307f0..8e45d03c 100644 --- a/src/utils/constants.py +++ b/src/utils/constants.py @@ -1,4 +1,5 @@ from pathlib import Path -web_directory = Path(__file__).parent.parent / 'interface/web/' +app_root_directory = Path(__file__).parent.parent.parent +web_directory = app_root_directory / 'src/interface/web/' empty_escape_sequences = r'\n|\r\t ' From 62eb66b8cab945c8d8eb8d59f39c3fb2c0588e48 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Wed, 10 Aug 2022 22:28:51 +0300 Subject: [PATCH 24/47] Rename load_config_from_file to more descriptive parse_config_from_file --- src/utils/cli.py | 4 ++-- src/utils/yaml.py | 21 ++++++++++++++++++--- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/utils/cli.py b/src/utils/cli.py index b24cc1bf..a77b35a7 100644 --- a/src/utils/cli.py +++ b/src/utils/cli.py @@ -4,7 +4,7 @@ import pathlib # Internal Packages from src.utils.helpers import resolve_absolute_path -from src.utils.yaml import load_config_from_file +from src.utils.yaml import parse_config_from_file def cli(args=None): @@ -22,6 +22,6 @@ def cli(args=None): if not resolve_absolute_path(args.config_file).exists(): args.config = None else: - args.config = load_config_from_file(args.config_file) + args.config = parse_config_from_file(args.config_file) return args \ No newline at end of file diff --git a/src/utils/yaml.py b/src/utils/yaml.py index 223f99fd..c7a7fd99 100644 --- a/src/utils/yaml.py +++ b/src/utils/yaml.py @@ -5,11 +5,26 @@ import yaml from src.utils.helpers import get_absolute_path from src.utils.rawconfig import FullConfig + +def save_config_to_file(yaml_config, yaml_config_file): + "Write config to YML file" + with open(get_absolute_path(yaml_config_file), 'w', encoding='utf-8') as config_file: + yaml.dump(yaml_config, config_file, allow_unicode=True) + + def load_config_from_file(yaml_config_file): - # Read Config from YML file + "Read config from YML file" config_from_file = None with open(get_absolute_path(yaml_config_file), 'r', encoding='utf-8') as config_file: config_from_file = yaml.safe_load(config_file) + return config_from_file - # Parse, Validate Config in YML file - return FullConfig.parse_obj(config_from_file) + +def parse_config_from_string(yaml_config): + "Parse and validate config in YML string" + return FullConfig.parse_obj(yaml_config) + + +def parse_config_from_file(yaml_config_file): + "Parse and validate config in YML file" + return parse_config_from_string(load_config_from_file(yaml_config_file)) From 9628ca073cf22cafde15cf3e7922c500fafd290e Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Wed, 10 Aug 2022 22:30:13 +0300 Subject: [PATCH 25/47] Extract conversation processor from config into separate function - Only pass processor config arg required by configure_processor. Not the unused full config object - Type arguments passed to methods configure processors - Import json for use by conversation processor to load logs --- src/configure.py | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/configure.py b/src/configure.py index f7cb49e1..bdd5d79c 100644 --- a/src/configure.py +++ b/src/configure.py @@ -1,5 +1,6 @@ # External Packages import torch +import json # Internal Packages from src.processor.ledger.beancount_to_jsonl import beancount_to_jsonl @@ -9,7 +10,7 @@ from src.search_type import image_search, text_search from src.utils.config import SearchType, SearchModels, ProcessorConfigModel, ConversationProcessorConfigModel from src.utils import state from src.utils.helpers import get_absolute_path -from src.utils.rawconfig import FullConfig +from src.utils.rawconfig import FullConfig, ProcessorConfig def configure_server(args): @@ -26,7 +27,7 @@ def configure_server(args): state.model = configure_search(state.model, args.config, args.regenerate, device=state.device, verbose=state.verbose) # Initialize Processor from Config - state.processor_config = configure_processor(args.config, verbose=state.verbose) + state.processor_config = configure_processor(args.config.processor, verbose=state.verbose) return args.host, args.port, args.socket @@ -60,28 +61,34 @@ def configure_search(model: SearchModels, config: FullConfig, regenerate: bool, return model -def configure_processor(config: FullConfig, verbose: int): - if not config.processor: +def configure_processor(processor_config: ProcessorConfig, verbose: int): + if not processor_config: return - processor_config = ProcessorConfigModel() + processor = ProcessorConfigModel() # Initialize Conversation Processor - processor_config.conversation = ConversationProcessorConfigModel(config.processor.conversation, verbose) + processor.conversation = configure_conversation_processor(processor_config.conversation, verbose) - conversation_logfile = processor_config.conversation.conversation_logfile - if processor_config.conversation.verbose: + return processor + + +def configure_conversation_processor(conversation_processor_config, verbose: int): + conversation_processor = ConversationProcessorConfigModel(conversation_processor_config, verbose) + + conversation_logfile = conversation_processor.conversation_logfile + if conversation_processor.verbose: print('INFO:\tLoading conversation logs from disk...') if conversation_logfile.expanduser().absolute().is_file(): # Load Metadata Logs from Conversation Logfile with open(get_absolute_path(conversation_logfile), 'r') as f: - processor_config.conversation.meta_log = json.load(f) + conversation_processor.meta_log = json.load(f) print('INFO:\tConversation logs loaded from disk.') else: # Initialize Conversation Logs - processor_config.conversation.meta_log = {} - processor_config.conversation.chat_session = "" + conversation_processor.meta_log = {} + conversation_processor.chat_session = "" - return processor_config \ No newline at end of file + return conversation_processor \ No newline at end of file From 82a7059b6a3bd73d7fd79911ede80571ce84e3a9 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Wed, 10 Aug 2022 22:34:03 +0300 Subject: [PATCH 26/47] Only setup conversation processor if it has configuration set --- src/configure.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/configure.py b/src/configure.py index bdd5d79c..34051f8b 100644 --- a/src/configure.py +++ b/src/configure.py @@ -68,7 +68,8 @@ def configure_processor(processor_config: ProcessorConfig, verbose: int): processor = ProcessorConfigModel() # Initialize Conversation Processor - processor.conversation = configure_conversation_processor(processor_config.conversation, verbose) + if processor_config.conversation: + processor.conversation = configure_conversation_processor(processor_config.conversation, verbose) return processor From f63f11186fdd68ce5bc1f325a1dd636dcd1d1ec8 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Wed, 10 Aug 2022 22:42:32 +0300 Subject: [PATCH 27/47] Pass config file for app to configure screen --- src/interface/desktop/configure_screen.py | 3 ++- src/main.py | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index e21b761a..2000dff5 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -15,8 +15,9 @@ class ConfigureScreen(QtWidgets.QDialog): 3. Save the configuration to khoj.yml and start the server """ - def __init__(self, parent=None): + def __init__(self, config_file, parent=None): super(ConfigureScreen, self).__init__(parent=parent) + self.config_file = config_file # Initialize Configure Window self.setWindowFlags(Qt.WindowType.WindowStaysOnTopHint) diff --git a/src/main.py b/src/main.py index b35ddbbe..99fd3036 100644 --- a/src/main.py +++ b/src/main.py @@ -24,16 +24,16 @@ app.include_router(router) def run(): + # Load config from CLI + args = cli(sys.argv[1:]) + # Setup Base GUI gui = QtWidgets.QApplication([]) gui.setQuitOnLastWindowClosed(False) - configure_screen = ConfigureScreen() + configure_screen = ConfigureScreen(args.config_file) tray = create_system_tray(gui, configure_screen) tray.show() - # Load config from CLI - args = cli(sys.argv[1:]) - # Trigger First Run Experience, if required if args.config is None: configure_screen.show() From f42f54019bfcaf542d17b805a27c10c274427b9f Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Wed, 10 Aug 2022 22:43:20 +0300 Subject: [PATCH 28/47] Type parent_layout passed as arguments to ConfigureScreen methods --- src/interface/desktop/configure_screen.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index 2000dff5..3472938e 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -33,7 +33,7 @@ class ConfigureScreen(QtWidgets.QDialog): self.settings_panels += [self.add_settings_panel(search_type, layout)] self.add_action_panel(layout) - def add_settings_panel(self, search_type: SearchType, parent_layout): + def add_settings_panel(self, search_type: SearchType, parent_layout: QtWidgets.QLayout): "Add Settings Panel for specified Search Type. Toggle Editable Search Types" search_type_settings = QtWidgets.QWidget() search_type_layout = QtWidgets.QVBoxLayout(search_type_settings) @@ -50,7 +50,7 @@ class ConfigureScreen(QtWidgets.QDialog): parent_layout.addWidget(search_type_settings) return search_type_settings - def add_action_panel(self, parent_layout): + def add_action_panel(self, parent_layout: QtWidgets.QLayout): "Add Action Panel" # Button to Save Settings action_bar = QtWidgets.QWidget() From dae65c5b6ba003fa663f7b612cc60f7e6fc9297b Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Wed, 10 Aug 2022 22:44:37 +0300 Subject: [PATCH 29/47] Create child class of Qt CheckBox to track search type it enables/disables --- src/interface/desktop/configure_screen.py | 8 +++++++- src/interface/desktop/file_browser.py | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index 3472938e..75286408 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -38,7 +38,7 @@ class ConfigureScreen(QtWidgets.QDialog): search_type_settings = QtWidgets.QWidget() search_type_layout = QtWidgets.QVBoxLayout(search_type_settings) - enable_search_type = QtWidgets.QCheckBox(f"Search {search_type.name}") + enable_search_type = CheckBox(f"Search {search_type.name}", search_type) input_files = FileBrowser(f'{search_type.name} Files', search_type) input_files.setEnabled(enable_search_type.isChecked()) @@ -72,3 +72,9 @@ class ConfigureScreen(QtWidgets.QDialog): print(f"{child.text()} is disabled") elif isinstance(child, FileBrowser): print(f"{child.search_type} files are {child.getPaths()}") + + +class CheckBox(QtWidgets.QCheckBox): + def __init__(self, text, search_type: SearchType, parent=None): + self.search_type = search_type + super(CheckBox, self).__init__(text, parent=parent) \ No newline at end of file diff --git a/src/interface/desktop/file_browser.py b/src/interface/desktop/file_browser.py index a924a771..df6e60a8 100644 --- a/src/interface/desktop/file_browser.py +++ b/src/interface/desktop/file_browser.py @@ -5,6 +5,7 @@ from PyQt6.QtCore import QDir # Internal Packages from src.utils.config import SearchType + class FileBrowser(QtWidgets.QWidget): def __init__(self, title, search_type: SearchType=None): QtWidgets.QWidget.__init__(self) From cc6ef0f450e3cb4b5e1c948aba7c590274c16954 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Wed, 10 Aug 2022 23:10:39 +0300 Subject: [PATCH 30/47] Save configure screen settings to app config yaml on clicking Start --- src/interface/desktop/configure_screen.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index 75286408..f98a9d3f 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -3,6 +3,7 @@ from PyQt6 import QtWidgets from PyQt6.QtCore import Qt # Internal Packages +from src.utils import constants, yaml as yaml_utils from src.utils.config import SearchType from src.interface.desktop.file_browser import FileBrowser @@ -62,17 +63,25 @@ class ConfigureScreen(QtWidgets.QDialog): parent_layout.addWidget(action_bar) def save_settings(self, _): - # Save the settings to khoj.yml + "Save the settings to khoj.yml" + # Load the default config + config = yaml_utils.load_config_from_file(constants.app_root_directory / 'config/khoj_sample.yml') + + # Update the default config with the settings from the UI for settings_panel in self.settings_panels: for child in settings_panel.children(): - if isinstance(child, QtWidgets.QCheckBox): - if child.isChecked(): - print(f"{child.text()} is enabled") - else: - print(f"{child.text()} is disabled") - elif isinstance(child, FileBrowser): + if isinstance(child, CheckBox) and not child.isChecked(): + del config['content-type'][child.search_type] + elif isinstance(child, FileBrowser) and child.search_type in config['content-type']: + config['content-type'][child.search_type]['input-files'] = child.getPaths() print(f"{child.search_type} files are {child.getPaths()}") + # Save the config to app config file + del config['processor']['conversation'] + yaml_utils.save_config_to_file(config, self.config_file) + + self.hide() + class CheckBox(QtWidgets.QCheckBox): def __init__(self, text, search_type: SearchType, parent=None): From 34018c7d4b85e1aae523252133a705ff8a683cbc Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Thu, 11 Aug 2022 00:10:21 +0300 Subject: [PATCH 31/47] Store args passed from commandline at app start in global app state --- src/main.py | 5 +++-- src/utils/state.py | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main.py b/src/main.py index 99fd3036..4e75537d 100644 --- a/src/main.py +++ b/src/main.py @@ -11,7 +11,7 @@ from PyQt6.QtCore import QThread # Internal Packages from src.configure import configure_server from src.router import router -from src.utils import constants +from src.utils import constants, state from src.utils.cli import cli from src.interface.desktop.configure_screen import ConfigureScreen from src.interface.desktop.system_tray import create_system_tray @@ -25,7 +25,8 @@ app.include_router(router) def run(): # Load config from CLI - args = cli(sys.argv[1:]) + state.cli_args = sys.argv[1:] + args = cli(state.cli_args) # Setup Base GUI gui = QtWidgets.QApplication([]) diff --git a/src/utils/state.py b/src/utils/state.py index 964fa458..90f9296a 100644 --- a/src/utils/state.py +++ b/src/utils/state.py @@ -12,4 +12,5 @@ model = SearchModels() processor_config = ProcessorConfigModel() config_file: Path = "" verbose: int = 0 -device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu") # Set device to GPU if available \ No newline at end of file +device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu") # Set device to GPU if available +cli_args = None \ No newline at end of file From f7fdf8d8ce2092bbbf45229e7b3f4f13f2ee6bf7 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Thu, 11 Aug 2022 00:13:14 +0300 Subject: [PATCH 32/47] Refactor app start to start server even if backend not configured - Decouple configuring backend from starting server. Backend search and processors can be configured after the backend server has started - Set global state in main instead of in configure_server method. This allows the app to start even if configure_server exits early in the first run scenario, where no config available to configure server - Now start server, even if no config, before GUI started in main - This refactor of app startup flow will allow users to configure backend using the configure screen after server start --- src/configure.py | 25 +++++++++++++------------ src/main.py | 31 ++++++++++++++----------------- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/src/configure.py b/src/configure.py index 34051f8b..2981dc9d 100644 --- a/src/configure.py +++ b/src/configure.py @@ -1,3 +1,6 @@ +# System Packages +import sys + # External Packages import torch import json @@ -13,24 +16,22 @@ from src.utils.helpers import get_absolute_path from src.utils.rawconfig import FullConfig, ProcessorConfig -def configure_server(args): - # Stores the file path to the config file. - state.config_file = args.config_file - - # Store the raw config data. - state.config = args.config - - # Store the verbose flag - state.verbose = args.verbose +def configure_server(args, required=False): + if args.config is None: + if required: + print('Exiting as Khoj is not configured. Configure the application to use it.') + sys.exit(1) + else: + return + else: + state.config = args.config # Initialize the search model from Config - state.model = configure_search(state.model, args.config, args.regenerate, device=state.device, verbose=state.verbose) + state.model = configure_search(state.model, state.config, args.regenerate, device=state.device, verbose=state.verbose) # Initialize Processor from Config state.processor_config = configure_processor(args.config.processor, verbose=state.verbose) - return args.host, args.port, args.socket - def configure_search(model: SearchModels, config: FullConfig, regenerate: bool, t: SearchType = None, device=torch.device("cpu"), verbose: int = 0): # Initialize Org Notes Search diff --git a/src/main.py b/src/main.py index 4e75537d..ecef329e 100644 --- a/src/main.py +++ b/src/main.py @@ -27,38 +27,35 @@ def run(): # Load config from CLI state.cli_args = sys.argv[1:] args = cli(state.cli_args) + set_state(args) - # Setup Base GUI + # Setup GUI gui = QtWidgets.QApplication([]) gui.setQuitOnLastWindowClosed(False) configure_screen = ConfigureScreen(args.config_file) tray = create_system_tray(gui, configure_screen) tray.show() + # Setup Server + configure_server(args, required=False) + server = ServerThread(app, args.host, args.port, args.socket) + # Trigger First Run Experience, if required if args.config is None: - configure_screen.show() - gui.exec() + configure_screen.show() - # Reload config after first run - args = cli(sys.argv[1:]) - # Quit if app still not configured - if args.config is None: - print('Exiting as Khoj is not configured. Configure the application to use it.') - sys.exit(1) - - # Setup Application Server - host, port, socket = configure_server(args) - - # Start Application Server - server = ServerThread(app, host, port, socket) + # Start Application server.start() gui.aboutToQuit.connect(server.terminate) - - # Start the GUI gui.exec() +def set_state(args): + state.config_file = args.config_file + state.config = args.config + state.verbose = args.verbose + + class ServerThread(QThread): def __init__(self, app, host=None, port=None, socket=None): super(ServerThread, self).__init__() From 3cec6229ad73e0fc66f1ff6156fe007b819a2ba8 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Thu, 11 Aug 2022 00:32:11 +0300 Subject: [PATCH 33/47] Hot swap backend config via config screen start button click - Update configuration to use by the backend, while app is running - Trigger after user hits start button with their config. The config gets written to khoj.yml file first, then the updated config is loaded onto memory --- src/interface/desktop/configure_screen.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index f98a9d3f..fc8f137b 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -3,7 +3,9 @@ from PyQt6 import QtWidgets from PyQt6.QtCore import Qt # Internal Packages -from src.utils import constants, yaml as yaml_utils +from src.configure import configure_server +from src.utils import constants, state, yaml as yaml_utils +from src.utils.cli import cli from src.utils.config import SearchType from src.interface.desktop.file_browser import FileBrowser @@ -80,6 +82,12 @@ class ConfigureScreen(QtWidgets.QDialog): del config['processor']['conversation'] yaml_utils.save_config_to_file(config, self.config_file) + # Load config from app config file + args = cli(state.cli_args) + + # Configure server with loaded config + configure_server(args, required=True) + self.hide() From c1fcf444059ee119c55abe8839f20a7856782487 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Thu, 11 Aug 2022 04:20:49 +0300 Subject: [PATCH 34/47] Initialize Settings on Config Screen with Existing Settings from File --- src/interface/desktop/configure_screen.py | 49 +++++++++++++++-------- src/interface/desktop/file_browser.py | 16 +++++--- 2 files changed, 42 insertions(+), 23 deletions(-) diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index fc8f137b..c32c53c6 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -22,6 +22,11 @@ class ConfigureScreen(QtWidgets.QDialog): super(ConfigureScreen, self).__init__(parent=parent) self.config_file = config_file + # Load config from existing config, if exists, else load from default config + self.config = yaml_utils.load_config_from_file(self.config_file) + if self.config is None: + self.config = yaml_utils.load_config_from_file(constants.app_root_directory / 'config/khoj_sample.yml') + # Initialize Configure Window self.setWindowFlags(Qt.WindowType.WindowStaysOnTopHint) self.setWindowTitle("Khoj - Configure") @@ -33,24 +38,35 @@ class ConfigureScreen(QtWidgets.QDialog): # Add Settings Panels for each Search Type to Configure Window Layout self.settings_panels = [] for search_type in SearchType: - self.settings_panels += [self.add_settings_panel(search_type, layout)] + current_content_config = self.config['content-type'].get(search_type, {}) + self.settings_panels += [self.add_settings_panel(search_type, current_content_config, layout)] self.add_action_panel(layout) - def add_settings_panel(self, search_type: SearchType, parent_layout: QtWidgets.QLayout): + def add_settings_panel(self, search_type: SearchType, current_content_config: dict, parent_layout: QtWidgets.QLayout): "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', []) + else: + current_content_files = current_content_config.get('input-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 = CheckBox(f"Search {search_type.name}", search_type) - input_files = FileBrowser(f'{search_type.name} Files', search_type) - input_files.setEnabled(enable_search_type.isChecked()) + # Add file browser to set input files for given search type + input_files = FileBrowser(f'{search_type.name} Files', search_type, current_content_files) + # Set enabled/disabled based on checkbox state + enable_search_type.setChecked(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())) + # Add setting widgets for given search type to panel search_type_layout.addWidget(enable_search_type) search_type_layout.addWidget(input_files) - parent_layout.addWidget(search_type_settings) + return search_type_settings def add_action_panel(self, parent_layout: QtWidgets.QLayout): @@ -66,23 +82,22 @@ class ConfigureScreen(QtWidgets.QDialog): def save_settings(self, _): "Save the settings to khoj.yml" - # Load the default config - config = yaml_utils.load_config_from_file(constants.app_root_directory / 'config/khoj_sample.yml') - - # Update the default config with the settings from the UI + # Update config with settings from UI for settings_panel in self.settings_panels: for child in settings_panel.children(): + if isinstance(child, (CheckBox, FileBrowser)) and child.search_type not in self.config['content-type']: + continue if isinstance(child, CheckBox) and not child.isChecked(): - del config['content-type'][child.search_type] - elif isinstance(child, FileBrowser) and child.search_type in config['content-type']: - config['content-type'][child.search_type]['input-files'] = child.getPaths() + del self.config['content-type'][child.search_type] + elif isinstance(child, FileBrowser): + self.config['content-type'][child.search_type]['input-files'] = child.getPaths() print(f"{child.search_type} files are {child.getPaths()}") # Save the config to app config file - del config['processor']['conversation'] - yaml_utils.save_config_to_file(config, self.config_file) + del self.config['processor']['conversation'] + yaml_utils.save_config_to_file(self.config, self.config_file) - # Load config from app config file + # Load parsed, validated config from app config file args = cli(state.cli_args) # Configure server with loaded config @@ -94,4 +109,4 @@ class ConfigureScreen(QtWidgets.QDialog): class CheckBox(QtWidgets.QCheckBox): def __init__(self, text, search_type: SearchType, parent=None): self.search_type = search_type - super(CheckBox, self).__init__(text, parent=parent) \ No newline at end of file + super(CheckBox, self).__init__(text, parent=parent) diff --git a/src/interface/desktop/file_browser.py b/src/interface/desktop/file_browser.py index df6e60a8..8ed69056 100644 --- a/src/interface/desktop/file_browser.py +++ b/src/interface/desktop/file_browser.py @@ -7,7 +7,7 @@ from src.utils.config import SearchType class FileBrowser(QtWidgets.QWidget): - def __init__(self, title, search_type: SearchType=None): + def __init__(self, title, search_type: SearchType=None, default_files=[]): QtWidgets.QWidget.__init__(self) layout = QtWidgets.QHBoxLayout() self.setLayout(layout) @@ -15,8 +15,7 @@ class FileBrowser(QtWidgets.QWidget): self.filter_name = self.getFileFilter(search_type) self.dirpath = QDir.homePath() - self.filepaths = [] - + self.label = QtWidgets.QLabel() self.label.setText(title) self.label.setFixedWidth(95) @@ -24,6 +23,7 @@ class FileBrowser(QtWidgets.QWidget): self.lineEdit = QtWidgets.QLineEdit(self) self.lineEdit.setFixedWidth(180) + self.setFiles(default_files) layout.addWidget(self.lineEdit) @@ -51,14 +51,18 @@ class FileBrowser(QtWidgets.QWidget): self.dirpath = path def getFile(self): - self.filepaths = [] + filepaths = [] if self.search_type == SearchType.Image: - self.filepaths.append(QtWidgets.QFileDialog.getExistingDirectory(self, caption='Choose Directory', + filepaths.append(QtWidgets.QFileDialog.getExistingDirectory(self, caption='Choose Directory', directory=self.dirpath)) else: - self.filepaths.extend(QtWidgets.QFileDialog.getOpenFileNames(self, caption='Choose Files', + filepaths.extend(QtWidgets.QFileDialog.getOpenFileNames(self, caption='Choose Files', directory=self.dirpath, filter=self.filter_name)[0]) + self.setFiles(filepaths) + + def setFiles(self, paths): + self.filepaths = paths if len(self.filepaths) == 0: return elif len(self.filepaths) == 1: From 678fb6a3c742f03d8a1e3c1fe464b7c88522e8e6 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Thu, 11 Aug 2022 04:42:58 +0300 Subject: [PATCH 35/47] Add Settings Panel for Conversation Settings to Config Screen --- src/interface/desktop/configure_screen.py | 31 ++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index c32c53c6..894a6f5c 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -40,6 +40,7 @@ class ConfigureScreen(QtWidgets.QDialog): for search_type in SearchType: current_content_config = self.config['content-type'].get(search_type, {}) self.settings_panels += [self.add_settings_panel(search_type, current_content_config, layout)] + self.add_conversation_processor_panel(layout) self.add_action_panel(layout) def add_settings_panel(self, search_type: SearchType, current_content_config: dict, parent_layout: QtWidgets.QLayout): @@ -69,6 +70,35 @@ class ConfigureScreen(QtWidgets.QDialog): return search_type_settings + def add_conversation_processor_panel(self, parent_layout: QtWidgets.QLayout): + "Add Conversation Processor Panel" + processor_type_settings = QtWidgets.QWidget() + processor_type_layout = QtWidgets.QVBoxLayout(processor_type_settings) + + enable_conversation = QtWidgets.QCheckBox(f"Conversation") + + 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 = QtWidgets.QLineEdit() + 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) + + processor_type_layout.addWidget(enable_conversation) + processor_type_layout.addWidget(conversation_settings) + processor_type_layout.addStretch() + + parent_layout.addWidget(processor_type_settings) + return processor_type_settings + def add_action_panel(self, parent_layout: QtWidgets.QLayout): "Add Action Panel" # Button to Save Settings @@ -94,7 +124,6 @@ class ConfigureScreen(QtWidgets.QDialog): print(f"{child.search_type} files are {child.getPaths()}") # Save the config to app config file - del self.config['processor']['conversation'] yaml_utils.save_config_to_file(self.config, self.config_file) # Load parsed, validated config from app config file From 23e06f483d92e8219d76b76eeacad0066d167c7d Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Thu, 11 Aug 2022 19:08:36 +0300 Subject: [PATCH 36/47] Do not emit type tags when dumping config YAML to file --- src/utils/yaml.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/utils/yaml.py b/src/utils/yaml.py index c7a7fd99..588acbda 100644 --- a/src/utils/yaml.py +++ b/src/utils/yaml.py @@ -1,3 +1,6 @@ +# Standard Packages +from pathlib import Path + # External Packages import yaml @@ -5,14 +8,16 @@ import yaml from src.utils.helpers import get_absolute_path from src.utils.rawconfig import FullConfig +# Do not emit tags when dumping to YAML +yaml.emitter.Emitter.process_tag = lambda self, *args, **kwargs: None -def save_config_to_file(yaml_config, yaml_config_file): +def save_config_to_file(yaml_config: dict, yaml_config_file: Path): "Write config to YML file" with open(get_absolute_path(yaml_config_file), 'w', encoding='utf-8') as config_file: - yaml.dump(yaml_config, config_file, allow_unicode=True) + yaml.safe_dump(yaml_config, config_file, allow_unicode=True) -def load_config_from_file(yaml_config_file): +def load_config_from_file(yaml_config_file: Path) -> dict: "Read config from YML file" config_from_file = None with open(get_absolute_path(yaml_config_file), 'r', encoding='utf-8') as config_file: @@ -20,7 +25,7 @@ def load_config_from_file(yaml_config_file): return config_from_file -def parse_config_from_string(yaml_config): +def parse_config_from_string(yaml_config: dict) -> FullConfig: "Parse and validate config in YML string" return FullConfig.parse_obj(yaml_config) From 1ff049599f9b2f8f8d0d16c8667ed24a329f1f51 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Thu, 11 Aug 2022 19:11:25 +0300 Subject: [PATCH 37/47] Show current config on config screen. Load default config if config unset - Track current (saved/loaded) config separate from the new config (to be written) when user clicks Start - Fallback to using default config when no config for the specific content type or processor is specified in khoj.yml - Earlier were only loading default config on first run, not after - Create Child CheckBox, LineEdit classes for Processor Widgets - Create ProcessorType, similar to SearchType - Track ProcessorType the widgets are associated with - Simplify update, save, load of config based on type --- src/interface/desktop/configure_screen.py | 122 +++++++++++++++++----- src/interface/desktop/file_browser.py | 2 +- src/utils/config.py | 4 + 3 files changed, 98 insertions(+), 30 deletions(-) diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index 894a6f5c..35c231fb 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -1,13 +1,17 @@ +# Standard Packages +from pathlib import Path + # External Packages from PyQt6 import QtWidgets from PyQt6.QtCore import Qt # Internal Packages from src.configure import configure_server +from src.interface.desktop.file_browser import FileBrowser from src.utils import constants, state, yaml as yaml_utils from src.utils.cli import cli -from src.utils.config import SearchType -from src.interface.desktop.file_browser import FileBrowser +from src.utils.config import SearchType, ProcessorType +from src.utils.helpers import merge_dicts class ConfigureScreen(QtWidgets.QDialog): @@ -18,14 +22,15 @@ class ConfigureScreen(QtWidgets.QDialog): 3. Save the configuration to khoj.yml and start the server """ - def __init__(self, config_file, parent=None): + def __init__(self, config_file: Path, parent=None): super(ConfigureScreen, self).__init__(parent=parent) self.config_file = config_file # Load config from existing config, if exists, else load from default config - self.config = yaml_utils.load_config_from_file(self.config_file) - if self.config is None: - self.config = yaml_utils.load_config_from_file(constants.app_root_directory / 'config/khoj_sample.yml') + self.current_config = yaml_utils.load_config_from_file(self.config_file) + if self.current_config is None: + self.current_config = yaml_utils.load_config_from_file(constants.app_root_directory / 'config/khoj_sample.yml') + self.new_config = self.current_config # Initialize Configure Window self.setWindowFlags(Qt.WindowType.WindowStaysOnTopHint) @@ -36,14 +41,20 @@ class ConfigureScreen(QtWidgets.QDialog): self.setLayout(layout) # Add Settings Panels for each Search Type to Configure Window Layout - self.settings_panels = [] + self.search_settings_panels = [] for search_type in SearchType: - current_content_config = self.config['content-type'].get(search_type, {}) - self.settings_panels += [self.add_settings_panel(search_type, current_content_config, layout)] - self.add_conversation_processor_panel(layout) + current_content_config = self.current_config['content-type'].get(search_type, {}) + self.search_settings_panels += [self.add_settings_panel(current_content_config, search_type, layout)] + + # Add Conversation Processor Panel to Configure Screen + self.processor_settings_panels = [] + conversation_type = ProcessorType.Conversation + current_conversation_config = self.current_config['processor'].get(conversation_type, {}) + self.processor_settings_panels += [self.add_processor_panel(current_conversation_config, conversation_type, layout)] + self.add_action_panel(layout) - def add_settings_panel(self, search_type: SearchType, current_content_config: dict, parent_layout: QtWidgets.QLayout): + def add_settings_panel(self, current_content_config: dict, search_type: SearchType, parent_layout: QtWidgets.QLayout): "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: @@ -54,12 +65,12 @@ class ConfigureScreen(QtWidgets.QDialog): # 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 = CheckBox(f"Search {search_type.name}", search_type) + 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(f'{search_type.name} Files', search_type, current_content_files) # Set enabled/disabled based on checkbox state - enable_search_type.setChecked(len(current_content_files) > 0) + 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())) @@ -70,12 +81,14 @@ class ConfigureScreen(QtWidgets.QDialog): return search_type_settings - def add_conversation_processor_panel(self, parent_layout: QtWidgets.QLayout): + def add_processor_panel(self, current_conversation_config: dict, processor_type: ProcessorType, parent_layout: QtWidgets.QLayout): "Add Conversation Processor Panel" + current_openai_api_key = current_conversation_config.get('openai-api-key', None) processor_type_settings = QtWidgets.QWidget() processor_type_layout = QtWidgets.QVBoxLayout(processor_type_settings) - enable_conversation = QtWidgets.QCheckBox(f"Conversation") + enable_conversation = ProcessorCheckBox(f"Conversation", processor_type) + enable_conversation.setChecked(current_openai_api_key is not None) conversation_settings = QtWidgets.QWidget() conversation_settings_layout = QtWidgets.QHBoxLayout(conversation_settings) @@ -83,10 +96,10 @@ class ConfigureScreen(QtWidgets.QDialog): input_label.setText("OpenAI API Key") input_label.setFixedWidth(95) - input_field = QtWidgets.QLineEdit() + input_field = ProcessorLineEdit(current_openai_api_key, processor_type) input_field.setFixedWidth(245) - input_field.setEnabled(enable_conversation.isChecked()) + input_field.setEnabled(enable_conversation.isChecked()) enable_conversation.stateChanged.connect(lambda _: input_field.setEnabled(enable_conversation.isChecked())) conversation_settings_layout.addWidget(input_label) @@ -94,7 +107,6 @@ class ConfigureScreen(QtWidgets.QDialog): processor_type_layout.addWidget(enable_conversation) processor_type_layout.addWidget(conversation_settings) - processor_type_layout.addStretch() parent_layout.addWidget(processor_type_settings) return processor_type_settings @@ -110,24 +122,62 @@ class ConfigureScreen(QtWidgets.QDialog): action_bar_layout.addWidget(save_button) parent_layout.addWidget(action_bar) + def get_default_config(self, search_type:SearchType=None, processor_type:ProcessorType=None): + "Get default config" + config = yaml_utils.load_config_from_file(constants.app_root_directory / 'config/khoj_sample.yml') + if search_type: + return config['content-type'][search_type] + elif processor_type: + return config['processor'][processor_type] + else: + return config + def save_settings(self, _): "Save the settings to khoj.yml" - # Update config with settings from UI - for settings_panel in self.settings_panels: + # Update config with search settings from UI + for settings_panel in self.search_settings_panels: for child in settings_panel.children(): - if isinstance(child, (CheckBox, FileBrowser)) and child.search_type not in self.config['content-type']: + if not isinstance(child, (SearchCheckBox, FileBrowser)): continue - if isinstance(child, CheckBox) and not child.isChecked(): - del self.config['content-type'][child.search_type] - elif isinstance(child, FileBrowser): - self.config['content-type'][child.search_type]['input-files'] = child.getPaths() - print(f"{child.search_type} files are {child.getPaths()}") + 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, {}) + 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']: + self.new_config['content-type'][child.search_type.value]['input-files'] = child.getPaths() + + # 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): + 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, ProcessorLineEdit) 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 + # Save the config to app config file - yaml_utils.save_config_to_file(self.config, self.config_file) + yaml_utils.save_config_to_file(self.new_config, self.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) @@ -135,7 +185,21 @@ class ConfigureScreen(QtWidgets.QDialog): self.hide() -class CheckBox(QtWidgets.QCheckBox): +class SearchCheckBox(QtWidgets.QCheckBox): def __init__(self, text, search_type: SearchType, parent=None): self.search_type = search_type - super(CheckBox, self).__init__(text, parent=parent) + 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 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/file_browser.py b/src/interface/desktop/file_browser.py index 8ed69056..66315e68 100644 --- a/src/interface/desktop/file_browser.py +++ b/src/interface/desktop/file_browser.py @@ -63,7 +63,7 @@ class FileBrowser(QtWidgets.QWidget): def setFiles(self, paths): self.filepaths = paths - if len(self.filepaths) == 0: + if not self.filepaths or len(self.filepaths) == 0: return elif len(self.filepaths) == 1: self.lineEdit.setText(self.filepaths[0]) diff --git a/src/utils/config.py b/src/utils/config.py index 74745660..6af3a510 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -15,6 +15,10 @@ class SearchType(str, Enum): Image = "image" +class ProcessorType(str, Enum): + Conversation = "conversation" + + class TextSearchModel(): def __init__(self, entries, corpus_embeddings, bi_encoder, cross_encoder, top_k, verbose): self.entries = entries From c1e1466fb1392d3e644a5f47ed8dfd0df6aefba1 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Thu, 11 Aug 2022 19:18:22 +0300 Subject: [PATCH 38/47] Validate new config before write. Show error if new config invalid --- src/interface/desktop/configure_screen.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index 35c231fb..1b45508b 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -132,6 +132,13 @@ class ConfigureScreen(QtWidgets.QDialog): else: return config + def add_error_message(self, message: str, parent_layout: QtWidgets.QLayout): + "Add Error Message to Configure Screen" + error_message = QtWidgets.QLabel() + error_message.setText(message) + error_message.setStyleSheet("color: red") + parent_layout.addWidget(error_message) + def save_settings(self, _): "Save the settings to khoj.yml" # Update config with search settings from UI @@ -171,6 +178,20 @@ class ConfigureScreen(QtWidgets.QDialog): if child.processor_type == ProcessorType.Conversation: self.new_config['processor'][child.processor_type.value]['openai-api-key'] = child.text() if child.text() != '' else None + # 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"Error validating config: {e}", self.layout()) + return + else: + # Remove error message if present + for i in range(self.layout().count()): + current_widget = self.layout().itemAt(i).widget() + if isinstance(current_widget, QtWidgets.QLabel) and current_widget.text().startswith("Error validating config:"): + self.layout().removeWidget(current_widget) + current_widget.deleteLater() # Save the config to app config file yaml_utils.save_config_to_file(self.new_config, self.config_file) From fd4e41495c6635d24a66069cafb62836f68c149a Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Thu, 11 Aug 2022 19:45:19 +0300 Subject: [PATCH 39/47] Use appropriate label for directory input types to minimize confusion --- 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 1b45508b..87ca2a44 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -59,15 +59,17 @@ class ConfigureScreen(QtWidgets.QDialog): # 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' 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(f'{search_type.name} Files', search_type, current_content_files) + input_files = FileBrowser(file_input_text, search_type, current_content_files) # Set enabled/disabled based on checkbox state enable_search_type.setChecked(current_content_files is not None and len(current_content_files) > 0) From 56ba91fec8562739e221f31e8f7132f4b5b65dbb Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Thu, 11 Aug 2022 19:46:09 +0300 Subject: [PATCH 40/47] Remove unused methods in file browser widget. Improve name of existing --- src/interface/desktop/file_browser.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/src/interface/desktop/file_browser.py b/src/interface/desktop/file_browser.py index 66315e68..bc87235e 100644 --- a/src/interface/desktop/file_browser.py +++ b/src/interface/desktop/file_browser.py @@ -28,13 +28,10 @@ class FileBrowser(QtWidgets.QWidget): layout.addWidget(self.lineEdit) self.button = QtWidgets.QPushButton('Add') - self.button.clicked.connect(self.getFile) + self.button.clicked.connect(self.storeFilesSelectedInFileDialog) layout.addWidget(self.button) layout.addStretch() - def setMode(self, search_type): - self.search_type = search_type - def getFileFilter(self, search_type): if search_type == SearchType.Org: return 'Org-Mode Files (*.org)' @@ -47,13 +44,10 @@ class FileBrowser(QtWidgets.QWidget): elif search_type == SearchType.Image: return 'Images (*.jp[e]g)' - def setDefaultDir(self, path): - self.dirpath = path - - def getFile(self): + def storeFilesSelectedInFileDialog(self): filepaths = [] if self.search_type == SearchType.Image: - filepaths.append(QtWidgets.QFileDialog.getExistingDirectory(self, caption='Choose Directory', + filepaths.append(QtWidgets.QFileDialog.getExistingDirectory(self, caption='Choose Folder', directory=self.dirpath)) else: filepaths.extend(QtWidgets.QFileDialog.getOpenFileNames(self, caption='Choose Files', @@ -70,11 +64,5 @@ class FileBrowser(QtWidgets.QWidget): else: self.lineEdit.setText(",".join(self.filepaths)) - def setLabelWidth(self, width): - self.label.setFixedWidth(width) - - def setlineEditWidth(self, width): - self.lineEdit.setFixedWidth(width) - def getPaths(self): return self.filepaths From dad9133598effd625a81084d10620c2accdf290e Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Thu, 11 Aug 2022 20:00:52 +0300 Subject: [PATCH 41/47] Split save_settings method into smaller methods for modularization --- src/interface/desktop/configure_screen.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index 87ca2a44..18069980 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -141,9 +141,8 @@ class ConfigureScreen(QtWidgets.QDialog): error_message.setStyleSheet("color: red") parent_layout.addWidget(error_message) - def save_settings(self, _): - "Save the settings to khoj.yml" - # Update config with search settings from UI + 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)): @@ -160,7 +159,8 @@ class ConfigureScreen(QtWidgets.QDialog): elif isinstance(child, FileBrowser) and child.search_type in self.new_config['content-type']: self.new_config['content-type'][child.search_type.value]['input-files'] = child.getPaths() - # Update config with conversation settings from UI + 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 isinstance(child, QtWidgets.QWidget) and child.findChild(ProcessorLineEdit): @@ -180,13 +180,14 @@ class ConfigureScreen(QtWidgets.QDialog): if child.processor_type == ProcessorType.Conversation: self.new_config['processor'][child.processor_type.value]['openai-api-key'] = child.text() if child.text() != '' else None + def save_settings_to_file(self) -> bool: # 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"Error validating config: {e}", self.layout()) - return + return False else: # Remove error message if present for i in range(self.layout().count()): @@ -197,7 +198,10 @@ class ConfigureScreen(QtWidgets.QDialog): # Save the config to app config file yaml_utils.save_config_to_file(self.new_config, self.config_file) + return True + def load_updated_settings(self): + "Hot swap to using the updated config from file" # Load parsed, validated config from app config file args = cli(state.cli_args) self.current_config = self.new_config @@ -205,7 +209,13 @@ class ConfigureScreen(QtWidgets.QDialog): # Configure server with loaded config configure_server(args, required=True) - self.hide() + def save_settings(self): + "Save the settings to khoj.yml" + self.update_search_settings() + self.update_processor_settings() + if self.save_settings_to_file(): + self.load_updated_settings() + self.hide() class SearchCheckBox(QtWidgets.QCheckBox): From 2646fa825b909e661c8a0216d4b47ad3d3b4999f Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Thu, 11 Aug 2022 20:48:45 +0300 Subject: [PATCH 42/47] Get Files from File input line to match user expectation - If a user manually edits the input file lines, clicking start should use that. Currently it just looks at the files selected last via file browser - We want to allow users to manually enter file paths in field. Which is why the field hasn't been set to read-only --- src/interface/desktop/configure_screen.py | 2 +- src/interface/desktop/file_browser.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index 18069980..e0b7e747 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -157,7 +157,7 @@ class ConfigureScreen(QtWidgets.QDialog): 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']: - self.new_config['content-type'][child.search_type.value]['input-files'] = child.getPaths() + self.new_config['content-type'][child.search_type.value]['input-files'] = child.getPaths() if child.getPaths() != [] else None def update_processor_settings(self): "Update config with conversation settings from UI" diff --git a/src/interface/desktop/file_browser.py b/src/interface/desktop/file_browser.py index bc87235e..901ad94e 100644 --- a/src/interface/desktop/file_browser.py +++ b/src/interface/desktop/file_browser.py @@ -65,4 +65,7 @@ class FileBrowser(QtWidgets.QWidget): self.lineEdit.setText(",".join(self.filepaths)) def getPaths(self): - return self.filepaths + if self.lineEdit.text() == '': + return [] + else: + return self.lineEdit.text().split(',') From b74ca1def65bbcd5de7ce025a7b21fa748f787a8 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Thu, 11 Aug 2022 20:51:56 +0300 Subject: [PATCH 43/47] Wrap error message instead of expanding screen to show message --- src/interface/desktop/configure_screen.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index e0b7e747..fe8bf313 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -137,6 +137,7 @@ class ConfigureScreen(QtWidgets.QDialog): def add_error_message(self, message: str, parent_layout: QtWidgets.QLayout): "Add Error Message to Configure Screen" error_message = QtWidgets.QLabel() + error_message.setWordWrap(True) error_message.setText(message) error_message.setStyleSheet("color: red") parent_layout.addWidget(error_message) From 6af2d6bb6def6e12defdf64f845eb01bc79eba30 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Thu, 11 Aug 2022 20:59:57 +0300 Subject: [PATCH 44/47] Add Flag to Start App without Native GUI --- src/main.py | 49 ++++++++++++++++++++++++++++-------------------- src/utils/cli.py | 1 + 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/src/main.py b/src/main.py index ecef329e..bace607f 100644 --- a/src/main.py +++ b/src/main.py @@ -29,25 +29,30 @@ def run(): args = cli(state.cli_args) set_state(args) - # Setup GUI - gui = QtWidgets.QApplication([]) - gui.setQuitOnLastWindowClosed(False) - configure_screen = ConfigureScreen(args.config_file) - tray = create_system_tray(gui, configure_screen) - tray.show() + if args.no_gui: + # Start Server + configure_server(args, required=True) + start_server(app, host=args.host, port=args.port, socket=args.socket) + else: + # Setup GUI + gui = QtWidgets.QApplication([]) + gui.setQuitOnLastWindowClosed(False) + configure_screen = ConfigureScreen(args.config_file) + tray = create_system_tray(gui, configure_screen) + tray.show() - # Setup Server - configure_server(args, required=False) - server = ServerThread(app, args.host, args.port, args.socket) + # Setup Server + configure_server(args, required=False) + server = ServerThread(app, args.host, args.port, args.socket) - # Trigger First Run Experience, if required - if args.config is None: - configure_screen.show() + # Trigger First Run Experience, if required + if args.config is None: + configure_screen.show() - # Start Application - server.start() - gui.aboutToQuit.connect(server.terminate) - gui.exec() + # Start Application + server.start() + gui.aboutToQuit.connect(server.terminate) + gui.exec() def set_state(args): @@ -56,6 +61,13 @@ def set_state(args): state.verbose = args.verbose +def start_server(app, host=None, port=None, socket=None): + if socket: + uvicorn.run(app, proxy_headers=True, uds=socket) + else: + uvicorn.run(app, host=host, port=port) + + class ServerThread(QThread): def __init__(self, app, host=None, port=None, socket=None): super(ServerThread, self).__init__() @@ -68,10 +80,7 @@ class ServerThread(QThread): self.wait() def run(self): - if self.socket: - uvicorn.run(app, proxy_headers=True, uds=self.socket) - else: - uvicorn.run(app, host=self.host, port=self.port) + start_server(self.app, self.host, self.port, self.socket) if __name__ == '__main__': diff --git a/src/utils/cli.py b/src/utils/cli.py index a77b35a7..0e8a61ea 100644 --- a/src/utils/cli.py +++ b/src/utils/cli.py @@ -11,6 +11,7 @@ def cli(args=None): # Setup Argument Parser for the Commandline Interface parser = argparse.ArgumentParser(description="Start Khoj; A Natural Language Search Engine for your personal Notes, Transactions and Photos") 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('--regenerate', action='store_true', default=False, help="Regenerate model embeddings from source files. Default: false") parser.add_argument('--verbose', '-v', action='count', default=0, help="Show verbose conversion logs. Default: 0") parser.add_argument('--host', type=str, default='127.0.0.1', help="Host address of the server. Default: 127.0.0.1") From fc48ee62ad0bd1849d05761a43173a1cb257f1ff Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Thu, 11 Aug 2022 22:26:02 +0300 Subject: [PATCH 45/47] Update CLI tests since config_file arg has become optional (again) --- tests/test_cli.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 6fb0b73f..7e7531fb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,28 +13,37 @@ from src.utils.cli import cli # ---------------------------------------------------------------------------------------------------- def test_cli_minimal_default(): # Act - actual_args = cli(['tests/data/config.yml']) + actual_args = cli([]) # Assert - assert actual_args.config_file == Path('tests/data/config.yml') + assert actual_args.config_file == Path('~/.khoj/khoj.yml') assert actual_args.regenerate == False + assert actual_args.no_gui == False assert actual_args.verbose == 0 # ---------------------------------------------------------------------------------------------------- def test_cli_invalid_config_file_path(): + # Arrange + non_existent_config_file = f"non-existent-khoj-{random()}.yml" + # Act - with pytest.raises(ValueError): - cli([f"non-existent-khoj-{random()}.yml"]) + actual_args = cli([f'-c={non_existent_config_file}']) + + # Assert + assert actual_args.config_file == Path(non_existent_config_file) + assert actual_args.config == None # ---------------------------------------------------------------------------------------------------- def test_cli_config_from_file(): # Act - actual_args = cli(['tests/data/config.yml', + actual_args = cli(['-c=tests/data/config.yml', '--regenerate', + '--no-gui', '-vvv']) # Assert assert actual_args.config_file == Path('tests/data/config.yml') + assert actual_args.no_gui == True assert actual_args.regenerate == True assert actual_args.config is not None assert actual_args.config.content_type.org.input_files == [Path('~/first_from_config.org'), Path('~/second_from_config.org')] From da5284bbda73049b0e2c7a3ad16d509028ee5d0f Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Thu, 11 Aug 2022 22:37:57 +0300 Subject: [PATCH 46/47] Install libegl to fix libegl1.so import error in Github tests workflow --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bea538c7..8c712dc0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,6 +34,7 @@ jobs: - name: Install Dependencies run: | + sudo apt install libegl1 -y python -m pip install --upgrade pip pip install pytest From 41520e16084a4d4023d8b3609bd4b772a00f31bf Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Thu, 11 Aug 2022 23:36:02 +0300 Subject: [PATCH 47/47] Improve Docstring for Configure Screen and System Tray class, funcs --- src/interface/desktop/configure_screen.py | 9 +++++---- src/interface/desktop/system_tray.py | 10 ++++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index fe8bf313..86585391 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -17,9 +17,9 @@ from src.utils.helpers import merge_dicts class ConfigureScreen(QtWidgets.QDialog): """Create Window to Configure Khoj Allow user to - 1. Enable/Disable search on 1. org-mode, 2. markdown, 3. beancount or 4. image content types - 2. Configure the server host and port - 3. Save the configuration to khoj.yml and start the server + 1. Configure content types to search + 2. Configure conversation processor + 3. Save the configuration to khoj.yml """ def __init__(self, config_file: Path, parent=None): @@ -202,7 +202,7 @@ class ConfigureScreen(QtWidgets.QDialog): return True def load_updated_settings(self): - "Hot swap to using the updated config from file" + "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 @@ -230,6 +230,7 @@ class ProcessorCheckBox(QtWidgets.QCheckBox): 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 diff --git a/src/interface/desktop/system_tray.py b/src/interface/desktop/system_tray.py index ebc465d0..5d53787b 100644 --- a/src/interface/desktop/system_tray.py +++ b/src/interface/desktop/system_tray.py @@ -9,11 +9,10 @@ from src.utils import constants def create_system_tray(gui: QtWidgets.QApplication, configure_screen: QtWidgets.QDialog): - """Create System Tray with Menu - Menu Actions should contain - 1. option to open search page at localhost:8000/ - 2. option to open config screen - 3. to quit + """Create System Tray with Menu. Menu contain options to + 1. Open Search Page on the Web Interface + 2. Open App Configuration Screen + 3. Quit Application """ # Create the system tray with icon @@ -40,4 +39,3 @@ def create_system_tray(gui: QtWidgets.QApplication, configure_screen: QtWidgets. tray.setContextMenu(menu) return tray -