From e71471c6938cac30e7f0f147bd39c327f2e16395 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Fri, 13 Dec 2024 05:23:02 -0600 Subject: [PATCH 1/2] Update project structure and enhance functionality - Added new entries to .gitignore for cache and history files. - Updated supported file formats in __init__.py. - Improved layout and widget management in various dialog and widget files. - Enhanced API key management and configuration loading in config_loader.py. - Refactored multiple dialog and widget classes for better usability and performance. - Added error handling and improved user input handling in various components. - Statically type almost everything - add linter configs to pyproject.toml --- .gitignore | 5 +- pyqt_openai/__init__.py | 22 +- pyqt_openai/aboutDialog.py | 189 +- pyqt_openai/chat_widget/center/aiChatUnit.py | 308 +- pyqt_openai/chat_widget/center/chatBrowser.py | 626 ++-- pyqt_openai/chat_widget/center/chatHome.py | 114 +- pyqt_openai/chat_widget/center/chatUnit.py | 17 +- pyqt_openai/chat_widget/center/chatWidget.py | 698 ++--- .../chat_widget/center/commandCompleter.py | 216 +- .../center/commandSuggestionWidget.py | 82 +- .../chat_widget/center/findTextWidget.py | 468 +-- pyqt_openai/chat_widget/center/menuWidget.py | 130 +- .../chat_widget/center/messageTextBrowser.py | 236 +- pyqt_openai/chat_widget/center/prompt.py | 874 +++--- .../chat_widget/center/realtimeApiWidget.py | 46 +- .../chat_widget/center/responseInfoDialog.py | 86 +- .../chat_widget/center/textEditPrompt.py | 189 +- .../chat_widget/center/textEditPromptGroup.py | 500 ++-- .../center/uploadedImageFileWidget.py | 293 +- .../chat_widget/center/userChatUnit.py | 14 +- pyqt_openai/chat_widget/chatMainWidget.py | 731 +++-- .../chat_widget/left_sidebar/chatNavWidget.py | 595 ++-- .../chat_widget/left_sidebar/exportDialog.py | 163 +- .../chat_widget/left_sidebar/importDialog.py | 392 ++- .../selectChatImportTypeDialog.py | 123 +- pyqt_openai/chat_widget/llamaIndexThread.py | 107 +- .../importPromptManualDialog.py | 109 +- .../promptCsvRightFormSampleDialog.py | 54 +- .../promptEntryDirectInputDialog.py | 157 +- .../promptGeneratorWidget.py | 175 +- .../promptGroupDirectInputDialog.py | 140 +- .../promptGroupExportDialog.py | 195 +- .../promptGroupImportDialog.py | 478 +-- .../prompt_gen_widget/promptGroupList.py | 428 +-- .../prompt_gen_widget/promptPage.py | 118 +- .../prompt_gen_widget/promptTable.py | 344 +-- .../right_sidebar/chatRightSideBarWidget.py | 154 +- .../right_sidebar/llama_widget/filesWidget.py | 226 +- .../right_sidebar/llama_widget/llamaPage.py | 138 +- .../supportedFileFormatsWidget.py | 76 +- .../right_sidebar/modelSearchBar.py | 34 +- .../chat_widget/right_sidebar/usingAPIPage.py | 719 ++--- .../chat_widget/right_sidebar/usingG4FPage.py | 214 +- pyqt_openai/config_loader.py | 358 +-- pyqt_openai/customizeDialog.py | 278 +- pyqt_openai/dalle_widget/dalleHome.py | 66 +- pyqt_openai/dalle_widget/dalleMainWidget.py | 54 +- pyqt_openai/dalle_widget/dalleRightSideBar.py | 450 ++- pyqt_openai/dalle_widget/dalleThread.py | 98 +- pyqt_openai/diff.txt | Bin 0 -> 888974 bytes pyqt_openai/doNotAskAgainDialog.py | 191 +- pyqt_openai/fontWidget.py | 688 +++-- pyqt_openai/g4f_image_widget/g4fImageHome.py | 76 +- .../g4f_image_widget/g4fImageMainWidget.py | 54 +- .../g4f_image_widget/g4fImageRightSideBar.py | 372 +-- .../g4f_image_widget/g4fImageThread.py | 140 +- pyqt_openai/globals.py | 41 +- pyqt_openai/ico/add.svg | 4 +- pyqt_openai/ico/case.svg | 6 +- pyqt_openai/ico/customize.svg | 20 +- pyqt_openai/ico/delete.svg | 4 +- pyqt_openai/ico/favorite_no.svg | 14 +- pyqt_openai/ico/favorite_yes.svg | 14 +- pyqt_openai/ico/file.svg | 18 +- pyqt_openai/ico/fullscreen.svg | 66 +- pyqt_openai/ico/import.svg | 6 +- pyqt_openai/ico/info.svg | 8 +- pyqt_openai/ico/next.svg | 68 +- pyqt_openai/ico/prev.svg | 6 +- pyqt_openai/ico/question.svg | 2 +- pyqt_openai/ico/record.svg | 34 +- pyqt_openai/ico/refresh.svg | 24 +- pyqt_openai/ico/search.svg | 68 +- pyqt_openai/ico/send.svg | 24 +- pyqt_openai/ico/sidebar.svg | 4 +- pyqt_openai/ico/speaker.svg | 44 +- pyqt_openai/ico/stackontop.svg | 42 +- pyqt_openai/ico/word.svg | 14 +- pyqt_openai/lang/translations.py | 84 +- pyqt_openai/main.py | 155 +- pyqt_openai/mainWindow.py | 1053 +++---- pyqt_openai/models.py | 355 ++- pyqt_openai/prompt_res/alex_brogan.json | 92 +- pyqt_openai/replicate_widget/replicateHome.py | 108 +- .../replicate_widget/replicateMainWidget.py | 88 +- .../replicate_widget/replicateRightSideBar.py | 378 +-- .../replicate_widget/replicateThread.py | 86 +- pyqt_openai/settings_dialog/apiWidget.py | 217 +- .../settings_dialog/generalSettingsWidget.py | 509 ++-- .../settings_dialog/markdownSettingsWidget.py | 248 +- pyqt_openai/settings_dialog/settingsDialog.py | 182 +- .../settings_dialog/voiceSettingsWidget.py | 262 +- pyqt_openai/shortcutDialog.py | 250 +- pyqt_openai/sqlite.py | 1492 +++++----- pyqt_openai/updateSoftwareDialog.py | 325 ++- pyqt_openai/util/button_style_helper.py | 252 +- pyqt_openai/util/common.py | 2593 ++++++++--------- pyqt_openai/util/llamaindex.py | 148 +- pyqt_openai/util/replicate.py | 204 +- pyqt_openai/widgets/APIInputButton.py | 144 +- pyqt_openai/widgets/animationButton.py | 98 +- pyqt_openai/widgets/baseNavWidget.py | 426 +-- pyqt_openai/widgets/button.py | 79 +- pyqt_openai/widgets/checkBoxListWidget.py | 185 +- pyqt_openai/widgets/checkBoxTableWidget.py | 321 +- pyqt_openai/widgets/circleProfileImage.py | 119 +- pyqt_openai/widgets/fileTableDialog.py | 78 +- pyqt_openai/widgets/findPathWidget.py | 314 +- pyqt_openai/widgets/imageControlWidget.py | 423 +-- pyqt_openai/widgets/imageMainWidget.py | 437 +-- pyqt_openai/widgets/imageNavWidget.py | 309 +- pyqt_openai/widgets/inputDialog.py | 122 +- pyqt_openai/widgets/jsonEditor.py | 449 +-- pyqt_openai/widgets/linkLabel.py | 61 +- pyqt_openai/widgets/modelInputManualDialog.py | 86 +- pyqt_openai/widgets/navWidget.py | 121 +- pyqt_openai/widgets/normalImageView.py | 103 +- pyqt_openai/widgets/notifier.py | 240 +- pyqt_openai/widgets/questionTooltipLabel.py | 33 +- .../randomImagePromptGeneratorWidget.py | 152 +- pyqt_openai/widgets/scrollableErrorDialog.py | 125 +- pyqt_openai/widgets/searchBar.py | 225 +- .../widgets/showingKeyUserInputLineEdit.py | 165 +- pyqt_openai/widgets/svgLabel.py | 61 +- pyqt_openai/widgets/thumbnailView.py | 362 +-- pyqt_openai/widgets/toast.py | 331 ++- pyqt_openai/widgets/toolButton.py | 80 +- 127 files changed, 14891 insertions(+), 14178 deletions(-) create mode 100644 pyqt_openai/diff.txt diff --git a/.gitignore b/.gitignore index 7cf4126e..3aff55c1 100644 --- a/.gitignore +++ b/.gitignore @@ -135,4 +135,7 @@ dmypy.json pyqt_openai/pyqt_openai.ini pyqt_openai/*.db pyqt_openai/test/ -pyqt_openai/config.yaml \ No newline at end of file +pyqt_openai/config.yaml +.history +.archivist +.ruff_cache \ No newline at end of file diff --git a/pyqt_openai/__init__.py b/pyqt_openai/__init__.py index e32e32ae..20dcd0bd 100644 --- a/pyqt_openai/__init__.py +++ b/pyqt_openai/__init__.py @@ -1,10 +1,9 @@ -""" -This file is used to store the constants and the global constants / variables that are used throughout the application. +"""This file is used to store the constants and the global constants / variables that are used throughout the application. Constants/Variables that are stored here are used throughout the application for the following purposes: - Initial values related to the environment settings - Values used internally within the application (e.g., application name, DB name, table name, etc.) - Values related to the design and UI of the application -- Default LLM list for the application settings +- Default LLM list for the application settings. Constants/Variables that are stored here are not supposed to be changed during the runtime except for __init__.py. Variables which are used globally and can be changed are stored in globals.py. @@ -14,7 +13,6 @@ import os import shutil import sys - from pathlib import Path SRC_DIR = Path(__file__).resolve().parent # VividNode/pyqt_openai @@ -47,7 +45,7 @@ def is_frozen(): # The executable path of the application def get_executable_path(): if is_frozen(): # For PyInstaller - executable_path = sys._MEIPASS + executable_path = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))) else: executable_path = os.path.dirname(os.path.abspath(__file__)) return executable_path @@ -61,7 +59,7 @@ def get_executable_path(): def get_config_directory(): if os.name == "nt": # Windows - config_dir = os.path.join(os.getenv("APPDATA"), DEFAULT_APP_NAME) + config_dir = os.path.join(os.getenv("APPDATA", os.path.expanduser("~")), DEFAULT_APP_NAME) elif os.name == "posix": # macOS/Linux config_dir = os.path.join( os.getenv("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), @@ -288,7 +286,7 @@ def move_bin(filename, dst_dir): LANGUAGE_FILE_BASE_NAME = "translations.json" LANGUAGE_FILE = os.path.join(get_config_directory(), LANGUAGE_FILE_BASE_NAME) LANGUAGE_FILE_SRC = os.path.join( - os.path.join(EXEC_PATH, "lang"), LANGUAGE_FILE_BASE_NAME + os.path.join(EXEC_PATH, "lang"), LANGUAGE_FILE_BASE_NAME, ) # Make sure the language file exists @@ -422,7 +420,7 @@ def move_bin(filename, dst_dir): "env_var_name": "OPENAI_API_KEY", "api_key": "", "manual_url": HOW_TO_GET_OPENAI_API_KEY_URL, - "model_list": ["gpt-4o", "gpt-4o-mini"] + O1_MODELS + "model_list": ["gpt-4o", "gpt-4o-mini"] + O1_MODELS, }, # Azure { @@ -482,7 +480,7 @@ def move_bin(filename, dst_dir): "api_key": "", "manual_url": HOW_TO_GET_GEMINI_API_KEY_URL, "prefix": "gemini", - "model_list": ["gemini/gemini-1.5-flash", "gemini/gemini-1.5-pro"] + "model_list": ["gemini/gemini-1.5-flash", "gemini/gemini-1.5-pro"], }, # Anthropic { @@ -490,7 +488,7 @@ def move_bin(filename, dst_dir): "env_var_name": "ANTHROPIC_API_KEY", "api_key": "", "manual_url": HOW_TO_GET_CLAUDE_API_KEY_URL, - "model_list": ["claude-3-haiku-20240307", "claude-3-5-sonnet-20240620"] + "model_list": ["claude-3-haiku-20240307", "claude-3-5-sonnet-20240620"], }, # AWS Sagemaker { @@ -907,8 +905,8 @@ def move_bin(filename, dst_dir): MAXIMUM_MESSAGES_IN_PARAMETER_RANGE = 2, 1000 # llamaIndex -LLAMA_INDEX_DEFAULT_SUPPORTED_FORMATS_LIST = ['.txt'] -LLAMA_INDEX_DEFAULT_ALL_SUPPORTED_FORMATS_LIST = ['.txt', '.docx', '.hwp', '.ipynb', '.csv', '.jpeg', '.jpg', '.mbox', '.md', '.mp3', '.mp4', '.pdf', '.png', '.ppt', '.pptx', '.pptm'] +LLAMA_INDEX_DEFAULT_SUPPORTED_FORMATS_LIST = [".txt"] +LLAMA_INDEX_DEFAULT_ALL_SUPPORTED_FORMATS_LIST = [".txt", ".docx", ".hwp", ".ipynb", ".csv", ".jpeg", ".jpg", ".mbox", ".md", ".mp3", ".mp4", ".pdf", ".png", ".ppt", ".pptx", ".pptm"] # PROMPT ## DEFAULT JSON FILENAME FOR PROMPT diff --git a/pyqt_openai/aboutDialog.py b/pyqt_openai/aboutDialog.py index 2f00106b..ccf31219 100644 --- a/pyqt_openai/aboutDialog.py +++ b/pyqt_openai/aboutDialog.py @@ -1,95 +1,94 @@ -import datetime - -from PySide6.QtCore import Qt -from PySide6.QtGui import QPixmap -from PySide6.QtWidgets import ( - QDialog, - QPushButton, - QHBoxLayout, - QWidget, - QVBoxLayout, - QLabel, -) - -import pyqt_openai -from pyqt_openai import DEFAULT_APP_ICON, LICENSE_URL, DEFAULT_APP_NAME, CONTACT -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.widgets.linkLabel import LinkLabel - - -class AboutDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - self.setWindowTitle(LangClass.TRANSLATIONS["About"]) - self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) - - self.__okBtn = QPushButton(LangClass.TRANSLATIONS["OK"]) - self.__okBtn.clicked.connect(self.accept) - - p = QPixmap(DEFAULT_APP_ICON) - logoLbl = QLabel() - logoLbl.setPixmap(p) - - descWidget1 = QLabel() - descWidget1.setText( - f""" -

{DEFAULT_APP_NAME}

- Software Version {pyqt_openai.__version__}

- © 2023 {datetime.datetime.now().year}. Used under the {pyqt_openai.LICENSE} License.
- Copyright (c) {datetime.datetime.now().year} {pyqt_openai.__author__}
- """ - ) - - descWidget2 = LinkLabel() - descWidget2.setText(LangClass.TRANSLATIONS["Read MIT License Full Text"]) - descWidget2.setUrl(LICENSE_URL) - - descWidget3 = QLabel() - descWidget3.setText( - f""" -

Contact: {CONTACT}
-

Powered by

PySide6, GPT4Free, LiteLLM,
LlamaIndex

- """ - ) - - descWidget1.setAlignment(Qt.AlignmentFlag.AlignTop) - descWidget2.setAlignment(Qt.AlignmentFlag.AlignTop) - descWidget3.setAlignment(Qt.AlignmentFlag.AlignTop) - - lay = QVBoxLayout() - lay.addWidget(descWidget1) - lay.addWidget(descWidget2) - lay.addWidget(descWidget3) - lay.setAlignment(Qt.AlignmentFlag.AlignTop) - lay.setContentsMargins(0, 0, 0, 0) - - rightWidget = QWidget() - rightWidget.setLayout(lay) - - lay = QHBoxLayout() - lay.addWidget(logoLbl) - lay.addWidget(rightWidget) - - topWidget = QWidget() - topWidget.setLayout(lay) - - cancelBtn = QPushButton(LangClass.TRANSLATIONS["Cancel"]) - cancelBtn.clicked.connect(self.close) - - lay = QHBoxLayout() - lay.addWidget(self.__okBtn) - lay.addWidget(cancelBtn) - lay.setAlignment(Qt.AlignmentFlag.AlignRight) - lay.setContentsMargins(0, 0, 0, 0) - - okCancelWidget = QWidget() - okCancelWidget.setLayout(lay) - - lay = QVBoxLayout() - lay.addWidget(topWidget) - lay.addWidget(okCancelWidget) - - self.setLayout(lay) +from __future__ import annotations + +import datetime + +from qtpy.QtCore import Qt +from qtpy.QtGui import QPixmap +from qtpy.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget + +import pyqt_openai + +from pyqt_openai import CONTACT, DEFAULT_APP_ICON, DEFAULT_APP_NAME, LICENSE_URL +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.widgets.linkLabel import LinkLabel + + +class AboutDialog(QDialog): + def __init__( + self, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.__initUi() + + def __initUi(self): + self.setWindowTitle(LangClass.TRANSLATIONS["About"]) + self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) + + self.__okBtn: QPushButton = QPushButton(LangClass.TRANSLATIONS["OK"]) + self.__okBtn.clicked.connect(self.accept) + + p = QPixmap(DEFAULT_APP_ICON) + logoLbl = QLabel() + logoLbl.setPixmap(p) + + descWidget1 = QLabel() + descWidget1.setText( + f""" +

{DEFAULT_APP_NAME}

+ Software Version {pyqt_openai.__version__}

+ © 2023 {datetime.datetime.now().year}. Used under the {pyqt_openai.LICENSE} License.
+ Copyright (c) {datetime.datetime.now().year} {pyqt_openai.__author__}
+ """, + ) + + descWidget2 = LinkLabel() + descWidget2.setText(LangClass.TRANSLATIONS["Read MIT License Full Text"]) + descWidget2.setUrl(LICENSE_URL) + + descWidget3 = QLabel() + descWidget3.setText( + f""" +

Contact: {CONTACT}
+

Powered by

PySide6, GPT4Free, LiteLLM,
LlamaIndex

+ """, + ) + + descWidget1.setAlignment(Qt.AlignmentFlag.AlignTop) + descWidget2.setAlignment(Qt.AlignmentFlag.AlignTop) + descWidget3.setAlignment(Qt.AlignmentFlag.AlignTop) + + lay = QVBoxLayout() + lay.addWidget(descWidget1) + lay.addWidget(descWidget2) + lay.addWidget(descWidget3) + lay.setAlignment(Qt.AlignmentFlag.AlignTop) + lay.setContentsMargins(0, 0, 0, 0) + + rightWidget = QWidget() + rightWidget.setLayout(lay) + + lay = QHBoxLayout() + lay.addWidget(logoLbl) + lay.addWidget(rightWidget) + + topWidget = QWidget() + topWidget.setLayout(lay) + + cancelBtn = QPushButton(LangClass.TRANSLATIONS["Cancel"]) + cancelBtn.clicked.connect(self.close) + + lay = QHBoxLayout() + lay.addWidget(self.__okBtn) + lay.addWidget(cancelBtn) + lay.setAlignment(Qt.AlignmentFlag.AlignRight) + lay.setContentsMargins(0, 0, 0, 0) + + okCancelWidget = QWidget() + okCancelWidget.setLayout(lay) + + lay = QVBoxLayout() + lay.addWidget(topWidget) + lay.addWidget(okCancelWidget) + + self.setLayout(lay) diff --git a/pyqt_openai/chat_widget/center/aiChatUnit.py b/pyqt_openai/chat_widget/center/aiChatUnit.py index 8c65bc28..34739675 100644 --- a/pyqt_openai/chat_widget/center/aiChatUnit.py +++ b/pyqt_openai/chat_widget/center/aiChatUnit.py @@ -1,154 +1,154 @@ -from PySide6.QtGui import QPalette -from PySide6.QtWidgets import QMessageBox - -from pyqt_openai import ( - ICON_FAVORITE_NO, - ICON_INFO, - ICON_FAVORITE_YES, - ICON_SPEAKER, - WHISPER_TTS_MODEL, - ICON_FILE, -) -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.chat_widget.center.chatUnit import ChatUnit -from pyqt_openai.chat_widget.center.responseInfoDialog import ResponseInfoDialog -from pyqt_openai.models import ChatMessageContainer -from pyqt_openai.globals import DB -from pyqt_openai.util.common import stream_to_speakers -from pyqt_openai.widgets.button import Button -from pyqt_openai.widgets.fileTableDialog import FileTableDialog - - -class AIChatUnit(ChatUnit): - def __init__(self, parent=None): - super().__init__(parent) - self.__initVal() - self.__initAIChatUi() - - def __initVal(self): - self.__result_info = "" - self.__show_as_markdown = CONFIG_MANAGER.get_general_property( - "show_as_markdown" - ) - - def __initAIChatUi(self): - self.__favoriteBtn = Button() - self.__favoriteBtn.setStyleAndIcon(ICON_FAVORITE_NO) - self.__favoriteBtn.setCheckable(True) - self.__favoriteBtn.toggled.connect(self.__favorite) - - self.__infoBtn = Button() - self.__infoBtn.setStyleAndIcon(ICON_INFO) - self.__infoBtn.clicked.connect(self.__showResponseInfoDialog) - - self.__fileListBtn = Button() - self.__fileListBtn.setStyleAndIcon(ICON_FILE) - self.__fileListBtn.setToolTip("File List") - self.__fileListBtn.clicked.connect(self.__showFileListDialog) - - self.__speakerBtn = Button() - self.__speakerBtn.setStyleAndIcon(ICON_SPEAKER) - self.__speakerBtn.setCheckable(True) - self.__speakerBtn.toggled.connect(self.__speak) - - self.thread = None - - self.getMenuWidget().layout().insertWidget(3, self.__fileListBtn) - self.getMenuWidget().layout().insertWidget(4, self.__favoriteBtn) - self.getMenuWidget().layout().insertWidget(5, self.__infoBtn) - self.getMenuWidget().layout().insertWidget(6, self.__speakerBtn) - - self.setBackgroundRole(QPalette.ColorRole.AlternateBase) - self.setAutoFillBackground(True) - - def __favorite(self, f, insert_f=True): - favorite = 1 if f else 0 - if favorite: - self.__favoriteBtn.setStyleAndIcon(ICON_FAVORITE_YES) - else: - self.__favoriteBtn.setStyleAndIcon(ICON_FAVORITE_NO) - if insert_f and self.__result_info: - current_date = DB.updateMessage(self.__result_info.id, favorite) - self.__result_info.favorite = favorite - self.__result_info.favorite_set_date = current_date - - def __showResponseInfoDialog(self): - if self.__result_info: - dialog = ResponseInfoDialog(self.__result_info, parent=self) - dialog.exec() - - def __showFileListDialog(self): - if self.__result_info: - dialog = FileTableDialog(parent=self) - dialog.exec() - - def afterResponse(self, arg): - self.toggleGUI(True) - self.__result_info = arg - self._nameLbl.setText(arg.model) - self.__favorite(True if arg.favorite else False, insert_f=False) - - if arg.is_json_response_available: - self.getLbl().setJson(arg.content) - else: - self.getLbl().setMarkdown(arg.content) - self.getLbl().adjustBrowserHeight() - - def toggleGUI(self, f: bool): - self.__favoriteBtn.setEnabled(f) - self._copyBtn.setEnabled(f) - self.__infoBtn.setEnabled(f) - self.__speakerBtn.setEnabled(f) - - def getResponseInfo(self): - """ - Get the response information - :return: ChatMessageContainer - - Note: This function is used to get the response information after the response is generated. - """ - try: - if self.__result_info and isinstance( - self.__result_info, ChatMessageContainer - ): - return self.__result_info - else: - raise AttributeError("Response information is not available") - except AttributeError as e: - raise e - - def __speak(self, f): - if f: - text = self._lbl.toPlainText() - if text: - voice_provider = CONFIG_MANAGER.get_general_property("voice_provider") - - args = { - "model": WHISPER_TTS_MODEL, - "voice": CONFIG_MANAGER.get_general_property("voice"), - "input": text, - "speed": CONFIG_MANAGER.get_general_property("voice_speed"), - } - self.thread = stream_to_speakers(voice_provider, args) - self.thread.finished.connect(self.__on_thread_complete) - self.thread.errorGenerated.connect( - lambda x: QMessageBox.critical(self, "Error", x) - ) - self.thread.start() - else: - self.thread.stop() - - def __on_thread_complete(self): - self.__speakerBtn.setStyleAndIcon(ICON_SPEAKER) - self.__speakerBtn.setChecked(False) - - def setText(self, text: str): - if self.__show_as_markdown: - self._lbl.setMarkdown(text) - else: - self._lbl.setText(text) - self._lbl.adjustBrowserHeight() - - def addText(self, text: str): - self._lbl.setText(self._lbl.toPlainText() + text) - self._lbl.adjustBrowserHeight() +from __future__ import annotations + +from qtpy.QtGui import QPalette +from qtpy.QtWidgets import QMessageBox + +from pyqt_openai import ( + ICON_FAVORITE_NO, + ICON_FAVORITE_YES, + ICON_FILE, + ICON_INFO, + ICON_SPEAKER, + WHISPER_TTS_MODEL, +) +from pyqt_openai.chat_widget.center.chatUnit import ChatUnit +from pyqt_openai.chat_widget.center.responseInfoDialog import ResponseInfoDialog +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.globals import DB +from pyqt_openai.models import ChatMessageContainer +from pyqt_openai.util.common import stream_to_speakers +from pyqt_openai.widgets.button import Button +from pyqt_openai.widgets.fileTableDialog import FileTableDialog + + +class AIChatUnit(ChatUnit): + def __init__(self, parent=None): + super().__init__(parent) + self.__initVal() + self.__initAIChatUi() + + def __initVal(self): + self.__result_info = "" + self.__show_as_markdown = CONFIG_MANAGER.get_general_property( + "show_as_markdown", + ) + + def __initAIChatUi(self): + self.__favoriteBtn = Button() + self.__favoriteBtn.setStyleAndIcon(ICON_FAVORITE_NO) + self.__favoriteBtn.setCheckable(True) + self.__favoriteBtn.toggled.connect(self.__favorite) + + self.__infoBtn = Button() + self.__infoBtn.setStyleAndIcon(ICON_INFO) + self.__infoBtn.clicked.connect(self.__showResponseInfoDialog) + + self.__fileListBtn = Button() + self.__fileListBtn.setStyleAndIcon(ICON_FILE) + self.__fileListBtn.setToolTip("File List") + self.__fileListBtn.clicked.connect(self.__showFileListDialog) + + self.__speakerBtn = Button() + self.__speakerBtn.setStyleAndIcon(ICON_SPEAKER) + self.__speakerBtn.setCheckable(True) + self.__speakerBtn.toggled.connect(self.__speak) + + self.thread = None + + self.getMenuWidget().layout().insertWidget(3, self.__fileListBtn) + self.getMenuWidget().layout().insertWidget(4, self.__favoriteBtn) + self.getMenuWidget().layout().insertWidget(5, self.__infoBtn) + self.getMenuWidget().layout().insertWidget(6, self.__speakerBtn) + + self.setBackgroundRole(QPalette.ColorRole.AlternateBase) + self.setAutoFillBackground(True) + + def __favorite(self, f, insert_f=True): + favorite = 1 if f else 0 + if favorite: + self.__favoriteBtn.setStyleAndIcon(ICON_FAVORITE_YES) + else: + self.__favoriteBtn.setStyleAndIcon(ICON_FAVORITE_NO) + if insert_f and self.__result_info: + current_date = DB.updateMessage(self.__result_info.id, favorite) + self.__result_info.favorite = favorite + self.__result_info.favorite_set_date = current_date + + def __showResponseInfoDialog(self): + if self.__result_info: + dialog = ResponseInfoDialog(self.__result_info, parent=self) + dialog.exec() + + def __showFileListDialog(self): + if self.__result_info: + dialog = FileTableDialog(parent=self) + dialog.exec() + + def afterResponse(self, arg): + self.toggleGUI(True) + self.__result_info = arg + self._nameLbl.setText(arg.model) + self.__favorite(True if arg.favorite else False, insert_f=False) + + if arg.is_json_response_available: + self.getLbl().setJson(arg.content) + else: + self.getLbl().setMarkdown(arg.content) + self.getLbl().adjustBrowserHeight() + + def toggleGUI(self, f: bool): + self.__favoriteBtn.setEnabled(f) + self._copyBtn.setEnabled(f) + self.__infoBtn.setEnabled(f) + self.__speakerBtn.setEnabled(f) + + def getResponseInfo(self): + """Get the response information + :return: ChatMessageContainer. + + Note: This function is used to get the response information after the response is generated. + """ + try: + if self.__result_info and isinstance( + self.__result_info, ChatMessageContainer, + ): + return self.__result_info + raise AttributeError("Response information is not available") + except AttributeError as e: + raise e + + def __speak(self, f): + if f: + text = self._lbl.toPlainText() + if text: + voice_provider = CONFIG_MANAGER.get_general_property("voice_provider") + + args = { + "model": WHISPER_TTS_MODEL, + "voice": CONFIG_MANAGER.get_general_property("voice"), + "input": text, + "speed": CONFIG_MANAGER.get_general_property("voice_speed"), + } + self.thread = stream_to_speakers(voice_provider, args) + self.thread.finished.connect(self.__on_thread_complete) + self.thread.errorGenerated.connect( + lambda x: QMessageBox.critical(self, "Error", x), + ) + self.thread.start() + else: + self.thread.stop() + + def __on_thread_complete(self): + self.__speakerBtn.setStyleAndIcon(ICON_SPEAKER) + self.__speakerBtn.setChecked(False) + + def setText(self, text: str): + if self.__show_as_markdown: + self._lbl.setMarkdown(text) + else: + self._lbl.setText(text) + self._lbl.adjustBrowserHeight() + + def addText(self, text: str): + self._lbl.setText(self._lbl.toPlainText() + text) + self._lbl.adjustBrowserHeight() diff --git a/pyqt_openai/chat_widget/center/chatBrowser.py b/pyqt_openai/chat_widget/center/chatBrowser.py index 43432983..eb5ba684 100644 --- a/pyqt_openai/chat_widget/center/chatBrowser.py +++ b/pyqt_openai/chat_widget/center/chatBrowser.py @@ -1,320 +1,306 @@ -import re -from typing import List - -from PySide6.QtGui import QTextCharFormat, QColor, QTextCursor -from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import QScrollArea, QVBoxLayout, QWidget, QLabel - -from pyqt_openai import ( - MAXIMUM_MESSAGES_IN_PARAMETER, - DEFAULT_FOUND_TEXT_BG_COLOR, - DEFAULT_FOUND_TEXT_COLOR, -) -from pyqt_openai.chat_widget.center.aiChatUnit import AIChatUnit -from pyqt_openai.chat_widget.center.userChatUnit import UserChatUnit -from pyqt_openai.models import ChatMessageContainer -from pyqt_openai.globals import DB -from pyqt_openai.util.common import is_valid_regex - - -class ChatBrowser(QScrollArea): - messageUpdated = Signal(ChatMessageContainer) - onReplacedCurrentPage = Signal(int) - - def __init__(self, parent=None): - super().__init__(parent) - self.__initVal() - self.__initUi() - - def __initVal(self): - self.__cur_id = 0 - self.__user_image = "" - self.__ai_image = "" - - def __initUi(self): - lay = QVBoxLayout() - lay.setAlignment(Qt.AlignmentFlag.AlignTop) - lay.setSpacing(0) - lay.setContentsMargins(0, 0, 0, 0) - - self.__chatWidget = QWidget() - self.__chatWidget.setLayout(lay) - - self.setWidget(self.__chatWidget) - self.setWidgetResizable(True) - - def showLabel(self, text, stream_f, arg: ChatMessageContainer): - arg.thread_id = arg.thread_id if arg.thread_id else self.__cur_id - unit = self.__setLabel(text, stream_f, arg.role) - if not stream_f: - arg.id = DB.insertMessage(arg) - self.__setResponseInfo(unit, arg) - - def getLayout(self): - return self.widget().layout() - - def showLabelForFavorite(self, arg: ChatMessageContainer): - unit = self.__setLabel(arg.content, False, arg.role) - self.__setResponseInfo(unit, arg) - - def __getLastUnit(self) -> AIChatUnit | None: - item = self.getLayout().itemAt(self.getLayout().count() - 1) - if item: - return item.widget() - else: - return None - - def __setResponseInfo(self, unit, arg: ChatMessageContainer): - if isinstance(unit, AIChatUnit): - unit.afterResponse(arg) - - def streamFinished(self, arg: ChatMessageContainer): - unit = self.__getLastUnit() - arg.content = self.getLastResponse() - arg.id = DB.insertMessage(arg) - self.__setResponseInfo(unit, arg) - - def __setLabel(self, text, stream_f, role): - chatUnit = QLabel() - if role == "user": - chatUnit = UserChatUnit() - chatUnit.setText(text) - chatUnit.setIcon(self.__user_image) - else: - chatUnit = AIChatUnit() - if chatUnit.getIcon(): - pass - else: - chatUnit.setIcon(self.__ai_image) - if stream_f: - unit = self.__getLastUnit() - if isinstance(unit, AIChatUnit): - unit.toggleGUI(False) - unit.addText(text) - return - chatUnit.setText(text) - - self.getLayout().addWidget(chatUnit) - return chatUnit - - def event(self, event): - if event.type() == 43: - self.verticalScrollBar().setSliderPosition( - self.verticalScrollBar().maximum() - ) - return super().event(event) - - def getMessages(self, limit=MAXIMUM_MESSAGES_IN_PARAMETER): - messages = DB.selectCertainThreadMessages(self.__cur_id) - all_text_lst = [ - {"role": message.role, "content": message.content} for message in messages - ] - all_text_lst = all_text_lst[-limit:] - - return all_text_lst - - def getLastResponse(self): - lay = self.getLayout() - if lay: - i = lay.count() - 1 - if lay.itemAt(i) and lay.itemAt(i).widget(): - widget = lay.itemAt(i).widget() - if isinstance(widget, AIChatUnit): - return widget.getText() - return "" - - def clear(self): - """ - This method is used to clear the chat widget, not the database. - """ - lay = self.getLayout() - if lay: - for i in range(lay.count() - 1, -1, -1): - item = lay.itemAt(i) - if item and item.widget(): - item.widget().deleteLater() - self.onReplacedCurrentPage.emit(0) - - def setCurId(self, id): - self.__cur_id = id - - def getCurId(self): - return self.__cur_id - - def resetChatWidget(self, id): - self.clear() - self.setCurId(id) - - def __getLabelsByType(self, label_type=None): - """ - Retrieve all labels from the widget's layout, optionally filtering by a specific label type. - - :param label_type: The type of label to filter by (e.g., UserChatUnit, AIChatUnit). If None, retrieves all labels. - :return: A list of label widgets. - """ - lay = self.getLayout() - labels = [] - for i in range(lay.count()): - item = lay.itemAt(i) - if item: - widget = item.widget() - if label_type is None or isinstance(widget, label_type): - labels.append(widget) - return labels - - def __getEveryLabels(self): - """ - Retrieve all labels from the widget's layout. - - :return: A list of all label widgets. - """ - return self.__getLabelsByType() - - def __getEveryUserLabels(self): - """ - Retrieve all user-specific labels from the widget's layout. - - :return: A list of UserChatUnit label widgets. - """ - return self.__getLabelsByType(UserChatUnit) - - def __getEveryAILabels(self): - """ - Retrieve all AI-specific labels from the widget's layout. - - :return: A list of AIChatUnit label widgets. - """ - return self.__getLabelsByType(AIChatUnit) - - def isFinishedByLength(self): - return self.__getLastUnit().getResponseInfo().finish_reason == "length" - - def clearFormatting(self, label=None): - if label is None: - # if isinstance(lbl, AIChatUnit) or isinstance(lbl, UserChatUnit) should be added - # Or else AttributeError: 'QWidget' object has no attribute 'getLbl' will be raised when calling getLbl() - labels = [ - lbl.getLbl() - for lbl in self.__getEveryLabels() - if isinstance(lbl, AIChatUnit) or isinstance(lbl, UserChatUnit) - ] - for lbl in labels: - self.clearFormatting(lbl) - return - cursor = label.textCursor() - cursor.select(QTextCursor.Document) - format = QTextCharFormat() - cursor.setCharFormat(format) - - def highlightText(self, label, pattern, case_sensitive): - self.clearFormatting(label) # Clear any previous formatting - - if pattern == "": - return - - cursor = label.textCursor() - format = QTextCharFormat() - format.setBackground(QColor(DEFAULT_FOUND_TEXT_BG_COLOR)) - format.setForeground(QColor(DEFAULT_FOUND_TEXT_COLOR)) - - # Ensure we start from the beginning - cursor.setPosition(0) - - # Find and highlight all occurrences of the pattern - regex_flags = 0 if case_sensitive else re.IGNORECASE - regex = re.compile(pattern, regex_flags) - text = label.toPlainText() - - for match in regex.finditer(text): - start, end = match.span() - cursor.setPosition(start) - cursor.setPosition(end, QTextCursor.KeepAnchor) - cursor.setCharFormat(format) - - def setCurrentLabelIncludingTextBySliderPosition( - self, text, case_sensitive=False, word_only=False, is_regex=False - ): - labels = self.__getEveryLabels() - label_info = [ - {"class": label.getLbl(), "text": label.getText(), "pos": label.y()} - for label in labels - if isinstance(label, AIChatUnit) or isinstance(label, UserChatUnit) - ] - selections = [] - - for _ in label_info: - pattern = text - _["pattern"] = pattern - if is_regex: - if is_valid_regex(pattern): - if case_sensitive: - result = re.search(pattern, _["text"], re.IGNORECASE) - if result: - selections.append(_) - else: - result = re.search(pattern, _["text"]) - if result: - selections.append(_) - else: - if _["text"].find(text) != -1: - selections.append(_) - else: - if case_sensitive: - if word_only: - pattern = r"\b" + re.escape(text) + r"\b" - result = re.search(pattern, _["text"]) - if result: - selections.append(_) - else: - if _["text"].find(text) != -1: - selections.append(_) - else: - if word_only: - pattern = r"\b" + re.escape(text) + r"\b" - result = re.search(pattern, _["text"], re.IGNORECASE) - if result: - selections.append(_) - else: - pattern = re.escape(text) - result = re.search(pattern, _["text"], re.IGNORECASE) - if result: - selections.append(_) - - return selections - - def replaceThread(self, args: List[ChatMessageContainer], id): - """ - For showing messages from the thread - """ - self.clear() - self.setCurId(id) - self.onReplacedCurrentPage.emit(1) - for i in range(len(args)): - arg = args[i] - # stream is False no matter what - unit = self.__setLabel(arg.content, False, arg.role) - self.__setResponseInfo(unit, arg) - - def replaceThreadForFavorite(self, args: List[ChatMessageContainer]): - """ - For showing favorite messages - """ - self.clear() - self.onReplacedCurrentPage.emit(1) - for i in range(len(args)): - arg = args[i] - # stream is False no matter what - unit = self.__setLabel(arg.content, False, arg.role) - self.__setResponseInfo(unit, arg) - - def setUserImage(self, img): - self.__user_image = img - lbls = self.__getEveryUserLabels() - for lbl in lbls: - lbl.setIcon(img) - - def setAIImage(self, img): - self.__ai_image = img - lbls = self.__getEveryAILabels() - for lbl in lbls: - lbl.setIcon(img) +from __future__ import annotations + +import re + +from qtpy.QtCore import Qt, Signal +from qtpy.QtGui import QColor, QTextCharFormat, QTextCursor +from qtpy.QtWidgets import QLabel, QScrollArea, QVBoxLayout, QWidget + +from pyqt_openai import ( + DEFAULT_FOUND_TEXT_BG_COLOR, + DEFAULT_FOUND_TEXT_COLOR, + MAXIMUM_MESSAGES_IN_PARAMETER, +) +from pyqt_openai.chat_widget.center.aiChatUnit import AIChatUnit +from pyqt_openai.chat_widget.center.userChatUnit import UserChatUnit +from pyqt_openai.globals import DB +from pyqt_openai.models import ChatMessageContainer +from pyqt_openai.util.common import is_valid_regex + + +class ChatBrowser(QScrollArea): + messageUpdated = Signal(ChatMessageContainer) + onReplacedCurrentPage = Signal(int) + + def __init__(self, parent=None): + super().__init__(parent) + self.__initVal() + self.__initUi() + + def __initVal(self): + self.__cur_id = 0 + self.__user_image = "" + self.__ai_image = "" + + def __initUi(self): + lay = QVBoxLayout() + lay.setAlignment(Qt.AlignmentFlag.AlignTop) + lay.setSpacing(0) + lay.setContentsMargins(0, 0, 0, 0) + + self.__chatWidget = QWidget() + self.__chatWidget.setLayout(lay) + + self.setWidget(self.__chatWidget) + self.setWidgetResizable(True) + + def showLabel(self, text, stream_f, arg: ChatMessageContainer): + arg.thread_id = arg.thread_id if arg.thread_id else self.__cur_id + unit = self.__setLabel(text, stream_f, arg.role) + if not stream_f: + arg.id = DB.insertMessage(arg) + self.__setResponseInfo(unit, arg) + + def getLayout(self): + return self.widget().layout() + + def showLabelForFavorite(self, arg: ChatMessageContainer): + unit = self.__setLabel(arg.content, False, arg.role) + self.__setResponseInfo(unit, arg) + + def __getLastUnit(self) -> AIChatUnit | None: + item = self.getLayout().itemAt(self.getLayout().count() - 1) + if item: + return item.widget() + return None + + def __setResponseInfo(self, unit, arg: ChatMessageContainer): + if isinstance(unit, AIChatUnit): + unit.afterResponse(arg) + + def streamFinished(self, arg: ChatMessageContainer): + unit = self.__getLastUnit() + arg.content = self.getLastResponse() + arg.id = DB.insertMessage(arg) + self.__setResponseInfo(unit, arg) + + def __setLabel(self, text, stream_f, role): + chatUnit = QLabel() + if role == "user": + chatUnit = UserChatUnit() + chatUnit.setText(text) + chatUnit.setIcon(self.__user_image) + else: + chatUnit = AIChatUnit() + if chatUnit.getIcon(): + pass + else: + chatUnit.setIcon(self.__ai_image) + if stream_f: + unit = self.__getLastUnit() + if isinstance(unit, AIChatUnit): + unit.toggleGUI(False) + unit.addText(text) + return None + chatUnit.setText(text) + + self.getLayout().addWidget(chatUnit) + return chatUnit + + def event(self, event): + if event.type() == 43: + self.verticalScrollBar().setSliderPosition( + self.verticalScrollBar().maximum(), + ) + return super().event(event) + + def getMessages(self, limit=MAXIMUM_MESSAGES_IN_PARAMETER): + messages = DB.selectCertainThreadMessages(self.__cur_id) + all_text_lst = [ + {"role": message.role, "content": message.content} for message in messages + ] + all_text_lst = all_text_lst[-limit:] + + return all_text_lst + + def getLastResponse(self): + lay = self.getLayout() + if lay: + i = lay.count() - 1 + if lay.itemAt(i) and lay.itemAt(i).widget(): + widget = lay.itemAt(i).widget() + if isinstance(widget, AIChatUnit): + return widget.getText() + return "" + + def clear(self): + """This method is used to clear the chat widget, not the database.""" + lay = self.getLayout() + if lay: + for i in range(lay.count() - 1, -1, -1): + item = lay.itemAt(i) + if item and item.widget(): + item.widget().deleteLater() + self.onReplacedCurrentPage.emit(0) + + def setCurId(self, id): + self.__cur_id = id + + def getCurId(self): + return self.__cur_id + + def resetChatWidget(self, id): + self.clear() + self.setCurId(id) + + def __getLabelsByType(self, label_type=None): + """Retrieve all labels from the widget's layout, optionally filtering by a specific label type. + + :param label_type: The type of label to filter by (e.g., UserChatUnit, AIChatUnit). If None, retrieves all labels. + :return: A list of label widgets. + """ + lay = self.getLayout() + labels = [] + for i in range(lay.count()): + item = lay.itemAt(i) + if item: + widget = item.widget() + if label_type is None or isinstance(widget, label_type): + labels.append(widget) + return labels + + def __getEveryLabels(self): + """Retrieve all labels from the widget's layout. + + :return: A list of all label widgets. + """ + return self.__getLabelsByType() + + def __getEveryUserLabels(self): + """Retrieve all user-specific labels from the widget's layout. + + :return: A list of UserChatUnit label widgets. + """ + return self.__getLabelsByType(UserChatUnit) + + def __getEveryAILabels(self): + """Retrieve all AI-specific labels from the widget's layout. + + :return: A list of AIChatUnit label widgets. + """ + return self.__getLabelsByType(AIChatUnit) + + def isFinishedByLength(self): + return self.__getLastUnit().getResponseInfo().finish_reason == "length" + + def clearFormatting(self, label=None): + if label is None: + # if isinstance(lbl, AIChatUnit) or isinstance(lbl, UserChatUnit) should be added + # Or else AttributeError: 'QWidget' object has no attribute 'getLbl' will be raised when calling getLbl() + labels = [ + lbl.getLbl() + for lbl in self.__getEveryLabels() + if isinstance(lbl, AIChatUnit) or isinstance(lbl, UserChatUnit) + ] + for lbl in labels: + self.clearFormatting(lbl) + return + cursor = label.textCursor() + cursor.select(QTextCursor.Document) + format = QTextCharFormat() + cursor.setCharFormat(format) + + def highlightText(self, label, pattern, case_sensitive): + self.clearFormatting(label) # Clear any previous formatting + + if pattern == "": + return + + cursor = label.textCursor() + format = QTextCharFormat() + format.setBackground(QColor(DEFAULT_FOUND_TEXT_BG_COLOR)) + format.setForeground(QColor(DEFAULT_FOUND_TEXT_COLOR)) + + # Ensure we start from the beginning + cursor.setPosition(0) + + # Find and highlight all occurrences of the pattern + regex_flags = 0 if case_sensitive else re.IGNORECASE + regex = re.compile(pattern, regex_flags) + text = label.toPlainText() + + for match in regex.finditer(text): + start, end = match.span() + cursor.setPosition(start) + cursor.setPosition(end, QTextCursor.KeepAnchor) + cursor.setCharFormat(format) + + def setCurrentLabelIncludingTextBySliderPosition( + self, text, case_sensitive=False, word_only=False, is_regex=False, + ): + labels = self.__getEveryLabels() + label_info = [ + {"class": label.getLbl(), "text": label.getText(), "pos": label.y()} + for label in labels + if isinstance(label, AIChatUnit) or isinstance(label, UserChatUnit) + ] + selections = [] + + for _ in label_info: + pattern = text + _["pattern"] = pattern + if is_regex: + if is_valid_regex(pattern): + if case_sensitive: + result = re.search(pattern, _["text"], re.IGNORECASE) + if result: + selections.append(_) + else: + result = re.search(pattern, _["text"]) + if result: + selections.append(_) + elif _["text"].find(text) != -1: + selections.append(_) + elif case_sensitive: + if word_only: + pattern = r"\b" + re.escape(text) + r"\b" + result = re.search(pattern, _["text"]) + if result: + selections.append(_) + elif _["text"].find(text) != -1: + selections.append(_) + elif word_only: + pattern = r"\b" + re.escape(text) + r"\b" + result = re.search(pattern, _["text"], re.IGNORECASE) + if result: + selections.append(_) + else: + pattern = re.escape(text) + result = re.search(pattern, _["text"], re.IGNORECASE) + if result: + selections.append(_) + + return selections + + def replaceThread(self, args: list[ChatMessageContainer], id): + """For showing messages from the thread.""" + self.clear() + self.setCurId(id) + self.onReplacedCurrentPage.emit(1) + for i in range(len(args)): + arg = args[i] + # stream is False no matter what + unit = self.__setLabel(arg.content, False, arg.role) + self.__setResponseInfo(unit, arg) + + def replaceThreadForFavorite(self, args: list[ChatMessageContainer]): + """For showing favorite messages.""" + self.clear() + self.onReplacedCurrentPage.emit(1) + for i in range(len(args)): + arg = args[i] + # stream is False no matter what + unit = self.__setLabel(arg.content, False, arg.role) + self.__setResponseInfo(unit, arg) + + def setUserImage(self, img): + self.__user_image = img + lbls = self.__getEveryUserLabels() + for lbl in lbls: + lbl.setIcon(img) + + def setAIImage(self, img): + self.__ai_image = img + lbls = self.__getEveryAILabels() + for lbl in lbls: + lbl.setIcon(img) diff --git a/pyqt_openai/chat_widget/center/chatHome.py b/pyqt_openai/chat_widget/center/chatHome.py index d72c88bc..9ce28483 100644 --- a/pyqt_openai/chat_widget/center/chatHome.py +++ b/pyqt_openai/chat_widget/center/chatHome.py @@ -1,57 +1,57 @@ -# Currently this page is home page of the application. - -from PySide6.QtCore import Qt -from PySide6.QtGui import QFont, QPixmap -from PySide6.QtWidgets import QLabel, QWidget, QVBoxLayout, QScrollArea - -from pyqt_openai import ( - DEFAULT_APP_NAME, - HOW_TO_GET_OPENAI_API_KEY_URL, - LARGE_LABEL_PARAM, - MEDIUM_LABEL_PARAM, - QUICKSTART_MANUAL_URL, -) -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.widgets.linkLabel import LinkLabel - - -class ChatHome(QScrollArea): - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - title = QLabel(f"Welcome to {DEFAULT_APP_NAME}!", self) - title.setFont(QFont(*LARGE_LABEL_PARAM)) - title.setAlignment(Qt.AlignmentFlag.AlignCenter) - - description = QLabel( - LangClass.TRANSLATIONS["Enjoy convenient chatting, all day long!"] - ) - - self.__quickStartManualLbl = LinkLabel() - self.__quickStartManualLbl.setText(LangClass.TRANSLATIONS["Quick Start Manual"]) - self.__quickStartManualLbl.setUrl(QUICKSTART_MANUAL_URL) - self.__quickStartManualLbl.setFont(QFont(*MEDIUM_LABEL_PARAM)) - self.__quickStartManualLbl.setAlignment(Qt.AlignmentFlag.AlignCenter) - - self.__background_image = QLabel() - - description.setFont(QFont(*MEDIUM_LABEL_PARAM)) - description.setAlignment(Qt.AlignmentFlag.AlignCenter) - - lay = QVBoxLayout() - lay.addWidget(title) - lay.addWidget(description) - lay.addWidget(self.__quickStartManualLbl) - lay.addWidget(self.__background_image) - lay.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.setLayout(lay) - - mainWidget = QWidget() - mainWidget.setLayout(lay) - self.setWidget(mainWidget) - self.setWidgetResizable(True) - - def setPixmap(self, filename): - self.__background_image.setPixmap(QPixmap(filename)) +# Currently this page is home page of the application. +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtGui import QFont, QPixmap +from qtpy.QtWidgets import QLabel, QScrollArea, QVBoxLayout, QWidget + +from pyqt_openai import ( + DEFAULT_APP_NAME, + LARGE_LABEL_PARAM, + MEDIUM_LABEL_PARAM, + QUICKSTART_MANUAL_URL, +) +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.widgets.linkLabel import LinkLabel + + +class ChatHome(QScrollArea): + def __init__(self, parent=None): + super().__init__(parent) + self.__initUi() + + def __initUi(self): + title = QLabel(f"Welcome to {DEFAULT_APP_NAME}!", self) + title.setFont(QFont(*LARGE_LABEL_PARAM)) + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + + description = QLabel( + LangClass.TRANSLATIONS["Enjoy convenient chatting, all day long!"], + ) + + self.__quickStartManualLbl = LinkLabel() + self.__quickStartManualLbl.setText(LangClass.TRANSLATIONS["Quick Start Manual"]) + self.__quickStartManualLbl.setUrl(QUICKSTART_MANUAL_URL) + self.__quickStartManualLbl.setFont(QFont(*MEDIUM_LABEL_PARAM)) + self.__quickStartManualLbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.__background_image = QLabel() + + description.setFont(QFont(*MEDIUM_LABEL_PARAM)) + description.setAlignment(Qt.AlignmentFlag.AlignCenter) + + lay = QVBoxLayout() + lay.addWidget(title) + lay.addWidget(description) + lay.addWidget(self.__quickStartManualLbl) + lay.addWidget(self.__background_image) + lay.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setLayout(lay) + + mainWidget = QWidget() + mainWidget.setLayout(lay) + self.setWidget(mainWidget) + self.setWidgetResizable(True) + + def setPixmap(self, filename): + self.__background_image.setPixmap(QPixmap(filename)) diff --git a/pyqt_openai/chat_widget/center/chatUnit.py b/pyqt_openai/chat_widget/center/chatUnit.py index d5dc1acd..7e732b80 100644 --- a/pyqt_openai/chat_widget/center/chatUnit.py +++ b/pyqt_openai/chat_widget/center/chatUnit.py @@ -1,13 +1,16 @@ +from __future__ import annotations + import pyperclip -from PySide6.QtCore import Qt -from PySide6.QtGui import QPalette -from PySide6.QtWidgets import ( - QWidget, - QVBoxLayout, + +from qtpy.QtCore import Qt +from qtpy.QtGui import QPalette +from qtpy.QtWidgets import ( QHBoxLayout, - QSpacerItem, - QSizePolicy, QLabel, + QSizePolicy, + QSpacerItem, + QVBoxLayout, + QWidget, ) from pyqt_openai import DEFAULT_ICON_SIZE, ICON_COPY diff --git a/pyqt_openai/chat_widget/center/chatWidget.py b/pyqt_openai/chat_widget/center/chatWidget.py index ef7dfe12..caafb0e1 100644 --- a/pyqt_openai/chat_widget/center/chatWidget.py +++ b/pyqt_openai/chat_widget/center/chatWidget.py @@ -1,348 +1,350 @@ -import json -import sys - -from PySide6.QtCore import Signal -from PySide6.QtWidgets import ( - QStackedWidget, - QWidget, - QSizePolicy, - QHBoxLayout, - QVBoxLayout, - QMessageBox, -) - -from pyqt_openai.chat_widget.center.chatBrowser import ChatBrowser -from pyqt_openai.chat_widget.center.chatHome import ChatHome -from pyqt_openai.chat_widget.center.menuWidget import MenuWidget -from pyqt_openai.chat_widget.center.prompt import Prompt -from pyqt_openai.chat_widget.llamaIndexThread import LlamaIndexThread -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.globals import LLAMAINDEX_WRAPPER, DB -from pyqt_openai.util.common import get_argument, ChatThread -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.models import ChatMessageContainer -from pyqt_openai.widgets.notifier import NotifierWidget - - -class ChatWidget(QWidget): - addThread = Signal() - onMenuCloseClicked = Signal() - - def __init__(self, parent=None): - super().__init__(parent) - self.__initVal() - self.__initUi() - - def __initVal(self): - self.__cur_id = 0 - self.__notify_finish = CONFIG_MANAGER.get_general_property("notify_finish") - self.__is_g4f = False - - def __initUi(self): - # Main widget - # This contains home page (at the beginning of the stack) and - # widget for main view - self.__mainWidget = QStackedWidget() - - self.__homePage = ChatHome() - self.__browser = ChatBrowser() - self.__browser.onReplacedCurrentPage.connect(self.__mainWidget.setCurrentIndex) - - self.__menuWidget = MenuWidget(self.__browser) - self.__menuWidget.onMenuCloseClicked.connect(self.__onMenuCloseClicked) - self.__menuWidget.setVisible(False) - - lay = QVBoxLayout() - lay.addWidget(self.__menuWidget) - lay.addWidget(self.__browser) - lay.setSpacing(0) - lay.setContentsMargins(0, 0, 0, 0) - - chatWidget = QWidget() - chatWidget.setLayout(lay) - - self.__mainWidget.addWidget(self.__homePage) - self.__mainWidget.addWidget(chatWidget) - - self.__prompt = Prompt(self) - self.__prompt.onRecording.connect(self.__toggleWidgetWhileRecording) - self.__prompt.onStoppedClicked.connect(self.__stopResponse) - self.__mainPrompt = self.__prompt.getMainPromptInput() - - lay = QHBoxLayout() - lay.addWidget(self.__prompt) - lay.setSpacing(0) - lay.setContentsMargins(0, 0, 0, 0) - - self.__queryWidget = QWidget() - self.__queryWidget.setLayout(lay) - self.__queryWidget.setSizePolicy( - QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum - ) - - lay = QVBoxLayout() - lay.addWidget(self.__mainWidget) - lay.addWidget(self.__queryWidget) - lay.setSpacing(0) - lay.setContentsMargins(0, 0, 0, 0) - - self.__mainPrompt.setFocus() - - self.setLayout(lay) - - self.__mainPrompt.returnPressed.connect(self.__chat) - - def showTitle(self, title): - self.__menuWidget.setTitle(title) - - def getChatBrowser(self): - return self.__browser - - def toggleMenuWidget(self, f): - self.__menuWidget.setVisible(f) - self.__menuWidget.getFindTextWidget().clearFormatting() - - def __onMenuCloseClicked(self): - self.__mainPrompt.setFocus() - self.onMenuCloseClicked.emit() - - def setAIEnabled(self, f): - self.__prompt.setEnabled(f) - - def refreshCustomizedInformation( - self, background_image=None, user_image=None, ai_image=None - ): - self.__homePage.setPixmap(background_image) - self.__browser.setUserImage(user_image) - self.__browser.setAIImage(ai_image) - - def setCurId(self, id): - self.__cur_id = id - self.__browser.setCurId(id) - - def getCurId(self): - return self.__cur_id - - def __chat(self): - # If main prompt is empty, do nothing - # TODO LANGUAGE - if not self.__prompt.getContent(): - QMessageBox.warning( - self, - LangClass.TRANSLATIONS["Warning"], - LangClass.TRANSLATIONS["Please write something before sending."], - ) - return - - try: - # Get necessary parameters - stream = CONFIG_MANAGER.get_general_property("stream") - model = ( - CONFIG_MANAGER.get_general_property("g4f_model") - if self.__is_g4f - else CONFIG_MANAGER.get_general_property("model") - ) - system = CONFIG_MANAGER.get_general_property("system") - temperature = CONFIG_MANAGER.get_general_property("temperature") - max_tokens = CONFIG_MANAGER.get_general_property("max_tokens") - top_p = CONFIG_MANAGER.get_general_property("top_p") - is_json_response_available = ( - 1 if CONFIG_MANAGER.get_general_property("json_object") else 0 - ) - frequency_penalty = CONFIG_MANAGER.get_general_property("frequency_penalty") - presence_penalty = CONFIG_MANAGER.get_general_property("presence_penalty") - use_llama_index = CONFIG_MANAGER.get_general_property("use_llama_index") - use_max_tokens = CONFIG_MANAGER.get_general_property("use_max_tokens") - provider = CONFIG_MANAGER.get_general_property("provider") - g4f_use_chat_history = CONFIG_MANAGER.get_general_property( - "g4f_use_chat_history" - ) - - # Get image files - images = self.__prompt.getImageBuffers() - - maximum_messages_in_parameter = CONFIG_MANAGER.get_general_property( - "maximum_messages_in_parameter" - ) - messages = self.__browser.getMessages(maximum_messages_in_parameter) - if self.__is_g4f and not g4f_use_chat_history: - messages = [] - - cur_text = self.__prompt.getContent() - - json_content = self.__prompt.getJSONContent() - - is_llama_available = False - if use_llama_index: - # Check llamaindex is available - is_llama_available = LLAMAINDEX_WRAPPER.get_directory() != "" - if is_llama_available: - if LLAMAINDEX_WRAPPER.is_query_engine_set(): - pass - else: - LLAMAINDEX_WRAPPER.set_query_engine( - streaming=stream, similarity_top_k=3 - ) - else: - QMessageBox.warning( - self, - LangClass.TRANSLATIONS["Warning"], - LangClass.TRANSLATIONS[ - "LLAMA index is not available. Please check the directory path or disable the llama index." - ], - ) - return - - # Check JSON response is valid - if is_json_response_available: - if not json_content: - QMessageBox.critical( - self, - LangClass.TRANSLATIONS["Error"], - LangClass.TRANSLATIONS[ - "JSON content is empty. Please fill in the JSON content field." - ], - ) - return - try: - json.loads(json_content) - except Exception as e: - QMessageBox.critical( - self, - LangClass.TRANSLATIONS["Error"], - f'{LangClass.TRANSLATIONS["JSON content is not valid. Please check the JSON content field."]}\n\n{e}', - ) - return - - param = get_argument( - model, - system, - messages, - cur_text, - temperature, - top_p, - frequency_penalty, - presence_penalty, - stream, - use_max_tokens, - max_tokens, - images, - is_llama_available, - is_json_response_available, - json_content, - self.__is_g4f, - ) - - # If there is no current conversation selected on the list to the left, make a new one. - if self.__mainWidget.currentIndex() == 0: - self.addThread.emit() - - # Additional information of user's input - additional_info = { - "role": "user", - "content": cur_text, - "model_name": param["model"], - "finish_reason": "", - "prompt_tokens": "", - "completion_tokens": "", - "total_tokens": "", - "is_json_response_available": is_json_response_available, - } - - container_param = { - k: v - for k, v in {**param, **additional_info}.items() - if k in ChatMessageContainer.get_keys() - } - - # Create a container for the user's input and output from the chatbot - container = ChatMessageContainer(**container_param) - - query_text = self.__prompt.getContent() - self.__browser.showLabel(query_text, False, container) - - # Run a different thread based on whether the llama-index is enabled or not. - if is_llama_available: - self.__t = LlamaIndexThread( - param, container, LLAMAINDEX_WRAPPER, query_text - ) - else: - self.__t = ChatThread( - param, info=container, is_g4f=self.__is_g4f, provider=provider - ) - - self.__t.started.connect(self.__beforeGenerated) - self.__t.replyGenerated.connect(self.__browser.showLabel) - self.__t.streamFinished.connect(self.__browser.streamFinished) - self.__t.start() - self.__t.finished.connect(self.__afterGenerated) - - # Remove image files widget from the window - self.__prompt.resetUploadImageFileWidget() - - except Exception as e: - # get the line of error and filename - exc_type, exc_obj, tb = sys.exc_info() - f = tb.tb_frame - lineno = tb.tb_lineno - filename = f.f_code.co_filename - QMessageBox.critical( - self, - LangClass.TRANSLATIONS["Error"], - f""" - {str(e)}, - 'File: {filename}', - 'Line: {lineno}' - """, - ) - - def __stopResponse(self): - self.__t.stop() - - def __toggleWidgetWhileRecording(self, f): - self.__mainPrompt.setExecuteEnabled(not f) - self.__prompt.sendEnabled(not f) - - def __toggleWidgetWhileChatting(self, f): - self.__mainPrompt.setExecuteEnabled(f) - self.__prompt.showWidgetInPromptDuringResponse(not f) - self.__prompt.sendEnabled(f) - - def __beforeGenerated(self): - self.__toggleWidgetWhileChatting(False) - self.__mainPrompt.clear() - - def __afterGenerated(self): - self.__toggleWidgetWhileChatting(True) - self.__mainPrompt.setFocus() - if not self.isVisible() or not self.window().isActiveWindow(): - if self.__notify_finish: - self.__notifierWidget = NotifierWidget( - informative_text=LangClass.TRANSLATIONS["Response 👌"], - detailed_text=self.__browser.getLastResponse(), - ) - self.__notifierWidget.show() - self.__notifierWidget.doubleClicked.connect(self.__bringWindowToFront) - - def __bringWindowToFront(self): - window = self.window() - window.showNormal() - window.raise_() - window.activateWindow() - - def setG4F(self, idx): - # Decide whether to use G4F based on the current tab index - self.__is_g4f = idx == 0 - - def toggleJSON(self, f): - self.__prompt.toggleJSON(f) - - def showMessages(self, cur_id): - self.__browser.resetChatWidget(cur_id) - self.__browser.replaceThread(DB.selectCertainThreadMessages(cur_id), cur_id) - self.__mainPrompt.setFocus() - # Reset menu widget - self.__menuWidget.getFindTextWidget().clearFormatting() - - def clearMessages(self): - self.__browser.resetChatWidget(0) +from __future__ import annotations + +import json +import sys + +from qtpy.QtCore import Signal +from qtpy.QtWidgets import ( + QHBoxLayout, + QMessageBox, + QSizePolicy, + QStackedWidget, + QVBoxLayout, + QWidget, +) + +from pyqt_openai.chat_widget.center.chatBrowser import ChatBrowser +from pyqt_openai.chat_widget.center.chatHome import ChatHome +from pyqt_openai.chat_widget.center.menuWidget import MenuWidget +from pyqt_openai.chat_widget.center.prompt import Prompt +from pyqt_openai.chat_widget.llamaIndexThread import LlamaIndexThread +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.globals import DB, LLAMAINDEX_WRAPPER +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.models import ChatMessageContainer +from pyqt_openai.util.common import ChatThread, get_argument +from pyqt_openai.widgets.notifier import NotifierWidget + + +class ChatWidget(QWidget): + addThread = Signal() + onMenuCloseClicked = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.__initVal() + self.__initUi() + + def __initVal(self): + self.__cur_id = 0 + self.__notify_finish = CONFIG_MANAGER.get_general_property("notify_finish") + self.__is_g4f = False + + def __initUi(self): + # Main widget + # This contains home page (at the beginning of the stack) and + # widget for main view + self.__mainWidget = QStackedWidget() + + self.__homePage = ChatHome() + self.__browser = ChatBrowser() + self.__browser.onReplacedCurrentPage.connect(self.__mainWidget.setCurrentIndex) + + self.__menuWidget = MenuWidget(self.__browser) + self.__menuWidget.onMenuCloseClicked.connect(self.__onMenuCloseClicked) + self.__menuWidget.setVisible(False) + + lay = QVBoxLayout() + lay.addWidget(self.__menuWidget) + lay.addWidget(self.__browser) + lay.setSpacing(0) + lay.setContentsMargins(0, 0, 0, 0) + + chatWidget = QWidget() + chatWidget.setLayout(lay) + + self.__mainWidget.addWidget(self.__homePage) + self.__mainWidget.addWidget(chatWidget) + + self.__prompt = Prompt(self) + self.__prompt.onRecording.connect(self.__toggleWidgetWhileRecording) + self.__prompt.onStoppedClicked.connect(self.__stopResponse) + self.__mainPrompt = self.__prompt.getMainPromptInput() + + lay = QHBoxLayout() + lay.addWidget(self.__prompt) + lay.setSpacing(0) + lay.setContentsMargins(0, 0, 0, 0) + + self.__queryWidget = QWidget() + self.__queryWidget.setLayout(lay) + self.__queryWidget.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum, + ) + + lay = QVBoxLayout() + lay.addWidget(self.__mainWidget) + lay.addWidget(self.__queryWidget) + lay.setSpacing(0) + lay.setContentsMargins(0, 0, 0, 0) + + self.__mainPrompt.setFocus() + + self.setLayout(lay) + + self.__mainPrompt.returnPressed.connect(self.__chat) + + def showTitle(self, title): + self.__menuWidget.setTitle(title) + + def getChatBrowser(self): + return self.__browser + + def toggleMenuWidget(self, f): + self.__menuWidget.setVisible(f) + self.__menuWidget.getFindTextWidget().clearFormatting() + + def __onMenuCloseClicked(self): + self.__mainPrompt.setFocus() + self.onMenuCloseClicked.emit() + + def setAIEnabled(self, f): + self.__prompt.setEnabled(f) + + def refreshCustomizedInformation( + self, background_image=None, user_image=None, ai_image=None, + ): + self.__homePage.setPixmap(background_image) + self.__browser.setUserImage(user_image) + self.__browser.setAIImage(ai_image) + + def setCurId(self, id): + self.__cur_id = id + self.__browser.setCurId(id) + + def getCurId(self): + return self.__cur_id + + def __chat(self): + # If main prompt is empty, do nothing + # TODO LANGUAGE + if not self.__prompt.getContent(): + QMessageBox.warning( + self, + LangClass.TRANSLATIONS["Warning"], + LangClass.TRANSLATIONS["Please write something before sending."], + ) + return + + try: + # Get necessary parameters + stream = CONFIG_MANAGER.get_general_property("stream") + model = ( + CONFIG_MANAGER.get_general_property("g4f_model") + if self.__is_g4f + else CONFIG_MANAGER.get_general_property("model") + ) + system = CONFIG_MANAGER.get_general_property("system") + temperature = CONFIG_MANAGER.get_general_property("temperature") + max_tokens = CONFIG_MANAGER.get_general_property("max_tokens") + top_p = CONFIG_MANAGER.get_general_property("top_p") + is_json_response_available = ( + 1 if CONFIG_MANAGER.get_general_property("json_object") else 0 + ) + frequency_penalty = CONFIG_MANAGER.get_general_property("frequency_penalty") + presence_penalty = CONFIG_MANAGER.get_general_property("presence_penalty") + use_llama_index = CONFIG_MANAGER.get_general_property("use_llama_index") + use_max_tokens = CONFIG_MANAGER.get_general_property("use_max_tokens") + provider = CONFIG_MANAGER.get_general_property("provider") + g4f_use_chat_history = CONFIG_MANAGER.get_general_property( + "g4f_use_chat_history", + ) + + # Get image files + images = self.__prompt.getImageBuffers() + + maximum_messages_in_parameter = CONFIG_MANAGER.get_general_property( + "maximum_messages_in_parameter", + ) + messages = self.__browser.getMessages(maximum_messages_in_parameter) + if self.__is_g4f and not g4f_use_chat_history: + messages = [] + + cur_text = self.__prompt.getContent() + + json_content = self.__prompt.getJSONContent() + + is_llama_available = False + if use_llama_index: + # Check llamaindex is available + is_llama_available = LLAMAINDEX_WRAPPER.get_directory() != "" + if is_llama_available: + if LLAMAINDEX_WRAPPER.is_query_engine_set(): + pass + else: + LLAMAINDEX_WRAPPER.set_query_engine( + streaming=stream, similarity_top_k=3, + ) + else: + QMessageBox.warning( + self, + LangClass.TRANSLATIONS["Warning"], + LangClass.TRANSLATIONS[ + "LLAMA index is not available. Please check the directory path or disable the llama index." + ], + ) + return + + # Check JSON response is valid + if is_json_response_available: + if not json_content: + QMessageBox.critical( + self, + LangClass.TRANSLATIONS["Error"], + LangClass.TRANSLATIONS[ + "JSON content is empty. Please fill in the JSON content field." + ], + ) + return + try: + json.loads(json_content) + except Exception as e: + QMessageBox.critical( + self, + LangClass.TRANSLATIONS["Error"], + f'{LangClass.TRANSLATIONS["JSON content is not valid. Please check the JSON content field."]}\n\n{e}', + ) + return + + param = get_argument( + model, + system, + messages, + cur_text, + temperature, + top_p, + frequency_penalty, + presence_penalty, + stream, + use_max_tokens, + max_tokens, + images, + is_llama_available, + is_json_response_available, + json_content, + self.__is_g4f, + ) + + # If there is no current conversation selected on the list to the left, make a new one. + if self.__mainWidget.currentIndex() == 0: + self.addThread.emit() + + # Additional information of user's input + additional_info = { + "role": "user", + "content": cur_text, + "model_name": param["model"], + "finish_reason": "", + "prompt_tokens": "", + "completion_tokens": "", + "total_tokens": "", + "is_json_response_available": is_json_response_available, + } + + container_param = { + k: v + for k, v in {**param, **additional_info}.items() + if k in ChatMessageContainer.get_keys() + } + + # Create a container for the user's input and output from the chatbot + container = ChatMessageContainer(**container_param) + + query_text = self.__prompt.getContent() + self.__browser.showLabel(query_text, False, container) + + # Run a different thread based on whether the llama-index is enabled or not. + if is_llama_available: + self.__t = LlamaIndexThread( + param, container, LLAMAINDEX_WRAPPER, query_text, + ) + else: + self.__t = ChatThread( + param, info=container, is_g4f=self.__is_g4f, provider=provider, + ) + + self.__t.started.connect(self.__beforeGenerated) + self.__t.replyGenerated.connect(self.__browser.showLabel) + self.__t.streamFinished.connect(self.__browser.streamFinished) + self.__t.start() + self.__t.finished.connect(self.__afterGenerated) + + # Remove image files widget from the window + self.__prompt.resetUploadImageFileWidget() + + except Exception as e: + # get the line of error and filename + exc_type, exc_obj, tb = sys.exc_info() + f = tb.tb_frame + lineno = tb.tb_lineno + filename = f.f_code.co_filename + QMessageBox.critical( + self, + LangClass.TRANSLATIONS["Error"], + f""" + {e!s}, + 'File: {filename}', + 'Line: {lineno}' + """, + ) + + def __stopResponse(self): + self.__t.stop() + + def __toggleWidgetWhileRecording(self, f): + self.__mainPrompt.setExecuteEnabled(not f) + self.__prompt.sendEnabled(not f) + + def __toggleWidgetWhileChatting(self, f): + self.__mainPrompt.setExecuteEnabled(f) + self.__prompt.showWidgetInPromptDuringResponse(not f) + self.__prompt.sendEnabled(f) + + def __beforeGenerated(self): + self.__toggleWidgetWhileChatting(False) + self.__mainPrompt.clear() + + def __afterGenerated(self): + self.__toggleWidgetWhileChatting(True) + self.__mainPrompt.setFocus() + if not self.isVisible() or not self.window().isActiveWindow(): + if self.__notify_finish: + self.__notifierWidget = NotifierWidget( + informative_text=LangClass.TRANSLATIONS["Response 👌"], + detailed_text=self.__browser.getLastResponse(), + ) + self.__notifierWidget.show() + self.__notifierWidget.doubleClicked.connect(self.__bringWindowToFront) + + def __bringWindowToFront(self): + window = self.window() + window.showNormal() + window.raise_() + window.activateWindow() + + def setG4F(self, idx): + # Decide whether to use G4F based on the current tab index + self.__is_g4f = idx == 0 + + def toggleJSON(self, f): + self.__prompt.toggleJSON(f) + + def showMessages(self, cur_id): + self.__browser.resetChatWidget(cur_id) + self.__browser.replaceThread(DB.selectCertainThreadMessages(cur_id), cur_id) + self.__mainPrompt.setFocus() + # Reset menu widget + self.__menuWidget.getFindTextWidget().clearFormatting() + + def clearMessages(self): + self.__browser.resetChatWidget(0) diff --git a/pyqt_openai/chat_widget/center/commandCompleter.py b/pyqt_openai/chat_widget/center/commandCompleter.py index 383ad163..f30fb962 100644 --- a/pyqt_openai/chat_widget/center/commandCompleter.py +++ b/pyqt_openai/chat_widget/center/commandCompleter.py @@ -1,107 +1,109 @@ -from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import ( - QTableWidget, - QHeaderView, - QScrollArea, - QStyledItemDelegate, - QStyle, - QTableWidgetItem, -) - - -class CommandCompleterTableWidgetDelegate(QStyledItemDelegate): - def paint(self, painter, option, index): - # Check if the item is selected - if option.state & QStyle.StateFlag.State_Active: - # Set the background color for selected item - option.palette.setColor(option.palette.Highlight, Qt.GlobalColor.lightGray) - - # Call the base paint method - super().paint(painter, option, index) - - -class CommandCompleterTableWidget(QTableWidget): - showText = Signal(str) - - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - self.setWindowFlags(Qt.WindowType.ToolTip) - self.setColumnCount(2) - - self.horizontalHeader().setVisible(False) - self.verticalHeader().setVisible(False) - self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) - - self.clicked.connect(self.__showText) - - delegate = CommandCompleterTableWidgetDelegate() - - self.setItemDelegate(delegate) - - def searchTexts(self, text): - matched_texts_lst = [] - for i in range(self.rowCount()): - widget = self.item(i, 0) - if widget: - widget_text = widget.text() - if text.strip() != "": - idx = widget_text.lower().find(text.lower()) - if idx != -1: - matched_texts_lst.append(text) - self.showRow(i) - else: - self.hideRow(i) - else: - self.hideRow(i) - return len(matched_texts_lst) > 0 - - def addPromptCommand(self, prompt_command_lst: list): - for prompt_command_unit in prompt_command_lst: - name = prompt_command_unit["name"] - value = prompt_command_unit["value"] - - item1 = QTableWidgetItem() - item1.setText(name) - item1.setTextAlignment(Qt.AlignmentFlag.AlignCenter) - - item2 = QTableWidgetItem() - item2.setText(value) - item2.setTextAlignment(Qt.AlignmentFlag.AlignCenter) - - row_idx = self.rowCount() - self.setRowCount(row_idx + 1) - self.setItem(row_idx, 0, item1) - self.setItem(row_idx, 1, item2) - self.hideRow(row_idx) - - def __showText(self, idx): - widget = self.indexWidget(idx) - if widget: - self.showText.emit(widget.text()) - - -class CommandCompleter(QScrollArea): - showText = Signal(str) - - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - self.__completerTable = CommandCompleterTableWidget() - self.__completerTable.showText.connect(self.__showText) - - self.setVisible(False) - self.setWidgetResizable(True) - - self.setWidget(self.__completerTable) - - def __showText(self, text): - self.setVisible(False) - self.showText.emit(text) - - def addPromptCommand(self, prompt_command_lst: list): - self.__completerTable.addPromptCommand(prompt_command_lst) +from __future__ import annotations + +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import ( + QHeaderView, + QScrollArea, + QStyle, + QStyledItemDelegate, + QTableWidget, + QTableWidgetItem, +) + + +class CommandCompleterTableWidgetDelegate(QStyledItemDelegate): + def paint(self, painter, option, index): + # Check if the item is selected + if option.state & QStyle.StateFlag.State_Active: + # Set the background color for selected item + option.palette.setColor(option.palette.Highlight, Qt.GlobalColor.lightGray) + + # Call the base paint method + super().paint(painter, option, index) + + +class CommandCompleterTableWidget(QTableWidget): + showText = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.__initUi() + + def __initUi(self): + self.setWindowFlags(Qt.WindowType.ToolTip) + self.setColumnCount(2) + + self.horizontalHeader().setVisible(False) + self.verticalHeader().setVisible(False) + self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + + self.clicked.connect(self.__showText) + + delegate = CommandCompleterTableWidgetDelegate() + + self.setItemDelegate(delegate) + + def searchTexts(self, text): + matched_texts_lst = [] + for i in range(self.rowCount()): + widget = self.item(i, 0) + if widget: + widget_text = widget.text() + if text.strip() != "": + idx = widget_text.lower().find(text.lower()) + if idx != -1: + matched_texts_lst.append(text) + self.showRow(i) + else: + self.hideRow(i) + else: + self.hideRow(i) + return len(matched_texts_lst) > 0 + + def addPromptCommand(self, prompt_command_lst: list): + for prompt_command_unit in prompt_command_lst: + name = prompt_command_unit["name"] + value = prompt_command_unit["value"] + + item1 = QTableWidgetItem() + item1.setText(name) + item1.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + + item2 = QTableWidgetItem() + item2.setText(value) + item2.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + + row_idx = self.rowCount() + self.setRowCount(row_idx + 1) + self.setItem(row_idx, 0, item1) + self.setItem(row_idx, 1, item2) + self.hideRow(row_idx) + + def __showText(self, idx): + widget = self.indexWidget(idx) + if widget: + self.showText.emit(widget.text()) + + +class CommandCompleter(QScrollArea): + showText = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.__initUi() + + def __initUi(self): + self.__completerTable = CommandCompleterTableWidget() + self.__completerTable.showText.connect(self.__showText) + + self.setVisible(False) + self.setWidgetResizable(True) + + self.setWidget(self.__completerTable) + + def __showText(self, text): + self.setVisible(False) + self.showText.emit(text) + + def addPromptCommand(self, prompt_command_lst: list): + self.__completerTable.addPromptCommand(prompt_command_lst) diff --git a/pyqt_openai/chat_widget/center/commandSuggestionWidget.py b/pyqt_openai/chat_widget/center/commandSuggestionWidget.py index a63e8e3c..db153e34 100644 --- a/pyqt_openai/chat_widget/center/commandSuggestionWidget.py +++ b/pyqt_openai/chat_widget/center/commandSuggestionWidget.py @@ -1,40 +1,42 @@ -from PySide6.QtCore import Signal -from PySide6.QtWidgets import QListWidget, QWidget, QVBoxLayout, QLabel - -from pyqt_openai.lang.translations import LangClass - - -class CommandSuggestionWidget(QWidget): - toggleCommandSuggestion = Signal(bool) - - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - self.__commandList = QListWidget() - self.__commandList.currentRowChanged.connect(self.onCountChanged) - - lay = QVBoxLayout() - lay.addWidget(QLabel(LangClass.TRANSLATIONS["Command List"])) - lay.addWidget(self.__commandList) - lay.setContentsMargins(0, 5, 0, 0) - - self.setLayout(lay) - - def getCommandList(self): - return self.__commandList - - def onCountChanged(self): - itemHeight = self.__commandList.sizeHintForRow(0) - itemCount = self.__commandList.count() - contentHeight = itemHeight * itemCount - scrollbarHeight = self.__commandList.verticalScrollBar().sizeHint().height() - totalHeight = contentHeight + itemHeight - self.setMaximumHeight(totalHeight + scrollbarHeight) - - def showEvent(self, event): - self.toggleCommandSuggestion.emit(True) - - def hideEvent(self, event): - self.toggleCommandSuggestion.emit(False) +from __future__ import annotations + +from qtpy.QtCore import Signal +from qtpy.QtWidgets import QLabel, QListWidget, QVBoxLayout, QWidget + +from pyqt_openai.lang.translations import LangClass + + +class CommandSuggestionWidget(QWidget): + toggleCommandSuggestion = Signal(bool) + + def __init__(self, parent=None): + super().__init__(parent) + self.__initUi() + + def __initUi(self): + self.__commandList = QListWidget() + self.__commandList.currentRowChanged.connect(self.onCountChanged) + + lay = QVBoxLayout() + lay.addWidget(QLabel(LangClass.TRANSLATIONS["Command List"])) + lay.addWidget(self.__commandList) + lay.setContentsMargins(0, 5, 0, 0) + + self.setLayout(lay) + + def getCommandList(self): + return self.__commandList + + def onCountChanged(self): + itemHeight = self.__commandList.sizeHintForRow(0) + itemCount = self.__commandList.count() + contentHeight = itemHeight * itemCount + scrollbarHeight = self.__commandList.verticalScrollBar().sizeHint().height() + totalHeight = contentHeight + itemHeight + self.setMaximumHeight(totalHeight + scrollbarHeight) + + def showEvent(self, event): + self.toggleCommandSuggestion.emit(True) + + def hideEvent(self, event): + self.toggleCommandSuggestion.emit(False) diff --git a/pyqt_openai/chat_widget/center/findTextWidget.py b/pyqt_openai/chat_widget/center/findTextWidget.py index f4ab601b..f60bc9c3 100644 --- a/pyqt_openai/chat_widget/center/findTextWidget.py +++ b/pyqt_openai/chat_widget/center/findTextWidget.py @@ -1,232 +1,236 @@ -import re - -from PySide6.QtCore import Signal, Qt -from PySide6.QtWidgets import ( - QWidget, - QLabel, - QHBoxLayout, - QGridLayout, - QLineEdit, - QMessageBox, -) - -from pyqt_openai.chat_widget.center.chatBrowser import ChatBrowser -from pyqt_openai import ( - DEFAULT_SHORTCUT_FIND_PREV, - DEFAULT_SHORTCUT_FIND_NEXT, - DEFAULT_SHORTCUT_GENERAL_ACTION, - DEFAULT_SHORTCUT_FIND_CLOSE, - ICON_PREV, - ICON_NEXT, - ICON_CASE, - ICON_WORD, - ICON_REGEX, - ICON_CLOSE, -) -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.widgets.button import Button - - -class FindTextWidget(QWidget): - - prevClicked = Signal(str) - nextClicked = Signal(str) - closeSignal = Signal() - - def __init__(self, chatBrowser: ChatBrowser, parent=None): - super().__init__(parent) - self.__chatBrowser = chatBrowser - self.__initVal() - self.__initUi() - - def __initVal(self): - self.__selections = [] - self.__cur_text = "" - self.__cur_idx = 0 - - def __initUi(self): - self.__findTextLineEdit = QLineEdit() - self.__findTextLineEdit.textChanged.connect(self.initFind) - self.__findTextLineEdit.returnPressed.connect(self.next) - self.setFocusProxy(self.__findTextLineEdit) - - self.__cnt_init_text = "{0} results" - self.__cnt_cur_idx_text = "{0}/{1}" - self.__cnt_lbl = QLabel(self.__cnt_init_text.format(0)) - - self.__prevBtn = Button() - self.__prevBtn.setStyleAndIcon(ICON_PREV) - self.__prevBtn.setShortcut(DEFAULT_SHORTCUT_FIND_PREV) - - self.__nextBtn = Button() - self.__nextBtn.setShortcut(DEFAULT_SHORTCUT_GENERAL_ACTION) - self.__nextBtn.setStyleAndIcon(ICON_NEXT) - self.__nextBtn.setShortcut(DEFAULT_SHORTCUT_FIND_NEXT) - - self.__prevBtn.clicked.connect(self.prev) - self.__nextBtn.clicked.connect(self.next) - - self.__btnToggled(False) - - self.__caseBtn = Button() - self.__caseBtn.setCheckable(True) - self.__caseBtn.toggled.connect(self.__caseToggled) - self.__caseBtn.setStyleAndIcon(ICON_CASE) - - self.__wordBtn = Button() - self.__wordBtn.setCheckable(True) - self.__wordBtn.toggled.connect(self.__wordToggled) - self.__wordBtn.setStyleAndIcon(ICON_WORD) - - self.__regexBtn = Button() - self.__regexBtn.setCheckable(True) - self.__regexBtn.toggled.connect(self.__regexToggled) - self.__regexBtn.setStyleAndIcon(ICON_REGEX) - - self.__prevBtn.setToolTip( - LangClass.TRANSLATIONS["Previous Occurrence"] - + f" ({DEFAULT_SHORTCUT_FIND_PREV})" - ) - self.__nextBtn.setToolTip( - LangClass.TRANSLATIONS["Next Occurrence"] - + f" ({DEFAULT_SHORTCUT_FIND_NEXT})" - ) - self.__caseBtn.setToolTip(LangClass.TRANSLATIONS["Match Case"]) - self.__wordBtn.setToolTip(LangClass.TRANSLATIONS["Match Word"]) - self.__regexBtn.setToolTip(LangClass.TRANSLATIONS["Regex"]) - - lay = QHBoxLayout() - lay.addWidget(self.__findTextLineEdit) - lay.addWidget(self.__cnt_lbl) - lay.addWidget(self.__prevBtn) - lay.addWidget(self.__nextBtn) - lay.addWidget(self.__caseBtn) - lay.addWidget(self.__wordBtn) - lay.addWidget(self.__regexBtn) - lay.setContentsMargins(0, 0, 0, 0) - - mainWidget = QWidget() - mainWidget.setLayout(lay) - - lay = QGridLayout() - lay.addWidget(mainWidget) - lay.setContentsMargins(0, 0, 0, 0) - - self.setLayout(lay) - - def __initSelections(self, text): - # Check for "bad pattern" when using regex - if self.__isBadPattern(text): - self.__showWarning() - return - - self.__selections = self.__getSelections(text) - is_exist = self.__isSelectionExist(text) - - if is_exist: - self.__setCurrentPosition() - else: - self.__chatBrowser.clearFormatting() - - self.__btnToggled(is_exist) - - def __isBadPattern(self, text): - return self.__regexBtn.isChecked() and re.escape(text) == re.escape("\\") - - def __showWarning(self): - QMessageBox.warning( - self, - LangClass.TRANSLATIONS["Warning"], - LangClass.TRANSLATIONS["Bad pattern"], - ) - - def __getSelections(self, text): - return self.__chatBrowser.setCurrentLabelIncludingTextBySliderPosition( - text, - case_sensitive=self.__caseBtn.isChecked(), - word_only=self.__wordBtn.isChecked(), - is_regex=self.__regexBtn.isChecked(), - ) - - def __isSelectionExist(self, text): - return ( - len(list(map(lambda x: x["pattern"], self.__selections))) > 0 - and text.strip() != "" - ) - - def clearFormatting(self): - self.__chatBrowser.clearFormatting() - self.__selections = [] - self.__btnToggled(False) - self.__setCount() - - def initFind(self, text): - self.__cur_text = text.strip() - self.__cur_idx = 0 - if self.__cur_text: - self.__initSelections(self.__cur_text) - else: - self.clearFormatting() - self.__setCount() - - def __setCount(self): - word_cnt = len(self.__selections) - self.__cnt_lbl.setText(self.__cnt_init_text.format(word_cnt)) - - def __btnToggled(self, f): - self.__prevBtn.setEnabled(f) - self.__nextBtn.setEnabled(f) - - def __setCurrentPosition(self): - self.__chatBrowser.highlightText( - self.__selections[self.__cur_idx]["class"], - self.__selections[self.__cur_idx]["pattern"], - self.__caseBtn.isChecked(), - ) - self.__chatBrowser.verticalScrollBar().setSliderPosition( - self.__selections[self.__cur_idx]["pos"] - ) - - def prev(self): - self.__chatBrowser.clearFormatting(self.__selections[self.__cur_idx]["class"]) - self.__cur_idx -= 1 - if self.__cur_idx == -1: - QMessageBox.information( - self, - LangClass.TRANSLATIONS["Information"], - LangClass.TRANSLATIONS["Reached the beginning"], - ) - self.__cur_idx = 0 - self.__setCurrentPosition() - - def next(self): - self.__chatBrowser.clearFormatting(self.__selections[self.__cur_idx]["class"]) - self.__cur_idx += 1 - if self.__cur_idx == len(self.__selections): - QMessageBox.information( - self, - LangClass.TRANSLATIONS["Information"], - LangClass.TRANSLATIONS["Reached the end"], - ) - self.__cur_idx = len(self.__selections) - 1 - self.__setCurrentPosition() - - def __caseToggled(self, f): - text = self.__findTextLineEdit.text() - self.initFind(text) - - def __wordToggled(self, f): - text = self.__findTextLineEdit.text() - self.initFind(text) - - def __regexToggled(self, f): - # regex and word-only feature can't be use at the same time - self.__wordBtn.setChecked(False) - text = self.__findTextLineEdit.text() - self.initFind(text) - - def getLineEdit(self): - return self.__findTextLineEdit - - def setLineEdit(self, text: str): - self.__findTextLineEdit.setText(text) +from __future__ import annotations + +import re + +from typing import TYPE_CHECKING + +from qtpy.QtCore import Signal +from qtpy.QtWidgets import ( + QGridLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QWidget, +) + +from pyqt_openai import ( + DEFAULT_SHORTCUT_FIND_NEXT, + DEFAULT_SHORTCUT_FIND_PREV, + DEFAULT_SHORTCUT_GENERAL_ACTION, + ICON_CASE, + ICON_NEXT, + ICON_PREV, + ICON_REGEX, + ICON_WORD, +) +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.widgets.button import Button + +if TYPE_CHECKING: + from pyqt_openai.chat_widget.center.chatBrowser import ChatBrowser + + +class FindTextWidget(QWidget): + + prevClicked = Signal(str) + nextClicked = Signal(str) + closeSignal = Signal() + + def __init__(self, chatBrowser: ChatBrowser, parent=None): + super().__init__(parent) + self.__chatBrowser = chatBrowser + self.__initVal() + self.__initUi() + + def __initVal(self): + self.__selections = [] + self.__cur_text = "" + self.__cur_idx = 0 + + def __initUi(self): + self.__findTextLineEdit = QLineEdit() + self.__findTextLineEdit.textChanged.connect(self.initFind) + self.__findTextLineEdit.returnPressed.connect(self.next) + self.setFocusProxy(self.__findTextLineEdit) + + self.__cnt_init_text = "{0} results" + self.__cnt_cur_idx_text = "{0}/{1}" + self.__cnt_lbl = QLabel(self.__cnt_init_text.format(0)) + + self.__prevBtn = Button() + self.__prevBtn.setStyleAndIcon(ICON_PREV) + self.__prevBtn.setShortcut(DEFAULT_SHORTCUT_FIND_PREV) + + self.__nextBtn = Button() + self.__nextBtn.setShortcut(DEFAULT_SHORTCUT_GENERAL_ACTION) + self.__nextBtn.setStyleAndIcon(ICON_NEXT) + self.__nextBtn.setShortcut(DEFAULT_SHORTCUT_FIND_NEXT) + + self.__prevBtn.clicked.connect(self.prev) + self.__nextBtn.clicked.connect(self.next) + + self.__btnToggled(False) + + self.__caseBtn = Button() + self.__caseBtn.setCheckable(True) + self.__caseBtn.toggled.connect(self.__caseToggled) + self.__caseBtn.setStyleAndIcon(ICON_CASE) + + self.__wordBtn = Button() + self.__wordBtn.setCheckable(True) + self.__wordBtn.toggled.connect(self.__wordToggled) + self.__wordBtn.setStyleAndIcon(ICON_WORD) + + self.__regexBtn = Button() + self.__regexBtn.setCheckable(True) + self.__regexBtn.toggled.connect(self.__regexToggled) + self.__regexBtn.setStyleAndIcon(ICON_REGEX) + + self.__prevBtn.setToolTip( + LangClass.TRANSLATIONS["Previous Occurrence"] + + f" ({DEFAULT_SHORTCUT_FIND_PREV})", + ) + self.__nextBtn.setToolTip( + LangClass.TRANSLATIONS["Next Occurrence"] + + f" ({DEFAULT_SHORTCUT_FIND_NEXT})", + ) + self.__caseBtn.setToolTip(LangClass.TRANSLATIONS["Match Case"]) + self.__wordBtn.setToolTip(LangClass.TRANSLATIONS["Match Word"]) + self.__regexBtn.setToolTip(LangClass.TRANSLATIONS["Regex"]) + + lay = QHBoxLayout() + lay.addWidget(self.__findTextLineEdit) + lay.addWidget(self.__cnt_lbl) + lay.addWidget(self.__prevBtn) + lay.addWidget(self.__nextBtn) + lay.addWidget(self.__caseBtn) + lay.addWidget(self.__wordBtn) + lay.addWidget(self.__regexBtn) + lay.setContentsMargins(0, 0, 0, 0) + + mainWidget = QWidget() + mainWidget.setLayout(lay) + + lay = QGridLayout() + lay.addWidget(mainWidget) + lay.setContentsMargins(0, 0, 0, 0) + + self.setLayout(lay) + + def __initSelections(self, text): + # Check for "bad pattern" when using regex + if self.__isBadPattern(text): + self.__showWarning() + return + + self.__selections = self.__getSelections(text) + is_exist = self.__isSelectionExist(text) + + if is_exist: + self.__setCurrentPosition() + else: + self.__chatBrowser.clearFormatting() + + self.__btnToggled(is_exist) + + def __isBadPattern(self, text): + return self.__regexBtn.isChecked() and re.escape(text) == re.escape("\\") + + def __showWarning(self): + QMessageBox.warning( + self, + LangClass.TRANSLATIONS["Warning"], + LangClass.TRANSLATIONS["Bad pattern"], + ) + + def __getSelections(self, text): + return self.__chatBrowser.setCurrentLabelIncludingTextBySliderPosition( + text, + case_sensitive=self.__caseBtn.isChecked(), + word_only=self.__wordBtn.isChecked(), + is_regex=self.__regexBtn.isChecked(), + ) + + def __isSelectionExist(self, text): + return ( + len(list(map(lambda x: x["pattern"], self.__selections))) > 0 + and text.strip() != "" + ) + + def clearFormatting(self): + self.__chatBrowser.clearFormatting() + self.__selections = [] + self.__btnToggled(False) + self.__setCount() + + def initFind(self, text): + self.__cur_text = text.strip() + self.__cur_idx = 0 + if self.__cur_text: + self.__initSelections(self.__cur_text) + else: + self.clearFormatting() + self.__setCount() + + def __setCount(self): + word_cnt = len(self.__selections) + self.__cnt_lbl.setText(self.__cnt_init_text.format(word_cnt)) + + def __btnToggled(self, f): + self.__prevBtn.setEnabled(f) + self.__nextBtn.setEnabled(f) + + def __setCurrentPosition(self): + self.__chatBrowser.highlightText( + self.__selections[self.__cur_idx]["class"], + self.__selections[self.__cur_idx]["pattern"], + self.__caseBtn.isChecked(), + ) + self.__chatBrowser.verticalScrollBar().setSliderPosition( + self.__selections[self.__cur_idx]["pos"], + ) + + def prev(self): + self.__chatBrowser.clearFormatting(self.__selections[self.__cur_idx]["class"]) + self.__cur_idx -= 1 + if self.__cur_idx == -1: + QMessageBox.information( + self, + LangClass.TRANSLATIONS["Information"], + LangClass.TRANSLATIONS["Reached the beginning"], + ) + self.__cur_idx = 0 + self.__setCurrentPosition() + + def next(self): + self.__chatBrowser.clearFormatting(self.__selections[self.__cur_idx]["class"]) + self.__cur_idx += 1 + if self.__cur_idx == len(self.__selections): + QMessageBox.information( + self, + LangClass.TRANSLATIONS["Information"], + LangClass.TRANSLATIONS["Reached the end"], + ) + self.__cur_idx = len(self.__selections) - 1 + self.__setCurrentPosition() + + def __caseToggled(self, f): + text = self.__findTextLineEdit.text() + self.initFind(text) + + def __wordToggled(self, f): + text = self.__findTextLineEdit.text() + self.initFind(text) + + def __regexToggled(self, f): + # regex and word-only feature can't be use at the same time + self.__wordBtn.setChecked(False) + text = self.__findTextLineEdit.text() + self.initFind(text) + + def getLineEdit(self): + return self.__findTextLineEdit + + def setLineEdit(self, text: str): + self.__findTextLineEdit.setText(text) diff --git a/pyqt_openai/chat_widget/center/menuWidget.py b/pyqt_openai/chat_widget/center/menuWidget.py index 167f9c66..919f9484 100644 --- a/pyqt_openai/chat_widget/center/menuWidget.py +++ b/pyqt_openai/chat_widget/center/menuWidget.py @@ -1,62 +1,68 @@ -from PySide6.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout, QLabel -from PySide6.QtCore import Signal - -from pyqt_openai import DEFAULT_SHORTCUT_FIND_CLOSE, ICON_CLOSE -from pyqt_openai.chat_widget.center.findTextWidget import FindTextWidget -from pyqt_openai.chat_widget.center.chatBrowser import ChatBrowser -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.widgets.button import Button - - -class MenuWidget(QWidget): - onMenuCloseClicked = Signal() - - def __init__(self, widget: ChatBrowser, parent=None): - super().__init__(parent) - self.__initUi(widget=widget) - - def __initUi(self, widget): - self.__titleLbl = QLabel(LangClass.TRANSLATIONS["Title"]) - self.__findTextWidget = FindTextWidget(widget) - self.__chatBrowser = widget - - self.__closeBtn = Button() - self.__closeBtn.clicked.connect(self.__onMenuCloseClicked) - self.__closeBtn.setShortcut(DEFAULT_SHORTCUT_FIND_CLOSE) - self.__closeBtn.setStyleAndIcon(ICON_CLOSE) - - self.__closeBtn.setToolTip( - LangClass.TRANSLATIONS["Close"] + f" ({DEFAULT_SHORTCUT_FIND_CLOSE})" - ) - - lay = QVBoxLayout() - lay.addWidget(self.__titleLbl) - lay.addWidget(self.__findTextWidget) - lay.addWidget(self.__closeBtn) - lay.setContentsMargins(0, 0, 0, 0) - - mainWidget = QWidget() - mainWidget.setLayout(lay) - - lay = QHBoxLayout() - lay.addWidget(mainWidget) - lay.addWidget(self.__closeBtn) - lay.setContentsMargins(4, 4, 4, 4) - - self.setLayout(lay) - - def setTitle(self, title): - self.__titleLbl.setText(title) - - def showEvent(self, event): - self.__findTextWidget.getLineEdit().setFocus() - self.__findTextWidget.initFind(self.__findTextWidget.getLineEdit().text()) - super().showEvent(event) - - def __onMenuCloseClicked(self): - self.__findTextWidget.clearFormatting() - self.onMenuCloseClicked.emit() - self.hide() - - def getFindTextWidget(self): - return self.__findTextWidget +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtCore import Signal +from qtpy.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget + +from pyqt_openai import DEFAULT_SHORTCUT_FIND_CLOSE, ICON_CLOSE +from pyqt_openai.chat_widget.center.findTextWidget import FindTextWidget +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.widgets.button import Button + +if TYPE_CHECKING: + from pyqt_openai.chat_widget.center.chatBrowser import ChatBrowser + + +class MenuWidget(QWidget): + onMenuCloseClicked = Signal() + + def __init__(self, widget: ChatBrowser, parent=None): + super().__init__(parent) + self.__initUi(widget=widget) + + def __initUi(self, widget): + self.__titleLbl = QLabel(LangClass.TRANSLATIONS["Title"]) + self.__findTextWidget = FindTextWidget(widget) + self.__chatBrowser = widget + + self.__closeBtn = Button() + self.__closeBtn.clicked.connect(self.__onMenuCloseClicked) + self.__closeBtn.setShortcut(DEFAULT_SHORTCUT_FIND_CLOSE) + self.__closeBtn.setStyleAndIcon(ICON_CLOSE) + + self.__closeBtn.setToolTip( + LangClass.TRANSLATIONS["Close"] + f" ({DEFAULT_SHORTCUT_FIND_CLOSE})", + ) + + lay = QVBoxLayout() + lay.addWidget(self.__titleLbl) + lay.addWidget(self.__findTextWidget) + lay.addWidget(self.__closeBtn) + lay.setContentsMargins(0, 0, 0, 0) + + mainWidget = QWidget() + mainWidget.setLayout(lay) + + lay = QHBoxLayout() + lay.addWidget(mainWidget) + lay.addWidget(self.__closeBtn) + lay.setContentsMargins(4, 4, 4, 4) + + self.setLayout(lay) + + def setTitle(self, title): + self.__titleLbl.setText(title) + + def showEvent(self, event): + self.__findTextWidget.getLineEdit().setFocus() + self.__findTextWidget.initFind(self.__findTextWidget.getLineEdit().text()) + super().showEvent(event) + + def __onMenuCloseClicked(self): + self.__findTextWidget.clearFormatting() + self.onMenuCloseClicked.emit() + self.hide() + + def getFindTextWidget(self): + return self.__findTextWidget diff --git a/pyqt_openai/chat_widget/center/messageTextBrowser.py b/pyqt_openai/chat_widget/center/messageTextBrowser.py index 51361215..b4a3ebd6 100644 --- a/pyqt_openai/chat_widget/center/messageTextBrowser.py +++ b/pyqt_openai/chat_widget/center/messageTextBrowser.py @@ -1,117 +1,119 @@ -import json - -# from PySide6.QtWidgets import QApplication, QWidget, QVBoxLayout -from PySide6.QtGui import QPalette, QColor, QDesktopServices -from PySide6.QtWidgets import QTextBrowser - -from pyqt_openai import MESSAGE_MAXIMUM_HEIGHT, MESSAGE_PADDING, INDENT_SIZE - - -class MessageTextBrowser(QTextBrowser): - def __init__(self, parent=None): - super().__init__(parent) - - # Make the remote links clickable - self.anchorClicked.connect(self.on_anchor_clicked) - self.setOpenExternalLinks(True) - self.__initUi() - - def on_anchor_clicked(self, url): - QDesktopServices.openUrl(url) - - def __initUi(self): - # Transparent background - palette = self.palette() - palette.setColor(QPalette.Base, QColor(0, 0, 0, 0)) - self.setPalette(palette) - - # Remove edge - self.setFrameShape(QTextBrowser.NoFrame) - - # Padding - self.document().setDocumentMargin(MESSAGE_PADDING) - - self.setContentsMargins(0, 0, 0, 0) - - def setJson(self, json_str): - try: - json_data = json.loads(json_str) - pretty_json = json.dumps(json_data, indent=INDENT_SIZE) - self.setPlainText(pretty_json) - except json.JSONDecodeError as e: - self.setPlainText(f"Error decoding JSON: {e}") - - def adjustBrowserHeight(self): - document_height = self.document().size().height() - max_height = MESSAGE_MAXIMUM_HEIGHT - - if document_height < max_height: - self.setMinimumHeight(int(document_height)) - else: - self.setMinimumHeight(int(max_height)) - self.verticalScrollBar().setSliderPosition(self.verticalScrollBar().maximum()) - - def setMarkdown(self, markdown: str) -> None: - super().setMarkdown(markdown) - # Convert markdown to HTML using QTextDocument - - # document = QTextDocument() - # document.setMarkdown(markdown) - # html_text = document.toHtml() - # with open("test.html", "w") as f: - # f.write(html_text) - # # - # # # Customize the converted HTML (e.g., add style tags) - # custom_html = f""" - # {html_text} - # """ - # self.setHtml(custom_html) - # - # def eventFilter(self, obj, event): - # print(obj, int(event.type())) - # return super().eventFilter(obj, event) - - -# -# def main(): -# app = QApplication([]) -# -# window = QWidget() -# layout = QVBoxLayout() -# -# text_browser = MessageTextBrowser() -# -# markdown_text = """ -# https://www.google.com -# """ -# text_browser.setMarkdown(markdown_text) -# -# layout.addWidget(text_browser) -# window.setLayout(layout) -# window.show() -# -# app.exec() -# -# -# if __name__ == "__main__": -# main() +from __future__ import annotations + +import json + +# from qtpy.QtWidgets import QApplication, QWidget, QVBoxLayout +from qtpy.QtGui import QColor, QDesktopServices, QPalette +from qtpy.QtWidgets import QTextBrowser + +from pyqt_openai import INDENT_SIZE, MESSAGE_MAXIMUM_HEIGHT, MESSAGE_PADDING + + +class MessageTextBrowser(QTextBrowser): + def __init__(self, parent=None): + super().__init__(parent) + + # Make the remote links clickable + self.anchorClicked.connect(self.on_anchor_clicked) + self.setOpenExternalLinks(True) + self.__initUi() + + def on_anchor_clicked(self, url): + QDesktopServices.openUrl(url) + + def __initUi(self): + # Transparent background + palette = self.palette() + palette.setColor(QPalette.Base, QColor(0, 0, 0, 0)) + self.setPalette(palette) + + # Remove edge + self.setFrameShape(QTextBrowser.NoFrame) + + # Padding + self.document().setDocumentMargin(MESSAGE_PADDING) + + self.setContentsMargins(0, 0, 0, 0) + + def setJson(self, json_str): + try: + json_data = json.loads(json_str) + pretty_json = json.dumps(json_data, indent=INDENT_SIZE) + self.setPlainText(pretty_json) + except json.JSONDecodeError as e: + self.setPlainText(f"Error decoding JSON: {e}") + + def adjustBrowserHeight(self): + document_height = self.document().size().height() + max_height = MESSAGE_MAXIMUM_HEIGHT + + if document_height < max_height: + self.setMinimumHeight(int(document_height)) + else: + self.setMinimumHeight(int(max_height)) + self.verticalScrollBar().setSliderPosition(self.verticalScrollBar().maximum()) + + def setMarkdown(self, markdown: str) -> None: + super().setMarkdown(markdown) + # Convert markdown to HTML using QTextDocument + + # document = QTextDocument() + # document.setMarkdown(markdown) + # html_text = document.toHtml() + # with open("test.html", "w") as f: + # f.write(html_text) + # # + # # # Customize the converted HTML (e.g., add style tags) + # custom_html = f""" + # {html_text} + # """ + # self.setHtml(custom_html) + # + # def eventFilter(self, obj, event): + # print(obj, int(event.type())) + # return super().eventFilter(obj, event) + + +# +# def main(): +# app = QApplication([]) +# +# window = QWidget() +# layout = QVBoxLayout() +# +# text_browser = MessageTextBrowser() +# +# markdown_text = """ +# https://www.google.com +# """ +# text_browser.setMarkdown(markdown_text) +# +# layout.addWidget(text_browser) +# window.setLayout(layout) +# window.show() +# +# app.exec() +# +# +# if __name__ == "__main__": +# main() diff --git a/pyqt_openai/chat_widget/center/prompt.py b/pyqt_openai/chat_widget/center/prompt.py index f828994c..cb67311c 100644 --- a/pyqt_openai/chat_widget/center/prompt.py +++ b/pyqt_openai/chat_widget/center/prompt.py @@ -1,436 +1,438 @@ -from pathlib import Path - -from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QAction -from PySide6.QtWidgets import ( - QVBoxLayout, - QPushButton, - QFileDialog, - QToolButton, - QMenu, - QWidget, - QHBoxLayout, - QMessageBox, -) - -from pyqt_openai import ( - READ_FILE_EXT_LIST_STR, - PROMPT_BEGINNING_KEY_NAME, - PROMPT_END_KEY_NAME, - PROMPT_JSON_KEY_NAME, - DEFAULT_SHORTCUT_PROMPT_BEGINNING, - DEFAULT_SHORTCUT_PROMPT_ENDING, - DEFAULT_SHORTCUT_SUPPORT_PROMPT_COMMAND, - ICON_VERTICAL_THREE_DOTS, - ICON_SEND, - PROMPT_MAIN_KEY_NAME, - IMAGE_FILE_EXT_LIST, - TEXT_FILE_EXT_LIST, - QFILEDIALOG_DEFAULT_DIRECTORY, - DEFAULT_SHORTCUT_SEND, - DEFAULT_SHORTCUT_RECORD, - ICON_RECORD, -) -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.globals import DB -from pyqt_openai.chat_widget.center.commandSuggestionWidget import ( - CommandSuggestionWidget, -) -from pyqt_openai.chat_widget.center.textEditPromptGroup import TextEditPromptGroup -from pyqt_openai.chat_widget.center.uploadedImageFileWidget import ( - UploadedImageFileWidget, -) -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.util.common import ( - get_content_of_text_file_for_send, - RecorderThread, - STTThread, -) -from pyqt_openai.widgets.button import Button -from pyqt_openai.widgets.jsonEditor import JSONEditor -from pyqt_openai.widgets.toolButton import ToolButton - - -class Prompt(QWidget): - onRecording = Signal(bool) - onStoppedClicked = Signal() - - def __init__(self, parent=None): - super().__init__(parent) - self.__initVal() - self.__initUi() - - def __initVal(self): - # prompt group - self.__p_grp = [] - - # False by default - self.__commandEnabled = False - - self.__json_object = CONFIG_MANAGER.get_general_property("json_object") - - self.stt_thread = None - - def __initUi(self): - # Prompt control buttons - self.__stopBtn = QPushButton(LangClass.TRANSLATIONS["Stop"]) - self.__stopBtn.clicked.connect(self.onStoppedClicked.emit) - - lay = QHBoxLayout() - lay.setContentsMargins(0, 0, 0, 0) - lay.setSpacing(0) - lay.addWidget(self.__stopBtn) - lay.setAlignment(Qt.AlignmentFlag.AlignCenter) - - self.__controlWidgetDuringGeneration = QWidget() - self.__controlWidgetDuringGeneration.setLayout(lay) - - # Create the command suggestion list - self.__suggestionWidget = CommandSuggestionWidget() - self.__suggestionWidget.toggleCommandSuggestion.connect( - self.__setCommandSuggestionEnabled - ) - self.__suggestion_list = self.__suggestionWidget.getCommandList() - - self.__uploadedImageFileWidget = UploadedImageFileWidget() - - self.__textEditGroup = TextEditPromptGroup() - self.__textEditGroup.textChanged.connect(self.updateHeight) - - # set command suggestion - self.__textEditGroup.onUpdateSuggestion.connect(self.__updateSuggestions) - self.__textEditGroup.onSendKeySignalToSuggestion.connect( - self.__sendKeySignalToSuggestion - ) - self.__textEditGroup.onPasteFile.connect( - self.__uploadedImageFileWidget.addImageBuffer - ) - - self.__suggestion_list.itemClicked.connect(self.executeCommand) - - lay = QVBoxLayout() - lay.addWidget(self.__suggestionWidget) - lay.addWidget(self.__uploadedImageFileWidget) - lay.addWidget(self.__textEditGroup) - lay.setContentsMargins(0, 0, 0, 0) - lay.setSpacing(0) - - leftWidget = QWidget() - leftWidget.setLayout(lay) - - self.__sendBtn = Button() - self.__sendBtn.setStyleAndIcon(ICON_SEND) - self.__sendBtn.setToolTip( - LangClass.TRANSLATIONS["Send"] + f" ({DEFAULT_SHORTCUT_SEND})" - ) - self.__sendBtn.setShortcut(DEFAULT_SHORTCUT_SEND) - - self.__recordBtn = Button() - self.__recordBtn.setStyleAndIcon(ICON_RECORD) - self.__recordBtn.setToolTip(LangClass.TRANSLATIONS["Record"]) - self.__recordBtn.setCheckable(True) - self.__recordBtn.setShortcut(DEFAULT_SHORTCUT_RECORD) - - settingsBtn = ToolButton() - settingsBtn.setStyleAndIcon(ICON_VERTICAL_THREE_DOTS) - settingsBtn.setToolTip(LangClass.TRANSLATIONS["Prompt Settings"]) - - # Create the menu - menu = QMenu(self) - - # Create the actions - beginningAction = QAction( - LangClass.TRANSLATIONS["Show Beginning"] - + f" ({DEFAULT_SHORTCUT_PROMPT_BEGINNING})", - self, - ) - beginningAction.setShortcut(DEFAULT_SHORTCUT_PROMPT_BEGINNING) - beginningAction.setCheckable(True) - beginningAction.toggled.connect(self.__showBeginning) - - endingAction = QAction( - LangClass.TRANSLATIONS["Show Ending"] - + f" ({DEFAULT_SHORTCUT_PROMPT_ENDING})", - self, - ) - endingAction.setShortcut(DEFAULT_SHORTCUT_PROMPT_ENDING) - endingAction.setCheckable(True) - endingAction.toggled.connect(self.__showEnding) - - supportPromptCommandAction = QAction( - LangClass.TRANSLATIONS["Support Prompt Command"] - + f" ({DEFAULT_SHORTCUT_SUPPORT_PROMPT_COMMAND})", - self, - ) - supportPromptCommandAction.setShortcut(DEFAULT_SHORTCUT_SUPPORT_PROMPT_COMMAND) - supportPromptCommandAction.setCheckable(True) - supportPromptCommandAction.toggled.connect(self.__supportPromptCommand) - - readingFilesAction = QAction(LangClass.TRANSLATIONS["Upload Files..."], self) - readingFilesAction.triggered.connect(self.__readingFiles) - - self.__writeJSONAction = QAction(LangClass.TRANSLATIONS["Write JSON"], self) - self.__writeJSONAction.toggled.connect(self.__showJSON) - self.__writeJSONAction.setCheckable(True) - self.__toggleJSONAction(self.__json_object) - - # Add the actions to the menu - menu.addAction(beginningAction) - menu.addAction(endingAction) - menu.addAction(supportPromptCommandAction) - menu.addAction(self.__writeJSONAction) - menu.addAction(readingFilesAction) - - # Connect the button to the menu - settingsBtn.setMenu(menu) - settingsBtn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) - - lay = QHBoxLayout() - lay.addWidget(self.__recordBtn) - lay.addWidget(self.__sendBtn) - lay.addWidget(settingsBtn) - lay.setContentsMargins(1, 1, 1, 1) - lay.setAlignment(Qt.AlignmentFlag.AlignRight) - lay.setSpacing(0) - - rightWidget = QWidget() - rightWidget.setLayout(lay) - - lay = QHBoxLayout() - lay.addWidget(leftWidget) - lay.addWidget(rightWidget) - lay.setContentsMargins(0, 0, 0, 0) - lay.setSpacing(0) - - bottomWidget = QWidget() - bottomWidget.setLayout(lay) - - lay = QVBoxLayout() - lay.addWidget(self.__controlWidgetDuringGeneration) - lay.addWidget(bottomWidget) - lay.setContentsMargins(0, 0, 0, 0) - lay.setSpacing(0) - - self.setLayout(lay) - - self.showWidgetInPromptDuringResponse(False) - - self.__suggestionWidget.setVisible(False) - - self.updateHeight() - - self.__sendBtn.clicked.connect(self.getMainPromptInput().sendMessage) - - self.__recordBtn.toggled.connect(self.record) - - self.__textEditGroup.installEventFilter(self) - - def __setEveryPromptCommands(self): - command_obj_lst = [] - for group in DB.selectPromptGroup(): - entries = [attr for attr in DB.selectPromptEntry(group_id=group.id)] - if group.prompt_type == "form": - value = "" - for entry in entries: - prompt = entry.prompt - if prompt and prompt.strip(): - value += f"{entry.act}: {prompt}\n" - command_obj_lst.append({"name": group.name, "value": value}) - elif group.prompt_type == "sentence": - for entry in entries: - command_obj_lst.append( - {"name": f"{entry.act}({group.name})", "value": entry.prompt} - ) - self.__p_grp = [ - {"name": obj["name"], "value": obj["value"]} for obj in command_obj_lst - ] - - def __getEveryPromptCommands(self, get_name_only=False): - if get_name_only: - return [obj["name"] for obj in self.__p_grp] - return self.__p_grp - - def __setCommandSuggestionEnabled(self, f): - for w in self.__textEditGroup.getGroup().values(): - if isinstance(w, JSONEditor): - pass - else: - w.setCommandSuggestionEnabled(f) - - def __updateSuggestions(self): - w = self.__textEditGroup.getCurrentTextEdit() - if w and self.__commandEnabled: - input_text_chunk = w.toPlainText().split() - input_text_chunk_exists = len(input_text_chunk) > 0 - self.__suggestionWidget.setVisible(input_text_chunk_exists) - if input_text_chunk_exists: - input_text_chunk = input_text_chunk[-1] - starts_with_f = input_text_chunk.startswith("/") - self.__suggestionWidget.setVisible(starts_with_f) - if starts_with_f: - command_word = input_text_chunk[1:] - # Set every prompt commands first - self.__setEveryPromptCommands() - # Get the commands - commands = self.__getEveryPromptCommands(get_name_only=True) - filtered_commands = commands - if command_word: - filtered_commands = [ - command - for command in commands - if command_word.lower() in command.lower() - ] - filtered_commands_exists_f = len(filtered_commands) > 0 - self.__suggestionWidget.setVisible(filtered_commands_exists_f) - if filtered_commands_exists_f: - # Clear previous suggestions - self.__suggestion_list.clear() - - # Add the filtered suggestions to the list - self.__suggestion_list.addItems(filtered_commands) - self.__suggestion_list.setCurrentRow(0) - - def __sendKeySignalToSuggestion(self, key): - if key == "up": - self.__suggestion_list.setCurrentRow( - max(0, self.__suggestion_list.currentRow() - 1) - ) - elif key == "down": - self.__suggestion_list.setCurrentRow( - min( - self.__suggestion_list.currentRow() + 1, - self.__suggestion_list.count() - 1, - ) - ) - elif key == "enter": - self.executeCommand(self.__suggestion_list.currentItem()) - - def showWidgetInPromptDuringResponse(self, f): - self.__controlWidgetDuringGeneration.setVisible(f) - - def executeCommand(self, item): - self.__textEditGroup.showPromptContent(item, self.__p_grp) - - def updateHeight(self): - overallHeight = self.__textEditGroup.adjustHeight() - # Set the maximum height of the widget - should fit the device screen - self.setMaximumHeight( - overallHeight - + self.__suggestionWidget.height() - + self.__uploadedImageFileWidget.height() - + 100 - ) - - def getMainPromptInput(self): - return self.__textEditGroup.getMainTextEdit() - - def getContent(self): - return self.__textEditGroup.getContent() - - def getJSONContent(self): - return self.__textEditGroup.getJSONContent() - - def __showBeginning(self, f): - self.__textEditGroup.setVisibleTo(PROMPT_BEGINNING_KEY_NAME, f) - if f: - self.__textEditGroup.getGroup()[PROMPT_BEGINNING_KEY_NAME].setFocus() - else: - self.__textEditGroup.getGroup()[PROMPT_BEGINNING_KEY_NAME].clear() - self.__textEditGroup.getGroup()[PROMPT_MAIN_KEY_NAME].setFocus() - - def __showEnding(self, f): - self.__textEditGroup.setVisibleTo(PROMPT_END_KEY_NAME, f) - if f: - self.__textEditGroup.getGroup()[PROMPT_END_KEY_NAME].setFocus() - else: - self.__textEditGroup.getGroup()[PROMPT_END_KEY_NAME].clear() - self.__textEditGroup.getGroup()[PROMPT_MAIN_KEY_NAME].setFocus() - - def __supportPromptCommand(self, f): - self.__commandEnabled = f - - def __readingFiles(self): - filenames = QFileDialog.getOpenFileNames( - self, - LangClass.TRANSLATIONS["Find"], - QFILEDIALOG_DEFAULT_DIRECTORY, - READ_FILE_EXT_LIST_STR, - ) - if filenames[0]: - filenames = filenames[0] - cur_file_extension = Path(filenames[0]).suffix - # Text - if cur_file_extension in TEXT_FILE_EXT_LIST: - prompt_context = get_content_of_text_file_for_send(filenames) - self.getMainPromptInput().setText(prompt_context) - # Image - elif cur_file_extension in IMAGE_FILE_EXT_LIST: - self.__uploadedImageFileWidget.addFiles(filenames) - - def getImageBuffers(self): - return self.__uploadedImageFileWidget.getImageBuffers() - - def resetUploadImageFileWidget(self): - self.__uploadedImageFileWidget.setVisible(False) - self.__uploadedImageFileWidget.clear() - - def __toggleJSONAction(self, f): - self.__writeJSONAction.setEnabled(f) - self.__writeJSONAction.setChecked(f) - - def toggleJSON(self, f): - self.__toggleJSONAction(f) - self.__showJSON(f) - json_text_edit = self.__textEditGroup.getGroup()[PROMPT_JSON_KEY_NAME] - json_text_edit.clear() - if f: - json_text_edit.setFocus() - else: - self.__textEditGroup.getGroup()[PROMPT_MAIN_KEY_NAME].setFocus() - - def __showJSON(self, f): - self.__json_object = f - self.__textEditGroup.setVisibleTo(PROMPT_JSON_KEY_NAME, f) - - def sendEnabled(self, f): - self.getMainPromptInput().setEnabled(f) - self.__sendBtn.setEnabled(f) - - def eventFilter(self, source, event): - if event.type() == 6: - if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: - if event.modifiers() == Qt.KeyboardModifier.ControlModifier: - self.__sendBtn.click() - return True - return super().eventFilter(source, event) - - def record(self, checked): - if checked: - self.recorder_thread = RecorderThread() - self.recorder_thread.recording_finished.connect(self.on_recording_finished) - self.recorder_thread.errorGenerated.connect(self.on_recording_error) - if self.__textEditGroup.getCurrentTextEdit(): - self.__textEditGroup.getCurrentTextEdit().clear() - self.recorder_thread.start() - else: - self.recorder_thread.stop() - - def on_recording_error(self, error): - self.__recordBtn.setChecked(False) - QMessageBox.critical(self, LangClass.TRANSLATIONS["Error"], error) - - def on_recording_finished(self, filename): - self.__recordBtn.setChecked(False) - self.stt_thread = STTThread(filename) - self.stt_thread.stt_finished.connect(self.on_stt_finished) - self.stt_thread.errorGenerated.connect( - lambda x: QMessageBox.critical(self, LangClass.TRANSLATIONS["Error"], x) - ) - self.stt_thread.start() - - def on_stt_finished(self, text): - t = self.__textEditGroup.getCurrentTextEdit() - if t: - t.setText(text) - else: - self.__textEditGroup.getMainTextEdit().setText(text) +from __future__ import annotations + +from pathlib import Path + +from qtpy.QtCore import Qt, Signal +from qtpy.QtGui import QAction +from qtpy.QtWidgets import ( + QFileDialog, + QHBoxLayout, + QMenu, + QMessageBox, + QPushButton, + QToolButton, + QVBoxLayout, + QWidget, +) + +from pyqt_openai import ( + DEFAULT_SHORTCUT_PROMPT_BEGINNING, + DEFAULT_SHORTCUT_PROMPT_ENDING, + DEFAULT_SHORTCUT_RECORD, + DEFAULT_SHORTCUT_SEND, + DEFAULT_SHORTCUT_SUPPORT_PROMPT_COMMAND, + ICON_RECORD, + ICON_SEND, + ICON_VERTICAL_THREE_DOTS, + IMAGE_FILE_EXT_LIST, + PROMPT_BEGINNING_KEY_NAME, + PROMPT_END_KEY_NAME, + PROMPT_JSON_KEY_NAME, + PROMPT_MAIN_KEY_NAME, + QFILEDIALOG_DEFAULT_DIRECTORY, + READ_FILE_EXT_LIST_STR, + TEXT_FILE_EXT_LIST, +) +from pyqt_openai.chat_widget.center.commandSuggestionWidget import ( + CommandSuggestionWidget, +) +from pyqt_openai.chat_widget.center.textEditPromptGroup import TextEditPromptGroup +from pyqt_openai.chat_widget.center.uploadedImageFileWidget import ( + UploadedImageFileWidget, +) +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.globals import DB +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.util.common import ( + RecorderThread, + STTThread, + get_content_of_text_file_for_send, +) +from pyqt_openai.widgets.button import Button +from pyqt_openai.widgets.jsonEditor import JSONEditor +from pyqt_openai.widgets.toolButton import ToolButton + + +class Prompt(QWidget): + onRecording = Signal(bool) + onStoppedClicked = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.__initVal() + self.__initUi() + + def __initVal(self): + # prompt group + self.__p_grp = [] + + # False by default + self.__commandEnabled = False + + self.__json_object = CONFIG_MANAGER.get_general_property("json_object") + + self.stt_thread = None + + def __initUi(self): + # Prompt control buttons + self.__stopBtn = QPushButton(LangClass.TRANSLATIONS["Stop"]) + self.__stopBtn.clicked.connect(self.onStoppedClicked.emit) + + lay = QHBoxLayout() + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(0) + lay.addWidget(self.__stopBtn) + lay.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.__controlWidgetDuringGeneration = QWidget() + self.__controlWidgetDuringGeneration.setLayout(lay) + + # Create the command suggestion list + self.__suggestionWidget = CommandSuggestionWidget() + self.__suggestionWidget.toggleCommandSuggestion.connect( + self.__setCommandSuggestionEnabled, + ) + self.__suggestion_list = self.__suggestionWidget.getCommandList() + + self.__uploadedImageFileWidget = UploadedImageFileWidget() + + self.__textEditGroup = TextEditPromptGroup() + self.__textEditGroup.textChanged.connect(self.updateHeight) + + # set command suggestion + self.__textEditGroup.onUpdateSuggestion.connect(self.__updateSuggestions) + self.__textEditGroup.onSendKeySignalToSuggestion.connect( + self.__sendKeySignalToSuggestion, + ) + self.__textEditGroup.onPasteFile.connect( + self.__uploadedImageFileWidget.addImageBuffer, + ) + + self.__suggestion_list.itemClicked.connect(self.executeCommand) + + lay = QVBoxLayout() + lay.addWidget(self.__suggestionWidget) + lay.addWidget(self.__uploadedImageFileWidget) + lay.addWidget(self.__textEditGroup) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(0) + + leftWidget = QWidget() + leftWidget.setLayout(lay) + + self.__sendBtn = Button() + self.__sendBtn.setStyleAndIcon(ICON_SEND) + self.__sendBtn.setToolTip( + LangClass.TRANSLATIONS["Send"] + f" ({DEFAULT_SHORTCUT_SEND})", + ) + self.__sendBtn.setShortcut(DEFAULT_SHORTCUT_SEND) + + self.__recordBtn = Button() + self.__recordBtn.setStyleAndIcon(ICON_RECORD) + self.__recordBtn.setToolTip(LangClass.TRANSLATIONS["Record"]) + self.__recordBtn.setCheckable(True) + self.__recordBtn.setShortcut(DEFAULT_SHORTCUT_RECORD) + + settingsBtn = ToolButton() + settingsBtn.setStyleAndIcon(ICON_VERTICAL_THREE_DOTS) + settingsBtn.setToolTip(LangClass.TRANSLATIONS["Prompt Settings"]) + + # Create the menu + menu = QMenu(self) + + # Create the actions + beginningAction = QAction( + LangClass.TRANSLATIONS["Show Beginning"] + + f" ({DEFAULT_SHORTCUT_PROMPT_BEGINNING})", + self, + ) + beginningAction.setShortcut(DEFAULT_SHORTCUT_PROMPT_BEGINNING) + beginningAction.setCheckable(True) + beginningAction.toggled.connect(self.__showBeginning) + + endingAction = QAction( + LangClass.TRANSLATIONS["Show Ending"] + + f" ({DEFAULT_SHORTCUT_PROMPT_ENDING})", + self, + ) + endingAction.setShortcut(DEFAULT_SHORTCUT_PROMPT_ENDING) + endingAction.setCheckable(True) + endingAction.toggled.connect(self.__showEnding) + + supportPromptCommandAction = QAction( + LangClass.TRANSLATIONS["Support Prompt Command"] + + f" ({DEFAULT_SHORTCUT_SUPPORT_PROMPT_COMMAND})", + self, + ) + supportPromptCommandAction.setShortcut(DEFAULT_SHORTCUT_SUPPORT_PROMPT_COMMAND) + supportPromptCommandAction.setCheckable(True) + supportPromptCommandAction.toggled.connect(self.__supportPromptCommand) + + readingFilesAction = QAction(LangClass.TRANSLATIONS["Upload Files..."], self) + readingFilesAction.triggered.connect(self.__readingFiles) + + self.__writeJSONAction = QAction(LangClass.TRANSLATIONS["Write JSON"], self) + self.__writeJSONAction.toggled.connect(self.__showJSON) + self.__writeJSONAction.setCheckable(True) + self.__toggleJSONAction(self.__json_object) + + # Add the actions to the menu + menu.addAction(beginningAction) + menu.addAction(endingAction) + menu.addAction(supportPromptCommandAction) + menu.addAction(self.__writeJSONAction) + menu.addAction(readingFilesAction) + + # Connect the button to the menu + settingsBtn.setMenu(menu) + settingsBtn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + + lay = QHBoxLayout() + lay.addWidget(self.__recordBtn) + lay.addWidget(self.__sendBtn) + lay.addWidget(settingsBtn) + lay.setContentsMargins(1, 1, 1, 1) + lay.setAlignment(Qt.AlignmentFlag.AlignRight) + lay.setSpacing(0) + + rightWidget = QWidget() + rightWidget.setLayout(lay) + + lay = QHBoxLayout() + lay.addWidget(leftWidget) + lay.addWidget(rightWidget) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(0) + + bottomWidget = QWidget() + bottomWidget.setLayout(lay) + + lay = QVBoxLayout() + lay.addWidget(self.__controlWidgetDuringGeneration) + lay.addWidget(bottomWidget) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(0) + + self.setLayout(lay) + + self.showWidgetInPromptDuringResponse(False) + + self.__suggestionWidget.setVisible(False) + + self.updateHeight() + + self.__sendBtn.clicked.connect(self.getMainPromptInput().sendMessage) + + self.__recordBtn.toggled.connect(self.record) + + self.__textEditGroup.installEventFilter(self) + + def __setEveryPromptCommands(self): + command_obj_lst = [] + for group in DB.selectPromptGroup(): + entries = [attr for attr in DB.selectPromptEntry(group_id=group.id)] + if group.prompt_type == "form": + value = "" + for entry in entries: + prompt = entry.prompt + if prompt and prompt.strip(): + value += f"{entry.act}: {prompt}\n" + command_obj_lst.append({"name": group.name, "value": value}) + elif group.prompt_type == "sentence": + for entry in entries: + command_obj_lst.append( + {"name": f"{entry.act}({group.name})", "value": entry.prompt}, + ) + self.__p_grp = [ + {"name": obj["name"], "value": obj["value"]} for obj in command_obj_lst + ] + + def __getEveryPromptCommands(self, get_name_only=False): + if get_name_only: + return [obj["name"] for obj in self.__p_grp] + return self.__p_grp + + def __setCommandSuggestionEnabled(self, f): + for w in self.__textEditGroup.getGroup().values(): + if isinstance(w, JSONEditor): + pass + else: + w.setCommandSuggestionEnabled(f) + + def __updateSuggestions(self): + w = self.__textEditGroup.getCurrentTextEdit() + if w and self.__commandEnabled: + input_text_chunk = w.toPlainText().split() + input_text_chunk_exists = len(input_text_chunk) > 0 + self.__suggestionWidget.setVisible(input_text_chunk_exists) + if input_text_chunk_exists: + input_text_chunk = input_text_chunk[-1] + starts_with_f = input_text_chunk.startswith("/") + self.__suggestionWidget.setVisible(starts_with_f) + if starts_with_f: + command_word = input_text_chunk[1:] + # Set every prompt commands first + self.__setEveryPromptCommands() + # Get the commands + commands = self.__getEveryPromptCommands(get_name_only=True) + filtered_commands = commands + if command_word: + filtered_commands = [ + command + for command in commands + if command_word.lower() in command.lower() + ] + filtered_commands_exists_f = len(filtered_commands) > 0 + self.__suggestionWidget.setVisible(filtered_commands_exists_f) + if filtered_commands_exists_f: + # Clear previous suggestions + self.__suggestion_list.clear() + + # Add the filtered suggestions to the list + self.__suggestion_list.addItems(filtered_commands) + self.__suggestion_list.setCurrentRow(0) + + def __sendKeySignalToSuggestion(self, key): + if key == "up": + self.__suggestion_list.setCurrentRow( + max(0, self.__suggestion_list.currentRow() - 1), + ) + elif key == "down": + self.__suggestion_list.setCurrentRow( + min( + self.__suggestion_list.currentRow() + 1, + self.__suggestion_list.count() - 1, + ), + ) + elif key == "enter": + self.executeCommand(self.__suggestion_list.currentItem()) + + def showWidgetInPromptDuringResponse(self, f): + self.__controlWidgetDuringGeneration.setVisible(f) + + def executeCommand(self, item): + self.__textEditGroup.showPromptContent(item, self.__p_grp) + + def updateHeight(self): + overallHeight = self.__textEditGroup.adjustHeight() + # Set the maximum height of the widget - should fit the device screen + self.setMaximumHeight( + overallHeight + + self.__suggestionWidget.height() + + self.__uploadedImageFileWidget.height() + + 100, + ) + + def getMainPromptInput(self): + return self.__textEditGroup.getMainTextEdit() + + def getContent(self): + return self.__textEditGroup.getContent() + + def getJSONContent(self): + return self.__textEditGroup.getJSONContent() + + def __showBeginning(self, f): + self.__textEditGroup.setVisibleTo(PROMPT_BEGINNING_KEY_NAME, f) + if f: + self.__textEditGroup.getGroup()[PROMPT_BEGINNING_KEY_NAME].setFocus() + else: + self.__textEditGroup.getGroup()[PROMPT_BEGINNING_KEY_NAME].clear() + self.__textEditGroup.getGroup()[PROMPT_MAIN_KEY_NAME].setFocus() + + def __showEnding(self, f): + self.__textEditGroup.setVisibleTo(PROMPT_END_KEY_NAME, f) + if f: + self.__textEditGroup.getGroup()[PROMPT_END_KEY_NAME].setFocus() + else: + self.__textEditGroup.getGroup()[PROMPT_END_KEY_NAME].clear() + self.__textEditGroup.getGroup()[PROMPT_MAIN_KEY_NAME].setFocus() + + def __supportPromptCommand(self, f): + self.__commandEnabled = f + + def __readingFiles(self): + filenames = QFileDialog.getOpenFileNames( + self, + LangClass.TRANSLATIONS["Find"], + QFILEDIALOG_DEFAULT_DIRECTORY, + READ_FILE_EXT_LIST_STR, + ) + if filenames[0]: + filenames = filenames[0] + cur_file_extension = Path(filenames[0]).suffix + # Text + if cur_file_extension in TEXT_FILE_EXT_LIST: + prompt_context = get_content_of_text_file_for_send(filenames) + self.getMainPromptInput().setText(prompt_context) + # Image + elif cur_file_extension in IMAGE_FILE_EXT_LIST: + self.__uploadedImageFileWidget.addFiles(filenames) + + def getImageBuffers(self): + return self.__uploadedImageFileWidget.getImageBuffers() + + def resetUploadImageFileWidget(self): + self.__uploadedImageFileWidget.setVisible(False) + self.__uploadedImageFileWidget.clear() + + def __toggleJSONAction(self, f): + self.__writeJSONAction.setEnabled(f) + self.__writeJSONAction.setChecked(f) + + def toggleJSON(self, f): + self.__toggleJSONAction(f) + self.__showJSON(f) + json_text_edit = self.__textEditGroup.getGroup()[PROMPT_JSON_KEY_NAME] + json_text_edit.clear() + if f: + json_text_edit.setFocus() + else: + self.__textEditGroup.getGroup()[PROMPT_MAIN_KEY_NAME].setFocus() + + def __showJSON(self, f): + self.__json_object = f + self.__textEditGroup.setVisibleTo(PROMPT_JSON_KEY_NAME, f) + + def sendEnabled(self, f): + self.getMainPromptInput().setEnabled(f) + self.__sendBtn.setEnabled(f) + + def eventFilter(self, source, event): + if event.type() == 6: + if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: + if event.modifiers() == Qt.KeyboardModifier.ControlModifier: + self.__sendBtn.click() + return True + return super().eventFilter(source, event) + + def record(self, checked): + if checked: + self.recorder_thread = RecorderThread() + self.recorder_thread.recording_finished.connect(self.on_recording_finished) + self.recorder_thread.errorGenerated.connect(self.on_recording_error) + if self.__textEditGroup.getCurrentTextEdit(): + self.__textEditGroup.getCurrentTextEdit().clear() + self.recorder_thread.start() + else: + self.recorder_thread.stop() + + def on_recording_error(self, error): + self.__recordBtn.setChecked(False) + QMessageBox.critical(self, LangClass.TRANSLATIONS["Error"], error) + + def on_recording_finished(self, filename): + self.__recordBtn.setChecked(False) + self.stt_thread = STTThread(filename) + self.stt_thread.stt_finished.connect(self.on_stt_finished) + self.stt_thread.errorGenerated.connect( + lambda x: QMessageBox.critical(self, LangClass.TRANSLATIONS["Error"], x), + ) + self.stt_thread.start() + + def on_stt_finished(self, text): + t = self.__textEditGroup.getCurrentTextEdit() + if t: + t.setText(text) + else: + self.__textEditGroup.getMainTextEdit().setText(text) diff --git a/pyqt_openai/chat_widget/center/realtimeApiWidget.py b/pyqt_openai/chat_widget/center/realtimeApiWidget.py index 5c0a153d..18d07323 100644 --- a/pyqt_openai/chat_widget/center/realtimeApiWidget.py +++ b/pyqt_openai/chat_widget/center/realtimeApiWidget.py @@ -1,22 +1,24 @@ -from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel -from PySide6.QtGui import QFont -from PySide6.QtCore import Qt - -from pyqt_openai import LARGE_LABEL_PARAM - - -class RealtimeApiWidget(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.initUI() - - def initUI(self): - lay = QVBoxLayout() - - title = QLabel("Coming Soon...", self) - title.setFont(QFont(*LARGE_LABEL_PARAM)) - title.setAlignment(Qt.AlignmentFlag.AlignCenter) - - lay.addWidget(title) - - self.setLayout(lay) +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtGui import QFont +from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget + +from pyqt_openai import LARGE_LABEL_PARAM + + +class RealtimeApiWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.initUI() + + def initUI(self): + lay = QVBoxLayout() + + title = QLabel("Coming Soon...", self) + title.setFont(QFont(*LARGE_LABEL_PARAM)) + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + + lay.addWidget(title) + + self.setLayout(lay) diff --git a/pyqt_openai/chat_widget/center/responseInfoDialog.py b/pyqt_openai/chat_widget/center/responseInfoDialog.py index 2d4f924f..2cf00c8e 100644 --- a/pyqt_openai/chat_widget/center/responseInfoDialog.py +++ b/pyqt_openai/chat_widget/center/responseInfoDialog.py @@ -1,40 +1,46 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QDialog, QFormLayout, QLabel, QPushButton - -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.models import ChatMessageContainer -from pyqt_openai.util.common import getSeparator - - -class ResponseInfoDialog(QDialog): - def __init__(self, result_info: ChatMessageContainer, parent=None): - super().__init__(parent) - self.__initVal(result_info) - self.__initUi() - - def __initVal(self, result_info): - self.__result_info = result_info - - def __initUi(self): - self.setWindowTitle(LangClass.TRANSLATIONS["Message Result"]) - self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) - - lbls = [] - for k, v in self.__result_info.get_items(excludes=["content"]): - if k == "favorite": - lbls.append(QLabel(f'{k}: {"Yes" if v else "No"}')) - else: - lbls.append(QLabel(f"{k}: {v}")) - - sep = getSeparator("horizontal") - - okBtn = QPushButton("OK") - okBtn.clicked.connect(self.accept) - - lay = QFormLayout() - for lbl in lbls: - lay.addWidget(lbl) - lay.addWidget(sep) - lay.addWidget(okBtn) - - self.setLayout(lay) +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QDialog, QFormLayout, QLabel, QPushButton + +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.util.common import getSeparator + +if TYPE_CHECKING: + from pyqt_openai.models import ChatMessageContainer + + +class ResponseInfoDialog(QDialog): + def __init__(self, result_info: ChatMessageContainer, parent=None): + super().__init__(parent) + self.__initVal(result_info) + self.__initUi() + + def __initVal(self, result_info): + self.__result_info = result_info + + def __initUi(self): + self.setWindowTitle(LangClass.TRANSLATIONS["Message Result"]) + self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) + + lbls = [] + for k, v in self.__result_info.get_items(excludes=["content"]): + if k == "favorite": + lbls.append(QLabel(f'{k}: {"Yes" if v else "No"}')) + else: + lbls.append(QLabel(f"{k}: {v}")) + + sep = getSeparator("horizontal") + + okBtn = QPushButton("OK") + okBtn.clicked.connect(self.accept) + + lay = QFormLayout() + for lbl in lbls: + lay.addWidget(lbl) + lay.addWidget(sep) + lay.addWidget(okBtn) + + self.setLayout(lay) diff --git a/pyqt_openai/chat_widget/center/textEditPrompt.py b/pyqt_openai/chat_widget/center/textEditPrompt.py index 8f7e8957..0a1d2b8a 100644 --- a/pyqt_openai/chat_widget/center/textEditPrompt.py +++ b/pyqt_openai/chat_widget/center/textEditPrompt.py @@ -1,93 +1,96 @@ -from pathlib import Path - -from PySide6.QtCore import Qt, Signal, QMimeData -from PySide6.QtWidgets import QTextEdit - -from pyqt_openai import IMAGE_FILE_EXT_LIST - - -class TextEditPrompt(QTextEdit): - returnPressed = Signal() - sendSuggestionWidget = Signal(str) - moveCursorToOtherPrompt = Signal(str) - handleDrop = Signal(list) - - def __init__(self, parent=None): - super().__init__(parent) - self.__initVal() - self.__initUi() - - def __initVal(self): - self.__commandSuggestionEnabled = False - self.__executeEnabled = True - - def __initUi(self): - self.setAcceptRichText(False) - - def setExecuteEnabled(self, f): - self.__executeEnabled = f - - def setCommandSuggestionEnabled(self, f): - self.__commandSuggestionEnabled = f - - def sendMessage(self): - self.returnPressed.emit() - - def keyPressEvent(self, event): - # Send key events to the suggestion widget if enabled - if self.__commandSuggestionEnabled: - if event.key() == Qt.Key.Key_Up: - self.sendSuggestionWidget.emit("up") - elif event.key() == Qt.Key.Key_Down: - self.sendSuggestionWidget.emit("down") - else: - if event.modifiers() == Qt.KeyboardModifier.ControlModifier: - if event.key() == Qt.Key.Key_Up: - self.moveCursorToOtherPrompt.emit("up") - return - elif event.key() == Qt.Key.Key_Down: - self.moveCursorToOtherPrompt.emit("down") - return - else: - return super().keyPressEvent(event) - - if ( - event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter - ) and self.__executeEnabled: - if event.modifiers() == Qt.KeyboardModifier.ShiftModifier: - return super().keyPressEvent(event) - else: - if self.__commandSuggestionEnabled: - self.sendSuggestionWidget.emit("enter") - else: - self.sendMessage() - else: - return super().keyPressEvent(event) - - def focusInEvent(self, event): - self.setCursorWidth(1) - return super().focusInEvent(event) - - def focusOutEvent(self, event): - self.setCursorWidth(0) - return super().focusInEvent(event) - - def dropEvent(self, e): - if e.mimeData().hasUrls(): - urls = [url.toLocalFile() for url in e.mimeData().urls()] - self.handleDrop.emit(urls) - e.accept() - else: - e.ignore() - - def insertFromMimeData(self, source: QMimeData): - paths = [] - for url in source.urls(): - if url.isLocalFile(): - file_path = url.toLocalFile() - if Path(file_path).suffix in IMAGE_FILE_EXT_LIST: - paths.append(file_path) - if paths and len(paths) > 0: - self.handleDrop.emit(paths) - else: - super().insertFromMimeData(source) +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import QTextEdit + +from pyqt_openai import IMAGE_FILE_EXT_LIST + +if TYPE_CHECKING: + from qtpy.QtCore import QMimeData + + +class TextEditPrompt(QTextEdit): + returnPressed = Signal() + sendSuggestionWidget = Signal(str) + moveCursorToOtherPrompt = Signal(str) + handleDrop = Signal(list) + + def __init__(self, parent=None): + super().__init__(parent) + self.__initVal() + self.__initUi() + + def __initVal(self): + self.__commandSuggestionEnabled = False + self.__executeEnabled = True + + def __initUi(self): + self.setAcceptRichText(False) + + def setExecuteEnabled(self, f): + self.__executeEnabled = f + + def setCommandSuggestionEnabled(self, f): + self.__commandSuggestionEnabled = f + + def sendMessage(self): + self.returnPressed.emit() + + def keyPressEvent(self, event): + # Send key events to the suggestion widget if enabled + if self.__commandSuggestionEnabled: + if event.key() == Qt.Key.Key_Up: + self.sendSuggestionWidget.emit("up") + elif event.key() == Qt.Key.Key_Down: + self.sendSuggestionWidget.emit("down") + elif event.modifiers() == Qt.KeyboardModifier.ControlModifier: + if event.key() == Qt.Key.Key_Up: + self.moveCursorToOtherPrompt.emit("up") + return None + if event.key() == Qt.Key.Key_Down: + self.moveCursorToOtherPrompt.emit("down") + return None + return super().keyPressEvent(event) + + if ( + event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter + ) and self.__executeEnabled: + if event.modifiers() == Qt.KeyboardModifier.ShiftModifier: + return super().keyPressEvent(event) + if self.__commandSuggestionEnabled: + self.sendSuggestionWidget.emit("enter") + else: + self.sendMessage() + else: + return super().keyPressEvent(event) + + def focusInEvent(self, event): + self.setCursorWidth(1) + return super().focusInEvent(event) + + def focusOutEvent(self, event): + self.setCursorWidth(0) + return super().focusInEvent(event) + + def dropEvent(self, e): + if e.mimeData().hasUrls(): + urls = [url.toLocalFile() for url in e.mimeData().urls()] + self.handleDrop.emit(urls) + e.accept() + else: + e.ignore() + + def insertFromMimeData(self, source: QMimeData): + paths = [] + for url in source.urls(): + if url.isLocalFile(): + file_path = url.toLocalFile() + if Path(file_path).suffix in IMAGE_FILE_EXT_LIST: + paths.append(file_path) + if paths and len(paths) > 0: + self.handleDrop.emit(paths) + else: + super().insertFromMimeData(source) diff --git a/pyqt_openai/chat_widget/center/textEditPromptGroup.py b/pyqt_openai/chat_widget/center/textEditPromptGroup.py index c6cc1d6f..5bfc853c 100644 --- a/pyqt_openai/chat_widget/center/textEditPromptGroup.py +++ b/pyqt_openai/chat_widget/center/textEditPromptGroup.py @@ -1,251 +1,249 @@ -from pathlib import Path - -from PySide6.QtCore import Signal, QBuffer, QByteArray -from PySide6.QtGui import QTextCursor, QKeySequence -from PySide6.QtWidgets import QVBoxLayout, QWidget, QApplication - -from pyqt_openai import ( - PROMPT_BEGINNING_KEY_NAME, - PROMPT_MAIN_KEY_NAME, - PROMPT_END_KEY_NAME, - PROMPT_JSON_KEY_NAME, - CONTEXT_DELIMITER, - IMAGE_FILE_EXT_LIST, - TEXT_FILE_EXT_LIST, -) -from pyqt_openai.chat_widget.center.textEditPrompt import TextEditPrompt -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.widgets.jsonEditor import JSONEditor - - -class TextEditPromptGroup(QWidget): - textChanged = Signal() - onUpdateSuggestion = Signal() - onSendKeySignalToSuggestion = Signal(str) - onPasteFile = Signal(QByteArray) - onPasteText = Signal(str) - - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - self.__beginningTextEdit = TextEditPrompt() - self.__beginningTextEdit.setPlaceholderText(LangClass.TRANSLATIONS["Beginning"]) - - self.__textEdit = TextEditPrompt() - self.__textEdit.setPlaceholderText(LangClass.TRANSLATIONS["Write some text..."]) - - self.__endingTextEdit = TextEditPrompt() - self.__endingTextEdit.setPlaceholderText(LangClass.TRANSLATIONS["Ending"]) - - self.__jsonTextEdit = JSONEditor() - - # Grouping text edit widgets for easy access and management - self.__textGroup = { - PROMPT_BEGINNING_KEY_NAME: self.__beginningTextEdit, - PROMPT_MAIN_KEY_NAME: self.__textEdit, - PROMPT_JSON_KEY_NAME: self.__jsonTextEdit, - PROMPT_END_KEY_NAME: self.__endingTextEdit, - } - - lay = QVBoxLayout() - for w in self.__textGroup.values(): - # Connect every group text edit widget to the signal - if isinstance(w, JSONEditor): - pass - else: - w.textChanged.connect(self.onUpdateSuggestion) - w.textChanged.connect(self.textChanged) - w.moveCursorToOtherPrompt.connect(self.__moveCursorToOtherPrompt) - - # Connect TextEditPrompt signal - if isinstance(w, TextEditPrompt): - w.sendSuggestionWidget.connect(self.onSendKeySignalToSuggestion) - lay.addWidget(w) - lay.setContentsMargins(0, 0, 0, 0) - lay.setSpacing(0) - - self.setLayout(lay) - - self.setVisibleTo(PROMPT_BEGINNING_KEY_NAME, False) - self.setVisibleTo(PROMPT_JSON_KEY_NAME, False) - self.setVisibleTo(PROMPT_END_KEY_NAME, False) - - self.__textGroup[PROMPT_MAIN_KEY_NAME].installEventFilter(self) - self.__textGroup[PROMPT_MAIN_KEY_NAME].handleDrop.connect(self.handleDrop) - - def showPromptContent(self, item, grp): - command_key = item.text() - command = "" - for i in range(len(grp)): - if grp[i].get("name", "") == command_key: - command = grp[i].get("value", "") - - w = self.getCurrentTextEdit() - if w: - cursor = w.textCursor() - cursor.deletePreviousChar() - cursor.select(QTextCursor.SelectionType.WordUnderCursor) - w.setTextCursor(cursor) - w.insertPlainText(command) - - self.adjustHeight() - - def getCurrentTextEdit(self): - for w in self.__textGroup.values(): - if w.hasFocus(): - return w - - def adjustHeight(self) -> int: - """ - Adjust overall height of text edit group based on their contents and return adjusted height - """ - group_height = 0 - for w in self.__textGroup.values(): - document = w.document() - height = document.size().height() - overall_height = int(height + document.documentMargin()) - w.setMaximumHeight(overall_height) - group_height += overall_height - return group_height - - def setVisibleTo(self, key, f): - if key in self.__textGroup: - self.__textGroup[key].setVisible(f) - - def getContent(self): - b = self.__textGroup[PROMPT_BEGINNING_KEY_NAME].toPlainText().strip() - m = self.__textGroup[PROMPT_MAIN_KEY_NAME].toPlainText().strip() - e = self.__textGroup[PROMPT_END_KEY_NAME].toPlainText().strip() - - content = "" - if b: - content = b + CONTEXT_DELIMITER - content += m - if e: - content += CONTEXT_DELIMITER + e - - return content - - def getJSONContent(self): - j = self.__textGroup[PROMPT_JSON_KEY_NAME].toPlainText().strip() - return j - - def getMainTextEdit(self): - return self.__textEdit - - def getGroup(self): - """ - Get the text group. - These are only used when you need to handle widgets in the group in detail. - """ - return self.__textGroup - - def eventFilter(self, source, event): - if event.type() == 6: # QEvent.KeyPress - if event.matches(QKeySequence.Paste): - self.handlePaste() - return True - return super().eventFilter(source, event) - - def handleDrop(self, urls): - for url in urls: - if Path(url).suffix in IMAGE_FILE_EXT_LIST: - with open(url, "rb") as f: - data = f.read() - self.onPasteFile.emit(data) - elif Path(url).suffix in TEXT_FILE_EXT_LIST: - with open(url, "r") as f: - data = f.read() - self.onPasteText.emit(data) - - def handlePaste(self): - clipboard = QApplication.clipboard() - mime_data = clipboard.mimeData() - - # Image file - if mime_data.hasImage(): - image = clipboard.image() - - # Save image to a memory buffer - buffer = QBuffer() - buffer.open(QBuffer.ReadWrite) - - # Try saving the image as PNG first - if not image.save(buffer, "PNG"): - # If PNG fails, try saving as JPG - if not image.save(buffer, "JPG"): - # Both formats failed - buffer.close() - return - else: - image_format = "JPG" - - buffer.seek(0) - image_data = buffer.data() - buffer.close() - - # Emit the image data - self.onPasteFile.emit(image_data) - # TXT file - # elif mime_data.hasUrls() and mime_data.hasText(): - # text = mime_data.text() - # self.onPasteText.emit(text) - else: - self.__textEdit.paste() - - def __moveCursorToOtherPrompt(self, direction): - if direction == "up": - if ( - self.__textGroup[PROMPT_MAIN_KEY_NAME].isVisible() - and self.__textGroup[PROMPT_MAIN_KEY_NAME].hasFocus() - ): - if self.__textGroup[PROMPT_BEGINNING_KEY_NAME].isVisible(): - self.__textGroup[PROMPT_MAIN_KEY_NAME].clearFocus() - self.__textGroup[PROMPT_BEGINNING_KEY_NAME].setFocus() - elif ( - self.__textGroup[PROMPT_END_KEY_NAME].isVisible() - and self.__textGroup[PROMPT_END_KEY_NAME].hasFocus() - ): - if self.__textGroup[PROMPT_JSON_KEY_NAME].isVisible(): - self.__textGroup[PROMPT_END_KEY_NAME].clearFocus() - self.__textGroup[PROMPT_JSON_KEY_NAME].setFocus() - elif self.__textGroup[PROMPT_MAIN_KEY_NAME].isVisible(): - self.__textGroup[PROMPT_END_KEY_NAME].clearFocus() - self.__textGroup[PROMPT_MAIN_KEY_NAME].setFocus() - elif ( - self.__textGroup[PROMPT_JSON_KEY_NAME].isVisible() - and self.__textGroup[PROMPT_JSON_KEY_NAME].hasFocus() - ): - if self.__textGroup[PROMPT_MAIN_KEY_NAME].isVisible(): - self.__textGroup[PROMPT_JSON_KEY_NAME].clearFocus() - self.__textGroup[PROMPT_MAIN_KEY_NAME].setFocus() - elif direction == "down": - if ( - self.__textGroup[PROMPT_BEGINNING_KEY_NAME].isVisible() - and self.__textGroup[PROMPT_BEGINNING_KEY_NAME].hasFocus() - ): - if self.__textGroup[PROMPT_MAIN_KEY_NAME].isVisible(): - self.__textGroup[PROMPT_BEGINNING_KEY_NAME].clearFocus() - self.__textGroup[PROMPT_MAIN_KEY_NAME].setFocus() - elif ( - self.__textGroup[PROMPT_MAIN_KEY_NAME].isVisible() - and self.__textGroup[PROMPT_MAIN_KEY_NAME].hasFocus() - ): - if self.__textGroup[PROMPT_JSON_KEY_NAME].isVisible(): - self.__textGroup[PROMPT_MAIN_KEY_NAME].clearFocus() - self.__textGroup[PROMPT_JSON_KEY_NAME].setFocus() - elif self.__textGroup[PROMPT_END_KEY_NAME].isVisible(): - self.__textGroup[PROMPT_MAIN_KEY_NAME].clearFocus() - self.__textGroup[PROMPT_END_KEY_NAME].setFocus() - elif ( - self.__textGroup[PROMPT_JSON_KEY_NAME].isVisible() - and self.__textGroup[PROMPT_JSON_KEY_NAME].hasFocus() - ): - if self.__textGroup[PROMPT_END_KEY_NAME].isVisible(): - self.__textGroup[PROMPT_JSON_KEY_NAME].clearFocus() - self.__textGroup[PROMPT_END_KEY_NAME].setFocus() - - else: - print("Invalid direction:", direction) +from __future__ import annotations + +from pathlib import Path + +from qtpy.QtCore import QBuffer, QByteArray, Signal +from qtpy.QtGui import QKeySequence, QTextCursor +from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget + +from pyqt_openai import ( + CONTEXT_DELIMITER, + IMAGE_FILE_EXT_LIST, + PROMPT_BEGINNING_KEY_NAME, + PROMPT_END_KEY_NAME, + PROMPT_JSON_KEY_NAME, + PROMPT_MAIN_KEY_NAME, + TEXT_FILE_EXT_LIST, +) +from pyqt_openai.chat_widget.center.textEditPrompt import TextEditPrompt +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.widgets.jsonEditor import JSONEditor + + +class TextEditPromptGroup(QWidget): + textChanged = Signal() + onUpdateSuggestion = Signal() + onSendKeySignalToSuggestion = Signal(str) + onPasteFile = Signal(QByteArray) + onPasteText = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.__initUi() + + def __initUi(self): + self.__beginningTextEdit = TextEditPrompt() + self.__beginningTextEdit.setPlaceholderText(LangClass.TRANSLATIONS["Beginning"]) + + self.__textEdit = TextEditPrompt() + self.__textEdit.setPlaceholderText(LangClass.TRANSLATIONS["Write some text..."]) + + self.__endingTextEdit = TextEditPrompt() + self.__endingTextEdit.setPlaceholderText(LangClass.TRANSLATIONS["Ending"]) + + self.__jsonTextEdit = JSONEditor() + + # Grouping text edit widgets for easy access and management + self.__textGroup = { + PROMPT_BEGINNING_KEY_NAME: self.__beginningTextEdit, + PROMPT_MAIN_KEY_NAME: self.__textEdit, + PROMPT_JSON_KEY_NAME: self.__jsonTextEdit, + PROMPT_END_KEY_NAME: self.__endingTextEdit, + } + + lay = QVBoxLayout() + for w in self.__textGroup.values(): + # Connect every group text edit widget to the signal + if isinstance(w, JSONEditor): + pass + else: + w.textChanged.connect(self.onUpdateSuggestion) + w.textChanged.connect(self.textChanged) + w.moveCursorToOtherPrompt.connect(self.__moveCursorToOtherPrompt) + + # Connect TextEditPrompt signal + if isinstance(w, TextEditPrompt): + w.sendSuggestionWidget.connect(self.onSendKeySignalToSuggestion) + lay.addWidget(w) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(0) + + self.setLayout(lay) + + self.setVisibleTo(PROMPT_BEGINNING_KEY_NAME, False) + self.setVisibleTo(PROMPT_JSON_KEY_NAME, False) + self.setVisibleTo(PROMPT_END_KEY_NAME, False) + + self.__textGroup[PROMPT_MAIN_KEY_NAME].installEventFilter(self) + self.__textGroup[PROMPT_MAIN_KEY_NAME].handleDrop.connect(self.handleDrop) + + def showPromptContent(self, item, grp): + command_key = item.text() + command = "" + for i in range(len(grp)): + if grp[i].get("name", "") == command_key: + command = grp[i].get("value", "") + + w = self.getCurrentTextEdit() + if w: + cursor = w.textCursor() + cursor.deletePreviousChar() + cursor.select(QTextCursor.SelectionType.WordUnderCursor) + w.setTextCursor(cursor) + w.insertPlainText(command) + + self.adjustHeight() + + def getCurrentTextEdit(self): + for w in self.__textGroup.values(): + if w.hasFocus(): + return w + + def adjustHeight(self) -> int: + """Adjust overall height of text edit group based on their contents and return adjusted height.""" + group_height = 0 + for w in self.__textGroup.values(): + document = w.document() + height = document.size().height() + overall_height = int(height + document.documentMargin()) + w.setMaximumHeight(overall_height) + group_height += overall_height + return group_height + + def setVisibleTo(self, key, f): + if key in self.__textGroup: + self.__textGroup[key].setVisible(f) + + def getContent(self): + b = self.__textGroup[PROMPT_BEGINNING_KEY_NAME].toPlainText().strip() + m = self.__textGroup[PROMPT_MAIN_KEY_NAME].toPlainText().strip() + e = self.__textGroup[PROMPT_END_KEY_NAME].toPlainText().strip() + + content = "" + if b: + content = b + CONTEXT_DELIMITER + content += m + if e: + content += CONTEXT_DELIMITER + e + + return content + + def getJSONContent(self): + j = self.__textGroup[PROMPT_JSON_KEY_NAME].toPlainText().strip() + return j + + def getMainTextEdit(self): + return self.__textEdit + + def getGroup(self): + """Get the text group. + These are only used when you need to handle widgets in the group in detail. + """ + return self.__textGroup + + def eventFilter(self, source, event): + if event.type() == 6: # QEvent.KeyPress + if event.matches(QKeySequence.Paste): + self.handlePaste() + return True + return super().eventFilter(source, event) + + def handleDrop(self, urls): + for url in urls: + if Path(url).suffix in IMAGE_FILE_EXT_LIST: + with open(url, "rb") as f: + data = f.read() + self.onPasteFile.emit(data) + elif Path(url).suffix in TEXT_FILE_EXT_LIST: + with open(url) as f: + data = f.read() + self.onPasteText.emit(data) + + def handlePaste(self): + clipboard = QApplication.clipboard() + mime_data = clipboard.mimeData() + + # Image file + if mime_data.hasImage(): + image = clipboard.image() + + # Save image to a memory buffer + buffer = QBuffer() + buffer.open(QBuffer.ReadWrite) + + # Try saving the image as PNG first + if not image.save(buffer, "PNG"): + # If PNG fails, try saving as JPG + if not image.save(buffer, "JPG"): + # Both formats failed + buffer.close() + return + image_format = "JPG" + + buffer.seek(0) + image_data = buffer.data() + buffer.close() + + # Emit the image data + self.onPasteFile.emit(image_data) + # TXT file + # elif mime_data.hasUrls() and mime_data.hasText(): + # text = mime_data.text() + # self.onPasteText.emit(text) + else: + self.__textEdit.paste() + + def __moveCursorToOtherPrompt(self, direction): + if direction == "up": + if ( + self.__textGroup[PROMPT_MAIN_KEY_NAME].isVisible() + and self.__textGroup[PROMPT_MAIN_KEY_NAME].hasFocus() + ): + if self.__textGroup[PROMPT_BEGINNING_KEY_NAME].isVisible(): + self.__textGroup[PROMPT_MAIN_KEY_NAME].clearFocus() + self.__textGroup[PROMPT_BEGINNING_KEY_NAME].setFocus() + elif ( + self.__textGroup[PROMPT_END_KEY_NAME].isVisible() + and self.__textGroup[PROMPT_END_KEY_NAME].hasFocus() + ): + if self.__textGroup[PROMPT_JSON_KEY_NAME].isVisible(): + self.__textGroup[PROMPT_END_KEY_NAME].clearFocus() + self.__textGroup[PROMPT_JSON_KEY_NAME].setFocus() + elif self.__textGroup[PROMPT_MAIN_KEY_NAME].isVisible(): + self.__textGroup[PROMPT_END_KEY_NAME].clearFocus() + self.__textGroup[PROMPT_MAIN_KEY_NAME].setFocus() + elif ( + self.__textGroup[PROMPT_JSON_KEY_NAME].isVisible() + and self.__textGroup[PROMPT_JSON_KEY_NAME].hasFocus() + ): + if self.__textGroup[PROMPT_MAIN_KEY_NAME].isVisible(): + self.__textGroup[PROMPT_JSON_KEY_NAME].clearFocus() + self.__textGroup[PROMPT_MAIN_KEY_NAME].setFocus() + elif direction == "down": + if ( + self.__textGroup[PROMPT_BEGINNING_KEY_NAME].isVisible() + and self.__textGroup[PROMPT_BEGINNING_KEY_NAME].hasFocus() + ): + if self.__textGroup[PROMPT_MAIN_KEY_NAME].isVisible(): + self.__textGroup[PROMPT_BEGINNING_KEY_NAME].clearFocus() + self.__textGroup[PROMPT_MAIN_KEY_NAME].setFocus() + elif ( + self.__textGroup[PROMPT_MAIN_KEY_NAME].isVisible() + and self.__textGroup[PROMPT_MAIN_KEY_NAME].hasFocus() + ): + if self.__textGroup[PROMPT_JSON_KEY_NAME].isVisible(): + self.__textGroup[PROMPT_MAIN_KEY_NAME].clearFocus() + self.__textGroup[PROMPT_JSON_KEY_NAME].setFocus() + elif self.__textGroup[PROMPT_END_KEY_NAME].isVisible(): + self.__textGroup[PROMPT_MAIN_KEY_NAME].clearFocus() + self.__textGroup[PROMPT_END_KEY_NAME].setFocus() + elif ( + self.__textGroup[PROMPT_JSON_KEY_NAME].isVisible() + and self.__textGroup[PROMPT_JSON_KEY_NAME].hasFocus() + ): + if self.__textGroup[PROMPT_END_KEY_NAME].isVisible(): + self.__textGroup[PROMPT_JSON_KEY_NAME].clearFocus() + self.__textGroup[PROMPT_END_KEY_NAME].setFocus() + + else: + print("Invalid direction:", direction) diff --git a/pyqt_openai/chat_widget/center/uploadedImageFileWidget.py b/pyqt_openai/chat_widget/center/uploadedImageFileWidget.py index 1a19a77b..329af200 100644 --- a/pyqt_openai/chat_widget/center/uploadedImageFileWidget.py +++ b/pyqt_openai/chat_widget/center/uploadedImageFileWidget.py @@ -1,139 +1,154 @@ -import os - -from PySide6.QtCore import QByteArray, QBuffer, Qt -from PySide6.QtGui import QPixmap -from PySide6.QtWidgets import ( - QWidget, - QLabel, - QVBoxLayout, - QPushButton, - QHBoxLayout, - QSpacerItem, - QSizePolicy, - QScrollArea, -) - -from pyqt_openai import PROMPT_IMAGE_SCALE, IMAGE_FILE_EXT_LIST -from pyqt_openai.lang.translations import LangClass - - -class UploadedImageFileWidget(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.__initVal() - self.__initUi() - - def __initVal(self): - self.__delete_mode = False - self.__original_pixmaps = [] # Array of original images - - def __initUi(self): - lbl = QLabel(LangClass.TRANSLATIONS["Uploaded Files (Only Images)"]) - self.__activateDeleteBtn = QPushButton(LangClass.TRANSLATIONS["Delete"]) - self.__activateDeleteBtn.setCheckable(True) - self.__activateDeleteBtn.toggled.connect(self.__activateDelete) - - self.__manualLbl = QLabel(LangClass.TRANSLATIONS["Click the image to delete"]) - self.__manualLbl.setStyleSheet("color: red;") - - lay = QHBoxLayout() - lay.addWidget(lbl) - lay.addSpacerItem(QSpacerItem(10, 10, QSizePolicy.Policy.MinimumExpanding)) - lay.addWidget(self.__manualLbl) - lay.addWidget(self.__activateDeleteBtn) - lay.setContentsMargins(0, 0, 0, 0) - - topWidget = QWidget() - topWidget.setLayout(lay) - - imageWidget = QWidget() - - lay = QHBoxLayout() - lay.setAlignment(Qt.AlignmentFlag.AlignLeft) - imageWidget.setLayout(lay) - - self.__imageArea = QScrollArea() - self.__imageArea.setWidget(imageWidget) - - lay = QVBoxLayout() - lay.addWidget(topWidget) - lay.addWidget(self.__imageArea) - - self.setLayout(lay) - - self.__toggle(False) - self.__imageArea.setWidgetResizable(True) - - self.__manualLbl.setVisible(False) - - def __toggle(self, f): - self.__activateDeleteBtn.setEnabled(f) - self.setVisible(f) - - def addFiles(self, filenames: list[str]): - for filename in filenames: - if os.path.splitext(filename)[-1] in IMAGE_FILE_EXT_LIST: - buffer = QBuffer() - buffer.open(QBuffer.OpenModeFlag.ReadWrite) - buffer.write(open(filename, "rb").read()) - buffer = buffer.data() - self.addImageBuffer(buffer) - self.__toggle(True) - - def getLayout(self): - return self.__imageArea.widget().layout() - - def addImageBuffer(self, image_buffer: QByteArray): - lay = self.getLayout() - lbl = QLabel() - lbl.installEventFilter(self) - pixmap = QPixmap() - pixmap.loadFromData(image_buffer) - self.__original_pixmaps.append(pixmap) - pixmap = pixmap.scaled(*PROMPT_IMAGE_SCALE) - lbl.setPixmap(pixmap) - lay.addWidget(lbl) - self.__toggle(True) - - def getImageBuffers(self): - buffers = [] - # Make a copy of the original images - for pixmap in self.__original_pixmaps: - byte_array = QByteArray() - buffer = QBuffer(byte_array) - buffer.open(QBuffer.WriteOnly) - - # Save the pixmap to the buffer - pixmap.save(buffer, "PNG") - - # Convert the buffer to bytes - image_bytes = byte_array.data() - buffers.append(image_bytes) - - self.__original_pixmaps = [] - - return buffers - - def __activateDelete(self): - f = self.__activateDeleteBtn.isChecked() - self.__manualLbl.setVisible(f) - if f: - self.__activateDeleteBtn.setText(LangClass.TRANSLATIONS["Cancel"]) - else: - self.__activateDeleteBtn.setText(LangClass.TRANSLATIONS["Delete"]) - self.__delete_mode = f - - def clear(self): - lay = self.getLayout() - for i in range(lay.count()): - lay.itemAt(i).widget().deleteLater() - self.__toggle(False) - - def eventFilter(self, obj, event): - if isinstance(obj, QLabel): - if event.type() == 2: - if self.__delete_mode: - obj.deleteLater() - if self.getLayout().count() == 1: - self.__toggle(False) - return super().eventFilter(obj, event) +from __future__ import annotations + +import os + +from typing import TYPE_CHECKING + +from qtpy.QtCore import QBuffer, QByteArray, Qt +from qtpy.QtGui import QPixmap +from qtpy.QtWidgets import QHBoxLayout, QLabel, QPushButton, QScrollArea, QSizePolicy, QSpacerItem, QVBoxLayout, QWidget + +from pyqt_openai import IMAGE_FILE_EXT_LIST, PROMPT_IMAGE_SCALE +from pyqt_openai.lang.translations import LangClass + +if TYPE_CHECKING: + from qtpy.QtCore import QEvent, QObject + from qtpy.QtWidgets import QLayout + + +class UploadedImageFileWidget(QWidget): + def __init__( + self, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.__initVal() + self.__initUi() + + def __initVal(self): + self.__delete_mode: bool = False + self.__original_pixmaps: list[QPixmap] = [] # Array of original images + + def __initUi(self): + lbl = QLabel(LangClass.TRANSLATIONS["Uploaded Files (Only Images)"]) + self.__activateDeleteBtn: QPushButton = QPushButton(LangClass.TRANSLATIONS["Delete"]) + self.__activateDeleteBtn.setCheckable(True) + self.__activateDeleteBtn.toggled.connect(self.__activateDelete) + + self.__manualLbl: QLabel = QLabel(LangClass.TRANSLATIONS["Click the image to delete"]) + self.__manualLbl.setStyleSheet("color: red;") + + hlay = QHBoxLayout() + hlay.addWidget(lbl) + hlay.addSpacerItem(QSpacerItem(10, 10, QSizePolicy.Policy.MinimumExpanding)) + hlay.addWidget(self.__manualLbl) + hlay.addWidget(self.__activateDeleteBtn) + hlay.setContentsMargins(0, 0, 0, 0) + + topWidget = QWidget() + topWidget.setLayout(hlay) + + imageWidget = QWidget() + + hlay = QHBoxLayout() + hlay.setAlignment(Qt.AlignmentFlag.AlignLeft) + imageWidget.setLayout(hlay) + + self.__imageArea: QScrollArea = QScrollArea() + self.__imageArea.setWidget(imageWidget) + + vlay = QVBoxLayout() + vlay.addWidget(topWidget) + vlay.addWidget(self.__imageArea) + + self.setLayout(vlay) + + self.__toggle(False) + self.__imageArea.setWidgetResizable(True) + + self.__manualLbl.setVisible(False) + + def __toggle(self, f: bool) -> None: + self.__activateDeleteBtn.setEnabled(f) + self.setVisible(f) + + def addFiles(self, filenames: list[str]): + for filename in filenames: + if os.path.splitext(filename)[-1] in IMAGE_FILE_EXT_LIST: + buffer = QBuffer() + buffer.open(QBuffer.OpenModeFlag.ReadWrite) + buffer.write(open(filename, "rb").read()) + buffer_data = buffer.data() + self.addImageBuffer(buffer_data) + self.__toggle(True) + + def getLayout(self) -> QHBoxLayout | QLayout: + layout = self.__imageArea.widget().layout() + if not layout: + layout = QHBoxLayout() + layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.__imageArea.widget().setLayout(layout) + return layout + + def addImageBuffer(self, image_buffer: QByteArray): + lay = self.getLayout() + lbl = QLabel() + lbl.installEventFilter(self) + pixmap = QPixmap() + pixmap.loadFromData(image_buffer) + self.__original_pixmaps.append(pixmap) + pixmap = pixmap.scaled(*PROMPT_IMAGE_SCALE) + lbl.setPixmap(pixmap) + lay.addWidget(lbl) + self.__toggle(True) + + def getImageBuffers(self) -> list[bytes | bytearray | memoryview[int]]: + buffers = [] + # Make a copy of the original images + for pixmap in self.__original_pixmaps: + byte_array = QByteArray() + buffer = QBuffer(byte_array) + buffer.open(QBuffer.OpenModeFlag.WriteOnly) + + # Save the pixmap to the buffer + pixmap.save(buffer, "PNG") + + # Convert the buffer to bytes + image_bytes = byte_array.data() + buffers.append(image_bytes) + + self.__original_pixmaps = [] + + return buffers + + def __activateDelete(self): + f = self.__activateDeleteBtn.isChecked() + self.__manualLbl.setVisible(f) + if f: + self.__activateDeleteBtn.setText(LangClass.TRANSLATIONS["Cancel"]) + else: + self.__activateDeleteBtn.setText(LangClass.TRANSLATIONS["Delete"]) + self.__delete_mode = f + + def clear(self): + lay = self.getLayout() + for i in range(lay.count()): + lay_item_i = lay.itemAt(i) + assert lay_item_i is not None, "lay_item_i is None" + widget = lay_item_i.widget() + assert widget is not None, f"widget is None at index {i}" + widget.deleteLater() + self.__toggle(False) + + def eventFilter( + self, + obj: QObject, + event: QEvent, + ) -> bool: + if isinstance(obj, QLabel): + if event.type() == 2: + if self.__delete_mode: + obj.deleteLater() + if self.getLayout().count() == 1: + self.__toggle(False) + return super().eventFilter(obj, event) diff --git a/pyqt_openai/chat_widget/center/userChatUnit.py b/pyqt_openai/chat_widget/center/userChatUnit.py index 97448269..3f752507 100644 --- a/pyqt_openai/chat_widget/center/userChatUnit.py +++ b/pyqt_openai/chat_widget/center/userChatUnit.py @@ -1,6 +1,8 @@ -from pyqt_openai.chat_widget.center.chatUnit import ChatUnit - - -class UserChatUnit(ChatUnit): - def __init__(self, parent=None): - super().__init__(parent) +from __future__ import annotations + +from pyqt_openai.chat_widget.center.chatUnit import ChatUnit + + +class UserChatUnit(ChatUnit): + def __init__(self, parent=None): + super().__init__(parent) diff --git a/pyqt_openai/chat_widget/chatMainWidget.py b/pyqt_openai/chat_widget/chatMainWidget.py index c33c4c38..d3ca397f 100644 --- a/pyqt_openai/chat_widget/chatMainWidget.py +++ b/pyqt_openai/chat_widget/chatMainWidget.py @@ -1,372 +1,359 @@ -import os - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QHBoxLayout, - QWidget, - QVBoxLayout, - QSplitter, - QFileDialog, - QMessageBox, - QPushButton, - QStackedWidget, -) - -from pyqt_openai import ( - THREAD_TABLE_NAME, - JSON_FILE_EXT_LIST_STR, - ICON_SIDEBAR, - ICON_SETTING, - ICON_PROMPT, - FILE_NAME_LENGTH, - DEFAULT_SHORTCUT_FIND, - DEFAULT_SHORTCUT_LEFT_SIDEBAR_WINDOW, - DEFAULT_SHORTCUT_CONTROL_PROMPT_WINDOW, - DEFAULT_SHORTCUT_RIGHT_SIDEBAR_WINDOW, - QFILEDIALOG_DEFAULT_DIRECTORY, - ICON_REALTIME_API, -) -from pyqt_openai.chat_widget.center.realtimeApiWidget import RealtimeApiWidget -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.chat_widget.center.chatWidget import ChatWidget -from pyqt_openai.chat_widget.left_sidebar.chatNavWidget import ChatNavWidget -from pyqt_openai.chat_widget.prompt_gen_widget.promptGeneratorWidget import ( - PromptGeneratorWidget, -) -from pyqt_openai.chat_widget.right_sidebar.chatRightSideBarWidget import ( - ChatRightSideBarWidget, -) -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.models import ( - ChatThreadContainer, - ChatMessageContainer, - CustomizeParamsContainer, -) -from pyqt_openai.globals import DB, LLAMAINDEX_WRAPPER -from pyqt_openai.util.common import ( - open_directory, - get_generic_ext_out_of_qt_ext, - message_list_to_txt, - conv_unit_to_html, - add_file_to_zip, - getSeparator, -) -from pyqt_openai.widgets.button import Button - - -class ChatMainWidget(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.__initVal() - self.__initUi() - - def __initVal(self): - self.__notify_finish = CONFIG_MANAGER.get_general_property("notify_finish") - - self.__show_chat_list = CONFIG_MANAGER.get_general_property("show_chat_list") - self.__show_realtime_api = CONFIG_MANAGER.get_general_property( - "show_realtime_api" - ) - self.__show_setting = CONFIG_MANAGER.get_general_property("show_setting") - self.__show_prompt = CONFIG_MANAGER.get_general_property("show_prompt") - - self.__background_image = CONFIG_MANAGER.get_general_property( - "background_image" - ) - self.__user_image = CONFIG_MANAGER.get_general_property("user_image") - self.__ai_image = CONFIG_MANAGER.get_general_property("ai_image") - - self.__maximum_messages_in_parameter = CONFIG_MANAGER.get_general_property( - "maximum_messages_in_parameter" - ) - - def __initUi(self): - self.__chatNavWidget = ChatNavWidget( - ChatThreadContainer.get_keys(), THREAD_TABLE_NAME - ) - - self.__chatWidget = ChatWidget() - self.__chatWidget.addThread.connect(self.__addThread) - self.__chatWidget.onMenuCloseClicked.connect(self.__onMenuCloseClicked) - - self.__realtimeApiWidget = RealtimeApiWidget() - - self.__browser = self.__chatWidget.getChatBrowser() - - self.__chatRightSideBarWidget = ChatRightSideBarWidget() - self.__chatRightSideBarWidget.onToggleJSON.connect(self.__chatWidget.toggleJSON) - - self.__chatRightSideBarWidget.onTabChanged.connect(self.__chatWidget.setG4F) - - self.__chatWidget.setG4F(self.__chatRightSideBarWidget.currentTabIdx()) - - self.__promptGeneratorWidget = PromptGeneratorWidget() - - self.__sideBarBtn = Button() - self.__sideBarBtn.setStyleAndIcon(ICON_SIDEBAR) - self.__sideBarBtn.setCheckable(True) - self.__sideBarBtn.setToolTip( - LangClass.TRANSLATIONS["Chat List"] - + f" ({DEFAULT_SHORTCUT_LEFT_SIDEBAR_WINDOW})" - ) - self.__sideBarBtn.setChecked(self.__show_chat_list) - self.__sideBarBtn.toggled.connect(self.toggleSideBar) - self.__sideBarBtn.setShortcut(DEFAULT_SHORTCUT_LEFT_SIDEBAR_WINDOW) - - self.__useRealtimeApiBtn = Button() - self.__useRealtimeApiBtn.setStyleAndIcon(ICON_REALTIME_API) - self.__useRealtimeApiBtn.setToolTip(LangClass.TRANSLATIONS["Use Realtime API"]) - self.__useRealtimeApiBtn.setCheckable(True) - self.__useRealtimeApiBtn.setChecked(self.__show_realtime_api) - self.__useRealtimeApiBtn.toggled.connect(self.toggleRealtimeApiScreen) - - self.__settingBtn = Button() - self.__settingBtn.setStyleAndIcon(ICON_SETTING) - self.__settingBtn.setToolTip( - LangClass.TRANSLATIONS["Chat Settings"] - + f" ({DEFAULT_SHORTCUT_RIGHT_SIDEBAR_WINDOW})" - ) - self.__settingBtn.setCheckable(True) - self.__settingBtn.setChecked(self.__show_setting) - self.__settingBtn.toggled.connect(self.toggleSetting) - self.__settingBtn.setShortcut(DEFAULT_SHORTCUT_RIGHT_SIDEBAR_WINDOW) - - self.__promptBtn = Button() - self.__promptBtn.setStyleAndIcon(ICON_PROMPT) - self.__promptBtn.setToolTip( - LangClass.TRANSLATIONS["Prompt Generator"] - + f" ({DEFAULT_SHORTCUT_CONTROL_PROMPT_WINDOW})" - ) - self.__promptBtn.setCheckable(True) - self.__promptBtn.setChecked(self.__show_prompt) - self.__promptBtn.toggled.connect(self.togglePrompt) - self.__promptBtn.setShortcut(DEFAULT_SHORTCUT_CONTROL_PROMPT_WINDOW) - - sep = getSeparator("vertical") - - self.__toggleFindToolButton = QPushButton( - LangClass.TRANSLATIONS["Show Find Tool"] - ) - self.__toggleFindToolButton.setCheckable(True) - self.__toggleFindToolButton.setChecked(False) - self.__toggleFindToolButton.toggled.connect(self.__chatWidget.toggleMenuWidget) - self.__toggleFindToolButton.setShortcut(DEFAULT_SHORTCUT_FIND) - - lay = QHBoxLayout() - lay.addWidget(self.__sideBarBtn) - lay.addWidget(self.__useRealtimeApiBtn) - lay.addWidget(self.__settingBtn) - lay.addWidget(self.__promptBtn) - lay.addWidget(sep) - lay.addWidget(self.__toggleFindToolButton) - lay.setContentsMargins(2, 2, 2, 2) - lay.setAlignment(Qt.AlignmentFlag.AlignLeft) - - self.__menuWidget = QWidget() - self.__menuWidget.setLayout(lay) - self.__menuWidget.setMaximumHeight(self.__menuWidget.sizeHint().height()) - - self.__chatNavWidget.added.connect(self.__addThread) - self.__chatNavWidget.clicked.connect(self.__showChat) - self.__chatNavWidget.cleared.connect(self.__clearChat) - self.__chatNavWidget.onImport.connect(self.__importChat) - self.__chatNavWidget.onExport.connect(self.__exportChat) - self.__chatNavWidget.onFavoriteClicked.connect(self.__showFavorite) - - self.__rightSideBar = QSplitter() - self.__rightSideBar.setOrientation(Qt.Orientation.Vertical) - self.__rightSideBar.addWidget(self.__chatRightSideBarWidget) - self.__rightSideBar.addWidget(self.__promptGeneratorWidget) - self.__rightSideBar.setSizes([450, 550]) - self.__rightSideBar.setChildrenCollapsible(False) - self.__rightSideBar.setHandleWidth(2) - self.__rightSideBar.setStyleSheet( - """ - QSplitter::handle:vertical - { - background: #CCC; - height: 1px; - } - """ - ) - - self.__centerWidget = QStackedWidget() - self.__centerWidget.addWidget(self.__chatWidget) - self.__centerWidget.addWidget(self.__realtimeApiWidget) - self.__centerWidget.setCurrentIndex(1 if self.__show_realtime_api else 0) - - mainWidget = QSplitter() - mainWidget.addWidget(self.__chatNavWidget) - mainWidget.addWidget(self.__centerWidget) - mainWidget.addWidget(self.__rightSideBar) - mainWidget.setSizes([100, 500, 400]) - mainWidget.setChildrenCollapsible(False) - mainWidget.setHandleWidth(2) - mainWidget.setStyleSheet( - """ - QSplitter::handle:horizontal - { - background: #CCC; - height: 1px; - } - """ - ) - - sep = getSeparator("horizontal") - - lay = QVBoxLayout() - lay.addWidget(self.__menuWidget) - lay.addWidget(sep) - lay.addWidget(mainWidget) - lay.setContentsMargins(0, 0, 0, 0) - lay.setSpacing(0) - self.setLayout(lay) - - # self.__lineEdit.setFocus() - - # Put this below to prevent the widgets pop up when app is opened - self.__chatNavWidget.setVisible(self.__show_chat_list) - self.__chatRightSideBarWidget.setVisible(self.__show_setting) - self.__promptGeneratorWidget.setVisible(self.__show_prompt) - self.__rightSideBar.setVisible(self.__show_setting or self.__show_prompt) - - def toggleSideBar(self, x): - self.__chatNavWidget.setVisible(x) - self.__show_chat_list = x - CONFIG_MANAGER.set_general_property("show_chat_list", self.__show_chat_list) - - def toggleRealtimeApiScreen(self, x): - self.__centerWidget.setCurrentIndex(1 if x else 0) - self.__show_realtime_api = x - CONFIG_MANAGER.set_general_property( - "show_realtime_api", self.__show_realtime_api - ) - - def toggleSetting(self, x): - self.__chatRightSideBarWidget.setVisible(x) - self.__show_setting = x - CONFIG_MANAGER.set_general_property("show_setting", self.__show_setting) - if not self.__promptGeneratorWidget.isVisible(): - self.__rightSideBar.setVisible(x) - - def togglePrompt(self, x): - self.__promptGeneratorWidget.setVisible(x) - self.__show_prompt = x - CONFIG_MANAGER.set_general_property("show_prompt", self.__show_prompt) - if not self.__chatRightSideBarWidget.isVisible(): - self.__rightSideBar.setVisible(x) - - def toggleButtons(self, x): - self.__sideBarBtn.setChecked(x) - self.__settingBtn.setChecked(x) - self.__promptBtn.setChecked(x) - - def showThreadToolWidget(self, f): - self.__toggleFindToolButton.setChecked(f) - - def __onMenuCloseClicked(self): - self.__toggleFindToolButton.setChecked(False) - - def showSecondaryToolBar(self, f): - self.__menuWidget.setVisible(f) - CONFIG_MANAGER.set_general_property("show_secondary_toolbar", f) - - def setAIEnabled(self, f): - self.__chatWidget.setAIEnabled(f) - - def refreshCustomizedInformation(self, container: CustomizeParamsContainer): - self.__background_image = container.background_image - self.__user_image = container.user_image - self.__ai_image = container.ai_image - self.__chatWidget.refreshCustomizedInformation( - self.__background_image, self.__user_image, self.__ai_image - ) - - def __showChat(self, id, title): - self.__showFavorite(False) - self.__chatNavWidget.activateFavoriteFromParent(False) - self.__chatWidget.showTitle(title) - self.__chatWidget.showMessages(id) - - def __clearChat(self): - self.__chatWidget.showTitle("") - self.__chatWidget.clearMessages() - - def __addThread(self): - title = LangClass.TRANSLATIONS["New Chat"] - cur_id = DB.insertThread(title) - self.__chatWidget.showTitle(title) - self.__chatWidget.showMessages(cur_id) - - self.__chatNavWidget.add(called_from_parent=True) - - def __importChat(self, data): - try: - # Import thread - for thread in data: - cur_id = DB.insertThread( - thread["name"], thread["insert_dt"], thread["update_dt"] - ) - messages = thread["messages"] - # Import message - for message in messages: - message["thread_id"] = cur_id - container = ChatMessageContainer(**message) - DB.insertMessage(container, deactivate_trigger=True) - self.__chatNavWidget.refreshData() - except Exception as e: - QMessageBox.critical( - self, - LangClass.TRANSLATIONS["Error"], - LangClass.TRANSLATIONS[ - "Check whether the file is a valid JSON file for importing." - ], - ) - - def __exportChat(self, ids): - file_data = QFileDialog.getSaveFileName( - self, - LangClass.TRANSLATIONS["Save"], - QFILEDIALOG_DEFAULT_DIRECTORY, - f"{JSON_FILE_EXT_LIST_STR};;txt files Compressed File (*.zip);;html files Compressed File (*.zip)", - ) - if file_data[0]: - filename = file_data[0] - ext = os.path.splitext(filename)[-1] or get_generic_ext_out_of_qt_ext( - file_data[1] - ) - if ext == ".zip": - compressed_file_type = file_data[1].split(" ")[0].lower() - ext_dict = { - "txt": {"ext": ".txt", "func": message_list_to_txt}, - "html": {"ext": ".html", "func": conv_unit_to_html}, - } - for id in ids: - row_info = DB.selectThread(id) - # Limit the title length to file name length - title = row_info["name"][:FILE_NAME_LENGTH] - txt_filename = ( - f'{title}_{id}{ext_dict[compressed_file_type]["ext"]}' - ) - txt_content = ext_dict[compressed_file_type]["func"](DB, id, title) - add_file_to_zip( - txt_content, - txt_filename, - os.path.splitext(filename)[0] + ".zip", - ) - elif ext == ".json": - DB.export(ids, filename) - open_directory(os.path.dirname(filename)) - - def setColumns(self, columns): - self.__chatNavWidget.setColumns(columns) - - def __showFavorite(self, f): - if f: - lst = DB.selectFavorite() - if len(lst) == 0: - return - else: - lst = [ChatMessageContainer(**dict(c)) for c in lst] - self.__browser.replaceThreadForFavorite(lst) - self.__chatWidget.setAIEnabled(not f) +from __future__ import annotations + +import os + +from typing import TYPE_CHECKING, Any + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QFileDialog, QHBoxLayout, QMessageBox, QPushButton, QSplitter, QStackedWidget, QVBoxLayout, QWidget + +from pyqt_openai import ( + DEFAULT_SHORTCUT_CONTROL_PROMPT_WINDOW, + DEFAULT_SHORTCUT_FIND, + DEFAULT_SHORTCUT_LEFT_SIDEBAR_WINDOW, + DEFAULT_SHORTCUT_RIGHT_SIDEBAR_WINDOW, + FILE_NAME_LENGTH, + ICON_PROMPT, + ICON_REALTIME_API, + ICON_SETTING, + ICON_SIDEBAR, + JSON_FILE_EXT_LIST_STR, + QFILEDIALOG_DEFAULT_DIRECTORY, + THREAD_TABLE_NAME, +) +from pyqt_openai.chat_widget.center.chatWidget import ChatWidget +from pyqt_openai.chat_widget.center.realtimeApiWidget import RealtimeApiWidget +from pyqt_openai.chat_widget.left_sidebar.chatNavWidget import ChatNavWidget +from pyqt_openai.chat_widget.prompt_gen_widget.promptGeneratorWidget import PromptGeneratorWidget +from pyqt_openai.chat_widget.right_sidebar.chatRightSideBarWidget import ChatRightSideBarWidget +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.globals import DB +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.models import ChatMessageContainer, ChatThreadContainer +from pyqt_openai.util.common import add_file_to_zip, conv_unit_to_html, getSeparator, get_generic_ext_out_of_qt_ext, message_list_to_txt, open_directory +from pyqt_openai.widgets.button import Button + +if TYPE_CHECKING: + from collections.abc import Callable + + from pyqt_openai.models import CustomizeParamsContainer + + +class ChatMainWidget(QWidget): + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self.__initVal() + self.__initUi() + + def __initVal(self): + self.__notify_finish: bool = bool(CONFIG_MANAGER.get_general_property("notify_finish")) + + self.__show_chat_list: bool = bool(CONFIG_MANAGER.get_general_property("show_chat_list")) + self.__show_realtime_api: bool = bool(CONFIG_MANAGER.get_general_property("show_realtime_api")) + self.__show_setting: bool = bool(CONFIG_MANAGER.get_general_property("show_setting")) + self.__show_prompt: bool = bool(CONFIG_MANAGER.get_general_property("show_prompt")) + + self.__background_image: str | None = CONFIG_MANAGER.get_general_property( + "background_image", + ) + self.__user_image: str | None = CONFIG_MANAGER.get_general_property("user_image") + self.__ai_image: str | None = CONFIG_MANAGER.get_general_property("ai_image") + + self.__maximum_messages_in_parameter: int = int( + CONFIG_MANAGER.get_general_property( + "maximum_messages_in_parameter", + ) or 100 + ) + + def __initUi(self): + self.__chatNavWidget = ChatNavWidget( + ChatThreadContainer.get_keys(), + THREAD_TABLE_NAME, + ) + + self.__chatWidget: ChatWidget = ChatWidget() + self.__chatWidget.addThread.connect(self.__addThread) + self.__chatWidget.onMenuCloseClicked.connect(self.__onMenuCloseClicked) + + self.__realtimeApiWidget: RealtimeApiWidget = RealtimeApiWidget() + + self.__browser: MessageTextBrowser = self.__chatWidget.getChatBrowser() + + self.__chatRightSideBarWidget: ChatRightSideBarWidget = ChatRightSideBarWidget() + self.__chatRightSideBarWidget.onToggleJSON.connect(self.__chatWidget.toggleJSON) + + self.__chatRightSideBarWidget.onTabChanged.connect(self.__chatWidget.setG4F) + + self.__chatWidget.setG4F(self.__chatRightSideBarWidget.currentTabIdx()) + + self.__promptGeneratorWidget: PromptGeneratorWidget = PromptGeneratorWidget() + + self.__sideBarBtn: Button = Button() + self.__sideBarBtn.setStyleAndIcon(ICON_SIDEBAR) + self.__sideBarBtn.setCheckable(True) + self.__sideBarBtn.setToolTip( + LangClass.TRANSLATIONS["Chat List"] + + f" ({DEFAULT_SHORTCUT_LEFT_SIDEBAR_WINDOW})", + ) + self.__sideBarBtn.setChecked(bool(self.__show_chat_list)) + self.__sideBarBtn.toggled.connect(self.toggleSideBar) + self.__sideBarBtn.setShortcut(DEFAULT_SHORTCUT_LEFT_SIDEBAR_WINDOW) + + self.__useRealtimeApiBtn: Button = Button() + self.__useRealtimeApiBtn.setStyleAndIcon(ICON_REALTIME_API) + self.__useRealtimeApiBtn.setToolTip(LangClass.TRANSLATIONS["Use Realtime API"]) + self.__useRealtimeApiBtn.setCheckable(True) + self.__useRealtimeApiBtn.setChecked(bool(self.__show_realtime_api)) + self.__useRealtimeApiBtn.toggled.connect(self.toggleRealtimeApiScreen) + + self.__settingBtn: Button = Button() + self.__settingBtn.setStyleAndIcon(ICON_SETTING) + self.__settingBtn.setToolTip( + LangClass.TRANSLATIONS["Chat Settings"] + + f" ({DEFAULT_SHORTCUT_RIGHT_SIDEBAR_WINDOW})", + ) + self.__settingBtn.setCheckable(True) + self.__settingBtn.setChecked(bool(self.__show_setting)) + self.__settingBtn.toggled.connect(self.toggleSetting) + self.__settingBtn.setShortcut(DEFAULT_SHORTCUT_RIGHT_SIDEBAR_WINDOW) + + self.__promptBtn: Button = Button() + self.__promptBtn.setStyleAndIcon(ICON_PROMPT) + self.__promptBtn.setToolTip( + LangClass.TRANSLATIONS["Prompt Generator"] + + f" ({DEFAULT_SHORTCUT_CONTROL_PROMPT_WINDOW})", + ) + self.__promptBtn.setCheckable(True) + self.__promptBtn.setChecked(bool(self.__show_prompt)) + self.__promptBtn.toggled.connect(self.togglePrompt) + self.__promptBtn.setShortcut(DEFAULT_SHORTCUT_CONTROL_PROMPT_WINDOW) + + sep = getSeparator("vertical") + + self.__toggleFindToolButton: QPushButton = QPushButton( + LangClass.TRANSLATIONS["Show Find Tool"], + ) + self.__toggleFindToolButton.setCheckable(True) + self.__toggleFindToolButton.setChecked(False) + self.__toggleFindToolButton.toggled.connect(self.__chatWidget.toggleMenuWidget) + self.__toggleFindToolButton.setShortcut(DEFAULT_SHORTCUT_FIND) + + lay = QHBoxLayout() + lay.addWidget(self.__sideBarBtn) + lay.addWidget(self.__useRealtimeApiBtn) + lay.addWidget(self.__settingBtn) + lay.addWidget(self.__promptBtn) + lay.addWidget(sep) + lay.addWidget(self.__toggleFindToolButton) + lay.setContentsMargins(2, 2, 2, 2) + lay.setAlignment(Qt.AlignmentFlag.AlignLeft) + + self.__menuWidget: QWidget = QWidget() + self.__menuWidget.setLayout(lay) + self.__menuWidget.setMaximumHeight(self.__menuWidget.sizeHint().height()) + + self.__chatNavWidget.added.connect(self.__addThread) + self.__chatNavWidget.clicked.connect(self.__showChat) + self.__chatNavWidget.cleared.connect(self.__clearChat) + self.__chatNavWidget.onImport.connect(self.__importChat) + self.__chatNavWidget.onExport.connect(self.__exportChat) + self.__chatNavWidget.onFavoriteClicked.connect(self.__showFavorite) + + self.__rightSideBar: QSplitter = QSplitter() + self.__rightSideBar.setOrientation(Qt.Orientation.Vertical) + self.__rightSideBar.addWidget(self.__chatRightSideBarWidget) + self.__rightSideBar.addWidget(self.__promptGeneratorWidget) + self.__rightSideBar.setSizes([450, 550]) + self.__rightSideBar.setChildrenCollapsible(False) + self.__rightSideBar.setHandleWidth(2) + self.__rightSideBar.setStyleSheet( + """ + QSplitter::handle:vertical + { + background: #CCC; + height: 1px; + } + """, + ) + + self.__centerWidget: QStackedWidget = QStackedWidget() + self.__centerWidget.addWidget(self.__chatWidget) + self.__centerWidget.addWidget(self.__realtimeApiWidget) + self.__centerWidget.setCurrentIndex(1 if self.__show_realtime_api else 0) + + mainWidget = QSplitter() + mainWidget.addWidget(self.__chatNavWidget) + mainWidget.addWidget(self.__centerWidget) + mainWidget.addWidget(self.__rightSideBar) + mainWidget.setSizes([100, 500, 400]) + mainWidget.setChildrenCollapsible(False) + mainWidget.setHandleWidth(2) + mainWidget.setStyleSheet( + """ + QSplitter::handle:horizontal + { + background: #CCC; + height: 1px; + } + """, + ) + + sep = getSeparator("horizontal") + + vlay = QVBoxLayout() + vlay.addWidget(self.__menuWidget) + vlay.addWidget(sep) + vlay.addWidget(mainWidget) + vlay.setContentsMargins(0, 0, 0, 0) + vlay.setSpacing(0) + self.setLayout(vlay) + + # self.__lineEdit.setFocus() + + # Put this below to prevent the widgets pop up when app is opened + self.__chatNavWidget.setVisible(bool(self.__show_chat_list)) + self.__chatRightSideBarWidget.setVisible(bool(self.__show_setting)) + self.__promptGeneratorWidget.setVisible(bool(self.__show_prompt)) + self.__rightSideBar.setVisible(bool(self.__show_setting or self.__show_prompt)) + + def toggleSideBar(self, x: bool): + self.__chatNavWidget.setVisible(x) + self.__show_chat_list = x + CONFIG_MANAGER.set_general_property("show_chat_list", str(self.__show_chat_list)) + + def toggleRealtimeApiScreen(self, x: bool): + self.__centerWidget.setCurrentIndex(1 if x else 0) + self.__show_realtime_api = x + CONFIG_MANAGER.set_general_property( + "show_realtime_api", str(self.__show_realtime_api), + ) + + def toggleSetting(self, x: bool): + self.__chatRightSideBarWidget.setVisible(x) + self.__show_setting = x + CONFIG_MANAGER.set_general_property("show_setting", str(self.__show_setting)) + if not self.__promptGeneratorWidget.isVisible(): + self.__rightSideBar.setVisible(x) + + def togglePrompt(self, x: bool): + self.__promptGeneratorWidget.setVisible(x) + self.__show_prompt = x + CONFIG_MANAGER.set_general_property("show_prompt", str(self.__show_prompt)) + if not self.__chatRightSideBarWidget.isVisible(): + self.__rightSideBar.setVisible(x) + + def toggleButtons(self, x: bool): + self.__sideBarBtn.setChecked(x) + self.__settingBtn.setChecked(x) + self.__promptBtn.setChecked(x) + + def showThreadToolWidget(self, f: bool): + self.__toggleFindToolButton.setChecked(f) + + def __onMenuCloseClicked(self): + self.__toggleFindToolButton.setChecked(False) + + def showSecondaryToolBar(self, f: bool): + self.__menuWidget.setVisible(f) + CONFIG_MANAGER.set_general_property("show_secondary_toolbar", f) + + def setAIEnabled(self, f: bool): + self.__chatWidget.setAIEnabled(f) + + def refreshCustomizedInformation(self, container: CustomizeParamsContainer): + self.__background_image = container.background_image + self.__user_image = container.user_image + self.__ai_image = container.ai_image + self.__chatWidget.refreshCustomizedInformation( + self.__background_image, self.__user_image, self.__ai_image, + ) + + def __showChat(self, id: int, title: str): + self.__showFavorite(False) + self.__chatNavWidget.activateFavoriteFromParent(False) + self.__chatWidget.showTitle(title) + self.__chatWidget.showMessages(id) + + def __clearChat(self): + self.__chatWidget.showTitle("") + self.__chatWidget.clearMessages() + + def __addThread(self): + title = LangClass.TRANSLATIONS["New Chat"] + cur_id = DB.insertThread(title) + self.__chatWidget.showTitle(title) + self.__chatWidget.showMessages(cur_id) + + self.__chatNavWidget.add(called_from_parent=True) + + def __importChat(self, data: list[dict[str, Any]]): + try: + # Import thread + for thread in data: + cur_id = DB.insertThread( + thread["name"], thread["insert_dt"], thread["update_dt"], + ) + messages = thread["messages"] + # Import message + for message in messages: + message["thread_id"] = cur_id + container = ChatMessageContainer(**message) + DB.insertMessage(container, deactivate_trigger=True) + self.__chatNavWidget.refreshData() + except Exception: + QMessageBox.critical( # type: ignore[call-arg] + self, + LangClass.TRANSLATIONS["Error"], + LangClass.TRANSLATIONS[ + "Check whether the file is a valid JSON file for importing." + ], + ) + + def __exportChat(self, ids: list[int]): + file_data = QFileDialog.getSaveFileName( + self, + LangClass.TRANSLATIONS["Save"], + QFILEDIALOG_DEFAULT_DIRECTORY, + f"{JSON_FILE_EXT_LIST_STR};;txt files Compressed File (*.zip);;html files Compressed File (*.zip)", + ) + if file_data[0]: + filename = file_data[0] + ext = os.path.splitext(filename)[-1] or get_generic_ext_out_of_qt_ext( + file_data[1], + ) + if ext == ".zip": + compressed_file_type = file_data[1].split(" ")[0].lower() + ext_dict: dict[str, dict[str, str | Callable]] = { + "txt": {"ext": ".txt", "func": message_list_to_txt}, + "html": {"ext": ".html", "func": conv_unit_to_html}, + } + for id in ids: + row_info = DB.selectThread(id) + # Limit the title length to file name length + title = row_info["name"][:FILE_NAME_LENGTH] + txt_filename = ( + f'{title}_{id}{ext_dict[compressed_file_type]["ext"]}' + ) + txt_content_func = ext_dict[compressed_file_type]["func"] + assert not isinstance(txt_content_func, str) + txt_content = txt_content_func(DB, id, title) + add_file_to_zip( + txt_content, + txt_filename, + os.path.splitext(filename)[0] + ".zip", + ) + elif ext == ".json": + DB.export(ids, filename) + open_directory(os.path.dirname(filename)) + + def setColumns(self, columns: list[str]): + self.__chatNavWidget.setColumns(columns) + + def __showFavorite(self, f: bool): + if f: + lst = DB.selectFavorite() + if len(lst) == 0: + return + lst = [ChatMessageContainer(**dict(c)) for c in lst] + self.__browser.replaceThreadForFavorite(lst) + self.__chatWidget.setAIEnabled(not f) diff --git a/pyqt_openai/chat_widget/left_sidebar/chatNavWidget.py b/pyqt_openai/chat_widget/left_sidebar/chatNavWidget.py index 0a4afda2..7ae2cce7 100644 --- a/pyqt_openai/chat_widget/left_sidebar/chatNavWidget.py +++ b/pyqt_openai/chat_widget/left_sidebar/chatNavWidget.py @@ -1,281 +1,314 @@ -from PySide6.QtCore import Signal, QSortFilterProxyModel, Qt -from PySide6.QtSql import QSqlTableModel, QSqlQuery -from PySide6.QtWidgets import ( - QWidget, - QVBoxLayout, - QMessageBox, - QPushButton, - QStyledItemDelegate, - QHBoxLayout, - QLabel, - QSpacerItem, - QSizePolicy, - QComboBox, - QDialog, -) - -from pyqt_openai import THREAD_ORDERBY, ICON_ADD, ICON_IMPORT, ICON_SAVE, ICON_REFRESH -from pyqt_openai.chat_widget.left_sidebar.exportDialog import ExportDialog -from pyqt_openai.chat_widget.left_sidebar.importDialog import ImportDialog -from pyqt_openai.chat_widget.left_sidebar.selectChatImportTypeDialog import SelectChatImportTypeDialog -from pyqt_openai.globals import DB -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.models import ChatThreadContainer -from pyqt_openai.widgets.baseNavWidget import BaseNavWidget -from pyqt_openai.widgets.button import Button - - -class FilterProxyModel(QSortFilterProxyModel): - def __init__(self): - super().__init__() - self.__searchedText = "" - - @property - def searchedText(self): - return self.__searchedText - - @searchedText.setter - def searchedText(self, value): - self.__searchedText = value - self.invalidateFilter() - - -# for align text in every cell to center -class AlignDelegate(QStyledItemDelegate): - def initStyleOption(self, option, index): - super().initStyleOption(option, index) - option.displayAlignment = Qt.AlignmentFlag.AlignCenter - - -class SqlTableModel(QSqlTableModel): - added = Signal(int, str) - updated = Signal(int, str) - deleted = Signal(list) - addedCol = Signal() - deletedCol = Signal() - - def flags(self, index): - if index.column() == self.column_index_by_name("name"): - return ( - Qt.ItemFlag.ItemIsEnabled - | Qt.ItemFlag.ItemIsSelectable - | Qt.ItemFlag.ItemIsEditable - ) - return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable - - def column_index_by_name(self, name): - return self.fieldIndex(name) - - -class ChatNavWidget(BaseNavWidget): - added = Signal() - clicked = Signal(int, str) - cleared = Signal() - onImport = Signal(list) - onExport = Signal(list) - onFavoriteClicked = Signal(bool) - - def __init__(self, columns, table_nm, parent=None): - super().__init__(columns, table_nm, parent) - self.__initUi() - - def __initUi(self): - self.setModel(table_type="chat") - - imageGenerationHistoryLbl = QLabel() - imageGenerationHistoryLbl.setText(LangClass.TRANSLATIONS["History"]) - - self.__addBtn = Button() - self.__importBtn = Button() - self.__saveBtn = Button() - self.__refreshBtn = Button() - - self.__addBtn.setStyleAndIcon(ICON_ADD) - self.__importBtn.setStyleAndIcon(ICON_IMPORT) - self.__saveBtn.setStyleAndIcon(ICON_SAVE) - self.__refreshBtn.setStyleAndIcon(ICON_REFRESH) - - self.__addBtn.setToolTip(LangClass.TRANSLATIONS["Add"]) - self.__importBtn.setToolTip(LangClass.TRANSLATIONS["Import"]) - self.__saveBtn.setToolTip(LangClass.TRANSLATIONS["Export"]) - self.__refreshBtn.setToolTip(LangClass.TRANSLATIONS["Refresh"]) - - self.__addBtn.clicked.connect(self.add) - self.__importBtn.clicked.connect(self.__import) - self.__saveBtn.clicked.connect(self.__export) - self.__refreshBtn.clicked.connect(self.__refresh) - - lay = QHBoxLayout() - lay.addWidget(imageGenerationHistoryLbl) - lay.addSpacerItem(QSpacerItem(10, 10, QSizePolicy.Policy.MinimumExpanding)) - lay.addWidget(self.__addBtn) - lay.addWidget(self._delBtn) - lay.addWidget(self._clearBtn) - lay.addWidget(self.__importBtn) - lay.addWidget(self.__saveBtn) - lay.addWidget(self.__refreshBtn) - lay.setContentsMargins(0, 0, 0, 0) - - menuSubWidget1 = QWidget() - menuSubWidget1.setLayout(lay) - - self.__searchOptionCmbBox = QComboBox() - self.__searchOptionCmbBox.addItems( - [LangClass.TRANSLATIONS["Title"], LangClass.TRANSLATIONS["Content"]] - ) - self.__searchOptionCmbBox.setMinimumHeight(self._searchBar.sizeHint().height()) - self.__searchOptionCmbBox.currentIndexChanged.connect( - lambda _: self._search(self._searchBar.getSearchBar().text()) - ) - - lay = QHBoxLayout() - lay.addWidget(self._searchBar) - lay.addWidget(self.__searchOptionCmbBox) - lay.setContentsMargins(0, 0, 0, 0) - - menuSubWidget2 = QWidget() - menuSubWidget2.setLayout(lay) - - lay = QVBoxLayout() - lay.addWidget(menuSubWidget1) - lay.addWidget(menuSubWidget2) - lay.setContentsMargins(0, 0, 0, 0) - - menuWidget = QWidget() - menuWidget.setLayout(lay) - - self._tableView.clicked.connect(self.__clicked) - self._tableView.activated.connect(self.__clicked) - - self.__favoriteBtn = QPushButton(LangClass.TRANSLATIONS["Favorite List"]) - self.__favoriteBtn.setCheckable(True) - self.__favoriteBtn.toggled.connect(self.__onFavoriteClicked) - - lay = QVBoxLayout() - lay.addWidget(menuWidget) - lay.addWidget(self._tableView) - lay.addWidget(self.__favoriteBtn) - self.setLayout(lay) - - self.refreshData() - - def add(self, called_from_parent=False): - if called_from_parent: - pass - else: - self.added.emit() - self._model.select() - - def __import(self): - dialog = SelectChatImportTypeDialog(parent=self) - reply = dialog.exec() - if reply == QDialog.Accepted: - import_type = dialog.getImportType() - chatImportDialog = ImportDialog(import_type=import_type, parent=self) - reply = chatImportDialog.exec() - if reply == QDialog.Accepted: - data = chatImportDialog.getData() - self.onImport.emit(data) - - def __export(self): - columns = ChatThreadContainer.get_keys() - data = DB.selectAllThread() - sort_by = THREAD_ORDERBY - if len(data) > 0: - dialog = ExportDialog(columns, data, sort_by=sort_by, parent=self) - reply = dialog.exec() - if reply == QDialog.Accepted: - self.onExport.emit(dialog.getSelectedIds()) - else: - QMessageBox.information( - self, - LangClass.TRANSLATIONS["Information"], - LangClass.TRANSLATIONS["No data to export."], - ) - - def refreshData(self, title=None): - self._model.select() - # index -1 will be read from all columns - # otherwise it will be read the current column number indicated by combobox - self._proxyModel.setFilterKeyColumn(-1) - # regular expression can be used - self._proxyModel.setFilterRegularExpression(title) - - def __clicked(self, idx): - # get the source index - source_idx = self._proxyModel.mapToSource(idx) - # get the primary key value of the row - cur_id = self._model.record(source_idx.row()).value("id") - clicked_thread = DB.selectThread(cur_id) - # get the title - title = clicked_thread["name"] - - self.clicked.emit(cur_id, title) - - def __getSelectedIds(self): - selected_idx_s = self._tableView.selectedIndexes() - ids = [] - for idx in selected_idx_s: - ids.append( - self._model.data( - self._proxyModel.mapToSource(idx.siblingAtColumn(0)), - role=Qt.ItemDataRole.DisplayRole, - ) - ) - ids = list(set(ids)) - return ids - - # TODO LANGUAGE - def _delete(self): - reply = QMessageBox.question( - self, - LangClass.TRANSLATIONS["Confirm"], - LangClass.TRANSLATIONS["Are you sure to delete the selected data?"], - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - ) - if reply == QMessageBox.StandardButton.Yes: - ids = self.__getSelectedIds() - for _id in ids: - DB.deleteThread(_id) - self._model.select() - self.cleared.emit() - - def _clear(self, table_type="chat"): - table_type = table_type or "chat" - super()._clear(table_type=table_type) - self.cleared.emit() - - def __refresh(self): - self._model.select() - - def _search(self, text): - # title - if self.__searchOptionCmbBox.currentText() == LangClass.TRANSLATIONS["Title"]: - self.refreshData(text) - # content - elif ( - self.__searchOptionCmbBox.currentText() == LangClass.TRANSLATIONS["Content"] - ): - if text: - threads = DB.selectAllContentOfThread(content_to_select=text) - ids = [_[0] for _ in threads] - self._model.setQuery( - QSqlQuery( - f"SELECT {','.join(self._columns)} FROM {self._table_nm} " - f"WHERE id IN ({','.join(map(str, ids))})" - ) - ) - else: - self.refreshData() - - def isCurrentConvExists(self): - return self._model.rowCount() > 0 and self._tableView.currentIndex() - - def setColumns(self, columns, table_type="chat"): - super().setColumns(columns, table_type="chat") - - def __onFavoriteClicked(self, f): - self.onFavoriteClicked.emit(f) - - def activateFavoriteFromParent(self, f): - self.__favoriteBtn.setChecked(f) +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtCore import QSortFilterProxyModel, Qt, Signal +from qtpy.QtSql import QSqlQuery, QSqlTableModel +from qtpy.QtWidgets import ( + QComboBox, + QDialog, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QSizePolicy, + QSpacerItem, + QStyledItemDelegate, + QVBoxLayout, + QWidget, +) + +from pyqt_openai import ICON_ADD, ICON_IMPORT, ICON_REFRESH, ICON_SAVE, THREAD_ORDERBY +from pyqt_openai.chat_widget.left_sidebar.exportDialog import ExportDialog +from pyqt_openai.chat_widget.left_sidebar.importDialog import ImportDialog +from pyqt_openai.chat_widget.left_sidebar.selectChatImportTypeDialog import SelectChatImportTypeDialog +from pyqt_openai.globals import DB +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.models import ChatThreadContainer +from pyqt_openai.widgets.baseNavWidget import BaseNavWidget +from pyqt_openai.widgets.button import Button + +if TYPE_CHECKING: + from qtpy.QtCore import QModelIndex, QPersistentModelIndex + from qtpy.QtWidgets import QStyleOptionViewItem + + +class FilterProxyModel(QSortFilterProxyModel): + def __init__(self): + super().__init__() + self.__searchedText: str = "" + + @property + def searchedText(self) -> str: + return self.__searchedText + + @searchedText.setter + def searchedText(self, value): + self.__searchedText = value + self.invalidateFilter() + + +# for align text in every cell to center +class AlignDelegate(QStyledItemDelegate): + def initStyleOption( + self, + option: QStyleOptionViewItem, + index: QModelIndex | QPersistentModelIndex, + ): + super().initStyleOption(option, index) + option.displayAlignment = Qt.AlignmentFlag.AlignCenter + + +class SqlTableModel(QSqlTableModel): + added = Signal(int, str) + updated = Signal(int, str) + deleted = Signal(list) + addedCol = Signal() + deletedCol = Signal() + + def flags( + self, + index: QModelIndex | QPersistentModelIndex, + ) -> Qt.ItemFlag: + if index.column() == self.column_index_by_name("name"): + return ( + Qt.ItemFlag.ItemIsEnabled + | Qt.ItemFlag.ItemIsSelectable + | Qt.ItemFlag.ItemIsEditable + ) + return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable + + def column_index_by_name(self, name: str) -> int: + return self.fieldIndex(name) + + +class ChatNavWidget(BaseNavWidget): + added: Signal = Signal() + clicked: Signal = Signal(int, str) + cleared: Signal = Signal() + onImport: Signal = Signal(list) + onExport: Signal = Signal(list) + onFavoriteClicked: Signal = Signal(bool) + + def __init__( + self, + columns: list[str], + table_nm: str, + parent: QWidget | None = None, + ): + super().__init__(columns, table_nm, parent) + self.__initUi() + + def __initUi(self): + self.setModel(table_type="chat") + + imageGenerationHistoryLbl = QLabel() + imageGenerationHistoryLbl.setText(LangClass.TRANSLATIONS["History"]) + + self.__addBtn = Button() + self.__importBtn = Button() + self.__saveBtn = Button() + self.__refreshBtn = Button() + + self.__addBtn.setStyleAndIcon(ICON_ADD) + self.__importBtn.setStyleAndIcon(ICON_IMPORT) + self.__saveBtn.setStyleAndIcon(ICON_SAVE) + self.__refreshBtn.setStyleAndIcon(ICON_REFRESH) + + self.__addBtn.setToolTip(LangClass.TRANSLATIONS["Add"]) + self.__importBtn.setToolTip(LangClass.TRANSLATIONS["Import"]) + self.__saveBtn.setToolTip(LangClass.TRANSLATIONS["Export"]) + self.__refreshBtn.setToolTip(LangClass.TRANSLATIONS["Refresh"]) + + self.__addBtn.clicked.connect(self.add) + self.__importBtn.clicked.connect(self.__import) + self.__saveBtn.clicked.connect(self.__export) + self.__refreshBtn.clicked.connect(self.__refresh) + + lay = QHBoxLayout() + lay.addWidget(imageGenerationHistoryLbl) + lay.addSpacerItem(QSpacerItem(10, 10, QSizePolicy.Policy.MinimumExpanding)) + lay.addWidget(self.__addBtn) + lay.addWidget(self._delBtn) + lay.addWidget(self._clearBtn) + lay.addWidget(self.__importBtn) + lay.addWidget(self.__saveBtn) + lay.addWidget(self.__refreshBtn) + lay.setContentsMargins(0, 0, 0, 0) + + menuSubWidget1 = QWidget() + menuSubWidget1.setLayout(lay) + + self.__searchOptionCmbBox = QComboBox() + self.__searchOptionCmbBox.addItems( + [LangClass.TRANSLATIONS["Title"], LangClass.TRANSLATIONS["Content"]], + ) + self.__searchOptionCmbBox.setMinimumHeight(self._searchBar.sizeHint().height()) + self.__searchOptionCmbBox.currentIndexChanged.connect( + lambda _: self._search(self._searchBar.getSearchBar().text()), + ) + + hlay = QHBoxLayout() + hlay.addWidget(self._searchBar) + hlay.addWidget(self.__searchOptionCmbBox) + hlay.setContentsMargins(0, 0, 0, 0) + + menuSubWidget2 = QWidget() + menuSubWidget2.setLayout(hlay) + + vlay1 = QVBoxLayout() + vlay1.addWidget(menuSubWidget1) + vlay1.addWidget(menuSubWidget2) + vlay1.setContentsMargins(0, 0, 0, 0) + + menuWidget = QWidget() + menuWidget.setLayout(vlay1) + + self._tableView.clicked.connect(self.__clicked) + self._tableView.activated.connect(self.__clicked) + + self.__favoriteBtn = QPushButton(LangClass.TRANSLATIONS["Favorite List"]) + self.__favoriteBtn.setCheckable(True) + self.__favoriteBtn.toggled.connect(self.__onFavoriteClicked) + + vlay2 = QVBoxLayout() + vlay2.addWidget(menuWidget) + vlay2.addWidget(self._tableView) + vlay2.addWidget(self.__favoriteBtn) + self.setLayout(vlay2) + + self.refreshData() + + def add(self, called_from_parent=False): + if called_from_parent: + pass + else: + self.added.emit() + self._model.select() + + def __import(self): + dialog = SelectChatImportTypeDialog(parent=self) + reply = dialog.exec() + if reply == QDialog.DialogCode.Accepted: + import_type = dialog.getImportType() + chatImportDialog = ImportDialog( + import_type=import_type, # type: ignore[arg-type] + parent=self, + ) + reply = chatImportDialog.exec() + if reply == QDialog.DialogCode.Accepted: + data = chatImportDialog.getData() + self.onImport.emit(data) + + def __export(self): + columns = ChatThreadContainer.get_keys() + data = DB.selectAllThread() + sort_by = THREAD_ORDERBY + if len(data) > 0: + dialog = ExportDialog(columns, data, sort_by=sort_by, parent=self) + reply = dialog.exec() + if reply == QDialog.DialogCode.Accepted: + self.onExport.emit(dialog.getSelectedIds()) + else: + QMessageBox.information( + self, + LangClass.TRANSLATIONS["Information"], + LangClass.TRANSLATIONS["No data to export."], + ) + + def refreshData( + self, + title: str | None = None, + ): + self._model.select() + # index -1 will be read from all columns + # otherwise it will be read the current column number indicated by combobox + self._proxyModel.setFilterKeyColumn(-1) + # regular expression can be used + self._proxyModel.setFilterRegularExpression(title or "") + + def __clicked(self, idx: QModelIndex | QPersistentModelIndex): + # get the source index + source_idx = self._proxyModel.mapToSource(idx) + # get the primary key value of the row + cur_id = self._model.record(source_idx.row()).value("id") + clicked_thread = DB.selectThread(cur_id) + # get the title + title = clicked_thread["name"] + + self.clicked.emit(cur_id, title) + + def __getSelectedIds(self) -> list[int]: + selected_idx_s = self._tableView.selectedIndexes() + ids = [] + for idx in selected_idx_s: + ids.append( + self._model.data( + self._proxyModel.mapToSource(idx.siblingAtColumn(0)), + role=Qt.ItemDataRole.DisplayRole, + ), + ) + ids = list(set(ids)) + return ids + + # TODO LANGUAGE + def _delete(self): + reply = QMessageBox.question( # type: ignore[call-arg] + self, + LangClass.TRANSLATIONS["Confirm"], + LangClass.TRANSLATIONS["Are you sure to delete the selected data?"], + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + ids = self.__getSelectedIds() + for _id in ids: + DB.deleteThread(_id) + self._model.select() + self.cleared.emit() + + def _clear( + self, + table_type: str = "chat", + ): + table_type = table_type or "chat" + super()._clear(table_type=table_type) + self.cleared.emit() + + def __refresh(self): + self._model.select() + + def _search(self, text: str): + # title + if self.__searchOptionCmbBox.currentText() == LangClass.TRANSLATIONS["Title"]: + self.refreshData(text) + # content + elif ( + self.__searchOptionCmbBox.currentText() == LangClass.TRANSLATIONS["Content"] + ): + if text: + threads = DB.selectAllContentOfThread(content_to_select=text) + ids = [_[0] for _ in threads] + self._model.setQuery( + QSqlQuery( + f"SELECT {','.join(self._columns)} FROM {self._table_nm} " + f"WHERE id IN ({','.join(map(str, ids))})", + ), + ) + else: + self.refreshData() + + def isCurrentConvExists(self) -> QModelIndex | None: + return self._model.rowCount() > 0 and self._tableView.currentIndex() or None + + def setColumns( + self, + columns: list[str], + table_type: str = "chat", + ): + super().setColumns(columns, table_type=table_type) + + def __onFavoriteClicked(self, f: bool): + self.onFavoriteClicked.emit(f) + + def activateFavoriteFromParent(self, f: bool): + self.__favoriteBtn.setChecked(f) diff --git a/pyqt_openai/chat_widget/left_sidebar/exportDialog.py b/pyqt_openai/chat_widget/left_sidebar/exportDialog.py index b12d2196..9e5a425d 100644 --- a/pyqt_openai/chat_widget/left_sidebar/exportDialog.py +++ b/pyqt_openai/chat_widget/left_sidebar/exportDialog.py @@ -1,77 +1,86 @@ -""" -This dialog is for exporting conversation threads selected by the user from the history. -""" - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QTableWidgetItem, - QLabel, - QDialogButtonBox, - QCheckBox, - QDialog, - QVBoxLayout, -) - -from pyqt_openai import THREAD_ORDERBY -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.widgets.checkBoxTableWidget import CheckBoxTableWidget - - -class ExportDialog(QDialog): - def __init__(self, columns, data, sort_by=THREAD_ORDERBY, parent=None): - super().__init__(parent) - self.__initVal(columns, data, sort_by) - self.__initUi() - - def __initVal(self, columns, data, sort_by): - self.__columns = columns - self.__data = data - self.__sort_by = sort_by - - def __initUi(self): - self.setWindowTitle(LangClass.TRANSLATIONS["Export"]) - self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) - - self.__checkBoxTableWidget = CheckBoxTableWidget() - self.__checkBoxTableWidget.setHorizontalHeaderLabels(self.__columns) - self.__checkBoxTableWidget.setRowCount(len(self.__data)) - - for r_idx, r in enumerate(self.__data): - for c_idx, c in enumerate(self.__columns): - v = r[c] - self.__checkBoxTableWidget.setItem( - r_idx, c_idx + 1, QTableWidgetItem(str(v)) - ) - - self.__checkBoxTableWidget.resizeColumnsToContents() - self.__checkBoxTableWidget.setSortingEnabled(True) - if self.__sort_by in self.__columns: - self.__checkBoxTableWidget.sortByColumn( - self.__columns.index(self.__sort_by) + 1, Qt.SortOrder.DescendingOrder - ) - - # Dialog buttons - buttonBox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - buttonBox.accepted.connect(self.accept) - buttonBox.rejected.connect(self.reject) - - allCheckBox = QCheckBox(LangClass.TRANSLATIONS["Select All"]) - allCheckBox.stateChanged.connect( - self.__checkBoxTableWidget.toggleState - ) # if allChkBox is checked, tablewidget checkboxes will also be checked - - lay = QVBoxLayout() - lay.addWidget( - QLabel(LangClass.TRANSLATIONS["Select the threads you want to export."]) - ) - lay.addWidget(allCheckBox) - lay.addWidget(self.__checkBoxTableWidget) - lay.addWidget(buttonBox) - self.setLayout(lay) - - def getSelectedIds(self): - ids = [ - self.__checkBoxTableWidget.item(r, 1).text() - for r in self.__checkBoxTableWidget.getCheckedRows() - ] - return ids +"""This dialog is for exporting conversation threads selected by the user from the history.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QCheckBox, QDialog, QDialogButtonBox, QLabel, QTableWidgetItem, QVBoxLayout + +from pyqt_openai import THREAD_ORDERBY +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.widgets.checkBoxTableWidget import CheckBoxTableWidget + +if TYPE_CHECKING: + from qtpy.QtWidgets import QWidget + + +class ExportDialog(QDialog): + def __init__( + self, + columns: list[str], + data: list[dict[str, Any]], + sort_by: str = THREAD_ORDERBY, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.__initVal(columns, data, sort_by) + self.__initUi() + + def __initVal( + self, + columns: list[str], + data: list[dict[str, Any]], + sort_by: str, + ): + self.__columns = columns + self.__data = data + self.__sort_by = sort_by + + def __initUi(self): + self.setWindowTitle(LangClass.TRANSLATIONS["Export"]) + self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) + + self.__checkBoxTableWidget: CheckBoxTableWidget = CheckBoxTableWidget() + self.__checkBoxTableWidget.setHorizontalHeaderLabels(self.__columns) + self.__checkBoxTableWidget.setRowCount(len(self.__data)) + + for r_idx, r in enumerate(self.__data): + for c_idx, c in enumerate(self.__columns): + v = r[c] + self.__checkBoxTableWidget.setItem( + r_idx, c_idx + 1, QTableWidgetItem(str(v)), + ) + + self.__checkBoxTableWidget.resizeColumnsToContents() + self.__checkBoxTableWidget.setSortingEnabled(True) + if self.__sort_by in self.__columns: + self.__checkBoxTableWidget.sortByColumn( + self.__columns.index(self.__sort_by) + 1, Qt.SortOrder.DescendingOrder, + ) + + # Dialog buttons + buttonBox = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttonBox.accepted.connect(self.accept) + buttonBox.rejected.connect(self.reject) + + allCheckBox = QCheckBox(LangClass.TRANSLATIONS["Select All"]) + allCheckBox.stateChanged.connect( + self.__checkBoxTableWidget.toggleState, + ) # if allChkBox is checked, tablewidget checkboxes will also be checked + + lay = QVBoxLayout() + lay.addWidget( + QLabel(LangClass.TRANSLATIONS["Select the threads you want to export."]), + ) + lay.addWidget(allCheckBox) + lay.addWidget(self.__checkBoxTableWidget) + lay.addWidget(buttonBox) + self.setLayout(lay) + + def getSelectedIds(self) -> list[str]: + ids = [ + self.__checkBoxTableWidget.item(r, 1).text() # type: ignore[union-attr] + for r in self.__checkBoxTableWidget.getCheckedRows() + ] + return ids + diff --git a/pyqt_openai/chat_widget/left_sidebar/importDialog.py b/pyqt_openai/chat_widget/left_sidebar/importDialog.py index 572df274..ecde73e6 100644 --- a/pyqt_openai/chat_widget/left_sidebar/importDialog.py +++ b/pyqt_openai/chat_widget/left_sidebar/importDialog.py @@ -1,200 +1,192 @@ -import json - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QWidget, - QAbstractItemView, - QSpinBox, - QTableWidgetItem, - QMessageBox, - QGroupBox, - QLabel, - QDialogButtonBox, - QCheckBox, - QDialog, - QVBoxLayout, -) - -from pyqt_openai import ( - JSON_FILE_EXT_LIST_STR, - THREAD_ORDERBY, - HOW_TO_EXPORT_CHATGPT_CONVERSATION_HISTORY_URL, -) -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.util.common import ( - get_chatgpt_data_for_import, - get_chatgpt_data_for_preview, -) -from pyqt_openai.widgets.checkBoxTableWidget import CheckBoxTableWidget -from pyqt_openai.widgets.findPathWidget import FindPathWidget -from pyqt_openai.widgets.linkLabel import LinkLabel - - -class ImportDialog(QDialog): - def __init__(self, import_type="general", parent=None): - super().__init__(parent) - self.__initVal(import_type) - self.__initUi() - - def __initVal(self, import_type): - self.__import_type = import_type - # Get the most recent n conversation threads - self.__most_recent_n = 10 - # Data to be imported - self.__data = [] - - def __initUi(self): - self.setWindowTitle(LangClass.TRANSLATIONS["Import"]) - self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) - - findPathWidget = FindPathWidget() - findPathWidget.getLineEdit().setPlaceholderText( - LangClass.TRANSLATIONS["Select a json file to import"] - ) - findPathWidget.setExtOfFiles(JSON_FILE_EXT_LIST_STR) - findPathWidget.added.connect(self.__setPath) - - self.__chkBoxMostRecent = QCheckBox(LangClass.TRANSLATIONS["Get most recent"]) - self.__chkBoxMostRecent.setChecked(False) - - self.__mostRecentNSpinBox = QSpinBox() - self.__mostRecentNSpinBox.setRange(1, 10000) - self.__mostRecentNSpinBox.setValue(self.__most_recent_n) - self.__mostRecentNSpinBox.setEnabled(False) - - self.__chkBoxMostRecent.stateChanged.connect( - lambda state: self.__mostRecentNSpinBox.setEnabled( - Qt.CheckState(state) == Qt.CheckState.Checked - ) - ) - - importOptionsGrpBox = QGroupBox(LangClass.TRANSLATIONS["Import Options"]) - lay = QVBoxLayout() - lay.addWidget(self.__chkBoxMostRecent) - lay.addWidget(self.__mostRecentNSpinBox) - importOptionsGrpBox.setLayout(lay) - - self.__checkBoxTableWidget = CheckBoxTableWidget() - self.__checkBoxTableWidget.setColumnCount(0) - self.__checkBoxTableWidget.setEditTriggers( - QAbstractItemView.EditTrigger.NoEditTriggers - ) - - self.__allCheckBox = QCheckBox(LangClass.TRANSLATIONS["Select All"]) - self.__allCheckBox.stateChanged.connect(self.__checkBoxTableWidget.toggleState) - - lay = QVBoxLayout() - lay.addWidget( - QLabel(LangClass.TRANSLATIONS["Select the threads you want to import."]) - ) - lay.addWidget(self.__allCheckBox) - lay.addWidget(self.__checkBoxTableWidget) - - self.__dataGrpBox = QGroupBox(LangClass.TRANSLATIONS["Content"]) - self.__dataGrpBox.setLayout(lay) - self.__dataGrpBox.setEnabled(False) - - self.__buttonBox = QDialogButtonBox( - QDialogButtonBox.Ok | QDialogButtonBox.Cancel - ) - self.__buttonBox.accepted.connect(self.accept) - self.__buttonBox.rejected.connect(self.reject) - self.__buttonBox.button(QDialogButtonBox.Ok).setEnabled(False) - - manual_lbl = QLabel( - LangClass.TRANSLATIONS[ - "You can import a JSON file created through the export feature." - ] - ) - lay = QVBoxLayout() - lay.addWidget(manual_lbl) - - if self.__import_type == "chatgpt": - viewManualLbl = LinkLabel() - viewManualLbl.setText( - LangClass.TRANSLATIONS["How to import your ChatGPT data"] - ) - viewManualLbl.setUrl(HOW_TO_EXPORT_CHATGPT_CONVERSATION_HISTORY_URL) - lay.addWidget(viewManualLbl) - - manualWidget = QWidget() - manualWidget.setLayout(lay) - - lay = QVBoxLayout() - lay.addWidget(findPathWidget) - lay.addWidget(importOptionsGrpBox) - lay.addWidget(manualWidget) - lay.addWidget(self.__dataGrpBox) - lay.addWidget(self.__buttonBox) - - self.setLayout(lay) - - self.resize(800, 600) - - self.__checkBoxTableWidget.checkedSignal.connect(self.__toggleBtn) - self.__allCheckBox.stateChanged.connect(self.__toggleBtn) - - def __toggleBtn(self): - self.__buttonBox.button(QDialogButtonBox.Ok).setEnabled( - len(self.__checkBoxTableWidget.getCheckedRows()) > 0 - ) - - def __setPath(self, path): - try: - most_recent_n = ( - self.__mostRecentNSpinBox.value() - if self.__chkBoxMostRecent.isChecked() - else None - ) - columns = [] - if self.__import_type == "general": - self.__path = path - self.__data = json.load(open(path)) - self.__data = sorted( - self.__data, key=lambda x: x[THREAD_ORDERBY] or "", reverse=True - ) - # Get most recent one - if most_recent_n is not None: - self.__data = self.__data[:most_recent_n] - columns = ["id", "name", "insert_dt", "update_dt"] - self.__checkBoxTableWidget.setHorizontalHeaderLabels(columns) - self.__checkBoxTableWidget.setRowCount(len(self.__data)) - for r_idx, r in enumerate(self.__data): - for c_idx, c in enumerate(columns): - v = r[c] - self.__checkBoxTableWidget.setItem( - r_idx, c_idx + 1, QTableWidgetItem(str(v)) - ) - elif self.__import_type == "chatgpt": - result_dict = get_chatgpt_data_for_preview(path, most_recent_n) - columns = result_dict["columns"] - self.__data = result_dict["data"] - self.__checkBoxTableWidget.setHorizontalHeaderLabels(columns) - self.__checkBoxTableWidget.setRowCount(len(self.__data)) - - for r_idx, r in enumerate(self.__data): - for c_idx, c in enumerate(columns): - v = r[c] - self.__checkBoxTableWidget.setItem( - r_idx, c_idx + 1, QTableWidgetItem(str(v)) - ) - else: - raise Exception("Invalid import type") - - self.__checkBoxTableWidget.resizeColumnsToContents() - self.__dataGrpBox.setEnabled(True) - self.__allCheckBox.setChecked(True) - self.__toggleBtn() - except Exception as e: - QMessageBox.critical(self, LangClass.TRANSLATIONS["Error"], str(e)) - return - - def getData(self): - checked_rows = self.__checkBoxTableWidget.getCheckedRows() - if self.__import_type == "general": - self.__data = [self.__data[r] for r in checked_rows] - elif self.__import_type == "chatgpt": - self.__data = get_chatgpt_data_for_import( - [self.__data[r] for r in checked_rows] - ) - return self.__data +from __future__ import annotations + +import json + +from typing import Any + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QAbstractItemView, QCheckBox, QDialog, QDialogButtonBox, QGroupBox, QLabel, QMessageBox, QSpinBox, QTableWidgetItem, QVBoxLayout, QWidget + +from pyqt_openai import HOW_TO_EXPORT_CHATGPT_CONVERSATION_HISTORY_URL, JSON_FILE_EXT_LIST_STR, THREAD_ORDERBY +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.util.common import get_chatgpt_data_for_import, get_chatgpt_data_for_preview +from pyqt_openai.widgets.checkBoxTableWidget import CheckBoxTableWidget +from pyqt_openai.widgets.findPathWidget import FindPathWidget +from pyqt_openai.widgets.linkLabel import LinkLabel + + +class ImportDialog(QDialog): + def __init__( + self, + import_type: str = "general", + parent: QWidget | None = None, + ): + super().__init__(parent) + self.__initVal(import_type) + self.__initUi() + + def __initVal( + self, + import_type: str, + ): + self.__import_type = import_type + # Get the most recent n conversation threads + self.__most_recent_n = 10 + # Data to be imported + self.__data: list[dict[str, Any]] = [] + + def __initUi(self): + self.setWindowTitle(LangClass.TRANSLATIONS["Import"]) + self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) + + findPathWidget = FindPathWidget() + findPathWidget.getLineEdit().setPlaceholderText( + LangClass.TRANSLATIONS["Select a json file to import"], + ) + findPathWidget.setExtOfFiles(JSON_FILE_EXT_LIST_STR) + findPathWidget.added.connect(self.__setPath) + + self.__chkBoxMostRecent: QCheckBox = QCheckBox(LangClass.TRANSLATIONS["Get most recent"]) + self.__chkBoxMostRecent.setChecked(False) + + self.__mostRecentNSpinBox: QSpinBox = QSpinBox() + self.__mostRecentNSpinBox.setRange(1, 10000) + self.__mostRecentNSpinBox.setValue(self.__most_recent_n) + self.__mostRecentNSpinBox.setEnabled(False) + + self.__chkBoxMostRecent.stateChanged.connect( + lambda state: self.__mostRecentNSpinBox.setEnabled( + Qt.CheckState(state) == Qt.CheckState.Checked, + ), + ) + + importOptionsGrpBox = QGroupBox(LangClass.TRANSLATIONS["Import Options"]) + lay = QVBoxLayout() + lay.addWidget(self.__chkBoxMostRecent) + lay.addWidget(self.__mostRecentNSpinBox) + importOptionsGrpBox.setLayout(lay) + + self.__checkBoxTableWidget: CheckBoxTableWidget = CheckBoxTableWidget() + self.__checkBoxTableWidget.setColumnCount(0) + self.__checkBoxTableWidget.setEditTriggers( + QAbstractItemView.EditTrigger.NoEditTriggers, + ) + + self.__allCheckBox: QCheckBox = QCheckBox(LangClass.TRANSLATIONS["Select All"]) + self.__allCheckBox.stateChanged.connect(self.__checkBoxTableWidget.toggleState) + + lay = QVBoxLayout() + lay.addWidget( + QLabel(LangClass.TRANSLATIONS["Select the threads you want to import."]), + ) + lay.addWidget(self.__allCheckBox) + lay.addWidget(self.__checkBoxTableWidget) + + self.__dataGrpBox = QGroupBox(LangClass.TRANSLATIONS["Content"]) + self.__dataGrpBox.setLayout(lay) + self.__dataGrpBox.setEnabled(False) + + self.__buttonBox: QDialogButtonBox = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, + ) + self.__buttonBox.accepted.connect(self.accept) + self.__buttonBox.rejected.connect(self.reject) + self.__buttonBox.button(QDialogButtonBox.StandardButton.Ok).setEnabled(False) + + manual_lbl = QLabel( + LangClass.TRANSLATIONS[ + "You can import a JSON file created through the export feature." + ], + ) + lay = QVBoxLayout() + lay.addWidget(manual_lbl) + + if self.__import_type == "chatgpt": + viewManualLbl = LinkLabel() + viewManualLbl.setText( + LangClass.TRANSLATIONS["How to import your ChatGPT data"], + ) + viewManualLbl.setUrl(HOW_TO_EXPORT_CHATGPT_CONVERSATION_HISTORY_URL) + lay.addWidget(viewManualLbl) + + manualWidget = QWidget() + manualWidget.setLayout(lay) + + lay = QVBoxLayout() + lay.addWidget(findPathWidget) + lay.addWidget(importOptionsGrpBox) + lay.addWidget(manualWidget) + lay.addWidget(self.__dataGrpBox) + lay.addWidget(self.__buttonBox) + + self.setLayout(lay) + + self.resize(800, 600) + + self.__checkBoxTableWidget.checkedSignal.connect(self.__toggleBtn) + self.__allCheckBox.stateChanged.connect(self.__toggleBtn) + + def __toggleBtn(self): + self.__buttonBox.button(QDialogButtonBox.StandardButton.Ok).setEnabled( + len(self.__checkBoxTableWidget.getCheckedRows()) > 0, + ) + + def __setPath(self, path): + try: + most_recent_n = ( + self.__mostRecentNSpinBox.value() + if self.__chkBoxMostRecent.isChecked() + else None + ) + columns = [] + if self.__import_type == "general": + self.__path = path + self.__data = json.load(open(path)) + self.__data = sorted( + self.__data, key=lambda x: x[THREAD_ORDERBY] or "", reverse=True, + ) + # Get most recent one + if most_recent_n is not None: + self.__data = self.__data[:most_recent_n] + columns = ["id", "name", "insert_dt", "update_dt"] + self.__checkBoxTableWidget.setHorizontalHeaderLabels(columns) + self.__checkBoxTableWidget.setRowCount(len(self.__data)) + for r_idx, r in enumerate(self.__data): + for c_idx, c in enumerate(columns): + v = r[c] + self.__checkBoxTableWidget.setItem( + r_idx, c_idx + 1, QTableWidgetItem(str(v)), + ) + elif self.__import_type == "chatgpt": + result_dict = get_chatgpt_data_for_preview(path, most_recent_n or 0) + columns = result_dict["columns"] + self.__data = result_dict["data"] + self.__checkBoxTableWidget.setHorizontalHeaderLabels(columns) + self.__checkBoxTableWidget.setRowCount(len(self.__data)) + + for r_idx, r in enumerate(self.__data): + for c_idx, c in enumerate(columns): + v = r[c] + self.__checkBoxTableWidget.setItem( + r_idx, c_idx + 1, QTableWidgetItem(str(v)), + ) + else: + raise Exception("Invalid import type") + + self.__checkBoxTableWidget.resizeColumnsToContents() + self.__dataGrpBox.setEnabled(True) + self.__allCheckBox.setChecked(True) + self.__toggleBtn() + except Exception as e: + QMessageBox.critical(self, LangClass.TRANSLATIONS["Error"], str(e)) # type: ignore[call-arg] + return + + def getData(self) -> list[dict[str, Any]]: + checked_rows = self.__checkBoxTableWidget.getCheckedRows() + if self.__import_type == "general": + self.__data = [self.__data[r] for r in checked_rows] + elif self.__import_type == "chatgpt": + self.__data = get_chatgpt_data_for_import( + [self.__data[r] for r in checked_rows], + ) + return self.__data diff --git a/pyqt_openai/chat_widget/left_sidebar/selectChatImportTypeDialog.py b/pyqt_openai/chat_widget/left_sidebar/selectChatImportTypeDialog.py index d95ae119..6f775a76 100644 --- a/pyqt_openai/chat_widget/left_sidebar/selectChatImportTypeDialog.py +++ b/pyqt_openai/chat_widget/left_sidebar/selectChatImportTypeDialog.py @@ -1,61 +1,62 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QButtonGroup, - QGroupBox, - QRadioButton, - QDialogButtonBox, - QDialog, - QVBoxLayout, -) - -from pyqt_openai.lang.translations import LangClass - - -class SelectChatImportTypeDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.__initVal() - self.__initUi() - - def __initVal(self): - self.__selected_import_type = None - - def __initUi(self): - self.setWindowTitle(LangClass.TRANSLATIONS["Import From..."]) - self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) - - self.__generalRadBtn = QRadioButton(LangClass.TRANSLATIONS["General"]) - self.__chatGptRadBtn = QRadioButton(LangClass.TRANSLATIONS["ChatGPT"]) - - self.__generalRadBtn.setChecked(True) - - self.__buttonGroup = QButtonGroup() - self.__buttonGroup.addButton(self.__generalRadBtn, 1) - self.__buttonGroup.addButton(self.__chatGptRadBtn, 2) - - lay = QVBoxLayout() - lay.addWidget(self.__generalRadBtn) - lay.addWidget(self.__chatGptRadBtn) - lay.setAlignment(Qt.AlignmentFlag.AlignTop) - - importTypeGrpBox = QGroupBox(LangClass.TRANSLATIONS["Import Type"]) - importTypeGrpBox.setLayout(lay) - - buttonBox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - buttonBox.accepted.connect(self.accept) - buttonBox.rejected.connect(self.reject) - - lay = QVBoxLayout() - lay.addWidget(importTypeGrpBox) - lay.addWidget(buttonBox) - - self.setLayout(lay) - - def getImportType(self): - selected_button_id = self.__buttonGroup.checkedId() - if selected_button_id == 1: - return "general" - elif selected_button_id == 2: - return "chatgpt" - else: - return None +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QButtonGroup, + QDialog, + QDialogButtonBox, + QGroupBox, + QRadioButton, + QVBoxLayout, +) + +from pyqt_openai.lang.translations import LangClass + + +class SelectChatImportTypeDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.__initVal() + self.__initUi() + + def __initVal(self): + self.__selected_import_type = None + + def __initUi(self): + self.setWindowTitle(LangClass.TRANSLATIONS["Import From..."]) + self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) + + self.__generalRadBtn = QRadioButton(LangClass.TRANSLATIONS["General"]) + self.__chatGptRadBtn = QRadioButton(LangClass.TRANSLATIONS["ChatGPT"]) + + self.__generalRadBtn.setChecked(True) + + self.__buttonGroup = QButtonGroup() + self.__buttonGroup.addButton(self.__generalRadBtn, 1) + self.__buttonGroup.addButton(self.__chatGptRadBtn, 2) + + lay = QVBoxLayout() + lay.addWidget(self.__generalRadBtn) + lay.addWidget(self.__chatGptRadBtn) + lay.setAlignment(Qt.AlignmentFlag.AlignTop) + + importTypeGrpBox = QGroupBox(LangClass.TRANSLATIONS["Import Type"]) + importTypeGrpBox.setLayout(lay) + + buttonBox = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttonBox.accepted.connect(self.accept) + buttonBox.rejected.connect(self.reject) + + lay = QVBoxLayout() + lay.addWidget(importTypeGrpBox) + lay.addWidget(buttonBox) + + self.setLayout(lay) + + def getImportType(self): + selected_button_id = self.__buttonGroup.checkedId() + if selected_button_id == 1: + return "general" + if selected_button_id == 2: + return "chatgpt" + return None diff --git a/pyqt_openai/chat_widget/llamaIndexThread.py b/pyqt_openai/chat_widget/llamaIndexThread.py index fa57fcb5..c29a2d29 100644 --- a/pyqt_openai/chat_widget/llamaIndexThread.py +++ b/pyqt_openai/chat_widget/llamaIndexThread.py @@ -1,53 +1,54 @@ -from llama_index.core.base.response.schema import StreamingResponse -from PySide6.QtCore import QThread, Signal - -from pyqt_openai.models import ChatMessageContainer - - -# Should combine with ChatThread -class LlamaIndexThread(QThread): - replyGenerated = Signal(str, bool, ChatMessageContainer) - streamFinished = Signal(ChatMessageContainer) - - def __init__(self, input_args, info: ChatMessageContainer, wrapper, query_text): - super().__init__() - self.__input_args = input_args - self.__stop = False - - self.__info = info - self.__info.role = "assistant" - - self.__wrapper = wrapper - self.__query_text = query_text - - def stop(self): - self.__stop = True - - def run(self): - try: - resp = self.__wrapper.get_response(self.__query_text) - f = isinstance(resp, StreamingResponse) - if f: - for chunk in resp.response_gen: - if self.__stop: - self.__info.finish_reason = "stopped by user" - self.streamFinished.emit(self.__info) - break - else: - self.replyGenerated.emit(chunk, True, self.__info) - else: - self.__info.content = resp.response - # self.__info.prompt_tokens = "" - # self.__info.completion_tokens = "" - # self.__info.total_tokens = "" - - self.__info.finish_reason = "stop" - - if self.__input_args["stream"]: - self.streamFinished.emit(self.__info) - else: - self.replyGenerated.emit(self.__info.content, False, self.__info) - except Exception as e: - self.__info.finish_reason = "Error" - self.__info.content = f'

{e}

' - self.replyGenerated.emit(self.__info.content, False, self.__info) +from __future__ import annotations + +from llama_index.core.base.response.schema import StreamingResponse +from qtpy.QtCore import QThread, Signal + +from pyqt_openai.models import ChatMessageContainer + + +# Should combine with ChatThread +class LlamaIndexThread(QThread): + replyGenerated = Signal(str, bool, ChatMessageContainer) + streamFinished = Signal(ChatMessageContainer) + + def __init__(self, input_args, info: ChatMessageContainer, wrapper, query_text): + super().__init__() + self.__input_args = input_args + self.__stop = False + + self.__info = info + self.__info.role = "assistant" + + self.__wrapper = wrapper + self.__query_text = query_text + + def stop(self): + self.__stop = True + + def run(self): + try: + resp = self.__wrapper.get_response(self.__query_text) + f = isinstance(resp, StreamingResponse) + if f: + for chunk in resp.response_gen: + if self.__stop: + self.__info.finish_reason = "stopped by user" + self.streamFinished.emit(self.__info) + break + self.replyGenerated.emit(chunk, True, self.__info) + else: + self.__info.content = resp.response + # self.__info.prompt_tokens = "" + # self.__info.completion_tokens = "" + # self.__info.total_tokens = "" + + self.__info.finish_reason = "stop" + + if self.__input_args["stream"]: + self.streamFinished.emit(self.__info) + else: + self.replyGenerated.emit(self.__info.content, False, self.__info) + except Exception as e: + self.__info.finish_reason = "Error" + self.__info.content = f'

{e}

' + self.replyGenerated.emit(self.__info.content, False, self.__info) diff --git a/pyqt_openai/chat_widget/prompt_gen_widget/importPromptManualDialog.py b/pyqt_openai/chat_widget/prompt_gen_widget/importPromptManualDialog.py index d923c4a0..defd3261 100644 --- a/pyqt_openai/chat_widget/prompt_gen_widget/importPromptManualDialog.py +++ b/pyqt_openai/chat_widget/prompt_gen_widget/importPromptManualDialog.py @@ -1,51 +1,58 @@ -from PySide6.QtWidgets import QDialog, QLabel, QVBoxLayout, QPushButton -from PySide6.QtCore import Qt - -from pyqt_openai import FORM_PROMPT_GROUP_SAMPLE, SENTENCE_PROMPT_GROUP_SAMPLE, AWESOME_CHATGPT_PROMPTS_URL -from pyqt_openai.chat_widget.prompt_gen_widget.promptCsvRightFormSampleDialog import PromptCSVRightFormSampleDialog -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.util.common import showJsonSample -from pyqt_openai.widgets.jsonEditor import JSONEditor - - -class ImportPromptManualDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - self.setWindowTitle(LangClass.TRANSLATIONS["Import Manual"]) - self.__jsonSampleWidget = JSONEditor() - jsonRightFormBtn = QPushButton( - LangClass.TRANSLATIONS[ - "What is the right form of json to be imported?" - ] - ) - - csvRightFormBtn = QPushButton( - LangClass.TRANSLATIONS[ - "What is the right form of csv to be imported?" - ] - ) - - jsonRightFormBtn.clicked.connect(self.__showJsonSample) - csvRightFormBtn.clicked.connect(self.__showCSVSample) - - awesomeChatGptPromptDownloadLink = QLabel(f'Try downloading Awesome ChatGPT prompts and import! 😊') - awesomeChatGptPromptDownloadLink.setTextInteractionFlags( - Qt.TextInteractionFlag.TextBrowserInteraction - ) - awesomeChatGptPromptDownloadLink.setOpenExternalLinks(True) # Enable hyperlink functionality. - - lay = QVBoxLayout() - lay.addWidget(jsonRightFormBtn) - lay.addWidget(csvRightFormBtn) - lay.addWidget(awesomeChatGptPromptDownloadLink) - self.setLayout(lay) - - def __showJsonSample(self): - showJsonSample(self.__jsonSampleWidget, SENTENCE_PROMPT_GROUP_SAMPLE) - - def __showCSVSample(self): - dialog = PromptCSVRightFormSampleDialog(self) - dialog.exec() +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QDialog, QLabel, QPushButton, QVBoxLayout + +from pyqt_openai import ( + AWESOME_CHATGPT_PROMPTS_URL, + SENTENCE_PROMPT_GROUP_SAMPLE, +) +from pyqt_openai.chat_widget.prompt_gen_widget.promptCsvRightFormSampleDialog import ( + PromptCSVRightFormSampleDialog, +) +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.util.common import showJsonSample +from pyqt_openai.widgets.jsonEditor import JSONEditor + + +class ImportPromptManualDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.__initUi() + + def __initUi(self): + self.setWindowTitle(LangClass.TRANSLATIONS["Import Manual"]) + self.__jsonSampleWidget = JSONEditor() + jsonRightFormBtn = QPushButton( + LangClass.TRANSLATIONS[ + "What is the right form of json to be imported?" + ], + ) + + csvRightFormBtn = QPushButton( + LangClass.TRANSLATIONS[ + "What is the right form of csv to be imported?" + ], + ) + + jsonRightFormBtn.clicked.connect(self.__showJsonSample) + csvRightFormBtn.clicked.connect(self.__showCSVSample) + + awesomeChatGptPromptDownloadLink = QLabel(f'Try downloading Awesome ChatGPT prompts and import! 😊') + awesomeChatGptPromptDownloadLink.setTextInteractionFlags( + Qt.TextInteractionFlag.TextBrowserInteraction, + ) + awesomeChatGptPromptDownloadLink.setOpenExternalLinks(True) # Enable hyperlink functionality. + + lay = QVBoxLayout() + lay.addWidget(jsonRightFormBtn) + lay.addWidget(csvRightFormBtn) + lay.addWidget(awesomeChatGptPromptDownloadLink) + self.setLayout(lay) + + def __showJsonSample(self): + showJsonSample(self.__jsonSampleWidget, SENTENCE_PROMPT_GROUP_SAMPLE) + + def __showCSVSample(self): + dialog = PromptCSVRightFormSampleDialog(self) + dialog.exec() diff --git a/pyqt_openai/chat_widget/prompt_gen_widget/promptCsvRightFormSampleDialog.py b/pyqt_openai/chat_widget/prompt_gen_widget/promptCsvRightFormSampleDialog.py index 3f0c8071..7427efe2 100644 --- a/pyqt_openai/chat_widget/prompt_gen_widget/promptCsvRightFormSampleDialog.py +++ b/pyqt_openai/chat_widget/prompt_gen_widget/promptCsvRightFormSampleDialog.py @@ -1,26 +1,28 @@ -from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel -from PySide6.QtGui import QPainter - -from pyqt_openai import IMAGE_IMPORT_PROMPT_WITH_CSV_RIGHT_FORM -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.widgets.normalImageView import NormalImageView - - -class PromptCSVRightFormSampleDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - self.setWindowTitle(LangClass.TRANSLATIONS["CSV Right Form Sample"]) - # Add image - view = NormalImageView() - view.setFilename(IMAGE_IMPORT_PROMPT_WITH_CSV_RIGHT_FORM) - # Anti-aliasing - view.setRenderHint(QPainter.RenderHint.Antialiasing, True) - - lay = QVBoxLayout() - lay.addWidget(view) - lay.addWidget(QLabel('This is from awesome_chatgpt_prompt.csv file from huggingface.')) - - self.setLayout(lay) \ No newline at end of file +from __future__ import annotations + +from qtpy.QtGui import QPainter +from qtpy.QtWidgets import QDialog, QLabel, QVBoxLayout + +from pyqt_openai import IMAGE_IMPORT_PROMPT_WITH_CSV_RIGHT_FORM +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.widgets.normalImageView import NormalImageView + + +class PromptCSVRightFormSampleDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.__initUi() + + def __initUi(self): + self.setWindowTitle(LangClass.TRANSLATIONS["CSV Right Form Sample"]) + # Add image + view = NormalImageView() + view.setFilename(IMAGE_IMPORT_PROMPT_WITH_CSV_RIGHT_FORM) + # Anti-aliasing + view.setRenderHint(QPainter.RenderHint.Antialiasing, True) + + lay = QVBoxLayout() + lay.addWidget(view) + lay.addWidget(QLabel("This is from awesome_chatgpt_prompt.csv file from huggingface.")) + + self.setLayout(lay) diff --git a/pyqt_openai/chat_widget/prompt_gen_widget/promptEntryDirectInputDialog.py b/pyqt_openai/chat_widget/prompt_gen_widget/promptEntryDirectInputDialog.py index e0bfc447..a59c40be 100644 --- a/pyqt_openai/chat_widget/prompt_gen_widget/promptEntryDirectInputDialog.py +++ b/pyqt_openai/chat_widget/prompt_gen_widget/promptEntryDirectInputDialog.py @@ -1,78 +1,79 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QPlainTextEdit, - QLineEdit, - QPushButton, - QHBoxLayout, - QWidget, - QMessageBox, -) - -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.util.common import is_prompt_entry_name_valid, getSeparator - - -class PromptEntryDirectInputDialog(QDialog): - def __init__(self, group_id, parent=None): - super().__init__(parent) - self.__initVal(group_id) - self.__initUi() - - def __initVal(self, group_id): - self.__group_id = group_id - - def __initUi(self): - self.setWindowTitle(LangClass.TRANSLATIONS["New Prompt"]) - self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) - - self.__name = QLineEdit() - self.__name.setPlaceholderText(LangClass.TRANSLATIONS["Name"]) - - self.__content = QPlainTextEdit() - self.__content.setPlaceholderText(LangClass.TRANSLATIONS["Content"]) - - sep = getSeparator("horizontal") - - self.__okBtn = QPushButton(LangClass.TRANSLATIONS["OK"]) - self.__okBtn.clicked.connect(self.__accept) - - cancelBtn = QPushButton(LangClass.TRANSLATIONS["Cancel"]) - cancelBtn.clicked.connect(self.close) - - lay = QHBoxLayout() - lay.addWidget(self.__okBtn) - lay.addWidget(cancelBtn) - lay.setAlignment(Qt.AlignmentFlag.AlignRight) - lay.setContentsMargins(0, 0, 0, 0) - - okCancelWidget = QWidget() - okCancelWidget.setLayout(lay) - - lay = QVBoxLayout() - lay.addWidget(self.__name) - lay.addWidget(self.__content) - lay.addWidget(sep) - lay.addWidget(okCancelWidget) - - self.setLayout(lay) - - def getAct(self): - return self.__name.text() - - def getPrompt(self): - return self.__content.toPlainText() - - def __accept(self): - exists_f = is_prompt_entry_name_valid(self.__group_id, self.__name.text()) - if exists_f: - self.__name.setFocus() - QMessageBox.warning( - self, - LangClass.TRANSLATIONS["Warning"], - LangClass.TRANSLATIONS["Entry name already exists."], - ) - return - else: - self.accept() +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QDialog, + QHBoxLayout, + QLineEdit, + QMessageBox, + QPlainTextEdit, + QPushButton, + QVBoxLayout, + QWidget, +) + +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.util.common import getSeparator, is_prompt_entry_name_valid + + +class PromptEntryDirectInputDialog(QDialog): + def __init__(self, group_id, parent=None): + super().__init__(parent) + self.__initVal(group_id) + self.__initUi() + + def __initVal(self, group_id): + self.__group_id = group_id + + def __initUi(self): + self.setWindowTitle(LangClass.TRANSLATIONS["New Prompt"]) + self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) + + self.__name = QLineEdit() + self.__name.setPlaceholderText(LangClass.TRANSLATIONS["Name"]) + + self.__content = QPlainTextEdit() + self.__content.setPlaceholderText(LangClass.TRANSLATIONS["Content"]) + + sep = getSeparator("horizontal") + + self.__okBtn = QPushButton(LangClass.TRANSLATIONS["OK"]) + self.__okBtn.clicked.connect(self.__accept) + + cancelBtn = QPushButton(LangClass.TRANSLATIONS["Cancel"]) + cancelBtn.clicked.connect(self.close) + + lay = QHBoxLayout() + lay.addWidget(self.__okBtn) + lay.addWidget(cancelBtn) + lay.setAlignment(Qt.AlignmentFlag.AlignRight) + lay.setContentsMargins(0, 0, 0, 0) + + okCancelWidget = QWidget() + okCancelWidget.setLayout(lay) + + lay = QVBoxLayout() + lay.addWidget(self.__name) + lay.addWidget(self.__content) + lay.addWidget(sep) + lay.addWidget(okCancelWidget) + + self.setLayout(lay) + + def getAct(self): + return self.__name.text() + + def getPrompt(self): + return self.__content.toPlainText() + + def __accept(self): + exists_f = is_prompt_entry_name_valid(self.__group_id, self.__name.text()) + if exists_f: + self.__name.setFocus() + QMessageBox.warning( # type: ignore[call-arg] + self, + LangClass.TRANSLATIONS["Warning"], + LangClass.TRANSLATIONS["Entry name already exists."], + ) + return + self.accept() diff --git a/pyqt_openai/chat_widget/prompt_gen_widget/promptGeneratorWidget.py b/pyqt_openai/chat_widget/prompt_gen_widget/promptGeneratorWidget.py index 3ac70372..a5a48819 100644 --- a/pyqt_openai/chat_widget/prompt_gen_widget/promptGeneratorWidget.py +++ b/pyqt_openai/chat_widget/prompt_gen_widget/promptGeneratorWidget.py @@ -1,86 +1,89 @@ -import pyperclip -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QTextBrowser, - QSplitter, - QWidget, - QLabel, - QVBoxLayout, - QPushButton, - QTabWidget, - QScrollArea, -) - -from pyqt_openai.chat_widget.prompt_gen_widget.promptPage import PromptPage -from pyqt_openai.lang.translations import LangClass - - -class PromptGeneratorWidget(QScrollArea): - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - promptLbl = QLabel(LangClass.TRANSLATIONS["Prompt"]) - - formPage = PromptPage(prompt_type='form') - formPage.updated.connect(self.__textChanged) - - sentencePage = PromptPage(prompt_type='sentence') - sentencePage.updated.connect(self.__textChanged) - - self.__prompt = QTextBrowser() - self.__prompt.setPlaceholderText(LangClass.TRANSLATIONS["Generated Prompt"]) - self.__prompt.setAcceptRichText(False) - - promptTabWidget = QTabWidget() - promptTabWidget.addTab(formPage, LangClass.TRANSLATIONS["Form"]) - promptTabWidget.addTab(sentencePage, LangClass.TRANSLATIONS["Sentence"]) - - previewLbl = QLabel(LangClass.TRANSLATIONS["Preview"]) - - copyBtn = QPushButton(LangClass.TRANSLATIONS["Copy"]) - copyBtn.clicked.connect(self.__copy) - - lay = QVBoxLayout() - lay.addWidget(promptLbl) - lay.addWidget(promptTabWidget) - - topWidget = QWidget() - topWidget.setLayout(lay) - - lay = QVBoxLayout() - lay.addWidget(previewLbl) - lay.addWidget(self.__prompt) - lay.addWidget(copyBtn) - - bottomWidget = QWidget() - bottomWidget.setLayout(lay) - - mainSplitter = QSplitter() - mainSplitter.addWidget(topWidget) - mainSplitter.addWidget(bottomWidget) - mainSplitter.setOrientation(Qt.Orientation.Vertical) - mainSplitter.setChildrenCollapsible(False) - mainSplitter.setHandleWidth(2) - mainSplitter.setStyleSheet( - """ - QSplitter::handle:vertical - { - background: #CCC; - height: 1px; - } - """ - ) - - self.setWidget(mainSplitter) - self.setWidgetResizable(True) - - self.setStyleSheet("QScrollArea { border: 0 }") - - def __textChanged(self, prompt_text): - self.__prompt.clear() - self.__prompt.setText(prompt_text) - - def __copy(self): - pyperclip.copy(self.__prompt.toPlainText()) +from __future__ import annotations + +import pyperclip + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QLabel, + QPushButton, + QScrollArea, + QSplitter, + QTabWidget, + QTextBrowser, + QVBoxLayout, + QWidget, +) + +from pyqt_openai.chat_widget.prompt_gen_widget.promptPage import PromptPage +from pyqt_openai.lang.translations import LangClass + + +class PromptGeneratorWidget(QScrollArea): + def __init__(self, parent=None): + super().__init__(parent) + self.__initUi() + + def __initUi(self): + promptLbl = QLabel(LangClass.TRANSLATIONS["Prompt"]) + + formPage = PromptPage(prompt_type="form") + formPage.updated.connect(self.__textChanged) + + sentencePage = PromptPage(prompt_type="sentence") + sentencePage.updated.connect(self.__textChanged) + + self.__prompt = QTextBrowser() + self.__prompt.setPlaceholderText(LangClass.TRANSLATIONS["Generated Prompt"]) + self.__prompt.setAcceptRichText(False) + + promptTabWidget = QTabWidget() + promptTabWidget.addTab(formPage, LangClass.TRANSLATIONS["Form"]) + promptTabWidget.addTab(sentencePage, LangClass.TRANSLATIONS["Sentence"]) + + previewLbl = QLabel(LangClass.TRANSLATIONS["Preview"]) + + copyBtn = QPushButton(LangClass.TRANSLATIONS["Copy"]) + copyBtn.clicked.connect(self.__copy) + + lay = QVBoxLayout() + lay.addWidget(promptLbl) + lay.addWidget(promptTabWidget) + + topWidget = QWidget() + topWidget.setLayout(lay) + + lay = QVBoxLayout() + lay.addWidget(previewLbl) + lay.addWidget(self.__prompt) + lay.addWidget(copyBtn) + + bottomWidget = QWidget() + bottomWidget.setLayout(lay) + + mainSplitter = QSplitter() + mainSplitter.addWidget(topWidget) + mainSplitter.addWidget(bottomWidget) + mainSplitter.setOrientation(Qt.Orientation.Vertical) + mainSplitter.setChildrenCollapsible(False) + mainSplitter.setHandleWidth(2) + mainSplitter.setStyleSheet( + """ + QSplitter::handle:vertical + { + background: #CCC; + height: 1px; + } + """, + ) + + self.setWidget(mainSplitter) + self.setWidgetResizable(True) + + self.setStyleSheet("QScrollArea { border: 0 }") + + def __textChanged(self, prompt_text): + self.__prompt.clear() + self.__prompt.setText(prompt_text) + + def __copy(self): + pyperclip.copy(self.__prompt.toPlainText()) diff --git a/pyqt_openai/chat_widget/prompt_gen_widget/promptGroupDirectInputDialog.py b/pyqt_openai/chat_widget/prompt_gen_widget/promptGroupDirectInputDialog.py index 5fbd0497..c25e813c 100644 --- a/pyqt_openai/chat_widget/prompt_gen_widget/promptGroupDirectInputDialog.py +++ b/pyqt_openai/chat_widget/prompt_gen_widget/promptGroupDirectInputDialog.py @@ -1,69 +1,71 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QMessageBox, - QLineEdit, - QPushButton, - QHBoxLayout, - QWidget, -) - -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.util.common import is_prompt_group_name_valid, getSeparator - - -class PromptGroupDirectInputDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - self.setWindowTitle(LangClass.TRANSLATIONS["New Prompt"]) - self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) - - self.__name = QLineEdit() - self.__name.setPlaceholderText(LangClass.TRANSLATIONS["Name"]) - self.__name.textChanged.connect( - lambda x: self.__okBtn.setEnabled(x.strip() != "") - ) - - sep = getSeparator("horizontal") - - self.__okBtn = QPushButton(LangClass.TRANSLATIONS["OK"]) - self.__okBtn.clicked.connect(self.__accept) - - cancelBtn = QPushButton(LangClass.TRANSLATIONS["Cancel"]) - cancelBtn.clicked.connect(self.close) - - lay = QHBoxLayout() - lay.addWidget(self.__okBtn) - lay.addWidget(cancelBtn) - lay.setAlignment(Qt.AlignmentFlag.AlignRight) - lay.setContentsMargins(0, 0, 0, 0) - - okCancelWidget = QWidget() - okCancelWidget.setLayout(lay) - - lay = QVBoxLayout() - lay.addWidget(self.__name) - lay.addWidget(sep) - lay.addWidget(okCancelWidget) - - self.setLayout(lay) - - def getPromptGroupName(self): - return self.__name.text() - - def __accept(self): - f = is_prompt_group_name_valid(self.__name.text()) - if f: - self.accept() - else: - self.__name.setFocus() - QMessageBox.warning( - self, - LangClass.TRANSLATIONS["Warning"], - LangClass.TRANSLATIONS["Prompt name already exists."], - ) - return +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QDialog, + QHBoxLayout, + QLineEdit, + QMessageBox, + QPushButton, + QVBoxLayout, + QWidget, +) + +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.util.common import getSeparator, is_prompt_group_name_valid + + +class PromptGroupDirectInputDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.__initUi() + + def __initUi(self): + self.setWindowTitle(LangClass.TRANSLATIONS["New Prompt"]) + self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) + + self.__name = QLineEdit() + self.__name.setPlaceholderText(LangClass.TRANSLATIONS["Name"]) + self.__name.textChanged.connect( + lambda x: self.__okBtn.setEnabled(x.strip() != ""), + ) + + sep = getSeparator("horizontal") + + self.__okBtn = QPushButton(LangClass.TRANSLATIONS["OK"]) + self.__okBtn.clicked.connect(self.__accept) + + cancelBtn = QPushButton(LangClass.TRANSLATIONS["Cancel"]) + cancelBtn.clicked.connect(self.close) + + hlay = QHBoxLayout() + hlay.addWidget(self.__okBtn) + hlay.addWidget(cancelBtn) + hlay.setAlignment(Qt.AlignmentFlag.AlignRight) + hlay.setContentsMargins(0, 0, 0, 0) + + okCancelWidget = QWidget() + okCancelWidget.setLayout(hlay) + + vlay = QVBoxLayout() + vlay.addWidget(self.__name) + vlay.addWidget(sep) + vlay.addWidget(okCancelWidget) + + self.setLayout(vlay) + + def getPromptGroupName(self) -> str: + return self.__name.text() + + def __accept(self): + f = is_prompt_group_name_valid(self.__name.text()) + if f: + self.accept() + else: + self.__name.setFocus() + QMessageBox.warning( # type: ignore[call-arg] + self, + LangClass.TRANSLATIONS["Warning"], + LangClass.TRANSLATIONS["Prompt name already exists."], + ) + return diff --git a/pyqt_openai/chat_widget/prompt_gen_widget/promptGroupExportDialog.py b/pyqt_openai/chat_widget/prompt_gen_widget/promptGroupExportDialog.py index d2d6c578..720cb058 100644 --- a/pyqt_openai/chat_widget/prompt_gen_widget/promptGroupExportDialog.py +++ b/pyqt_openai/chat_widget/prompt_gen_widget/promptGroupExportDialog.py @@ -1,96 +1,99 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QPushButton, - QCheckBox, - QDialogButtonBox, - QDialog, - QVBoxLayout, - QLabel, -) - -from pyqt_openai import SENTENCE_PROMPT_GROUP_SAMPLE, FORM_PROMPT_GROUP_SAMPLE -from pyqt_openai.chat_widget.prompt_gen_widget.promptCsvRightFormSampleDialog import PromptCSVRightFormSampleDialog -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.util.common import showJsonSample -from pyqt_openai.widgets.checkBoxListWidget import CheckBoxListWidget -from pyqt_openai.widgets.jsonEditor import JSONEditor - - -class PromptGroupExportDialog(QDialog): - def __init__(self, data, prompt_type="form", ext='.json', parent=None): - super().__init__(parent) - self.__initVal(data, prompt_type, ext) - self.__initUi() - - def __initVal(self, data, prompt_type, ext): - self.__data = data - self.__promptType = prompt_type - self.__ext = ext - - def __initUi(self): - self.setWindowTitle(LangClass.TRANSLATIONS["Export Prompt Group"]) - self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) - - btnText = LangClass.TRANSLATIONS["Preview of the JSON format to be created after export"] if self.__ext == '.json' else LangClass.TRANSLATIONS["Preview of the CSV format to be created after export"] - btn = QPushButton(btnText) - - if self.__ext == '.json': - btn.clicked.connect(self.__showJsonSample) - self.__jsonSampleWidget = JSONEditor() - elif self.__ext == '.csv': - btn.clicked.connect(self.__showCSVSample) - - allCheckBox = QCheckBox(LangClass.TRANSLATIONS["Select All"]) - self.__listWidget = CheckBoxListWidget() - self.__listWidget.addItems([d["name"] for d in self.__data]) - self.__listWidget.checkedSignal.connect(self.__toggledBtn) - allCheckBox.stateChanged.connect(self.__listWidget.toggleState) - - self.__buttonBox = QDialogButtonBox( - QDialogButtonBox.Ok | QDialogButtonBox.Cancel - ) - self.__buttonBox.accepted.connect(self.accept) - self.__buttonBox.rejected.connect(self.reject) - - lay = QVBoxLayout() - lay.addWidget( - QLabel(LangClass.TRANSLATIONS["Select the prompts you want to export."]) - ) - lay.addWidget(allCheckBox) - lay.addWidget(self.__listWidget) - lay.addWidget(btn) - lay.addWidget(self.__buttonBox) - self.setLayout(lay) - - self.setLayout(lay) - - allCheckBox.setChecked(True) - - def __toggledBtn(self): - self.__buttonBox.button(QDialogButtonBox.Ok).setEnabled( - len(self.__listWidget.getCheckedRows()) > 0 - ) - - def __showJsonSample(self): - json_sample = ( - FORM_PROMPT_GROUP_SAMPLE - if self.__promptType == "form" - else SENTENCE_PROMPT_GROUP_SAMPLE - ) - showJsonSample(self.__jsonSampleWidget, json_sample) - - def __showCSVSample(self): - dialog = PromptCSVRightFormSampleDialog(self) - dialog.exec() - - def getSelected(self): - """ - Get selected prompt group names. - The data is used to export the selected prompt groups. - This function is giving names instead of ids because the name field is unique anyway. - """ - names = [ - self.__listWidget.item(r).text() for r in self.__listWidget.getCheckedRows() - ] - result = [d for d in self.__data if d["name"] in names] - return result +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QCheckBox, + QDialog, + QDialogButtonBox, + QLabel, + QPushButton, + QVBoxLayout, +) + +from pyqt_openai import FORM_PROMPT_GROUP_SAMPLE, SENTENCE_PROMPT_GROUP_SAMPLE +from pyqt_openai.chat_widget.prompt_gen_widget.promptCsvRightFormSampleDialog import ( + PromptCSVRightFormSampleDialog, +) +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.util.common import showJsonSample +from pyqt_openai.widgets.checkBoxListWidget import CheckBoxListWidget +from pyqt_openai.widgets.jsonEditor import JSONEditor + + +class PromptGroupExportDialog(QDialog): + def __init__(self, data, prompt_type="form", ext=".json", parent=None): + super().__init__(parent) + self.__initVal(data, prompt_type, ext) + self.__initUi() + + def __initVal(self, data, prompt_type, ext): + self.__data = data + self.__promptType = prompt_type + self.__ext = ext + + def __initUi(self): + self.setWindowTitle(LangClass.TRANSLATIONS["Export Prompt Group"]) + self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) + + btnText = LangClass.TRANSLATIONS["Preview of the JSON format to be created after export"] if self.__ext == ".json" else LangClass.TRANSLATIONS["Preview of the CSV format to be created after export"] + btn = QPushButton(btnText) + + if self.__ext == ".json": + btn.clicked.connect(self.__showJsonSample) + self.__jsonSampleWidget = JSONEditor() + elif self.__ext == ".csv": + btn.clicked.connect(self.__showCSVSample) + + allCheckBox = QCheckBox(LangClass.TRANSLATIONS["Select All"]) + self.__listWidget = CheckBoxListWidget() + self.__listWidget.addItems([d["name"] for d in self.__data]) + self.__listWidget.checkedSignal.connect(self.__toggledBtn) + allCheckBox.stateChanged.connect(self.__listWidget.toggleState) + + self.__buttonBox = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, + ) + self.__buttonBox.accepted.connect(self.accept) + self.__buttonBox.rejected.connect(self.reject) + + lay = QVBoxLayout() + lay.addWidget( + QLabel(LangClass.TRANSLATIONS["Select the prompts you want to export."]), + ) + lay.addWidget(allCheckBox) + lay.addWidget(self.__listWidget) + lay.addWidget(btn) + lay.addWidget(self.__buttonBox) + self.setLayout(lay) + + self.setLayout(lay) + + allCheckBox.setChecked(True) + + def __toggledBtn(self): + self.__buttonBox.button(QDialogButtonBox.StandardButton.Ok).setEnabled( + len(self.__listWidget.getCheckedRows()) > 0, + ) + + def __showJsonSample(self): + json_sample = ( + FORM_PROMPT_GROUP_SAMPLE + if self.__promptType == "form" + else SENTENCE_PROMPT_GROUP_SAMPLE + ) + showJsonSample(self.__jsonSampleWidget, json_sample) + + def __showCSVSample(self): + dialog = PromptCSVRightFormSampleDialog(self) + dialog.exec() + + def getSelected(self): + """Get selected prompt group names. + The data is used to export the selected prompt groups. + This function is giving names instead of ids because the name field is unique anyway. + """ + names = [ + self.__listWidget.item(r).text() for r in self.__listWidget.getCheckedRows() + ] + result = [d for d in self.__data if d["name"] in names] + return result diff --git a/pyqt_openai/chat_widget/prompt_gen_widget/promptGroupImportDialog.py b/pyqt_openai/chat_widget/prompt_gen_widget/promptGroupImportDialog.py index 70c133b6..8da799a3 100644 --- a/pyqt_openai/chat_widget/prompt_gen_widget/promptGroupImportDialog.py +++ b/pyqt_openai/chat_widget/prompt_gen_widget/promptGroupImportDialog.py @@ -1,237 +1,241 @@ -import csv -import json -import random - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QPushButton, - QDialogButtonBox, - QMessageBox, - QDialog, - QVBoxLayout, - QTableWidget, - QSplitter, - QWidget, - QLabel, - QAbstractItemView, - QTableWidgetItem, - QCheckBox, -) - -from pyqt_openai import ( - JSON_FILE_EXT_LIST_STR, - FORM_PROMPT_GROUP_SAMPLE, CSV_FILE_EXT_LIST_STR, -) -from pyqt_openai.chat_widget.prompt_gen_widget.importPromptManualDialog import ImportPromptManualDialog -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.util.common import ( - validate_prompt_group_json, - is_prompt_group_name_valid, - getSeparator, showJsonSample, -) -from pyqt_openai.widgets.checkBoxListWidget import CheckBoxListWidget -from pyqt_openai.widgets.findPathWidget import FindPathWidget -from pyqt_openai.widgets.jsonEditor import JSONEditor - - -class PromptGroupImportDialog(QDialog): - def __init__(self, prompt_type="form", parent=None): - super().__init__(parent) - self.__initVal(prompt_type) - self.__initUi() - - def __initVal(self, prompt_type): - self.__promptType = prompt_type - self.__path = "" - - def __initUi(self): - self.setWindowTitle(LangClass.TRANSLATIONS["Import Prompt Group"]) - self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) - self.__jsonSampleWidget = JSONEditor() - - findPathWidget = FindPathWidget() - EXT = JSON_FILE_EXT_LIST_STR if self.__promptType == "form" else f"{CSV_FILE_EXT_LIST_STR};;{JSON_FILE_EXT_LIST_STR}" - - findPathWidget.setExtOfFiles(EXT) - findPathWidget.getLineEdit().setPlaceholderText( - # TODO LANGUAGE - LangClass.TRANSLATIONS["Select a file to import..."] - ) - findPathWidget.added.connect(self.__setPath) - - manualBtn = QPushButton() - if self.__promptType == "form": - manualBtn.setText(LangClass.TRANSLATIONS["What is the right form of json to be imported?"]) - manualBtn.clicked.connect(self.__showJsonSample) - else: - manualBtn.setText(LangClass.TRANSLATIONS["How to import a prompt group"]) - manualBtn.clicked.connect(self.__showManual) - - sep = getSeparator("horizontal") - - allCheckBox = QCheckBox(LangClass.TRANSLATIONS["Select All"]) - self.__listWidget = CheckBoxListWidget() - self.__listWidget.checkedSignal.connect(self.__toggleBtn) - self.__listWidget.currentRowChanged.connect(lambda x: self.__showEntries(x)) - allCheckBox.stateChanged.connect(self.__listWidget.toggleState) - - lay = QVBoxLayout() - lay.addWidget(QLabel(LangClass.TRANSLATIONS["Prompt Group"])) - lay.addWidget(allCheckBox) - lay.addWidget(self.__listWidget) - - leftWidget = QWidget() - leftWidget.setLayout(lay) - - self.__tableWidget = QTableWidget() - self.__tableWidget.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) - self.__tableWidget.setColumnCount(2) - self.__tableWidget.setHorizontalHeaderLabels( - [LangClass.TRANSLATIONS["Name"], LangClass.TRANSLATIONS["Value"]] - ) - self.__tableWidget.setColumnWidth(0, 200) - self.__tableWidget.setColumnWidth(1, 400) - - lay = QVBoxLayout() - lay.addWidget(QLabel(LangClass.TRANSLATIONS["Prompt Entry"])) - lay.addWidget(self.__tableWidget) - - rightWidget = QWidget() - rightWidget.setLayout(lay) - - splitter = QSplitter() - splitter.addWidget(leftWidget) - splitter.addWidget(rightWidget) - splitter.setHandleWidth(1) - splitter.setChildrenCollapsible(False) - splitter.setSizes([400, 600]) - splitter.setStyleSheet("QSplitterHandle {background-color: lightgray;}") - - self.__buttonBox = QDialogButtonBox( - QDialogButtonBox.Ok | QDialogButtonBox.Cancel - ) - self.__buttonBox.accepted.connect(self.__accept) - self.__buttonBox.rejected.connect(self.reject) - self.__buttonBox.button(QDialogButtonBox.StandardButton.Ok).setEnabled(False) - - lay = QVBoxLayout() - lay.addWidget(findPathWidget) - lay.addWidget(sep) - lay.addWidget(manualBtn) - lay.addWidget(splitter) - lay.addWidget(self.__buttonBox) - - self.setLayout(lay) - - self.setMinimumSize(600, 350) - - self.__buttonBox.button(QDialogButtonBox.Ok).setEnabled(False) - - def __toggleBtn(self): - self.__buttonBox.button(QDialogButtonBox.Ok).setEnabled( - len(self.__listWidget.getCheckedRows()) > 0 - ) - - def __showJsonSample(self): - showJsonSample(self.__jsonSampleWidget, FORM_PROMPT_GROUP_SAMPLE) - - def __showManual(self): - dialog = ImportPromptManualDialog(self) - dialog.exec() - - def __refreshTable(self): - self.__tableWidget.clearContents() - self.__tableWidget.setRowCount(0) - - def __setEntries(self, data): - for d in data: - act = d["act"] - prompt = d["prompt"] - self.__tableWidget.setRowCount(self.__tableWidget.rowCount() + 1) - self.__tableWidget.setItem( - self.__tableWidget.rowCount() - 1, 0, QTableWidgetItem(act) - ) - self.__tableWidget.setItem( - self.__tableWidget.rowCount() - 1, 1, QTableWidgetItem(prompt) - ) - - def __setPrompt(self, json_data): - self.__listWidget.clear() - - self.__refreshTable() - for d in json_data: - name = d["name"] - data = d["data"] - self.__listWidget.addItem(name) - self.__setEntries(data) - - self.__listWidget.item(0).setSelected(True) - self.__data = json_data - - def __setPath(self, path): - self.__path = path - # If path is .json file, load the file - if self.__path.endswith(".json"): - data = json.load(open(path)) - if validate_prompt_group_json(data): - self.__buttonBox.button(QDialogButtonBox.StandardButton.Ok).setEnabled(True) - self.__setPrompt(json_data=data) - else: - QMessageBox.critical( - self, - LangClass.TRANSLATIONS["Error"], - LangClass.TRANSLATIONS[ - "Check whether the file is a valid JSON file for importing." - ], - ) - else: - # If path is .csv file, load the file - if self.__path.endswith(".csv"): - try: - with open(path, newline='', encoding='utf-8') as csvfile: - reader = csv.DictReader(csvfile) - data = [row for row in reader] - # Make data to be the same format as JSON - data = [{ - # Random text with 10 characters (a-z, A-Z, 0-9) for temporary name - "name": f"prompt_{random.randint(1000000000, 9999999999)}", - "data": data, - }] - - self.__buttonBox.button(QDialogButtonBox.StandardButton.Ok).setEnabled(True) - self.__setPrompt(json_data=data) - except Exception as e: - QMessageBox.critical( - self, - LangClass.TRANSLATIONS["Error"], - f"Error loading CSV file: {e}", - ) - - def __showEntries(self, r_idx): - name = self.__listWidget.item(r_idx).text() - self.__refreshTable() - data = [d for d in self.__data if d["name"] == name][0]["data"] - self.__setEntries(data) - - def __accept(self): - names = [ - self.__listWidget.item(r).text() for r in self.__listWidget.getCheckedRows() - ] - new_names = list(filter(lambda x: is_prompt_group_name_valid(x), names)) - names_exist = list(filter(lambda x: x not in new_names, names)) - if names_exist: - reply = QMessageBox.warning( - self, - LangClass.TRANSLATIONS["Warning"], - f"{LangClass.TRANSLATIONS['Following prompt names already exists. Would you like to import the rest?']}" - f"\n{', '.join(names_exist)}", - ) - if reply == QMessageBox.StandardButton.Yes: - pass - else: - self.reject() - self.__data = [d for d in self.__data if d["name"] in new_names] - self.accept() - - def getSelected(self): - return self.__data +from __future__ import annotations + +import csv +import json +import random + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QAbstractItemView, + QCheckBox, + QDialog, + QDialogButtonBox, + QLabel, + QMessageBox, + QPushButton, + QSplitter, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from pyqt_openai import ( + CSV_FILE_EXT_LIST_STR, + FORM_PROMPT_GROUP_SAMPLE, + JSON_FILE_EXT_LIST_STR, +) +from pyqt_openai.chat_widget.prompt_gen_widget.importPromptManualDialog import ( + ImportPromptManualDialog, +) +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.util.common import ( + getSeparator, + is_prompt_group_name_valid, + showJsonSample, + validate_prompt_group_json, +) +from pyqt_openai.widgets.checkBoxListWidget import CheckBoxListWidget +from pyqt_openai.widgets.findPathWidget import FindPathWidget +from pyqt_openai.widgets.jsonEditor import JSONEditor + + +class PromptGroupImportDialog(QDialog): + def __init__(self, prompt_type="form", parent=None): + super().__init__(parent) + self.__initVal(prompt_type) + self.__initUi() + + def __initVal(self, prompt_type): + self.__promptType = prompt_type + self.__path = "" + + def __initUi(self): + self.setWindowTitle(LangClass.TRANSLATIONS["Import Prompt Group"]) + self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) + self.__jsonSampleWidget = JSONEditor() + + findPathWidget = FindPathWidget() + EXT = JSON_FILE_EXT_LIST_STR if self.__promptType == "form" else f"{CSV_FILE_EXT_LIST_STR};;{JSON_FILE_EXT_LIST_STR}" + + findPathWidget.setExtOfFiles(EXT) + findPathWidget.getLineEdit().setPlaceholderText( + # TODO LANGUAGE + LangClass.TRANSLATIONS["Select a file to import..."], + ) + findPathWidget.added.connect(self.__setPath) + + manualBtn = QPushButton() + if self.__promptType == "form": + manualBtn.setText(LangClass.TRANSLATIONS["What is the right form of json to be imported?"]) + manualBtn.clicked.connect(self.__showJsonSample) + else: + manualBtn.setText(LangClass.TRANSLATIONS["How to import a prompt group"]) + manualBtn.clicked.connect(self.__showManual) + + sep = getSeparator("horizontal") + + allCheckBox = QCheckBox(LangClass.TRANSLATIONS["Select All"]) + self.__listWidget = CheckBoxListWidget() + self.__listWidget.checkedSignal.connect(self.__toggleBtn) + self.__listWidget.currentRowChanged.connect(lambda x: self.__showEntries(x)) + allCheckBox.stateChanged.connect(self.__listWidget.toggleState) + + lay = QVBoxLayout() + lay.addWidget(QLabel(LangClass.TRANSLATIONS["Prompt Group"])) + lay.addWidget(allCheckBox) + lay.addWidget(self.__listWidget) + + leftWidget = QWidget() + leftWidget.setLayout(lay) + + self.__tableWidget = QTableWidget() + self.__tableWidget.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.__tableWidget.setColumnCount(2) + self.__tableWidget.setHorizontalHeaderLabels( + [LangClass.TRANSLATIONS["Name"], LangClass.TRANSLATIONS["Value"]], + ) + self.__tableWidget.setColumnWidth(0, 200) + self.__tableWidget.setColumnWidth(1, 400) + + lay = QVBoxLayout() + lay.addWidget(QLabel(LangClass.TRANSLATIONS["Prompt Entry"])) + lay.addWidget(self.__tableWidget) + + rightWidget = QWidget() + rightWidget.setLayout(lay) + + splitter = QSplitter() + splitter.addWidget(leftWidget) + splitter.addWidget(rightWidget) + splitter.setHandleWidth(1) + splitter.setChildrenCollapsible(False) + splitter.setSizes([400, 600]) + splitter.setStyleSheet("QSplitterHandle {background-color: lightgray;}") + + self.__buttonBox = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, + ) + self.__buttonBox.accepted.connect(self.__accept) + self.__buttonBox.rejected.connect(self.reject) + self.__buttonBox.button(QDialogButtonBox.StandardButton.Ok).setEnabled(False) + + lay = QVBoxLayout() + lay.addWidget(findPathWidget) + lay.addWidget(sep) + lay.addWidget(manualBtn) + lay.addWidget(splitter) + lay.addWidget(self.__buttonBox) + + self.setLayout(lay) + + self.setMinimumSize(600, 350) + + self.__buttonBox.button(QDialogButtonBox.StandardButton.Ok).setEnabled(False) + + def __toggleBtn(self): + self.__buttonBox.button(QDialogButtonBox.StandardButton.Ok).setEnabled( + len(self.__listWidget.getCheckedRows()) > 0, + ) + + def __showJsonSample(self): + showJsonSample(self.__jsonSampleWidget, FORM_PROMPT_GROUP_SAMPLE) + + def __showManual(self): + dialog = ImportPromptManualDialog(self) + dialog.exec() + + def __refreshTable(self): + self.__tableWidget.clearContents() + self.__tableWidget.setRowCount(0) + + def __setEntries(self, data): + for d in data: + act = d["act"] + prompt = d["prompt"] + self.__tableWidget.setRowCount(self.__tableWidget.rowCount() + 1) + self.__tableWidget.setItem( + self.__tableWidget.rowCount() - 1, 0, QTableWidgetItem(act), + ) + self.__tableWidget.setItem( + self.__tableWidget.rowCount() - 1, 1, QTableWidgetItem(prompt), + ) + + def __setPrompt(self, json_data): + self.__listWidget.clear() + + self.__refreshTable() + for d in json_data: + name = d["name"] + data = d["data"] + self.__listWidget.addItem(name) + self.__setEntries(data) + + self.__listWidget.item(0).setSelected(True) + self.__data = json_data + + def __setPath(self, path): + self.__path = path + # If path is .json file, load the file + if self.__path.endswith(".json"): + data = json.load(open(path)) + if validate_prompt_group_json(data): + self.__buttonBox.button(QDialogButtonBox.StandardButton.Ok).setEnabled(True) + self.__setPrompt(json_data=data) + else: + QMessageBox.critical( # type: ignore[call-arg] + self, + LangClass.TRANSLATIONS["Error"], + LangClass.TRANSLATIONS[ + "Check whether the file is a valid JSON file for importing." + ], + ) + elif self.__path.endswith(".csv"): + try: + with open(path, newline="", encoding="utf-8") as csvfile: + reader = csv.DictReader(csvfile) + data = [row for row in reader] + # Make data to be the same format as JSON + data = [{ + # Random text with 10 characters (a-z, A-Z, 0-9) for temporary name + "name": f"prompt_{random.randint(1000000000, 9999999999)}", + "data": data, + }] + + self.__buttonBox.button(QDialogButtonBox.StandardButton.Ok).setEnabled(True) + self.__setPrompt(json_data=data) + except Exception as e: + QMessageBox.critical( + self, + LangClass.TRANSLATIONS["Error"], + f"Error loading CSV file: {e}", + ) + + def __showEntries(self, r_idx): + name = self.__listWidget.item(r_idx).text() + self.__refreshTable() + data = [d for d in self.__data if d["name"] == name][0]["data"] + self.__setEntries(data) + + def __accept(self): + names = [ + self.__listWidget.item(r).text() for r in self.__listWidget.getCheckedRows() + ] + new_names = list(filter(lambda x: is_prompt_group_name_valid(x), names)) + names_exist = list(filter(lambda x: x not in new_names, names)) + if names_exist: + reply = QMessageBox.warning( + self, + LangClass.TRANSLATIONS["Warning"], + f"{LangClass.TRANSLATIONS['Following prompt names already exists. Would you like to import the rest?']}" + f"\n{', '.join(names_exist)}", + ) + if reply == QMessageBox.StandardButton.Yes: + pass + else: + self.reject() + self.__data = [d for d in self.__data if d["name"] in new_names] + self.accept() + + def getSelected(self): + return self.__data diff --git a/pyqt_openai/chat_widget/prompt_gen_widget/promptGroupList.py b/pyqt_openai/chat_widget/prompt_gen_widget/promptGroupList.py index e118fb39..6d674530 100644 --- a/pyqt_openai/chat_widget/prompt_gen_widget/promptGroupList.py +++ b/pyqt_openai/chat_widget/prompt_gen_widget/promptGroupList.py @@ -1,213 +1,215 @@ -import json -import os - -from PySide6.QtCore import Signal, Qt -from PySide6.QtWidgets import ( - QFileDialog, - QMessageBox, - QSizePolicy, - QSpacerItem, - QLabel, - QHBoxLayout, - QVBoxLayout, - QWidget, - QDialog, - QListWidget, - QListWidgetItem, -) - -from pyqt_openai import ( - JSON_FILE_EXT_LIST_STR, - ICON_ADD, - ICON_DELETE, - ICON_IMPORT, - ICON_EXPORT, - QFILEDIALOG_DEFAULT_DIRECTORY, - INDENT_SIZE, -) -from pyqt_openai.chat_widget.prompt_gen_widget.promptGroupDirectInputDialog import ( - PromptGroupDirectInputDialog, -) -from pyqt_openai.chat_widget.prompt_gen_widget.promptGroupExportDialog import ( - PromptGroupExportDialog, -) -from pyqt_openai.chat_widget.prompt_gen_widget.promptGroupImportDialog import ( - PromptGroupImportDialog, -) -from pyqt_openai.globals import DB -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.util.common import open_directory, get_prompt_data, export_prompt -from pyqt_openai.widgets.button import Button - - -class PromptGroupList(QWidget): - added = Signal(int) - deleted = Signal(int) - currentRowChanged = Signal(int) - itemChanged = Signal(int) - - def __init__(self, prompt_type='form', parent=None): - super().__init__(parent) - self.__initVal(prompt_type) - self.__initUi() - - def __initVal(self, prompt_type): - self.prompt_type = prompt_type - - def __initUi(self): - self.__addBtn = Button() - self.__delBtn = Button() - - self.__importBtn = Button() - self.__importBtn.setToolTip(LangClass.TRANSLATIONS["Import"]) - - self.__exportBtn = Button() - self.__exportBtn.setToolTip(LangClass.TRANSLATIONS["Export"]) - - self.__addBtn.setStyleAndIcon(ICON_ADD) - self.__delBtn.setStyleAndIcon(ICON_DELETE) - self.__importBtn.setStyleAndIcon(ICON_IMPORT) - self.__exportBtn.setStyleAndIcon(ICON_EXPORT) - - self.__addBtn.clicked.connect(self.__add) - self.__delBtn.clicked.connect(self.__delete) - self.__importBtn.clicked.connect(self.__import) - self.__exportBtn.clicked.connect(self.__export) - - lay = QHBoxLayout() - lay.addWidget(QLabel(LangClass.TRANSLATIONS[f"{self.prompt_type.capitalize()} Group"])) - lay.addSpacerItem(QSpacerItem(10, 10, QSizePolicy.Policy.MinimumExpanding)) - lay.addWidget(self.__addBtn) - lay.addWidget(self.__delBtn) - lay.addWidget(self.__importBtn) - lay.addWidget(self.__exportBtn) - lay.setAlignment(Qt.AlignmentFlag.AlignRight) - lay.setContentsMargins(0, 0, 0, 0) - - topWidget = QWidget() - topWidget.setLayout(lay) - - groups = DB.selectPromptGroup(prompt_type=self.prompt_type) - if len(groups) <= 0: - self.__delBtn.setEnabled(False) - - self.list = QListWidget() - - for group in groups: - id = group.id - name = group.name - self.__addGroupItem(id, name) - - self.list.currentRowChanged.connect(self.__currentRowChanged) - self.list.itemChanged.connect(self.__itemChanged) - - lay = QVBoxLayout() - lay.addWidget(topWidget) - lay.addWidget(self.list) - lay.setContentsMargins(0, 0, 5, 0) - - self.setLayout(lay) - - def __addGroupItem(self, id, name): - item = QListWidgetItem() - item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable) - item.setData(Qt.ItemDataRole.UserRole, id) - item.setText(name) - self.list.addItem(item) - self.list.setCurrentItem(item) - self.added.emit(id) - - self.__delBtn.setEnabled(True) - - def __add(self): - dialog = PromptGroupDirectInputDialog(self) - reply = dialog.exec() - if reply == QDialog.DialogCode.Accepted: - name = dialog.getPromptGroupName() - id = DB.insertPromptGroup(name, prompt_type=self.prompt_type) - self.__addGroupItem(id, name) - - def __delete(self): - i = self.list.currentRow() - item = self.list.takeItem(i) - id = item.data(Qt.ItemDataRole.UserRole) - DB.deletePromptGroup(id) - self.deleted.emit(id) - - groups = DB.selectPromptGroup(prompt_type=self.prompt_type) - if len(groups) <= 0: - self.__delBtn.setEnabled(False) - - def __import(self): - dialog = PromptGroupImportDialog(parent=self, prompt_type=self.prompt_type) - reply = dialog.exec() - if reply == QDialog.DialogCode.Accepted: - # Get the data - result = dialog.getSelected() - # Save the data - for group in result: - id = DB.insertPromptGroup(group["name"], prompt_type=self.prompt_type) - for entry in group["data"]: - DB.insertPromptEntry(id, entry["act"], entry["prompt"]) - name = group["name"] - self.__addGroupItem(id, name) - - def __export(self): - try: - if self.prompt_type == 'form': - # Get the file - file_data = QFileDialog.getSaveFileName( - self, - LangClass.TRANSLATIONS["Save"], - QFILEDIALOG_DEFAULT_DIRECTORY, - JSON_FILE_EXT_LIST_STR, - ) - if file_data[0]: - filename = file_data[0] - # Get the data - data = get_prompt_data(prompt_type=self.prompt_type) - dialog = PromptGroupExportDialog(data=data, parent=self) - reply = dialog.exec() - if reply == QDialog.DialogCode.Accepted: - data = dialog.getSelected() - # Save the data - with open(filename, "w") as f: - json.dump(data, f, indent=INDENT_SIZE) - elif self.prompt_type == 'sentence': - # Get the file - file_data = QFileDialog.getSaveFileName( - self, - LangClass.TRANSLATIONS["Save"], - QFILEDIALOG_DEFAULT_DIRECTORY, - f"CSV files Compressed File (*.zip);;{JSON_FILE_EXT_LIST_STR}", - ) - if file_data[0]: - filename = file_data[0] - # Get the data - data = get_prompt_data(self.prompt_type) - # Get extension - ext = os.path.splitext(filename)[1] - # If it is a compressed file, it is a compressed csv, so change the extension to csv - if ext == ".zip": - ext = ".csv" - dialog = PromptGroupExportDialog(data=data, ext=ext, parent=self) - reply = dialog.exec() - if reply == QDialog.DialogCode.Accepted: - data = dialog.getSelected() - export_prompt(data, filename, ext) - open_directory(os.path.dirname(filename)) - open_directory(os.path.dirname(filename)) - except Exception as e: - QMessageBox.critical(self, LangClass.TRANSLATIONS["Error"], str(e)) - print(e) - - def __itemChanged(self, item): - id = item.data(Qt.ItemDataRole.UserRole) - DB.updatePromptGroup(id, item.text()) - self.itemChanged.emit(id) - - def __currentRowChanged(self, r_idx): - item = self.list.item(r_idx) - if item: - id = item.data(Qt.ItemDataRole.UserRole) - self.currentRowChanged.emit(id) \ No newline at end of file +from __future__ import annotations + +import json +import os + +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import ( + QDialog, + QFileDialog, + QHBoxLayout, + QLabel, + QListWidget, + QListWidgetItem, + QMessageBox, + QSizePolicy, + QSpacerItem, + QVBoxLayout, + QWidget, +) + +from pyqt_openai import ( + ICON_ADD, + ICON_DELETE, + ICON_EXPORT, + ICON_IMPORT, + INDENT_SIZE, + JSON_FILE_EXT_LIST_STR, + QFILEDIALOG_DEFAULT_DIRECTORY, +) +from pyqt_openai.chat_widget.prompt_gen_widget.promptGroupDirectInputDialog import ( + PromptGroupDirectInputDialog, +) +from pyqt_openai.chat_widget.prompt_gen_widget.promptGroupExportDialog import ( + PromptGroupExportDialog, +) +from pyqt_openai.chat_widget.prompt_gen_widget.promptGroupImportDialog import ( + PromptGroupImportDialog, +) +from pyqt_openai.globals import DB +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.util.common import export_prompt, get_prompt_data, open_directory +from pyqt_openai.widgets.button import Button + + +class PromptGroupList(QWidget): + added = Signal(int) + deleted = Signal(int) + currentRowChanged = Signal(int) + itemChanged = Signal(int) + + def __init__(self, prompt_type="form", parent=None): + super().__init__(parent) + self.__initVal(prompt_type) + self.__initUi() + + def __initVal(self, prompt_type): + self.prompt_type = prompt_type + + def __initUi(self): + self.__addBtn = Button() + self.__delBtn = Button() + + self.__importBtn = Button() + self.__importBtn.setToolTip(LangClass.TRANSLATIONS["Import"]) + + self.__exportBtn = Button() + self.__exportBtn.setToolTip(LangClass.TRANSLATIONS["Export"]) + + self.__addBtn.setStyleAndIcon(ICON_ADD) + self.__delBtn.setStyleAndIcon(ICON_DELETE) + self.__importBtn.setStyleAndIcon(ICON_IMPORT) + self.__exportBtn.setStyleAndIcon(ICON_EXPORT) + + self.__addBtn.clicked.connect(self.__add) + self.__delBtn.clicked.connect(self.__delete) + self.__importBtn.clicked.connect(self.__import) + self.__exportBtn.clicked.connect(self.__export) + + lay = QHBoxLayout() + lay.addWidget(QLabel(LangClass.TRANSLATIONS[f"{self.prompt_type.capitalize()} Group"])) + lay.addSpacerItem(QSpacerItem(10, 10, QSizePolicy.Policy.MinimumExpanding)) + lay.addWidget(self.__addBtn) + lay.addWidget(self.__delBtn) + lay.addWidget(self.__importBtn) + lay.addWidget(self.__exportBtn) + lay.setAlignment(Qt.AlignmentFlag.AlignRight) + lay.setContentsMargins(0, 0, 0, 0) + + topWidget = QWidget() + topWidget.setLayout(lay) + + groups = DB.selectPromptGroup(prompt_type=self.prompt_type) + if len(groups) <= 0: + self.__delBtn.setEnabled(False) + + self.list = QListWidget() + + for group in groups: + id = group.id + name = group.name + self.__addGroupItem(id, name) + + self.list.currentRowChanged.connect(self.__currentRowChanged) + self.list.itemChanged.connect(self.__itemChanged) + + lay = QVBoxLayout() + lay.addWidget(topWidget) + lay.addWidget(self.list) + lay.setContentsMargins(0, 0, 5, 0) + + self.setLayout(lay) + + def __addGroupItem(self, id, name): + item = QListWidgetItem() + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable) + item.setData(Qt.ItemDataRole.UserRole, id) + item.setText(name) + self.list.addItem(item) + self.list.setCurrentItem(item) + self.added.emit(id) + + self.__delBtn.setEnabled(True) + + def __add(self): + dialog = PromptGroupDirectInputDialog(self) + reply = dialog.exec() + if reply == QDialog.DialogCode.Accepted: + name = dialog.getPromptGroupName() + id = DB.insertPromptGroup(name, prompt_type=self.prompt_type) + self.__addGroupItem(id, name) + + def __delete(self): + i = self.list.currentRow() + item = self.list.takeItem(i) + id = item.data(Qt.ItemDataRole.UserRole) + DB.deletePromptGroup(id) + self.deleted.emit(id) + + groups = DB.selectPromptGroup(prompt_type=self.prompt_type) + if len(groups) <= 0: + self.__delBtn.setEnabled(False) + + def __import(self): + dialog = PromptGroupImportDialog(parent=self, prompt_type=self.prompt_type) + reply = dialog.exec() + if reply == QDialog.DialogCode.Accepted: + # Get the data + result = dialog.getSelected() + # Save the data + for group in result: + id = DB.insertPromptGroup(group["name"], prompt_type=self.prompt_type) + for entry in group["data"]: + DB.insertPromptEntry(id, entry["act"], entry["prompt"]) + name = group["name"] + self.__addGroupItem(id, name) + + def __export(self): + try: + if self.prompt_type == "form": + # Get the file + file_data = QFileDialog.getSaveFileName( + self, + LangClass.TRANSLATIONS["Save"], + QFILEDIALOG_DEFAULT_DIRECTORY, + JSON_FILE_EXT_LIST_STR, + ) + if file_data[0]: + filename = file_data[0] + # Get the data + data = get_prompt_data(prompt_type=self.prompt_type) + dialog = PromptGroupExportDialog(data=data, parent=self) + reply = dialog.exec() + if reply == QDialog.DialogCode.Accepted: + data = dialog.getSelected() + # Save the data + with open(filename, "w") as f: + json.dump(data, f, indent=INDENT_SIZE) + elif self.prompt_type == "sentence": + # Get the file + file_data = QFileDialog.getSaveFileName( + self, + LangClass.TRANSLATIONS["Save"], + QFILEDIALOG_DEFAULT_DIRECTORY, + f"CSV files Compressed File (*.zip);;{JSON_FILE_EXT_LIST_STR}", + ) + if file_data[0]: + filename = file_data[0] + # Get the data + data = get_prompt_data(self.prompt_type) + # Get extension + ext = os.path.splitext(filename)[1] + # If it is a compressed file, it is a compressed csv, so change the extension to csv + if ext == ".zip": + ext = ".csv" + dialog = PromptGroupExportDialog(data=data, ext=ext, parent=self) + reply = dialog.exec() + if reply == QDialog.DialogCode.Accepted: + data = dialog.getSelected() + export_prompt(data, filename, ext) + open_directory(os.path.dirname(filename)) + open_directory(os.path.dirname(filename)) + except Exception as e: + QMessageBox.critical(self, LangClass.TRANSLATIONS["Error"], str(e)) + print(e) + + def __itemChanged(self, item): + id = item.data(Qt.ItemDataRole.UserRole) + DB.updatePromptGroup(id, item.text()) + self.itemChanged.emit(id) + + def __currentRowChanged(self, r_idx): + item = self.list.item(r_idx) + if item: + id = item.data(Qt.ItemDataRole.UserRole) + self.currentRowChanged.emit(id) diff --git a/pyqt_openai/chat_widget/prompt_gen_widget/promptPage.py b/pyqt_openai/chat_widget/prompt_gen_widget/promptPage.py index cb291942..7f7013d2 100644 --- a/pyqt_openai/chat_widget/prompt_gen_widget/promptPage.py +++ b/pyqt_openai/chat_widget/prompt_gen_widget/promptPage.py @@ -1,59 +1,59 @@ -from PySide6.QtCore import Signal -from PySide6.QtWidgets import ( - QVBoxLayout, - QWidget, - QSplitter, -) - -from pyqt_openai.chat_widget.prompt_gen_widget.promptGroupList import PromptGroupList -from pyqt_openai.chat_widget.prompt_gen_widget.promptTable import PromptTable -from pyqt_openai.globals import DB - - -class PromptPage(QWidget): - updated = Signal(str) - - def __init__(self, prompt_type='form', parent=None): - super().__init__(parent) - self.__initVal(prompt_type) - self.__initUi() - - def __initVal(self, prompt_type): - self.prompt_type = prompt_type - self.__groups = DB.selectPromptGroup(prompt_type=self.prompt_type) - - def __initUi(self): - leftWidget = PromptGroupList(prompt_type=self.prompt_type) - leftWidget.added.connect(self.add) - leftWidget.deleted.connect(self.delete) - - leftWidget.currentRowChanged.connect(self.__showEntries) - - self.__table = PromptTable() - if len(self.__groups) > 0: - leftWidget.list.setCurrentRow(0) - self.__table.showEntries(self.__groups[0].id) - self.__table.updated.connect(self.updated) - - mainWidget = QSplitter() - mainWidget.addWidget(leftWidget) - mainWidget.addWidget(self.__table) - mainWidget.setChildrenCollapsible(False) - mainWidget.setSizes([300, 700]) - - lay = QVBoxLayout() - lay.addWidget(mainWidget) - - self.setLayout(lay) - - def add(self, id): - self.__table.showEntries(id) - - def delete(self, id): - if self.__table.getId() == id: - self.__table.setNothingRightNow() - elif len(DB.selectPromptGroup(prompt_type=self.prompt_type)) == 0: - self.__table.setNothingRightNow() - - def __showEntries(self, id): - self.__table.showEntries(id) +from __future__ import annotations + +from qtpy.QtCore import Signal +from qtpy.QtWidgets import ( + QSplitter, + QVBoxLayout, + QWidget, +) + +from pyqt_openai.chat_widget.prompt_gen_widget.promptGroupList import PromptGroupList +from pyqt_openai.chat_widget.prompt_gen_widget.promptTable import PromptTable +from pyqt_openai.globals import DB + + +class PromptPage(QWidget): + updated = Signal(str) + + def __init__(self, prompt_type="form", parent=None): + super().__init__(parent) + self.__initVal(prompt_type) + self.__initUi() + + def __initVal(self, prompt_type): + self.prompt_type = prompt_type + self.__groups = DB.selectPromptGroup(prompt_type=self.prompt_type) + + def __initUi(self): + leftWidget = PromptGroupList(prompt_type=self.prompt_type) + leftWidget.added.connect(self.add) + leftWidget.deleted.connect(self.delete) + + leftWidget.currentRowChanged.connect(self.__showEntries) + + self.__table = PromptTable() + if len(self.__groups) > 0: + leftWidget.list.setCurrentRow(0) + self.__table.showEntries(self.__groups[0].id) + self.__table.updated.connect(self.updated) + + mainWidget = QSplitter() + mainWidget.addWidget(leftWidget) + mainWidget.addWidget(self.__table) + mainWidget.setChildrenCollapsible(False) + mainWidget.setSizes([300, 700]) + + lay = QVBoxLayout() + lay.addWidget(mainWidget) + + self.setLayout(lay) + + def add(self, id): + self.__table.showEntries(id) + + def delete(self, id): + if self.__table.getId() == id or len(DB.selectPromptGroup(prompt_type=self.prompt_type)) == 0: + self.__table.setNothingRightNow() + + def __showEntries(self, id): + self.__table.showEntries(id) diff --git a/pyqt_openai/chat_widget/prompt_gen_widget/promptTable.py b/pyqt_openai/chat_widget/prompt_gen_widget/promptTable.py index 97043bac..67ce41df 100644 --- a/pyqt_openai/chat_widget/prompt_gen_widget/promptTable.py +++ b/pyqt_openai/chat_widget/prompt_gen_widget/promptTable.py @@ -1,171 +1,173 @@ -from PySide6.QtCore import Signal, Qt -from PySide6.QtWidgets import ( - QTableWidget, - QSizePolicy, - QSpacerItem, - QLabel, - QAbstractItemView, - QTableWidgetItem, - QHeaderView, - QHBoxLayout, - QVBoxLayout, - QWidget, - QDialog, -) - -from pyqt_openai import ( - ICON_ADD, - ICON_DELETE, -) -from pyqt_openai.chat_widget.prompt_gen_widget.promptEntryDirectInputDialog import ( - PromptEntryDirectInputDialog, -) -from pyqt_openai.globals import DB -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.widgets.button import Button - - -class PromptTable(QWidget): - updated = Signal(str) - - def __init__(self, parent=None): - super().__init__(parent) - self.__initVal() - self.__initUi() - - def __initVal(self): - self.__title = "" - self.__entries = [] - - def __initUi(self): - self.__addBtn = Button() - self.__delBtn = Button() - - self.__addBtn.setStyleAndIcon(ICON_ADD) - self.__delBtn.setStyleAndIcon(ICON_DELETE) - - self.__addBtn.clicked.connect(self.__add) - self.__delBtn.clicked.connect(self.__delete) - - self.__titleLbl = QLabel() - - lay = QHBoxLayout() - lay.addWidget(self.__titleLbl) - lay.addSpacerItem(QSpacerItem(10, 10, QSizePolicy.Policy.MinimumExpanding)) - lay.addWidget(self.__addBtn) - lay.addWidget(self.__delBtn) - lay.setAlignment(Qt.AlignmentFlag.AlignRight) - lay.setContentsMargins(0, 0, 0, 0) - - topWidget = QWidget() - topWidget.setLayout(lay) - - self.__table = QTableWidget() - self.__table.setColumnCount(2) - self.__table.setSelectionBehavior( - QAbstractItemView.SelectionBehavior.SelectRows - ) - self.__table.setHorizontalHeaderLabels( - [LangClass.TRANSLATIONS["Name"], LangClass.TRANSLATIONS["Value"]] - ) - self.__table.horizontalHeader().setSectionResizeMode( - 1, QHeaderView.ResizeMode.Stretch - ) - self.__table.currentItemChanged.connect(self.__rowChanged) - self.__table.itemChanged.connect(self.__saveChangedPrompt) - - lay = QVBoxLayout() - lay.addWidget(topWidget) - lay.addWidget(self.__table) - lay.setContentsMargins(5, 0, 0, 0) - - self.setLayout(lay) - - def showEntries(self, id): - self.__group_id = id - - prompt_group = DB.selectCertainPromptGroup(id=self.__group_id) - self.__title = prompt_group.name - self.__entries = DB.selectPromptEntry(self.__group_id) - - self.__titleLbl.setText(self.__title) - - self.__table.setRowCount(len(self.__entries)) - for i in range(len(self.__entries)): - act = self.__entries[i].act - prompt = self.__entries[i].prompt - - item1 = QTableWidgetItem(act) - item1.setData(Qt.ItemDataRole.UserRole, self.__entries[i].id) - item1.setTextAlignment(Qt.AlignmentFlag.AlignCenter) - - item2 = QTableWidgetItem(prompt) - item2.setTextAlignment(Qt.AlignmentFlag.AlignCenter) - - self.__table.setItem(i, 0, item1) - self.__table.setItem(i, 1, item2) - - self.__addBtn.setEnabled(True) - self.__delBtn.setEnabled(True) - - def setNothingRightNow(self): - self.__title = "" - self.__titleLbl.setText(self.__title) - self.__table.clearContents() - self.__addBtn.setEnabled(False) - self.__delBtn.setEnabled(False) - - def getId(self): - return self.__group_id - - def __rowChanged(self, new_item: QTableWidgetItem, old_item: QTableWidgetItem): - prompt = "" - # To avoid AttributeError - if new_item: - prompt = ( - self.__table.item(new_item.row(), 1).text() - if new_item.column() == 0 - else new_item.text() - ) - self.updated.emit(prompt) - - def __saveChangedPrompt(self, item: QTableWidgetItem): - act = self.__table.item(item.row(), 0) - id = act.data(Qt.ItemDataRole.UserRole) - act = act.text() - - prompt = self.__table.item(item.row(), 1) - prompt = prompt.text() if prompt else "" - DB.updatePromptEntry(id, act, prompt) - - def __add(self): - dialog = PromptEntryDirectInputDialog(self.__group_id, self) - reply = dialog.exec() - if reply == QDialog.DialogCode.Accepted: - self.__table.itemChanged.disconnect(self.__saveChangedPrompt) - - act = dialog.getAct() - self.__table.setRowCount(self.__table.rowCount() + 1) - - item1 = QTableWidgetItem(act) - item1.setTextAlignment(Qt.AlignmentFlag.AlignCenter) - self.__table.setItem(self.__table.rowCount() - 1, 0, item1) - - prompt = dialog.getPrompt() - - item2 = QTableWidgetItem(prompt) - item2.setTextAlignment(Qt.AlignmentFlag.AlignCenter) - self.__table.setItem(self.__table.rowCount() - 1, 1, item2) - - id = DB.insertPromptEntry(self.__group_id, act, prompt) - item1.setData(Qt.ItemDataRole.UserRole, id) - - self.__table.itemChanged.connect(self.__saveChangedPrompt) - - def __delete(self): - for i in sorted( - set([i.row() for i in self.__table.selectedIndexes()]), reverse=True - ): - id = self.__table.item(i, 0).data(Qt.ItemDataRole.UserRole) - self.__table.removeRow(i) - DB.deletePromptEntry(self.__group_id, id) \ No newline at end of file +from __future__ import annotations + +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import ( + QAbstractItemView, + QDialog, + QHBoxLayout, + QHeaderView, + QLabel, + QSizePolicy, + QSpacerItem, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from pyqt_openai import ( + ICON_ADD, + ICON_DELETE, +) +from pyqt_openai.chat_widget.prompt_gen_widget.promptEntryDirectInputDialog import ( + PromptEntryDirectInputDialog, +) +from pyqt_openai.globals import DB +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.widgets.button import Button + + +class PromptTable(QWidget): + updated = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.__initVal() + self.__initUi() + + def __initVal(self): + self.__title = "" + self.__entries = [] + + def __initUi(self): + self.__addBtn = Button() + self.__delBtn = Button() + + self.__addBtn.setStyleAndIcon(ICON_ADD) + self.__delBtn.setStyleAndIcon(ICON_DELETE) + + self.__addBtn.clicked.connect(self.__add) + self.__delBtn.clicked.connect(self.__delete) + + self.__titleLbl = QLabel() + + lay = QHBoxLayout() + lay.addWidget(self.__titleLbl) + lay.addSpacerItem(QSpacerItem(10, 10, QSizePolicy.Policy.MinimumExpanding)) + lay.addWidget(self.__addBtn) + lay.addWidget(self.__delBtn) + lay.setAlignment(Qt.AlignmentFlag.AlignRight) + lay.setContentsMargins(0, 0, 0, 0) + + topWidget = QWidget() + topWidget.setLayout(lay) + + self.__table = QTableWidget() + self.__table.setColumnCount(2) + self.__table.setSelectionBehavior( + QAbstractItemView.SelectionBehavior.SelectRows, + ) + self.__table.setHorizontalHeaderLabels( + [LangClass.TRANSLATIONS["Name"], LangClass.TRANSLATIONS["Value"]], + ) + self.__table.horizontalHeader().setSectionResizeMode( + 1, QHeaderView.ResizeMode.Stretch, + ) + self.__table.currentItemChanged.connect(self.__rowChanged) + self.__table.itemChanged.connect(self.__saveChangedPrompt) + + lay = QVBoxLayout() + lay.addWidget(topWidget) + lay.addWidget(self.__table) + lay.setContentsMargins(5, 0, 0, 0) + + self.setLayout(lay) + + def showEntries(self, id): + self.__group_id = id + + prompt_group = DB.selectCertainPromptGroup(id=self.__group_id) + self.__title = prompt_group.name + self.__entries = DB.selectPromptEntry(self.__group_id) + + self.__titleLbl.setText(self.__title) + + self.__table.setRowCount(len(self.__entries)) + for i in range(len(self.__entries)): + act = self.__entries[i].act + prompt = self.__entries[i].prompt + + item1 = QTableWidgetItem(act) + item1.setData(Qt.ItemDataRole.UserRole, self.__entries[i].id) + item1.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + + item2 = QTableWidgetItem(prompt) + item2.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + + self.__table.setItem(i, 0, item1) + self.__table.setItem(i, 1, item2) + + self.__addBtn.setEnabled(True) + self.__delBtn.setEnabled(True) + + def setNothingRightNow(self): + self.__title = "" + self.__titleLbl.setText(self.__title) + self.__table.clearContents() + self.__addBtn.setEnabled(False) + self.__delBtn.setEnabled(False) + + def getId(self): + return self.__group_id + + def __rowChanged(self, new_item: QTableWidgetItem, old_item: QTableWidgetItem): + prompt = "" + # To avoid AttributeError + if new_item: + prompt = ( + self.__table.item(new_item.row(), 1).text() + if new_item.column() == 0 + else new_item.text() + ) + self.updated.emit(prompt) + + def __saveChangedPrompt(self, item: QTableWidgetItem): + act = self.__table.item(item.row(), 0) + id = act.data(Qt.ItemDataRole.UserRole) + act = act.text() + + prompt = self.__table.item(item.row(), 1) + prompt = prompt.text() if prompt else "" + DB.updatePromptEntry(id, act, prompt) + + def __add(self): + dialog = PromptEntryDirectInputDialog(self.__group_id, self) + reply = dialog.exec() + if reply == QDialog.DialogCode.Accepted: + self.__table.itemChanged.disconnect(self.__saveChangedPrompt) + + act = dialog.getAct() + self.__table.setRowCount(self.__table.rowCount() + 1) + + item1 = QTableWidgetItem(act) + item1.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + self.__table.setItem(self.__table.rowCount() - 1, 0, item1) + + prompt = dialog.getPrompt() + + item2 = QTableWidgetItem(prompt) + item2.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + self.__table.setItem(self.__table.rowCount() - 1, 1, item2) + + id = DB.insertPromptEntry(self.__group_id, act, prompt) + item1.setData(Qt.ItemDataRole.UserRole, id) + + self.__table.itemChanged.connect(self.__saveChangedPrompt) + + def __delete(self): + for i in sorted( + set([i.row() for i in self.__table.selectedIndexes()]), reverse=True, + ): + id = self.__table.item(i, 0).data(Qt.ItemDataRole.UserRole) + self.__table.removeRow(i) + DB.deletePromptEntry(self.__group_id, id) diff --git a/pyqt_openai/chat_widget/right_sidebar/chatRightSideBarWidget.py b/pyqt_openai/chat_widget/right_sidebar/chatRightSideBarWidget.py index d557d54a..9c19e0c7 100644 --- a/pyqt_openai/chat_widget/right_sidebar/chatRightSideBarWidget.py +++ b/pyqt_openai/chat_widget/right_sidebar/chatRightSideBarWidget.py @@ -1,76 +1,78 @@ -from functools import partial - -from PySide6.QtCore import Signal -from PySide6.QtWidgets import QScrollArea, QWidget, QTabWidget, QGridLayout, QMessageBox - -from pyqt_openai.chat_widget.right_sidebar.usingAPIPage import UsingAPIPage -from pyqt_openai.chat_widget.right_sidebar.llama_widget.llamaPage import LlamaPage -from pyqt_openai.chat_widget.right_sidebar.usingG4FPage import UsingG4FPage -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.globals import LLAMAINDEX_WRAPPER -from pyqt_openai.lang.translations import LangClass - - -class ChatRightSideBarWidget(QScrollArea): - onTabChanged = Signal(int) - onToggleJSON = Signal(bool) - - def __init__(self, parent=None): - super().__init__(parent) - self.__initVal() - self.__initUi() - - def __initVal(self): - self.__cur_idx = CONFIG_MANAGER.get_general_property("TAB_IDX") - self.__use_llama_index = CONFIG_MANAGER.get_general_property("use_llama_index") - self.__llama_index_directory = CONFIG_MANAGER.get_general_property( - "llama_index_directory" - ) - - def __initUi(self): - tabWidget = QTabWidget() - tabWidget.currentChanged.connect(self.onTabChanged.emit) - - usingG4FPage = UsingG4FPage() - usingAPIPage = UsingAPIPage() - self.__llamaPage = LlamaPage() - self.__llamaPage.onDirectorySelected.connect(self.__onDirectorySelected) - - # TODO LANGUAGE - tabWidget.addTab(usingG4FPage, "Using G4F (Free)") - tabWidget.addTab(usingAPIPage, "Using API") - tabWidget.addTab(self.__llamaPage, "LlamaIndex") - tabWidget.currentChanged.connect(self.__tabChanged) - tabWidget.setTabEnabled(2, self.__use_llama_index) - tabWidget.setCurrentIndex(self.__cur_idx) - - partial_func = partial(tabWidget.setTabEnabled, 2) - usingAPIPage.onToggleLlama.connect(lambda x: partial_func(x)) - usingAPIPage.onToggleJSON.connect(self.onToggleJSON) - - lay = QGridLayout() - lay.addWidget(tabWidget) - - mainWidget = QWidget() - mainWidget.setLayout(lay) - - self.setWidget(mainWidget) - self.setWidgetResizable(True) - - self.setStyleSheet("QScrollArea { border: 0 }") - - def __tabChanged(self, idx): - self.__cur_idx = idx - CONFIG_MANAGER.set_general_property("TAB_IDX", self.__cur_idx) - - def __onDirectorySelected(self, selected_dirname): - self.__llama_index_directory = selected_dirname - CONFIG_MANAGER.set_general_property("llama_index_directory", selected_dirname) - try: - LLAMAINDEX_WRAPPER.set_directory(selected_dirname) - except Exception as e: - QMessageBox.critical(self, LangClass.TRANSLATIONS["Error"], str(e)) - - - def currentTabIdx(self): - return self.__cur_idx +from __future__ import annotations + +from functools import partial + +from qtpy.QtCore import Signal +from qtpy.QtWidgets import QGridLayout, QMessageBox, QScrollArea, QTabWidget, QWidget + +from pyqt_openai.chat_widget.right_sidebar.llama_widget.llamaPage import LlamaPage +from pyqt_openai.chat_widget.right_sidebar.usingAPIPage import UsingAPIPage +from pyqt_openai.chat_widget.right_sidebar.usingG4FPage import UsingG4FPage +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.globals import LLAMAINDEX_WRAPPER +from pyqt_openai.lang.translations import LangClass + + +class ChatRightSideBarWidget(QScrollArea): + onTabChanged = Signal(int) + onToggleJSON = Signal(bool) + + def __init__(self, parent=None): + super().__init__(parent) + self.__initVal() + self.__initUi() + + def __initVal(self): + self.__cur_idx = CONFIG_MANAGER.get_general_property("TAB_IDX") + self.__use_llama_index = CONFIG_MANAGER.get_general_property("use_llama_index") + self.__llama_index_directory = CONFIG_MANAGER.get_general_property( + "llama_index_directory", + ) + + def __initUi(self): + tabWidget = QTabWidget() + tabWidget.currentChanged.connect(self.onTabChanged.emit) + + usingG4FPage = UsingG4FPage() + usingAPIPage = UsingAPIPage() + self.__llamaPage = LlamaPage() + self.__llamaPage.onDirectorySelected.connect(self.__onDirectorySelected) + + # TODO LANGUAGE + tabWidget.addTab(usingG4FPage, "Using G4F (Free)") + tabWidget.addTab(usingAPIPage, "Using API") + tabWidget.addTab(self.__llamaPage, "LlamaIndex") + tabWidget.currentChanged.connect(self.__tabChanged) + tabWidget.setTabEnabled(2, self.__use_llama_index) + tabWidget.setCurrentIndex(self.__cur_idx) + + partial_func = partial(tabWidget.setTabEnabled, 2) + usingAPIPage.onToggleLlama.connect(lambda x: partial_func(x)) + usingAPIPage.onToggleJSON.connect(self.onToggleJSON) + + lay = QGridLayout() + lay.addWidget(tabWidget) + + mainWidget = QWidget() + mainWidget.setLayout(lay) + + self.setWidget(mainWidget) + self.setWidgetResizable(True) + + self.setStyleSheet("QScrollArea { border: 0 }") + + def __tabChanged(self, idx): + self.__cur_idx = idx + CONFIG_MANAGER.set_general_property("TAB_IDX", self.__cur_idx) + + def __onDirectorySelected(self, selected_dirname): + self.__llama_index_directory = selected_dirname + CONFIG_MANAGER.set_general_property("llama_index_directory", selected_dirname) + try: + LLAMAINDEX_WRAPPER.set_directory(selected_dirname) + except Exception as e: + QMessageBox.critical(self, LangClass.TRANSLATIONS["Error"], str(e)) + + + def currentTabIdx(self): + return self.__cur_idx diff --git a/pyqt_openai/chat_widget/right_sidebar/llama_widget/filesWidget.py b/pyqt_openai/chat_widget/right_sidebar/llama_widget/filesWidget.py index 4c25cca6..9f045e57 100644 --- a/pyqt_openai/chat_widget/right_sidebar/llama_widget/filesWidget.py +++ b/pyqt_openai/chat_widget/right_sidebar/llama_widget/filesWidget.py @@ -1,112 +1,114 @@ -import os - -from PySide6.QtCore import Signal -from PySide6.QtWidgets import ( - QListWidget, - QWidget, - QVBoxLayout, - QLabel, - QHBoxLayout, - QSpacerItem, - QPushButton, - QSizePolicy, - QFileDialog, -) - -from pyqt_openai import TEXT_FILE_EXT_LIST, QFILEDIALOG_DEFAULT_DIRECTORY -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.util.common import getSeparator - - -class FilesWidget(QWidget): - itemUpdate = Signal(bool) - onDirectorySelected = Signal() - clicked = Signal(str) - - def __init__(self, parent=None): - super().__init__(parent) - self.__initVal() - self.__initUi() - - def __initVal(self): - self.__directory_label_prefix = LangClass.TRANSLATIONS["Directory"] - self.__current_directory_name = "" - self.__extension = CONFIG_MANAGER.get_general_property("llama_index_supported_formats") - - def __initUi(self): - lbl = QLabel(LangClass.TRANSLATIONS["Files"]) - setDirBtn = QPushButton(LangClass.TRANSLATIONS["Set Directory"]) - setDirBtn.clicked.connect(self.setDirectory) - self.__dirLbl = QLabel(self.__directory_label_prefix) - - lay = QHBoxLayout() - lay.addWidget(lbl) - lay.addSpacerItem(QSpacerItem(10, 10, QSizePolicy.Policy.MinimumExpanding)) - lay.addWidget(setDirBtn) - lay.setContentsMargins(0, 0, 0, 0) - - topWidget = QWidget() - topWidget.setLayout(lay) - - self.__listWidget = QListWidget() - self.__listWidget.itemClicked.connect(self.__sendDirectory) - - sep = getSeparator("horizontal") - - lay = QVBoxLayout() - lay.addWidget(topWidget) - lay.addWidget(sep) - lay.addWidget(self.__dirLbl) - lay.addWidget(self.__listWidget) - lay.setContentsMargins(0, 0, 0, 0) - self.setLayout(lay) - - def setExtension(self, ext): - # Set extension - self.__extension = ext - CONFIG_MANAGER.set_general_property("llama_index_supported_formats", ext) - - # Refresh list based on new extension - self.__listWidget.clear() - self.setDirectory(self.__current_directory_name, called_from_btn=False) - - def setDirectory(self, directory=None, called_from_btn=True): - try: - if called_from_btn: - if not directory: - directory = QFileDialog.getExistingDirectory( - self, - LangClass.TRANSLATIONS["Select Directory"], - QFILEDIALOG_DEFAULT_DIRECTORY, - QFileDialog.Option.ShowDirsOnly, - ) - if directory: - self.__listWidget.clear() - filenames = list( - filter( - lambda x: os.path.splitext(x)[-1] in self.__extension, - os.listdir(directory), - ) - ) - self.__listWidget.addItems(filenames) - self.itemUpdate.emit(len(filenames) > 0) - self.__current_directory_name = directory - self.__dirLbl.setText(self.__current_directory_name.split("/")[-1]) - - self.__listWidget.setCurrentRow(0) - # activate event as clicking first item (because this selects the first item anyway) - self.clicked.emit( - os.path.join( - self.__current_directory_name, self.__listWidget.currentItem().text() - ) - ) - self.onDirectorySelected.emit() - except Exception as e: - print(e) - - def getDirectory(self): - return self.__current_directory_name - - def __sendDirectory(self, item): - self.clicked.emit(os.path.join(self.__current_directory_name, item.text())) +from __future__ import annotations + +import os + +from qtpy.QtCore import Signal +from qtpy.QtWidgets import ( + QFileDialog, + QHBoxLayout, + QLabel, + QListWidget, + QPushButton, + QSizePolicy, + QSpacerItem, + QVBoxLayout, + QWidget, +) + +from pyqt_openai import QFILEDIALOG_DEFAULT_DIRECTORY +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.util.common import getSeparator + + +class FilesWidget(QWidget): + itemUpdate = Signal(bool) + onDirectorySelected = Signal() + clicked = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.__initVal() + self.__initUi() + + def __initVal(self): + self.__directory_label_prefix = LangClass.TRANSLATIONS["Directory"] + self.__current_directory_name = "" + self.__extension = CONFIG_MANAGER.get_general_property("llama_index_supported_formats") + + def __initUi(self): + lbl = QLabel(LangClass.TRANSLATIONS["Files"]) + setDirBtn = QPushButton(LangClass.TRANSLATIONS["Set Directory"]) + setDirBtn.clicked.connect(self.setDirectory) + self.__dirLbl = QLabel(self.__directory_label_prefix) + + lay = QHBoxLayout() + lay.addWidget(lbl) + lay.addSpacerItem(QSpacerItem(10, 10, QSizePolicy.Policy.MinimumExpanding)) + lay.addWidget(setDirBtn) + lay.setContentsMargins(0, 0, 0, 0) + + topWidget = QWidget() + topWidget.setLayout(lay) + + self.__listWidget = QListWidget() + self.__listWidget.itemClicked.connect(self.__sendDirectory) + + sep = getSeparator("horizontal") + + lay = QVBoxLayout() + lay.addWidget(topWidget) + lay.addWidget(sep) + lay.addWidget(self.__dirLbl) + lay.addWidget(self.__listWidget) + lay.setContentsMargins(0, 0, 0, 0) + self.setLayout(lay) + + def setExtension(self, ext): + # Set extension + self.__extension = ext + CONFIG_MANAGER.set_general_property("llama_index_supported_formats", ext) + + # Refresh list based on new extension + self.__listWidget.clear() + self.setDirectory(self.__current_directory_name, called_from_btn=False) + + def setDirectory(self, directory=None, called_from_btn=True): + try: + if called_from_btn: + if not directory: + directory = QFileDialog.getExistingDirectory( + self, + LangClass.TRANSLATIONS["Select Directory"], + QFILEDIALOG_DEFAULT_DIRECTORY, + QFileDialog.Option.ShowDirsOnly, + ) + if directory: + self.__listWidget.clear() + filenames = list( + filter( + lambda x: os.path.splitext(x)[-1] in self.__extension, + os.listdir(directory), + ), + ) + self.__listWidget.addItems(filenames) + self.itemUpdate.emit(len(filenames) > 0) + self.__current_directory_name = directory + self.__dirLbl.setText(self.__current_directory_name.split("/")[-1]) + + self.__listWidget.setCurrentRow(0) + # activate event as clicking first item (because this selects the first item anyway) + self.clicked.emit( + os.path.join( + self.__current_directory_name, self.__listWidget.currentItem().text(), + ), + ) + self.onDirectorySelected.emit() + except Exception as e: + print(e) + + def getDirectory(self): + return self.__current_directory_name + + def __sendDirectory(self, item): + self.clicked.emit(os.path.join(self.__current_directory_name, item.text())) diff --git a/pyqt_openai/chat_widget/right_sidebar/llama_widget/llamaPage.py b/pyqt_openai/chat_widget/right_sidebar/llama_widget/llamaPage.py index ec7b2c59..2243b059 100644 --- a/pyqt_openai/chat_widget/right_sidebar/llama_widget/llamaPage.py +++ b/pyqt_openai/chat_widget/right_sidebar/llama_widget/llamaPage.py @@ -1,68 +1,70 @@ -from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QFont -from PySide6.QtWidgets import QTextBrowser, QMessageBox -from PySide6.QtWidgets import QWidget, QLabel, QVBoxLayout - -from pyqt_openai import SMALL_LABEL_PARAM -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.chat_widget.right_sidebar.llama_widget.filesWidget import FilesWidget -from pyqt_openai.chat_widget.right_sidebar.llama_widget.supportedFileFormatsWidget import SupportedFileFormatsWidget -from pyqt_openai.globals import LLAMAINDEX_WRAPPER -from pyqt_openai.lang.translations import LangClass - - -class LlamaPage(QWidget): - onDirectorySelected = Signal(str) - - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - self.__apiCheckPreviewLbl = QLabel() - self.__apiCheckPreviewLbl.setFont(QFont(*SMALL_LABEL_PARAM)) - - self.__filesWidget = FilesWidget() - self.__filesWidget.clicked.connect(self.__setTextInBrowser) - self.__filesWidget.onDirectorySelected.connect(self.__onDirectorySelected) - - self.__supportedFileFormatsWidget = SupportedFileFormatsWidget() - self.__supportedFileFormatsWidget.checkedSignal.connect(self.__formatCheckedSignal) - - self.__txtBrowser = QTextBrowser() - self.__txtBrowser.setPlaceholderText( - LangClass.TRANSLATIONS[ - "This text browser shows selected file's content in the list." - ] - ) - self.__txtBrowser.setMaximumHeight(150) - - lay = QVBoxLayout() - lay.addWidget(self.__supportedFileFormatsWidget) - lay.addWidget(self.__filesWidget) - lay.addWidget(self.__txtBrowser) - lay.setAlignment(Qt.AlignmentFlag.AlignTop) - - self.setLayout(lay) - - self.setDirectory() - - def __onDirectorySelected(self): - selected_dirname = self.__filesWidget.getDirectory() - self.onDirectorySelected.emit(selected_dirname) - - def __setTextInBrowser(self, file): - try: - with open(file, "r", encoding="utf-8") as f: - self.__txtBrowser.setText(f.read()) - except UnicodeDecodeError as e: - self.__txtBrowser.setText('Some files like Excel files cannot be previewed.') - except Exception as e: - print(e) - - def setDirectory(self): - directory = CONFIG_MANAGER.get_general_property("llama_index_directory") - self.__filesWidget.setDirectory(directory, called_from_btn=False) - - def __formatCheckedSignal(self, ext): - self.__filesWidget.setExtension(ext) \ No newline at end of file +from __future__ import annotations + +from qtpy.QtCore import Qt, Signal +from qtpy.QtGui import QFont +from qtpy.QtWidgets import QLabel, QTextBrowser, QVBoxLayout, QWidget + +from pyqt_openai import SMALL_LABEL_PARAM +from pyqt_openai.chat_widget.right_sidebar.llama_widget.filesWidget import FilesWidget +from pyqt_openai.chat_widget.right_sidebar.llama_widget.supportedFileFormatsWidget import ( + SupportedFileFormatsWidget, +) +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.lang.translations import LangClass + + +class LlamaPage(QWidget): + onDirectorySelected = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.__initUi() + + def __initUi(self): + self.__apiCheckPreviewLbl = QLabel() + self.__apiCheckPreviewLbl.setFont(QFont(*SMALL_LABEL_PARAM)) + + self.__filesWidget = FilesWidget() + self.__filesWidget.clicked.connect(self.__setTextInBrowser) + self.__filesWidget.onDirectorySelected.connect(self.__onDirectorySelected) + + self.__supportedFileFormatsWidget = SupportedFileFormatsWidget() + self.__supportedFileFormatsWidget.checkedSignal.connect(self.__formatCheckedSignal) + + self.__txtBrowser = QTextBrowser() + self.__txtBrowser.setPlaceholderText( + LangClass.TRANSLATIONS[ + "This text browser shows selected file's content in the list." + ], + ) + self.__txtBrowser.setMaximumHeight(150) + + lay = QVBoxLayout() + lay.addWidget(self.__supportedFileFormatsWidget) + lay.addWidget(self.__filesWidget) + lay.addWidget(self.__txtBrowser) + lay.setAlignment(Qt.AlignmentFlag.AlignTop) + + self.setLayout(lay) + + self.setDirectory() + + def __onDirectorySelected(self): + selected_dirname = self.__filesWidget.getDirectory() + self.onDirectorySelected.emit(selected_dirname) + + def __setTextInBrowser(self, file): + try: + with open(file, encoding="utf-8") as f: + self.__txtBrowser.setText(f.read()) + except UnicodeDecodeError as e: + self.__txtBrowser.setText("Some files like Excel files cannot be previewed.") + except Exception as e: + print(e) + + def setDirectory(self): + directory = CONFIG_MANAGER.get_general_property("llama_index_directory") + self.__filesWidget.setDirectory(directory, called_from_btn=False) + + def __formatCheckedSignal(self, ext): + self.__filesWidget.setExtension(ext) diff --git a/pyqt_openai/chat_widget/right_sidebar/llama_widget/supportedFileFormatsWidget.py b/pyqt_openai/chat_widget/right_sidebar/llama_widget/supportedFileFormatsWidget.py index 4450e60e..99799dcd 100644 --- a/pyqt_openai/chat_widget/right_sidebar/llama_widget/supportedFileFormatsWidget.py +++ b/pyqt_openai/chat_widget/right_sidebar/llama_widget/supportedFileFormatsWidget.py @@ -1,37 +1,39 @@ -from PySide6.QtCore import Signal, Qt -from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel - -from pyqt_openai import LLAMA_INDEX_DEFAULT_ALL_SUPPORTED_FORMATS_LIST -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.widgets.checkBoxListWidget import CheckBoxListWidget - - -class SupportedFileFormatsWidget(QWidget): - checkedSignal = Signal(list) - - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - self.__listWidget = CheckBoxListWidget() - self.__listWidget.checkedSignal.connect(self.__sendCheckedSignal) - - all_supported_format_lst = LLAMA_INDEX_DEFAULT_ALL_SUPPORTED_FORMATS_LIST - self.__listWidget.addItems(all_supported_format_lst) - - current_supported_format_lst = CONFIG_MANAGER.get_general_property("llama_index_supported_formats") - for i in range(self.__listWidget.count()): - supported_format = self.__listWidget.item(i).text() - if supported_format in current_supported_format_lst: - self.__listWidget.item(i).setCheckState(Qt.CheckState.Checked) - - lay = QVBoxLayout() - # TODO LANGUAGE - lay.addWidget(QLabel('Supported File Formats')) - lay.addWidget(self.__listWidget) - lay.setContentsMargins(0, 0, 0, 0) - self.setLayout(lay) - - def __sendCheckedSignal(self, r_idx, state): - self.checkedSignal.emit(self.__listWidget.getCheckedItemsText()) \ No newline at end of file +from __future__ import annotations + +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget + +from pyqt_openai import LLAMA_INDEX_DEFAULT_ALL_SUPPORTED_FORMATS_LIST +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.widgets.checkBoxListWidget import CheckBoxListWidget + + +class SupportedFileFormatsWidget(QWidget): + checkedSignal = Signal(list) + + def __init__(self, parent=None): + super().__init__(parent) + self.__initUi() + + def __initUi(self): + self.__listWidget = CheckBoxListWidget() + self.__listWidget.checkedSignal.connect(self.__sendCheckedSignal) + + all_supported_format_lst = LLAMA_INDEX_DEFAULT_ALL_SUPPORTED_FORMATS_LIST + self.__listWidget.addItems(all_supported_format_lst) + + current_supported_format_lst = CONFIG_MANAGER.get_general_property("llama_index_supported_formats") + for i in range(self.__listWidget.count()): + supported_format = self.__listWidget.item(i).text() + if supported_format in current_supported_format_lst: + self.__listWidget.item(i).setCheckState(Qt.CheckState.Checked) + + lay = QVBoxLayout() + # TODO LANGUAGE + lay.addWidget(QLabel("Supported File Formats")) + lay.addWidget(self.__listWidget) + lay.setContentsMargins(0, 0, 0, 0) + self.setLayout(lay) + + def __sendCheckedSignal(self, r_idx, state): + self.checkedSignal.emit(self.__listWidget.getCheckedItemsText()) diff --git a/pyqt_openai/chat_widget/right_sidebar/modelSearchBar.py b/pyqt_openai/chat_widget/right_sidebar/modelSearchBar.py index 266b933d..1bbf0aec 100644 --- a/pyqt_openai/chat_widget/right_sidebar/modelSearchBar.py +++ b/pyqt_openai/chat_widget/right_sidebar/modelSearchBar.py @@ -1,16 +1,18 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QLineEdit, QCompleter - -from pyqt_openai.util.common import get_chat_model - - -class ModelSearchBar(QLineEdit): - def __init__(self, parent=None): - super().__init__(parent) - all_models = get_chat_model() - # TODO LANGAUGE - self.setPlaceholderText("Start typing a model name...") - - completer = QCompleter(all_models) - completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) - self.setCompleter(completer) +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QCompleter, QLineEdit + +from pyqt_openai.util.common import get_chat_model + + +class ModelSearchBar(QLineEdit): + def __init__(self, parent=None): + super().__init__(parent) + all_models = get_chat_model() + # TODO LANGAUGE + self.setPlaceholderText("Start typing a model name...") + + completer = QCompleter(all_models) + completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self.setCompleter(completer) diff --git a/pyqt_openai/chat_widget/right_sidebar/usingAPIPage.py b/pyqt_openai/chat_widget/right_sidebar/usingAPIPage.py index abe636c6..1584008f 100644 --- a/pyqt_openai/chat_widget/right_sidebar/usingAPIPage.py +++ b/pyqt_openai/chat_widget/right_sidebar/usingAPIPage.py @@ -1,358 +1,361 @@ -from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QFont -from PySide6.QtWidgets import ( - QWidget, - QDoubleSpinBox, - QSpinBox, - QFormLayout, - QSizePolicy, - QLabel, - QVBoxLayout, - QCheckBox, - QPushButton, - QScrollArea, - QGroupBox, - QHBoxLayout, - QTextBrowser, - QPlainTextEdit, -) - -from pyqt_openai import ( - DEFAULT_SHORTCUT_JSON_MODE, - OPENAI_TEMPERATURE_RANGE, - OPENAI_TEMPERATURE_STEP, - MAX_TOKENS_RANGE, - TOP_P_RANGE, - TOP_P_STEP, - FREQUENCY_PENALTY_RANGE, - PRESENCE_PENALTY_STEP, - PRESENCE_PENALTY_RANGE, - FREQUENCY_PENALTY_STEP, - LLAMAINDEX_URL, - O1_MODELS, - SMALL_LABEL_PARAM, DEFAULT_WARNING_COLOR, -) -from pyqt_openai.chat_widget.right_sidebar.modelSearchBar import ModelSearchBar -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.util.common import ( - getSeparator, - init_llama, -) -from pyqt_openai.widgets.linkLabel import LinkLabel -from pyqt_openai.widgets.APIInputButton import APIInputButton - - -class UsingAPIPage(QWidget): - onToggleLlama = Signal(bool) - onToggleJSON = Signal(bool) - - def __init__(self, parent=None): - super().__init__(parent) - self.__initVal() - self.__initUi() - - def __initVal(self): - self.__stream = CONFIG_MANAGER.get_general_property("stream") - self.__model = CONFIG_MANAGER.get_general_property("model") - self.__system = CONFIG_MANAGER.get_general_property("system") - self.__temperature = CONFIG_MANAGER.get_general_property("temperature") - self.__max_tokens = CONFIG_MANAGER.get_general_property("max_tokens") - self.__top_p = CONFIG_MANAGER.get_general_property("top_p") - self.__frequency_penalty = CONFIG_MANAGER.get_general_property( - "frequency_penalty" - ) - self.__presence_penalty = CONFIG_MANAGER.get_general_property( - "presence_penalty" - ) - self.__json_object = CONFIG_MANAGER.get_general_property("json_object") - - self.__use_max_tokens = CONFIG_MANAGER.get_general_property("use_max_tokens") - self.__use_llama_index = CONFIG_MANAGER.get_general_property("use_llama_index") - - self.__warningMessage = ( - "Note: For models other than OpenAI and Anthropic, please enter the model name in the format [ProviderName]/[ModelName].\n" - "For more information about ProviderName and ModelName, please refer to litellm documentation.\n" - "Certain models may not support JSON Mode or LlamaIndex." - ) - - def __initUi(self): - manualBrowser = QTextBrowser() - manualBrowser.setOpenExternalLinks(True) - manualBrowser.setOpenLinks(True) - - # TODO LANGUAGE - manualBrowser.setHtml( - """ -

Using API

-

Description

-

- Fast responses.

-

- Stable response server.

-

- Ability to save your AI usage history and statistics.

-

- Option to add custom LLMs you have created.

-

- Ability to save conversation history on the server.

-

- JSON response functionality available (limited to specific LLMs).

-

- LlamaIndex can be used.

-

- Various hyperparameters can be assigned.

- """ - ) - - manualBrowser.setSizePolicy( - QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.MinimumExpanding - ) - - systemlbl = QLabel(LangClass.TRANSLATIONS["System"]) - systemlbl.setToolTip( - LangClass.TRANSLATIONS[ - "Basically system means instructions or rules that the model should follow." - ] - + "\n" - + LangClass.TRANSLATIONS["You can write your own system instructions here."] - ) - - self.__systemTextEdit = QPlainTextEdit() - self.__systemTextEdit.setPlainText(self.__system) - self.__systemTextEdit.setSizePolicy( - QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Preferred - ) - self.__systemTextEdit.setMaximumHeight(100) - saveSystemBtn = QPushButton(LangClass.TRANSLATIONS["Save System"]) - saveSystemBtn.clicked.connect(self.__saveSystem) - - modelSearchBar = ModelSearchBar() - modelSearchBar.setText(self.__model) - modelSearchBar.textChanged.connect(self.__modelChanged) - - lay = QHBoxLayout() - lay.addWidget(QLabel(LangClass.TRANSLATIONS["Model"])) - lay.addWidget(modelSearchBar) - lay.setContentsMargins(0, 0, 0, 0) - - setApiBtn = APIInputButton() - # TODO LANGUAGE - setApiBtn.setText("Set API Key") - - selectModelWidget = QWidget() - selectModelWidget.setLayout(lay) - - self.__warningLbl = QLabel() - self.__warningLbl.setStyleSheet(f"color: {DEFAULT_WARNING_COLOR};") - self.__warningLbl.setWordWrap(True) - self.__warningLbl.setFont(QFont(SMALL_LABEL_PARAM)) - # TODO LANGUAGE - self.__warningLbl.setText(self.__warningMessage) - self.__warningLbl.setTextInteractionFlags( - Qt.TextInteractionFlag.TextSelectableByMouse - ) - - advancedSettingsScrollArea = QScrollArea() - - self.__temperatureSpinBox = QDoubleSpinBox() - self.__temperatureSpinBox.setRange(*OPENAI_TEMPERATURE_RANGE) - self.__temperatureSpinBox.setAccelerated(True) - self.__temperatureSpinBox.setSingleStep(OPENAI_TEMPERATURE_STEP) - self.__temperatureSpinBox.setValue(self.__temperature) - self.__temperatureSpinBox.valueChanged.connect(self.__valueChanged) - self.__temperatureSpinBox.setToolTip( - LangClass.TRANSLATIONS[ - "To control the randomness of responses, you adjust the temperature parameter." - ] - + "\n" - + LangClass.TRANSLATIONS[ - "A lower value results in less random completions." - ] - ) - - self.__maxTokensSpinBox = QSpinBox() - self.__maxTokensSpinBox.setRange(*MAX_TOKENS_RANGE) - self.__maxTokensSpinBox.setAccelerated(True) - self.__maxTokensSpinBox.setValue(self.__max_tokens) - self.__maxTokensSpinBox.valueChanged.connect(self.__valueChanged) - self.__maxTokensSpinBox.setToolTip( - LangClass.TRANSLATIONS[ - "To set a limit on the number of tokens to generate, you use the max tokens parameter." - ] - + "\n" - + LangClass.TRANSLATIONS[ - "The model will stop generating tokens once it reaches the limit." - ] - ) - - self.__toppSpinBox = QDoubleSpinBox() - self.__toppSpinBox.setRange(*TOP_P_RANGE) - self.__toppSpinBox.setAccelerated(True) - self.__toppSpinBox.setSingleStep(TOP_P_STEP) - self.__toppSpinBox.setValue(self.__top_p) - self.__toppSpinBox.valueChanged.connect(self.__valueChanged) - self.__toppSpinBox.setToolTip( - LangClass.TRANSLATIONS[ - "To set a threshold for nucleus sampling, you use the top p parameter." - ] - + "\n" - + LangClass.TRANSLATIONS[ - "The model will stop generating tokens once the cumulative probability of the generated tokens exceeds the threshold." - ] - ) - - self.__frequencyPenaltySpinBox = QDoubleSpinBox() - self.__frequencyPenaltySpinBox.setRange(*FREQUENCY_PENALTY_RANGE) - self.__frequencyPenaltySpinBox.setAccelerated(True) - self.__frequencyPenaltySpinBox.setSingleStep(FREQUENCY_PENALTY_STEP) - self.__frequencyPenaltySpinBox.setValue(self.__frequency_penalty) - self.__frequencyPenaltySpinBox.valueChanged.connect(self.__valueChanged) - self.__frequencyPenaltySpinBox.setToolTip( - LangClass.TRANSLATIONS[ - "To penalize the model from repeating the same tokens, you use the frequency penalty parameter." - ] - + "\n" - + LangClass.TRANSLATIONS[ - "The model will be less likely to generate tokens that have already been generated." - ] - ) - - self.__presencePenaltySpinBox = QDoubleSpinBox() - self.__presencePenaltySpinBox.setRange(*PRESENCE_PENALTY_RANGE) - self.__presencePenaltySpinBox.setAccelerated(True) - self.__presencePenaltySpinBox.setSingleStep(PRESENCE_PENALTY_STEP) - self.__presencePenaltySpinBox.setValue(self.__presence_penalty) - self.__presencePenaltySpinBox.valueChanged.connect(self.__valueChanged) - self.__presencePenaltySpinBox.setToolTip( - LangClass.TRANSLATIONS[ - "To penalize the model from generating tokens that are not present in the input, you use the presence penalty parameter." - ] - + "\n" - + LangClass.TRANSLATIONS[ - "The model will be less likely to generate tokens that are not present in the input." - ] - ) - - useMaxTokenChkBox = QCheckBox() - useMaxTokenChkBox.toggled.connect(self.__useMaxChecked) - useMaxTokenChkBox.setChecked(self.__use_max_tokens) - useMaxTokenChkBox.setText(LangClass.TRANSLATIONS["Use Max Tokens"]) - - self.__maxTokensSpinBox.setEnabled(self.__use_max_tokens) - - lay = QFormLayout() - - lay.addRow(useMaxTokenChkBox) - lay.addRow("Temperature", self.__temperatureSpinBox) - lay.addRow("Max Tokens", self.__maxTokensSpinBox) - lay.addRow("Top p", self.__toppSpinBox) - lay.addRow("Frequency Penalty", self.__frequencyPenaltySpinBox) - lay.addRow("Presence Penalty", self.__presencePenaltySpinBox) - - paramWidget = QWidget() - paramWidget.setLayout(lay) - - advancedSettingsScrollArea.setWidgetResizable(True) - advancedSettingsScrollArea.setWidget(paramWidget) - - lay = QVBoxLayout() - lay.addWidget(advancedSettingsScrollArea) - - advancedSettingsGrpBox = QGroupBox(LangClass.TRANSLATIONS["Advanced Settings"]) - advancedSettingsGrpBox.setLayout(lay) - - streamChkBox = QCheckBox() - streamChkBox.setChecked(self.__stream) - streamChkBox.toggled.connect(self.__streamChecked) - streamChkBox.setText(LangClass.TRANSLATIONS["Stream"]) - - self.__jsonChkBox = QCheckBox() - self.__jsonChkBox.setChecked(self.__json_object) - self.__jsonChkBox.toggled.connect(self.__jsonObjectChecked) - - self.__jsonChkBox.setText(LangClass.TRANSLATIONS["Enable JSON mode"]) - self.__jsonChkBox.setShortcut(DEFAULT_SHORTCUT_JSON_MODE) - self.__jsonChkBox.setToolTip( - LangClass.TRANSLATIONS[ - "When enabled, you can send a JSON object to the API and the response will be in JSON format. Otherwise, it will be in plain text." - ] - ) - - # TODO LANGUAGE - llamaManualLbl = LinkLabel() - llamaManualLbl.setText(LangClass.TRANSLATIONS["What is LlamaIndex?"]) - llamaManualLbl.setUrl(LLAMAINDEX_URL) - - self.__llamaChkBox = QCheckBox() - self.__llamaChkBox.setChecked(self.__use_llama_index) - self.__llamaChkBox.toggled.connect(self.__use_llama_indexChecked) - self.__llamaChkBox.setText(LangClass.TRANSLATIONS["Use LlamaIndex (You need OpenAI API key)"]) - - lay = QVBoxLayout() - lay.addWidget(manualBrowser) - lay.addWidget(getSeparator("horizontal")) - lay.addWidget(systemlbl) - lay.addWidget(self.__systemTextEdit) - lay.addWidget(saveSystemBtn) - lay.addWidget(getSeparator("horizontal")) - lay.addWidget(setApiBtn) - lay.addWidget(selectModelWidget) - lay.addWidget(self.__warningLbl) - lay.addWidget(streamChkBox) - lay.addWidget(self.__jsonChkBox) - lay.addWidget(self.__llamaChkBox) - lay.addWidget(llamaManualLbl) - lay.addWidget(getSeparator("horizontal")) - lay.addWidget(advancedSettingsGrpBox) - lay.setAlignment(Qt.AlignmentFlag.AlignTop) - - self.setLayout(lay) - - def __saveSystem(self): - self.__system = self.__systemTextEdit.toPlainText() - CONFIG_MANAGER.set_general_property("system", self.__system) - - def __modelChanged(self, v): - self.__model = v - CONFIG_MANAGER.set_general_property("model", v) - # TODO LANGUAGE - additional_message = ( - "\nNote: The selected model is only available at Tier 3 or higher." - ) - if self.__model in O1_MODELS: - self.__warningLbl.setText(self.__warningMessage + additional_message) - else: - self.__warningLbl.setText(self.__warningMessage) - - def __streamChecked(self, f): - self.__stream = f - CONFIG_MANAGER.set_general_property("stream", f) - - def __jsonObjectChecked(self, f): - self.__json_object = f - CONFIG_MANAGER.set_general_property("json_object", f) - self.onToggleJSON.emit(f) - - def __use_llama_indexChecked(self, f): - self.__use_llama_index = f - CONFIG_MANAGER.set_general_property("use_llama_index", f) - if f: - # Set llama index directory if it exists - init_llama() - self.onToggleLlama.emit(f) - - def __useMaxChecked(self, f): - self.__use_max_tokens = f - CONFIG_MANAGER.set_general_property("use_max_tokens", f) - self.__maxTokensSpinBox.setEnabled(f) - - def __valueChanged(self, v): - sender = self.sender() - if sender == self.__temperatureSpinBox: - self.__temperature = v - CONFIG_MANAGER.set_general_property("temperature", v) - elif sender == self.__maxTokensSpinBox: - self.__max_tokens = v - CONFIG_MANAGER.set_general_property("max_tokens", v) - elif sender == self.__toppSpinBox: - self.__top_p = v - CONFIG_MANAGER.set_general_property("top_p", v) - elif sender == self.__frequencyPenaltySpinBox: - self.__frequency_penalty = v - CONFIG_MANAGER.set_general_property("frequency_penalty", v) - elif sender == self.__presencePenaltySpinBox: - self.__presence_penalty = v - CONFIG_MANAGER.set_general_property("presence_penalty", v) +from __future__ import annotations + +from qtpy.QtCore import Qt, Signal +from qtpy.QtGui import QFont +from qtpy.QtWidgets import ( + QCheckBox, + QDoubleSpinBox, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QPlainTextEdit, + QPushButton, + QScrollArea, + QSizePolicy, + QSpinBox, + QTextBrowser, + QVBoxLayout, + QWidget, +) + +from pyqt_openai import ( + DEFAULT_SHORTCUT_JSON_MODE, + DEFAULT_WARNING_COLOR, + FREQUENCY_PENALTY_RANGE, + FREQUENCY_PENALTY_STEP, + LLAMAINDEX_URL, + MAX_TOKENS_RANGE, + O1_MODELS, + OPENAI_TEMPERATURE_RANGE, + OPENAI_TEMPERATURE_STEP, + PRESENCE_PENALTY_RANGE, + PRESENCE_PENALTY_STEP, + SMALL_LABEL_PARAM, + TOP_P_RANGE, + TOP_P_STEP, +) +from pyqt_openai.chat_widget.right_sidebar.modelSearchBar import ModelSearchBar +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.util.common import ( + getSeparator, + init_llama, +) +from pyqt_openai.widgets.APIInputButton import APIInputButton +from pyqt_openai.widgets.linkLabel import LinkLabel + + +class UsingAPIPage(QWidget): + onToggleLlama = Signal(bool) + onToggleJSON = Signal(bool) + + def __init__(self, parent=None): + super().__init__(parent) + self.__initVal() + self.__initUi() + + def __initVal(self): + self.__stream = CONFIG_MANAGER.get_general_property("stream") + self.__model = CONFIG_MANAGER.get_general_property("model") + self.__system = CONFIG_MANAGER.get_general_property("system") + self.__temperature = CONFIG_MANAGER.get_general_property("temperature") + self.__max_tokens = CONFIG_MANAGER.get_general_property("max_tokens") + self.__top_p = CONFIG_MANAGER.get_general_property("top_p") + self.__frequency_penalty = CONFIG_MANAGER.get_general_property( + "frequency_penalty", + ) + self.__presence_penalty = CONFIG_MANAGER.get_general_property( + "presence_penalty", + ) + self.__json_object = CONFIG_MANAGER.get_general_property("json_object") + + self.__use_max_tokens = CONFIG_MANAGER.get_general_property("use_max_tokens") + self.__use_llama_index = CONFIG_MANAGER.get_general_property("use_llama_index") + + self.__warningMessage = ( + "Note: For models other than OpenAI and Anthropic, please enter the model name in the format [ProviderName]/[ModelName].\n" + "For more information about ProviderName and ModelName, please refer to litellm documentation.\n" + "Certain models may not support JSON Mode or LlamaIndex." + ) + + def __initUi(self): + manualBrowser = QTextBrowser() + manualBrowser.setOpenExternalLinks(True) + manualBrowser.setOpenLinks(True) + + # TODO LANGUAGE + manualBrowser.setHtml( + """ +

Using API

+

Description

+

- Fast responses.

+

- Stable response server.

+

- Ability to save your AI usage history and statistics.

+

- Option to add custom LLMs you have created.

+

- Ability to save conversation history on the server.

+

- JSON response functionality available (limited to specific LLMs).

+

- LlamaIndex can be used.

+

- Various hyperparameters can be assigned.

+ """, + ) + + manualBrowser.setSizePolicy( + QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.MinimumExpanding, + ) + + systemlbl = QLabel(LangClass.TRANSLATIONS["System"]) + systemlbl.setToolTip( + LangClass.TRANSLATIONS[ + "Basically system means instructions or rules that the model should follow." + ] + + "\n" + + LangClass.TRANSLATIONS["You can write your own system instructions here."], + ) + + self.__systemTextEdit = QPlainTextEdit() + self.__systemTextEdit.setPlainText(self.__system) + self.__systemTextEdit.setSizePolicy( + QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Preferred, + ) + self.__systemTextEdit.setMaximumHeight(100) + saveSystemBtn = QPushButton(LangClass.TRANSLATIONS["Save System"]) + saveSystemBtn.clicked.connect(self.__saveSystem) + + modelSearchBar = ModelSearchBar() + modelSearchBar.setText(self.__model) + modelSearchBar.textChanged.connect(self.__modelChanged) + + lay = QHBoxLayout() + lay.addWidget(QLabel(LangClass.TRANSLATIONS["Model"])) + lay.addWidget(modelSearchBar) + lay.setContentsMargins(0, 0, 0, 0) + + setApiBtn = APIInputButton() + # TODO LANGUAGE + setApiBtn.setText("Set API Key") + + selectModelWidget = QWidget() + selectModelWidget.setLayout(lay) + + self.__warningLbl = QLabel() + self.__warningLbl.setStyleSheet(f"color: {DEFAULT_WARNING_COLOR};") + self.__warningLbl.setWordWrap(True) + self.__warningLbl.setFont(QFont(SMALL_LABEL_PARAM)) + # TODO LANGUAGE + self.__warningLbl.setText(self.__warningMessage) + self.__warningLbl.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse, + ) + + advancedSettingsScrollArea = QScrollArea() + + self.__temperatureSpinBox = QDoubleSpinBox() + self.__temperatureSpinBox.setRange(*OPENAI_TEMPERATURE_RANGE) + self.__temperatureSpinBox.setAccelerated(True) + self.__temperatureSpinBox.setSingleStep(OPENAI_TEMPERATURE_STEP) + self.__temperatureSpinBox.setValue(self.__temperature) + self.__temperatureSpinBox.valueChanged.connect(self.__valueChanged) + self.__temperatureSpinBox.setToolTip( + LangClass.TRANSLATIONS[ + "To control the randomness of responses, you adjust the temperature parameter." + ] + + "\n" + + LangClass.TRANSLATIONS[ + "A lower value results in less random completions." + ], + ) + + self.__maxTokensSpinBox = QSpinBox() + self.__maxTokensSpinBox.setRange(*MAX_TOKENS_RANGE) + self.__maxTokensSpinBox.setAccelerated(True) + self.__maxTokensSpinBox.setValue(self.__max_tokens) + self.__maxTokensSpinBox.valueChanged.connect(self.__valueChanged) + self.__maxTokensSpinBox.setToolTip( + LangClass.TRANSLATIONS[ + "To set a limit on the number of tokens to generate, you use the max tokens parameter." + ] + + "\n" + + LangClass.TRANSLATIONS[ + "The model will stop generating tokens once it reaches the limit." + ], + ) + + self.__toppSpinBox = QDoubleSpinBox() + self.__toppSpinBox.setRange(*TOP_P_RANGE) + self.__toppSpinBox.setAccelerated(True) + self.__toppSpinBox.setSingleStep(TOP_P_STEP) + self.__toppSpinBox.setValue(self.__top_p) + self.__toppSpinBox.valueChanged.connect(self.__valueChanged) + self.__toppSpinBox.setToolTip( + LangClass.TRANSLATIONS[ + "To set a threshold for nucleus sampling, you use the top p parameter." + ] + + "\n" + + LangClass.TRANSLATIONS[ + "The model will stop generating tokens once the cumulative probability of the generated tokens exceeds the threshold." + ], + ) + + self.__frequencyPenaltySpinBox = QDoubleSpinBox() + self.__frequencyPenaltySpinBox.setRange(*FREQUENCY_PENALTY_RANGE) + self.__frequencyPenaltySpinBox.setAccelerated(True) + self.__frequencyPenaltySpinBox.setSingleStep(FREQUENCY_PENALTY_STEP) + self.__frequencyPenaltySpinBox.setValue(self.__frequency_penalty) + self.__frequencyPenaltySpinBox.valueChanged.connect(self.__valueChanged) + self.__frequencyPenaltySpinBox.setToolTip( + LangClass.TRANSLATIONS[ + "To penalize the model from repeating the same tokens, you use the frequency penalty parameter." + ] + + "\n" + + LangClass.TRANSLATIONS[ + "The model will be less likely to generate tokens that have already been generated." + ], + ) + + self.__presencePenaltySpinBox = QDoubleSpinBox() + self.__presencePenaltySpinBox.setRange(*PRESENCE_PENALTY_RANGE) + self.__presencePenaltySpinBox.setAccelerated(True) + self.__presencePenaltySpinBox.setSingleStep(PRESENCE_PENALTY_STEP) + self.__presencePenaltySpinBox.setValue(self.__presence_penalty) + self.__presencePenaltySpinBox.valueChanged.connect(self.__valueChanged) + self.__presencePenaltySpinBox.setToolTip( + LangClass.TRANSLATIONS[ + "To penalize the model from generating tokens that are not present in the input, you use the presence penalty parameter." + ] + + "\n" + + LangClass.TRANSLATIONS[ + "The model will be less likely to generate tokens that are not present in the input." + ], + ) + + useMaxTokenChkBox = QCheckBox() + useMaxTokenChkBox.toggled.connect(self.__useMaxChecked) + useMaxTokenChkBox.setChecked(self.__use_max_tokens) + useMaxTokenChkBox.setText(LangClass.TRANSLATIONS["Use Max Tokens"]) + + self.__maxTokensSpinBox.setEnabled(self.__use_max_tokens) + + lay = QFormLayout() + + lay.addRow(useMaxTokenChkBox) + lay.addRow("Temperature", self.__temperatureSpinBox) + lay.addRow("Max Tokens", self.__maxTokensSpinBox) + lay.addRow("Top p", self.__toppSpinBox) + lay.addRow("Frequency Penalty", self.__frequencyPenaltySpinBox) + lay.addRow("Presence Penalty", self.__presencePenaltySpinBox) + + paramWidget = QWidget() + paramWidget.setLayout(lay) + + advancedSettingsScrollArea.setWidgetResizable(True) + advancedSettingsScrollArea.setWidget(paramWidget) + + lay = QVBoxLayout() + lay.addWidget(advancedSettingsScrollArea) + + advancedSettingsGrpBox = QGroupBox(LangClass.TRANSLATIONS["Advanced Settings"]) + advancedSettingsGrpBox.setLayout(lay) + + streamChkBox = QCheckBox() + streamChkBox.setChecked(self.__stream) + streamChkBox.toggled.connect(self.__streamChecked) + streamChkBox.setText(LangClass.TRANSLATIONS["Stream"]) + + self.__jsonChkBox = QCheckBox() + self.__jsonChkBox.setChecked(self.__json_object) + self.__jsonChkBox.toggled.connect(self.__jsonObjectChecked) + + self.__jsonChkBox.setText(LangClass.TRANSLATIONS["Enable JSON mode"]) + self.__jsonChkBox.setShortcut(DEFAULT_SHORTCUT_JSON_MODE) + self.__jsonChkBox.setToolTip( + LangClass.TRANSLATIONS[ + "When enabled, you can send a JSON object to the API and the response will be in JSON format. Otherwise, it will be in plain text." + ], + ) + + # TODO LANGUAGE + llamaManualLbl = LinkLabel() + llamaManualLbl.setText(LangClass.TRANSLATIONS["What is LlamaIndex?"]) + llamaManualLbl.setUrl(LLAMAINDEX_URL) + + self.__llamaChkBox = QCheckBox() + self.__llamaChkBox.setChecked(self.__use_llama_index) + self.__llamaChkBox.toggled.connect(self.__use_llama_indexChecked) + self.__llamaChkBox.setText(LangClass.TRANSLATIONS["Use LlamaIndex (You need OpenAI API key)"]) + + lay = QVBoxLayout() + lay.addWidget(manualBrowser) + lay.addWidget(getSeparator("horizontal")) + lay.addWidget(systemlbl) + lay.addWidget(self.__systemTextEdit) + lay.addWidget(saveSystemBtn) + lay.addWidget(getSeparator("horizontal")) + lay.addWidget(setApiBtn) + lay.addWidget(selectModelWidget) + lay.addWidget(self.__warningLbl) + lay.addWidget(streamChkBox) + lay.addWidget(self.__jsonChkBox) + lay.addWidget(self.__llamaChkBox) + lay.addWidget(llamaManualLbl) + lay.addWidget(getSeparator("horizontal")) + lay.addWidget(advancedSettingsGrpBox) + lay.setAlignment(Qt.AlignmentFlag.AlignTop) + + self.setLayout(lay) + + def __saveSystem(self): + self.__system = self.__systemTextEdit.toPlainText() + CONFIG_MANAGER.set_general_property("system", self.__system) + + def __modelChanged(self, v): + self.__model = v + CONFIG_MANAGER.set_general_property("model", v) + # TODO LANGUAGE + additional_message = ( + "\nNote: The selected model is only available at Tier 3 or higher." + ) + if self.__model in O1_MODELS: + self.__warningLbl.setText(self.__warningMessage + additional_message) + else: + self.__warningLbl.setText(self.__warningMessage) + + def __streamChecked(self, f): + self.__stream = f + CONFIG_MANAGER.set_general_property("stream", f) + + def __jsonObjectChecked(self, f): + self.__json_object = f + CONFIG_MANAGER.set_general_property("json_object", f) + self.onToggleJSON.emit(f) + + def __use_llama_indexChecked(self, f): + self.__use_llama_index = f + CONFIG_MANAGER.set_general_property("use_llama_index", f) + if f: + # Set llama index directory if it exists + init_llama() + self.onToggleLlama.emit(f) + + def __useMaxChecked(self, f): + self.__use_max_tokens = f + CONFIG_MANAGER.set_general_property("use_max_tokens", f) + self.__maxTokensSpinBox.setEnabled(f) + + def __valueChanged(self, v): + sender = self.sender() + if sender == self.__temperatureSpinBox: + self.__temperature = v + CONFIG_MANAGER.set_general_property("temperature", v) + elif sender == self.__maxTokensSpinBox: + self.__max_tokens = v + CONFIG_MANAGER.set_general_property("max_tokens", v) + elif sender == self.__toppSpinBox: + self.__top_p = v + CONFIG_MANAGER.set_general_property("top_p", v) + elif sender == self.__frequencyPenaltySpinBox: + self.__frequency_penalty = v + CONFIG_MANAGER.set_general_property("frequency_penalty", v) + elif sender == self.__presencePenaltySpinBox: + self.__presence_penalty = v + CONFIG_MANAGER.set_general_property("presence_penalty", v) diff --git a/pyqt_openai/chat_widget/right_sidebar/usingG4FPage.py b/pyqt_openai/chat_widget/right_sidebar/usingG4FPage.py index d57c321b..7455e085 100644 --- a/pyqt_openai/chat_widget/right_sidebar/usingG4FPage.py +++ b/pyqt_openai/chat_widget/right_sidebar/usingG4FPage.py @@ -1,106 +1,108 @@ -from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import ( - QWidget, - QComboBox, - QCheckBox, - QFormLayout, - QTextBrowser, - QSizePolicy, -) - -from pyqt_openai import G4F_PROVIDER_DEFAULT -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.util.common import ( - get_g4f_providers, - get_g4f_models_by_provider, - get_chat_model, - get_g4f_models, - getSeparator, -) - - -class UsingG4FPage(QWidget): - onToggleLlama = Signal(bool) - onToggleJSON = Signal(bool) - - def __init__(self, parent=None): - super().__init__(parent) - self.__initVal() - self.__initUi() - - def __initVal(self): - self.__stream = CONFIG_MANAGER.get_general_property("stream") - self.__model = CONFIG_MANAGER.get_general_property("g4f_model") - self.__provider = CONFIG_MANAGER.get_general_property("provider") - - def __initUi(self): - manualBrowser = QTextBrowser() - manualBrowser.setOpenExternalLinks(True) - manualBrowser.setOpenLinks(True) - - # TODO LANGUAGE - manualBrowser.setHtml( - """ -

Using GPT4Free (Free)

-

Description

-

- Responses may often be slow or incomplete.

-

- The response server may be unstable.

- """ - ) - manualBrowser.setSizePolicy( - QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred - ) - - self.__modelCmbBox = QComboBox() - self.__modelCmbBox.addItems(get_chat_model(is_g4f=True)) - self.__modelCmbBox.setCurrentText(self.__model) - self.__modelCmbBox.currentTextChanged.connect(self.__modelChanged) - - streamChkBox = QCheckBox() - streamChkBox.setChecked(self.__stream) - streamChkBox.toggled.connect(self.__streamChecked) - streamChkBox.setText(LangClass.TRANSLATIONS["Stream"]) - - providerCmbBox = QComboBox() - providerCmbBox.addItems(get_g4f_providers(including_auto=True)) - providerCmbBox.setCurrentText(self.__provider) - providerCmbBox.currentTextChanged.connect(self.__providerChanged) - - # TODO LANGUAGE - # TODO NEEDS ADDITIONAL DESCRIPTION - g4f_use_chat_historyChkBox = QCheckBox("Use chat history") - g4f_use_chat_historyChkBox.setChecked( - CONFIG_MANAGER.get_general_property("g4f_use_chat_history") - ) - g4f_use_chat_historyChkBox.toggled.connect(self.__saveChatHistory) - - lay = QFormLayout() - lay.addRow(manualBrowser) - lay.addRow(getSeparator("horizontal")) - lay.addRow("Model", self.__modelCmbBox) - lay.addRow("Provider", providerCmbBox) - lay.addRow(streamChkBox) - lay.addRow(g4f_use_chat_historyChkBox) - lay.setAlignment(Qt.AlignmentFlag.AlignTop) - - self.setLayout(lay) - - def __modelChanged(self, v): - self.__model = v - CONFIG_MANAGER.set_general_property("g4f_model", v) - - def __streamChecked(self, f): - self.__stream = f - CONFIG_MANAGER.set_general_property("stream", f) - - def __providerChanged(self, v): - self.__modelCmbBox.clear() - CONFIG_MANAGER.set_general_property("provider", v) - if v == G4F_PROVIDER_DEFAULT: - self.__modelCmbBox.addItems(get_g4f_models()) - else: - self.__modelCmbBox.addItems(get_g4f_models_by_provider(v)) - - def __saveChatHistory(self, f): - CONFIG_MANAGER.set_general_property("g4f_use_chat_history", f) +from __future__ import annotations + +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import ( + QCheckBox, + QComboBox, + QFormLayout, + QSizePolicy, + QTextBrowser, + QWidget, +) + +from pyqt_openai import G4F_PROVIDER_DEFAULT +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.util.common import ( + getSeparator, + get_chat_model, + get_g4f_models, + get_g4f_models_by_provider, + get_g4f_providers, +) + + +class UsingG4FPage(QWidget): + onToggleLlama = Signal(bool) + onToggleJSON = Signal(bool) + + def __init__(self, parent=None): + super().__init__(parent) + self.__initVal() + self.__initUi() + + def __initVal(self): + self.__stream = CONFIG_MANAGER.get_general_property("stream") + self.__model = CONFIG_MANAGER.get_general_property("g4f_model") + self.__provider = CONFIG_MANAGER.get_general_property("provider") + + def __initUi(self): + manualBrowser = QTextBrowser() + manualBrowser.setOpenExternalLinks(True) + manualBrowser.setOpenLinks(True) + + # TODO LANGUAGE + manualBrowser.setHtml( + """ +

Using GPT4Free (Free)

+

Description

+

- Responses may often be slow or incomplete.

+

- The response server may be unstable.

+ """, + ) + manualBrowser.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred, + ) + + self.__modelCmbBox = QComboBox() + self.__modelCmbBox.addItems(get_chat_model(is_g4f=True)) + self.__modelCmbBox.setCurrentText(self.__model) + self.__modelCmbBox.currentTextChanged.connect(self.__modelChanged) + + streamChkBox = QCheckBox() + streamChkBox.setChecked(self.__stream) + streamChkBox.toggled.connect(self.__streamChecked) + streamChkBox.setText(LangClass.TRANSLATIONS["Stream"]) + + providerCmbBox = QComboBox() + providerCmbBox.addItems(get_g4f_providers(including_auto=True)) + providerCmbBox.setCurrentText(self.__provider) + providerCmbBox.currentTextChanged.connect(self.__providerChanged) + + # TODO LANGUAGE + # TODO NEEDS ADDITIONAL DESCRIPTION + g4f_use_chat_historyChkBox = QCheckBox("Use chat history") + g4f_use_chat_historyChkBox.setChecked( + CONFIG_MANAGER.get_general_property("g4f_use_chat_history"), + ) + g4f_use_chat_historyChkBox.toggled.connect(self.__saveChatHistory) + + lay = QFormLayout() + lay.addRow(manualBrowser) + lay.addRow(getSeparator("horizontal")) + lay.addRow("Model", self.__modelCmbBox) + lay.addRow("Provider", providerCmbBox) + lay.addRow(streamChkBox) + lay.addRow(g4f_use_chat_historyChkBox) + lay.setAlignment(Qt.AlignmentFlag.AlignTop) + + self.setLayout(lay) + + def __modelChanged(self, v): + self.__model = v + CONFIG_MANAGER.set_general_property("g4f_model", v) + + def __streamChecked(self, f): + self.__stream = f + CONFIG_MANAGER.set_general_property("stream", f) + + def __providerChanged(self, v): + self.__modelCmbBox.clear() + CONFIG_MANAGER.set_general_property("provider", v) + if v == G4F_PROVIDER_DEFAULT: + self.__modelCmbBox.addItems(get_g4f_models()) + else: + self.__modelCmbBox.addItems(get_g4f_models_by_provider(v)) + + def __saveChatHistory(self, f): + CONFIG_MANAGER.set_general_property("g4f_use_chat_history", f) diff --git a/pyqt_openai/config_loader.py b/pyqt_openai/config_loader.py index 0723436e..3f52c2a3 100644 --- a/pyqt_openai/config_loader.py +++ b/pyqt_openai/config_loader.py @@ -1,164 +1,194 @@ -import configparser -import os -import shutil - -import yaml - -from pyqt_openai import ( - CONFIG_DATA, - INI_FILE_NAME, - get_config_directory, - ROOT_DIR, - SRC_DIR, - DEFAULT_API_CONFIGS, -) - -_config_cache = None - - -def parse_value(value): - # Boolean conversion - if value.lower() in ("true", "false"): - return value.lower() == "true" - # Numeric conversion - try: - return int(value) - except ValueError: - try: - return float(value) - except ValueError: - pass - # Default: return the value as is (string) - return value - - -def convert_list(value): - # Convert comma-separated string to list - return [item.strip() for item in value.split(",")] - - -def init_yaml(): - yaml_data = CONFIG_DATA - - config_dir = get_config_directory() - config_path = os.path.join(config_dir, INI_FILE_NAME) - - if not os.path.exists(config_path): - # Save as YAML file - with open(config_path, "w") as yaml_file: - yaml.dump(yaml_data, yaml_file, default_flow_style=False) - else: - with open(config_path, "r") as yaml_file: - prev_yaml_data = yaml.safe_load(yaml_file) - # Add new keys - for section, values in yaml_data.items(): - if section not in prev_yaml_data: - prev_yaml_data[section] = values - else: - for key, value in values.items(): - if key not in prev_yaml_data[section]: - prev_yaml_data[section][key] = value - # Save as YAML file - with open(config_path, "w") as yaml_file: - yaml.dump(prev_yaml_data, yaml_file, default_flow_style=False) - - -class ConfigManager: - def __init__(self, yaml_file=INI_FILE_NAME): - self.yaml_file = yaml_file - self.config = self._load_yaml() - - def _load_yaml(self): - with open(self.yaml_file, "r") as file: - return yaml.safe_load(file) - - def _save_yaml(self): - with open(self.yaml_file, "w") as file: - yaml.safe_dump(self.config, file) - - # Getter methods - def get_dalle(self): - return self.config.get("DALLE", {}) - - def get_general(self): - return self.config.get("General", {}) - - def get_replicate(self): - return self.config.get("REPLICATE", {}) - - def get_g4f_image(self): - return self.config.get("G4F_IMAGE", {}) - - def get_dalle_property(self, key): - return self.config.get("DALLE", {}).get(key) - - def get_general_property(self, key): - return self.config.get("General", {}).get(key) - - def get_replicate_property(self, key): - return self.config.get("REPLICATE", {}).get(key) - - def get_g4f_image_property(self, key): - return self.config.get("G4F_IMAGE", {}).get(key) - - # Setter methods - def set_dalle_property(self, key, value): - if "DALLE" not in self.config: - self.config["DALLE"] = {} - self.config["DALLE"][key] = value - self._save_yaml() - - def set_general_property(self, key, value): - if "General" not in self.config: - self.config["General"] = {} - self.config["General"][key] = value - self._save_yaml() - - def set_replicate_property(self, key, value): - if "REPLICATE" not in self.config: - self.config["REPLICATE"] = {} - self.config["REPLICATE"][key] = value - self._save_yaml() - - def set_g4f_image_property(self, key, value): - if "G4F_IMAGE" not in self.config: - self.config["G4F_IMAGE"] = {} - self.config["G4F_IMAGE"][key] = value - self._save_yaml() - - -def update_api_key(yaml_file_path): - with open(yaml_file_path, "r") as file: - data = yaml.safe_load(file) - - # Rename API_KEY to OPENAI_API_KEY - if "General" in data and "API_KEY" in data["General"]: - data["General"]["OPENAI_API_KEY"] = data["General"].pop("API_KEY") - - # Rename CLAUDE_API_KEY to ANTHROPIC_API_KEY - if "General" in data and "CLAUDE_API_KEY" in data["General"]: - data["General"]["ANTHROPIC_API_KEY"] = data["General"].pop("CLAUDE_API_KEY") - os.environ["ANTHROPIC_API_KEY"] = data["General"]["ANTHROPIC_API_KEY"] - - # REPLICATE_API_TOKEN IS USED IN REPLICATE PACKAGE. - # REPLICATE_API_KEY IS USED IN LITELLM. - if "REPLICATE" in data and "REPLICATE_API_TOKEN" in data["REPLICATE"]: - os.environ["REPLICATE_API_KEY"] = data["REPLICATE"]["REPLICATE_API_TOKEN"] - - with open(yaml_file_path, "w") as file: - yaml.safe_dump(data, file) - - -def load_api_keys(): - env_vars = [item["env_var_name"] for item in DEFAULT_API_CONFIGS] - - # Set API keys - for key, value in CONFIG_MANAGER.get_general().items(): - if key in env_vars: - os.environ[key] = value - - -init_yaml() -update_api_key(INI_FILE_NAME) -CONFIG_MANAGER = ConfigManager() -load_api_keys() +from __future__ import annotations + +import os +import logging +import yaml +from typing import Union + +from pyqt_openai import CONFIG_DATA, DEFAULT_API_CONFIGS, INI_FILE_NAME, get_config_directory + +_config_cache: dict[str, dict[str, str]] | None = None + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def parse_value(value: str) -> Union[bool, int, float, str]: + # Boolean conversion + if value.lower() in ("true", "false"): + return value.lower() == "true" + # Numeric conversion + try: + return int(value) + except ValueError: + try: + return float(value) + except ValueError: + pass + # Default: return the value as is (string) + return value + + +def convert_list(value: str) -> list[str]: + # Convert comma-separated string to list + return [item.strip() for item in value.split(",")] + + +def init_yaml() -> None: + logger.info("Initializing YAML configuration") + yaml_data: dict[str, dict[str, str]] = CONFIG_DATA # type: ignore[assignment] + + config_dir: str = get_config_directory() + config_path: str = os.path.join(config_dir, INI_FILE_NAME) + logger.info(f"Config path: {config_path}") + + if not os.path.exists(config_path): + logger.info("Config file does not exist, creating new one") + # Save as YAML file + with open(config_path, "w") as yaml_file: + yaml.dump(yaml_data, yaml_file, default_flow_style=False) + else: + logger.info("Loading existing config file") + with open(config_path) as yaml_file: + prev_yaml_data: dict[str, dict[str, str]] = yaml.safe_load(yaml_file) + # Add new keys + for section, values in yaml_data.items(): + if section not in prev_yaml_data: + logger.info(f"Adding new section: {section}") + prev_yaml_data[section] = values + else: + for key, value in values.items(): + if key not in prev_yaml_data[section]: + logger.info(f"Adding new key in {section}: {key}") + prev_yaml_data[section][key] = value + # Save as YAML file + with open(config_path, "w") as yaml_file: + yaml.dump(prev_yaml_data, yaml_file, default_flow_style=False) + + +class ConfigManager: + def __init__( + self, + yaml_file: str = INI_FILE_NAME, + ) -> None: + self.yaml_file: str = yaml_file + logger.info(f"Initializing ConfigManager with file: {yaml_file}") + self.config: dict[str, dict[str, str]] = self._load_yaml() + + def _load_yaml(self) -> dict[str, dict[str, str]]: + logger.info(f"Loading YAML file: {self.yaml_file}") + with open(self.yaml_file) as file: + return yaml.safe_load(file) + + def _save_yaml(self): + logger.info(f"Saving YAML file: {self.yaml_file}") + with open(self.yaml_file, "w") as file: + yaml.safe_dump(self.config, file) + + # Getter methods + def get_dalle(self) -> dict[str, str]: + return self.config.get("DALLE", {}) + + def get_general(self) -> dict[str, str]: + return self.config.get("General", {}) + + def get_replicate(self) -> dict[str, str]: + return self.config.get("REPLICATE", {}) + + def get_g4f_image(self) -> dict[str, str]: + return self.config.get("G4F_IMAGE", {}) + + def get_dalle_property(self, key: str) -> str | None: + return self.config.get("DALLE", {}).get(key) + + def get_general_property(self, key: str) -> str | None: + value = self.config.get("General", {}).get(key) + logger.info(f"Getting general property {key}: {repr(value)}") + return value + + def get_replicate_property(self, key: str) -> str | None: + return self.config.get("REPLICATE", {}).get(key) + + def get_g4f_image_property(self, key: str) -> str | None: + return self.config.get("G4F_IMAGE", {}).get(key) + + # Setter methods + def set_dalle_property(self, key: str, value: str) -> None: + if "DALLE" not in self.config: + self.config["DALLE"] = {} + self.config["DALLE"][key] = value + self._save_yaml() + + def set_general_property(self, key: str, value: str) -> None: + logger.info(f"Setting general property {key} with value: {repr(value)}") + if "General" not in self.config: + self.config["General"] = {} + self.config["General"][key] = value + self._save_yaml() + + def set_replicate_property(self, key: str, value: str) -> None: + if "REPLICATE" not in self.config: + self.config["REPLICATE"] = {} + self.config["REPLICATE"][key] = value + self._save_yaml() + + def set_g4f_image_property(self, key: str, value: str) -> None: + if "G4F_IMAGE" not in self.config: + self.config["G4F_IMAGE"] = {} + self.config["G4F_IMAGE"][key] = value + self._save_yaml() + + +def update_api_key(yaml_file_path: str) -> None: + logger.info(f"Updating API keys in: {yaml_file_path}") + with open(yaml_file_path) as file: + data: dict[str, dict[str, str]] = yaml.safe_load(file) + + # Rename API_KEY to OPENAI_API_KEY + if "General" in data and "API_KEY" in data["General"]: + value = data["General"].pop("API_KEY") + logger.info(f"Converting API_KEY to OPENAI_API_KEY: {repr(value)}") + data["General"]["OPENAI_API_KEY"] = value + + # Rename CLAUDE_API_KEY to ANTHROPIC_API_KEY + if "General" in data and "CLAUDE_API_KEY" in data["General"]: + value = data["General"].pop("CLAUDE_API_KEY") + logger.info(f"Converting CLAUDE_API_KEY to ANTHROPIC_API_KEY: {repr(value)}") + data["General"]["ANTHROPIC_API_KEY"] = value + if value: # Only set if value is not empty + os.environ["ANTHROPIC_API_KEY"] = value + + # REPLICATE_API_TOKEN IS USED IN REPLICATE PACKAGE. + # REPLICATE_API_KEY IS USED IN LITELLM. + if "REPLICATE" in data and "REPLICATE_API_TOKEN" in data["REPLICATE"]: + value = data["REPLICATE"]["REPLICATE_API_TOKEN"] + logger.info(f"Setting REPLICATE_API_KEY from REPLICATE_API_TOKEN: {repr(value)}") + if value: # Only set if value is not empty + os.environ["REPLICATE_API_KEY"] = value + + with open(yaml_file_path, "w") as file: + yaml.safe_dump(data, file) + + +def load_api_keys() -> None: + logger.info("Loading API keys from config to environment") + env_vars: list[str] = [item["env_var_name"] for item in DEFAULT_API_CONFIGS] + logger.info(f"Environment variables to load: {env_vars}") + + # Set API keys + general_config = CONFIG_MANAGER.get_general() + logger.info(f"Current general config keys: {list(general_config.keys())}") + + for key, value in general_config.items(): + if key in env_vars and value: # Only set if value is not empty + logger.info(f"Setting environment variable {key}: {repr(value)}") + os.environ[key] = value + logger.info(f"Environment now has {key}: {key in os.environ}") + elif key in env_vars: + logger.info(f"Skipping empty value for {key}") + + +init_yaml() +update_api_key(INI_FILE_NAME) +CONFIG_MANAGER = ConfigManager() +load_api_keys() diff --git a/pyqt_openai/customizeDialog.py b/pyqt_openai/customizeDialog.py index 19da8c4d..0c81be2f 100644 --- a/pyqt_openai/customizeDialog.py +++ b/pyqt_openai/customizeDialog.py @@ -1,139 +1,139 @@ -from PySide6.QtCore import Qt -from PySide6.QtGui import QFont -from PySide6.QtWidgets import ( - QDialog, - QPushButton, - QHBoxLayout, - QVBoxLayout, - QWidget, - QFormLayout, - QSplitter, - QSizePolicy, -) - -from pyqt_openai import IMAGE_FILE_EXT_LIST_STR, DEFAULT_ICON_SIZE -from pyqt_openai.fontWidget import FontWidget -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.models import CustomizeParamsContainer -from pyqt_openai.util.common import getSeparator -from pyqt_openai.widgets.circleProfileImage import RoundedImage -from pyqt_openai.widgets.findPathWidget import FindPathWidget -from pyqt_openai.widgets.normalImageView import NormalImageView - - -class CustomizeDialog(QDialog): - def __init__(self, args: CustomizeParamsContainer, parent=None): - super().__init__(parent) - self.__initVal(args) - self.__initUi() - - def __initVal(self, args): - self.__background_image = args.background_image - self.__user_image = args.user_image - self.__ai_image = args.ai_image - self.__font_size = args.font_size - self.__font_family = args.font_family - - def __initUi(self): - self.setWindowTitle(LangClass.TRANSLATIONS["Customize"]) - self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) - - self.__homePageGraphicsView = NormalImageView() - self.__homePageGraphicsView.setFilename(self.__background_image) - - self.__userImage = RoundedImage() - self.__userImage.setMaximumSize(*DEFAULT_ICON_SIZE) - self.__userImage.setImage(self.__user_image) - self.__AIImage = RoundedImage() - self.__AIImage.setImage(self.__ai_image) - self.__AIImage.setMaximumSize(*DEFAULT_ICON_SIZE) - - self.__findPathWidget1 = FindPathWidget() - self.__findPathWidget1.getLineEdit().setText(self.__background_image) - self.__findPathWidget1.added.connect(self.__homePageGraphicsView.setFilename) - self.__findPathWidget1.setExtOfFiles(IMAGE_FILE_EXT_LIST_STR) - - self.__findPathWidget2 = FindPathWidget() - self.__findPathWidget2.getLineEdit().setText(self.__user_image) - self.__findPathWidget2.added.connect(self.__userImage.setImage) - self.__findPathWidget2.setExtOfFiles(IMAGE_FILE_EXT_LIST_STR) - - self.__findPathWidget3 = FindPathWidget() - self.__findPathWidget3.getLineEdit().setText(self.__ai_image) - self.__findPathWidget3.added.connect(self.__AIImage.setImage) - self.__findPathWidget3.setExtOfFiles(IMAGE_FILE_EXT_LIST_STR) - - lay1 = QVBoxLayout() - lay1.setContentsMargins(0, 0, 0, 0) - lay1.addWidget(self.__homePageGraphicsView) - lay1.addWidget(self.__findPathWidget1) - homePageWidget = QWidget() - homePageWidget.setLayout(lay1) - - lay2 = QHBoxLayout() - lay2.setContentsMargins(0, 0, 0, 0) - lay2.addWidget(self.__userImage) - lay2.addWidget(self.__findPathWidget2) - userWidget = QWidget() - userWidget.setLayout(lay2) - - lay3 = QHBoxLayout() - lay3.setContentsMargins(0, 0, 0, 0) - lay3.addWidget(self.__AIImage) - lay3.addWidget(self.__findPathWidget3) - aiWidget = QWidget() - aiWidget.setLayout(lay3) - - lay = QFormLayout() - lay.addRow(LangClass.TRANSLATIONS["Home Image"], homePageWidget) - lay.addRow(LangClass.TRANSLATIONS["User Image"], userWidget) - lay.addRow(LangClass.TRANSLATIONS["AI Image"], aiWidget) - - leftWidget = QWidget() - leftWidget.setLayout(lay) - - self.__fontWidget = FontWidget(QFont(self.__font_family, self.__font_size)) - - self.__splitter = QSplitter() - self.__splitter.addWidget(leftWidget) - self.__splitter.addWidget(self.__fontWidget) - self.__splitter.setHandleWidth(1) - self.__splitter.setChildrenCollapsible(False) - self.__splitter.setSizes([500, 500]) - self.__splitter.setStyleSheet("QSplitterHandle {background-color: lightgray;}") - self.__splitter.setSizePolicy( - QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding - ) - - sep = getSeparator("horizontal") - - self.__okBtn = QPushButton(LangClass.TRANSLATIONS["OK"]) - self.__okBtn.clicked.connect(self.accept) - - cancelBtn = QPushButton(LangClass.TRANSLATIONS["Cancel"]) - cancelBtn.clicked.connect(self.close) - - lay = QHBoxLayout() - lay.addWidget(self.__okBtn) - lay.addWidget(cancelBtn) - lay.setAlignment(Qt.AlignmentFlag.AlignRight) - lay.setContentsMargins(0, 0, 0, 0) - - okCancelWidget = QWidget() - okCancelWidget.setLayout(lay) - - lay = QVBoxLayout() - lay.addWidget(self.__splitter) - lay.addWidget(sep) - lay.addWidget(okCancelWidget) - - self.setLayout(lay) - - def getParam(self): - return CustomizeParamsContainer( - background_image=self.__findPathWidget1.getFileName(), - user_image=self.__findPathWidget2.getFileName(), - ai_image=self.__findPathWidget3.getFileName(), - font_size=self.__fontWidget.getFont().pointSize(), - font_family=self.__fontWidget.getFont().family(), - ) +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtGui import QFont +from qtpy.QtWidgets import QDialog, QFormLayout, QHBoxLayout, QPushButton, QSizePolicy, QSplitter, QVBoxLayout, QWidget + +from pyqt_openai import DEFAULT_ICON_SIZE, IMAGE_FILE_EXT_LIST_STR +from pyqt_openai.fontWidget import FontWidget +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.models import CustomizeParamsContainer +from pyqt_openai.util.common import getSeparator +from pyqt_openai.widgets.circleProfileImage import RoundedImage +from pyqt_openai.widgets.findPathWidget import FindPathWidget +from pyqt_openai.widgets.normalImageView import NormalImageView + + +class CustomizeDialog(QDialog): + def __init__( + self, + args: CustomizeParamsContainer, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.__initVal(args) + self.__initUi() + + def __initVal( + self, + args: CustomizeParamsContainer, + ) -> None: + self.__background_image: str = args.background_image + self.__user_image: str = args.user_image + self.__ai_image: str = args.ai_image + self.__font_size: int = args.font_size + self.__font_family: str = args.font_family + + def __initUi(self): + self.setWindowTitle(LangClass.TRANSLATIONS["Customize"]) + self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) + + self.__homePageGraphicsView: NormalImageView = NormalImageView() + self.__homePageGraphicsView.setFilename(self.__background_image) + + self.__userImage: RoundedImage = RoundedImage() + self.__userImage.setMaximumSize(*DEFAULT_ICON_SIZE) + self.__userImage.setImage(self.__user_image) + self.__AIImage: RoundedImage = RoundedImage() + self.__AIImage.setImage(self.__ai_image) + self.__AIImage.setMaximumSize(*DEFAULT_ICON_SIZE) + + self.__findPathWidget1: FindPathWidget = FindPathWidget() + self.__findPathWidget1.getLineEdit().setText(self.__background_image) + self.__findPathWidget1.added.connect(self.__homePageGraphicsView.setFilename) + self.__findPathWidget1.setExtOfFiles(IMAGE_FILE_EXT_LIST_STR) + + self.__findPathWidget2: FindPathWidget = FindPathWidget() + self.__findPathWidget2.getLineEdit().setText(self.__user_image) + self.__findPathWidget2.added.connect(self.__userImage.setImage) + self.__findPathWidget2.setExtOfFiles(IMAGE_FILE_EXT_LIST_STR) + + self.__findPathWidget3: FindPathWidget = FindPathWidget() + self.__findPathWidget3.getLineEdit().setText(self.__ai_image) + self.__findPathWidget3.added.connect(self.__AIImage.setImage) + self.__findPathWidget3.setExtOfFiles(IMAGE_FILE_EXT_LIST_STR) + + lay1 = QVBoxLayout() + lay1.setContentsMargins(0, 0, 0, 0) + lay1.addWidget(self.__homePageGraphicsView) + lay1.addWidget(self.__findPathWidget1) + homePageWidget = QWidget() + homePageWidget.setLayout(lay1) + + lay2 = QHBoxLayout() + lay2.setContentsMargins(0, 0, 0, 0) + lay2.addWidget(self.__userImage) + lay2.addWidget(self.__findPathWidget2) + userWidget = QWidget() + userWidget.setLayout(lay2) + + lay3 = QHBoxLayout() + lay3.setContentsMargins(0, 0, 0, 0) + lay3.addWidget(self.__AIImage) + lay3.addWidget(self.__findPathWidget3) + aiWidget = QWidget() + aiWidget.setLayout(lay3) + + lay = QFormLayout() + lay.addRow(LangClass.TRANSLATIONS["Home Image"], homePageWidget) + lay.addRow(LangClass.TRANSLATIONS["User Image"], userWidget) + lay.addRow(LangClass.TRANSLATIONS["AI Image"], aiWidget) + + leftWidget = QWidget() + leftWidget.setLayout(lay) + + self.__fontWidget: FontWidget = FontWidget(QFont(self.__font_family, self.__font_size)) + + self.__splitter: QSplitter = QSplitter() + self.__splitter.addWidget(leftWidget) + self.__splitter.addWidget(self.__fontWidget) + self.__splitter.setHandleWidth(1) + self.__splitter.setChildrenCollapsible(False) + self.__splitter.setSizes([500, 500]) + self.__splitter.setStyleSheet("QSplitterHandle {background-color: lightgray;}") + self.__splitter.setSizePolicy( + QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.MinimumExpanding, + ) + + sep = getSeparator("horizontal") + + self.__okBtn: QPushButton = QPushButton(LangClass.TRANSLATIONS["OK"]) + self.__okBtn.clicked.connect(self.accept) + + cancelBtn = QPushButton(LangClass.TRANSLATIONS["Cancel"]) + cancelBtn.clicked.connect(self.close) + + lay = QHBoxLayout() + lay.addWidget(self.__okBtn) + lay.addWidget(cancelBtn) + lay.setAlignment(Qt.AlignmentFlag.AlignRight) + lay.setContentsMargins(0, 0, 0, 0) + + okCancelWidget = QWidget() + okCancelWidget.setLayout(lay) + + lay = QVBoxLayout() + lay.addWidget(self.__splitter) + lay.addWidget(sep) + lay.addWidget(okCancelWidget) + + self.setLayout(lay) + + def getParam(self) -> CustomizeParamsContainer: + return CustomizeParamsContainer( + background_image=self.__findPathWidget1.getFileName(), + user_image=self.__findPathWidget2.getFileName(), + ai_image=self.__findPathWidget3.getFileName(), + font_size=self.__fontWidget.getFont().pointSize(), + font_family=self.__fontWidget.getFont().family(), + ) diff --git a/pyqt_openai/dalle_widget/dalleHome.py b/pyqt_openai/dalle_widget/dalleHome.py index 51549ec4..a54f76b5 100644 --- a/pyqt_openai/dalle_widget/dalleHome.py +++ b/pyqt_openai/dalle_widget/dalleHome.py @@ -1,32 +1,34 @@ -from PySide6.QtCore import Qt -from PySide6.QtGui import QFont -from PySide6.QtWidgets import QLabel, QWidget, QVBoxLayout, QScrollArea - -from pyqt_openai import CONTEXT_DELIMITER, LARGE_LABEL_PARAM, MEDIUM_LABEL_PARAM - - -class DallEHome(QScrollArea): - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - title = QLabel("Welcome to DALL-E Page !", self) - title.setFont(QFont(*LARGE_LABEL_PARAM)) - title.setAlignment(Qt.AlignmentFlag.AlignCenter) - - description = QLabel("Generate images with DALL-E." + CONTEXT_DELIMITER) - - description.setFont(QFont(*MEDIUM_LABEL_PARAM)) - description.setAlignment(Qt.AlignmentFlag.AlignCenter) - - lay = QVBoxLayout() - lay.addWidget(title) - lay.addWidget(description) - lay.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.setLayout(lay) - - mainWidget = QWidget() - mainWidget.setLayout(lay) - self.setWidget(mainWidget) - self.setWidgetResizable(True) +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtGui import QFont +from qtpy.QtWidgets import QLabel, QScrollArea, QVBoxLayout, QWidget + +from pyqt_openai import CONTEXT_DELIMITER, LARGE_LABEL_PARAM, MEDIUM_LABEL_PARAM + + +class DallEHome(QScrollArea): + def __init__(self, parent=None): + super().__init__(parent) + self.__initUi() + + def __initUi(self): + title = QLabel("Welcome to DALL-E Page !", self) + title.setFont(QFont(*LARGE_LABEL_PARAM)) + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + + description = QLabel("Generate images with DALL-E." + CONTEXT_DELIMITER) + + description.setFont(QFont(*MEDIUM_LABEL_PARAM)) + description.setAlignment(Qt.AlignmentFlag.AlignCenter) + + lay = QVBoxLayout() + lay.addWidget(title) + lay.addWidget(description) + lay.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setLayout(lay) + + mainWidget = QWidget() + mainWidget.setLayout(lay) + self.setWidget(mainWidget) + self.setWidgetResizable(True) diff --git a/pyqt_openai/dalle_widget/dalleMainWidget.py b/pyqt_openai/dalle_widget/dalleMainWidget.py index a85eaedf..327a5506 100644 --- a/pyqt_openai/dalle_widget/dalleMainWidget.py +++ b/pyqt_openai/dalle_widget/dalleMainWidget.py @@ -1,26 +1,28 @@ -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.dalle_widget.dalleHome import DallEHome -from pyqt_openai.dalle_widget.dalleRightSideBar import DallERightSideBarWidget -from pyqt_openai.widgets.imageMainWidget import ImageMainWidget - - -class DallEMainWidget(ImageMainWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - self._homePage = DallEHome() - self._rightSideBarWidget = DallERightSideBarWidget() - - self._setHomeWidget(self._homePage) - self._setRightSideBarWidget(self._rightSideBarWidget) - self._completeUi() - - def toggleHistory(self, f): - super().toggleHistory(f) - CONFIG_MANAGER.set_dalle_property("show_history", f) - - def toggleSetting(self, f): - super().toggleSetting(f) - CONFIG_MANAGER.set_dalle_property("show_setting", f) +from __future__ import annotations + +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.dalle_widget.dalleHome import DallEHome +from pyqt_openai.dalle_widget.dalleRightSideBar import DallERightSideBarWidget +from pyqt_openai.widgets.imageMainWidget import ImageMainWidget + + +class DallEMainWidget(ImageMainWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.__initUi() + + def __initUi(self): + self._homePage = DallEHome() + self._rightSideBarWidget = DallERightSideBarWidget() + + self._setHomeWidget(self._homePage) + self._setRightSideBarWidget(self._rightSideBarWidget) + self._completeUi() + + def toggleHistory(self, f): + super().toggleHistory(f) + CONFIG_MANAGER.set_dalle_property("show_history", f) + + def toggleSetting(self, f): + super().toggleSetting(f) + CONFIG_MANAGER.set_dalle_property("show_setting", f) diff --git a/pyqt_openai/dalle_widget/dalleRightSideBar.py b/pyqt_openai/dalle_widget/dalleRightSideBar.py index 922e7a16..436915ae 100644 --- a/pyqt_openai/dalle_widget/dalleRightSideBar.py +++ b/pyqt_openai/dalle_widget/dalleRightSideBar.py @@ -1,229 +1,221 @@ -from PySide6.QtWidgets import ( - QSpinBox, - QGroupBox, - QVBoxLayout, - QComboBox, - QPlainTextEdit, - QFormLayout, - QLabel, - QRadioButton, -) - -from pyqt_openai import OPENAI_DEFAULT_IMAGE_MODEL -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.dalle_widget.dalleThread import DallEThread -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.widgets.imageControlWidget import ImageControlWidget -from pyqt_openai.widgets.APIInputButton import APIInputButton - - -class DallERightSideBarWidget(ImageControlWidget): - def __init__(self, parent=None): - super().__init__(parent) - self._initVal() - self._initUi() - - def _initVal(self): - super()._initVal() - - self._prompt = CONFIG_MANAGER.get_dalle_property("prompt") - self._continue_generation = CONFIG_MANAGER.get_dalle_property( - "continue_generation" - ) - self._save_prompt_as_text = CONFIG_MANAGER.get_dalle_property( - "save_prompt_as_text" - ) - self._is_save = CONFIG_MANAGER.get_dalle_property("is_save") - self._directory = CONFIG_MANAGER.get_dalle_property("directory") - self._number_of_images_to_create = CONFIG_MANAGER.get_dalle_property( - "number_of_images_to_create" - ) - - self.__quality = CONFIG_MANAGER.get_dalle_property("quality") - self.__n = CONFIG_MANAGER.get_dalle_property("n") - self.__size = CONFIG_MANAGER.get_dalle_property("size") - self.__style = CONFIG_MANAGER.get_dalle_property("style") - self.__response_format = CONFIG_MANAGER.get_dalle_property("response_format") - self.__prompt_type = CONFIG_MANAGER.get_dalle_property("prompt_type") - self.__width = CONFIG_MANAGER.get_dalle_property("width") - self.__height = CONFIG_MANAGER.get_dalle_property("height") - - def _initUi(self): - super()._initUi() - - # TODO LANGUAGE - self.__setApiBtn = APIInputButton() - self.__setApiBtn.setText("Set API Key") - - self.__promptTypeToShowRadioGrpBox = QGroupBox( - LangClass.TRANSLATIONS["Prompt Type To Show"] - ) - - self.__normalOne = QRadioButton(LangClass.TRANSLATIONS["Normal"]) - self.__revisedOne = QRadioButton(LangClass.TRANSLATIONS["Revised"]) - - if self.__prompt_type == 1: - self.__normalOne.setChecked(True) - else: - self.__revisedOne.setChecked(True) - - self.__normalOne.toggled.connect(self.__promptTypeToggled) - self.__revisedOne.toggled.connect(self.__promptTypeToggled) - - lay = QVBoxLayout() - lay.addWidget(self.__normalOne) - lay.addWidget(self.__revisedOne) - self.__promptTypeToShowRadioGrpBox.setLayout(lay) - - lay = QVBoxLayout() - lay.addWidget(self.__setApiBtn) - lay.addWidget(self._findPathWidget) - lay.addWidget(self._saveChkBox) - lay.addWidget(self._continueGenerationChkBox) - lay.addWidget(self._numberOfImagesToCreateSpinBox) - lay.addWidget(self._savePromptAsTextChkBox) - lay.addWidget(self.__promptTypeToShowRadioGrpBox) - self._generalGrpBox.setLayout(lay) - - self.__qualityCmbBox = QComboBox() - self.__qualityCmbBox.addItems(["standard", "hd"]) - self.__qualityCmbBox.setCurrentText(self.__quality) - self.__qualityCmbBox.currentTextChanged.connect(self.__dalleChanged) - - self.__nSpinBox = QSpinBox() - self.__nSpinBox.setRange(1, 10) - self.__nSpinBox.setValue(self.__n) - self.__nSpinBox.valueChanged.connect(self.__dalleChanged) - self.__nSpinBox.setEnabled(False) - - self.__sizeLimitLabel = QLabel( - LangClass.TRANSLATIONS[ - "※ Images can have a size of 1024x1024, 1024x1792 or 1792x1024 pixels." - ] - ) - self.__sizeLimitLabel.setWordWrap(True) - - self.__widthCmbBox = QComboBox() - self.__widthCmbBox.addItems(["1024", "1792"]) - self.__widthCmbBox.setCurrentText(str(self.__width)) - self.__widthCmbBox.currentTextChanged.connect(self.__dalleChanged) - - self.__heightCmbBox = QComboBox() - self.__heightCmbBox.addItems(["1024", "1792"]) - self.__heightCmbBox.setCurrentText(str(self.__height)) - self.__heightCmbBox.currentTextChanged.connect(self.__dalleChanged) - - self._promptTextEdit.textChanged.connect(self.__dalleTextChanged) - - self.__styleCmbBox = QComboBox() - self.__styleCmbBox.addItems(["vivid", "natural"]) - self.__styleCmbBox.currentTextChanged.connect(self.__dalleChanged) - - lay = QFormLayout() - lay.addRow(LangClass.TRANSLATIONS["Quality"], self.__qualityCmbBox) - lay.addRow(LangClass.TRANSLATIONS["Total"], self.__nSpinBox) - lay.addRow(self.__sizeLimitLabel) - lay.addRow(LangClass.TRANSLATIONS["Width"], self.__widthCmbBox) - lay.addRow(LangClass.TRANSLATIONS["Height"], self.__heightCmbBox) - lay.addRow(LangClass.TRANSLATIONS["Style"], self.__styleCmbBox) - - lay.addRow(self._randomImagePromptGeneratorWidget) - lay.addRow(QLabel(LangClass.TRANSLATIONS["Prompt"])) - lay.addRow(self._promptTextEdit) - - self._paramGrpBox.setLayout(lay) - - self._completeUi() - - def __dalleChanged(self, v): - sender = self.sender() - if sender == self.__qualityCmbBox: - self.__quality = v - CONFIG_MANAGER.set_dalle_property("quality", self.__quality) - elif sender == self.__nSpinBox: - self.__n = v - CONFIG_MANAGER.set_dalle_property("n", self.__n) - elif sender == self.__widthCmbBox: - if ( - self.__widthCmbBox.currentText() == "1792" - and self.__heightCmbBox.currentText() == "1792" - ): - self.__heightCmbBox.setCurrentText("1024") - self.__width = v - CONFIG_MANAGER.set_dalle_property("width", self.__width) - elif sender == self.__heightCmbBox: - if ( - self.__widthCmbBox.currentText() == "1792" - and self.__heightCmbBox.currentText() == "1792" - ): - self.__widthCmbBox.setCurrentText("1024") - self.__height = v - CONFIG_MANAGER.set_dalle_property("height", self.__height) - elif sender == self.__styleCmbBox: - self.__style = v - CONFIG_MANAGER.set_dalle_property("style", self.__style) - - # TODO combine __dalleTextChanged and __replicateTextChanged and rename them to __promptTextChanged - def __dalleTextChanged(self): - sender = self.sender() - if isinstance(sender, QPlainTextEdit): - if sender == self._promptTextEdit: - self._prompt = sender.toPlainText() - CONFIG_MANAGER.set_dalle_property("prompt", self._prompt) - - def _setSaveDirectory(self, directory): - super()._setSaveDirectory(directory) - CONFIG_MANAGER.set_dalle_property("directory", directory) - - def _saveChkBoxToggled(self, f): - super()._saveChkBoxToggled(f) - CONFIG_MANAGER.set_dalle_property("is_save", f) - - def _continueGenerationChkBoxToggled(self, f): - super()._continueGenerationChkBoxToggled(f) - CONFIG_MANAGER.set_dalle_property("continue_generation", f) - - def _savePromptAsTextChkBoxToggled(self, f): - super()._savePromptAsTextChkBoxToggled(f) - CONFIG_MANAGER.set_dalle_property("save_prompt_as_text", f) - - def _numberOfImagesToCreateSpinBoxValueChanged(self, value): - super()._numberOfImagesToCreateSpinBoxValueChanged(value) - CONFIG_MANAGER.set_dalle_property("number_of_images_to_create", value) - - def __promptTypeToggled(self, f): - sender = self.sender() - # Prompt type to show on the image - # 1 is normal, 2 is revised - if sender == self.__normalOne: - self.__prompt_type = 1 - CONFIG_MANAGER.set_dalle_property("prompt_type", self.__prompt_type) - elif sender == self.__revisedOne: - self.__prompt_type = 2 - CONFIG_MANAGER.set_dalle_property("prompt_type", self.__prompt_type) - - def _submit(self): - arg = self.getArgument() - number_of_images = ( - self._number_of_images_to_create if self._continue_generation else 1 - ) - random_prompt = ( - self._randomImagePromptGeneratorWidget.getRandomPromptSourceArr() - ) - - t = DallEThread(arg, number_of_images, random_prompt) - self._setThread(t) - super()._submit() - - def getArgument(self): - obj = super().getArgument() - return { - **obj, - "model": OPENAI_DEFAULT_IMAGE_MODEL, - "prompt": self._promptTextEdit.toPlainText(), - "n": self.__n, - "size": f"{self.__width}x{self.__height}", - "quality": self.__quality, - "style": self.__style, - "response_format": self.__response_format, - } +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from qtpy.QtWidgets import QComboBox, QFormLayout, QGroupBox, QLabel, QPlainTextEdit, QRadioButton, QSpinBox, QVBoxLayout + +from pyqt_openai import OPENAI_DEFAULT_IMAGE_MODEL +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.dalle_widget.dalleThread import DallEThread +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.widgets.APIInputButton import APIInputButton +from pyqt_openai.widgets.imageControlWidget import ImageControlWidget + +if TYPE_CHECKING: + from qtpy.QtWidgets import QWidget + + +class DallERightSideBarWidget(ImageControlWidget): + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self._initVal() + self._initUi() + + def _initVal(self): + super()._initVal() + + self._prompt: str = CONFIG_MANAGER.get_dalle_property("prompt") or "" + self._continue_generation: bool = bool(CONFIG_MANAGER.get_dalle_property("continue_generation")) + self._save_prompt_as_text: bool = bool(CONFIG_MANAGER.get_dalle_property("save_prompt_as_text")) + self._is_save: bool = bool(CONFIG_MANAGER.get_dalle_property("is_save")) + self._directory: str = CONFIG_MANAGER.get_dalle_property("directory") or "" + self._number_of_images_to_create: int = int(CONFIG_MANAGER.get_dalle_property("number_of_images_to_create") or 1) + + self.__quality: str = CONFIG_MANAGER.get_dalle_property("quality") or "standard" + self.__n: int = int(CONFIG_MANAGER.get_dalle_property("n") or 1) + self.__size: str = CONFIG_MANAGER.get_dalle_property("size") or "1024x1024" + self.__style: str = CONFIG_MANAGER.get_dalle_property("style") or "vivid" + self.__response_format: str = CONFIG_MANAGER.get_dalle_property("response_format") or "url" + self.__prompt_type: int = int(CONFIG_MANAGER.get_dalle_property("prompt_type") or 1) + self.__width: int = int(CONFIG_MANAGER.get_dalle_property("width") or 1024) + self.__height: int = int(CONFIG_MANAGER.get_dalle_property("height") or 1024) + + def _initUi(self): + super()._initUi() + + # TODO LANGUAGE + self.__setApiBtn = APIInputButton() + self.__setApiBtn.setText("Set API Key") + + self.__promptTypeToShowRadioGrpBox = QGroupBox( + LangClass.TRANSLATIONS["Prompt Type To Show"], + ) + + self.__normalOne = QRadioButton(LangClass.TRANSLATIONS["Normal"]) + self.__revisedOne = QRadioButton(LangClass.TRANSLATIONS["Revised"]) + + if self.__prompt_type == 1: + self.__normalOne.setChecked(True) + else: + self.__revisedOne.setChecked(True) + + self.__normalOne.toggled.connect(self.__promptTypeToggled) + self.__revisedOne.toggled.connect(self.__promptTypeToggled) + + lay = QVBoxLayout() + lay.addWidget(self.__normalOne) + lay.addWidget(self.__revisedOne) + self.__promptTypeToShowRadioGrpBox.setLayout(lay) + + lay = QVBoxLayout() + lay.addWidget(self.__setApiBtn) + lay.addWidget(self._findPathWidget) + lay.addWidget(self._saveChkBox) + lay.addWidget(self._continueGenerationChkBox) + lay.addWidget(self._numberOfImagesToCreateSpinBox) + lay.addWidget(self._savePromptAsTextChkBox) + lay.addWidget(self.__promptTypeToShowRadioGrpBox) + self._generalGrpBox.setLayout(lay) + + self.__qualityCmbBox = QComboBox() + self.__qualityCmbBox.addItems(["standard", "hd"]) + self.__qualityCmbBox.setCurrentText(self.__quality or "standard") + self.__qualityCmbBox.currentTextChanged.connect(self.__dalleChanged) + + self.__nSpinBox = QSpinBox() + self.__nSpinBox.setRange(1, 10) + self.__nSpinBox.setValue(int(self.__n or 1)) + self.__nSpinBox.valueChanged.connect(self.__dalleChanged) + self.__nSpinBox.setEnabled(False) + + self.__sizeLimitLabel = QLabel( + LangClass.TRANSLATIONS[ + "※ Images can have a size of 1024x1024, 1024x1792 or 1792x1024 pixels." + ], + ) + self.__sizeLimitLabel.setWordWrap(True) + + self.__widthCmbBox = QComboBox() + self.__widthCmbBox.addItems(["1024", "1792"]) + self.__widthCmbBox.setCurrentText(str(self.__width)) + self.__widthCmbBox.currentTextChanged.connect(self.__dalleChanged) + + self.__heightCmbBox = QComboBox() + self.__heightCmbBox.addItems(["1024", "1792"]) + self.__heightCmbBox.setCurrentText(str(self.__height)) + self.__heightCmbBox.currentTextChanged.connect(self.__dalleChanged) + + self._promptTextEdit.textChanged.connect(self.__dalleTextChanged) + + self.__styleCmbBox = QComboBox() + self.__styleCmbBox.addItems(["vivid", "natural"]) + self.__styleCmbBox.currentTextChanged.connect(self.__dalleChanged) + + lay = QFormLayout() + lay.addRow(LangClass.TRANSLATIONS["Quality"], self.__qualityCmbBox) + lay.addRow(LangClass.TRANSLATIONS["Total"], self.__nSpinBox) + lay.addRow(self.__sizeLimitLabel) + lay.addRow(LangClass.TRANSLATIONS["Width"], self.__widthCmbBox) + lay.addRow(LangClass.TRANSLATIONS["Height"], self.__heightCmbBox) + lay.addRow(LangClass.TRANSLATIONS["Style"], self.__styleCmbBox) + + lay.addRow(self._randomImagePromptGeneratorWidget) + lay.addRow(QLabel(LangClass.TRANSLATIONS["Prompt"])) + lay.addRow(self._promptTextEdit) + + self._paramGrpBox.setLayout(lay) + + self._completeUi() + + def __dalleChanged(self, v): + sender = self.sender() + if sender == self.__qualityCmbBox: + self.__quality = v + CONFIG_MANAGER.set_dalle_property("quality", str(self.__quality)) + elif sender == self.__nSpinBox: + self.__n = v + CONFIG_MANAGER.set_dalle_property("n", str(self.__n)) + elif sender == self.__widthCmbBox: + if ( + self.__widthCmbBox.currentText() == "1792" + and self.__heightCmbBox.currentText() == "1792" + ): + self.__heightCmbBox.setCurrentText("1024") + self.__width = v + CONFIG_MANAGER.set_dalle_property("width", str(self.__width)) + elif sender == self.__heightCmbBox: + if ( + self.__widthCmbBox.currentText() == "1792" + and self.__heightCmbBox.currentText() == "1792" + ): + self.__widthCmbBox.setCurrentText("1024") + self.__height = v + CONFIG_MANAGER.set_dalle_property("height", str(self.__height)) + elif sender == self.__styleCmbBox: + self.__style = v + CONFIG_MANAGER.set_dalle_property("style", str(self.__style)) + + # TODO combine __dalleTextChanged and __replicateTextChanged and rename them to __promptTextChanged + def __dalleTextChanged(self): + sender = self.sender() + if isinstance(sender, QPlainTextEdit): + if sender == self._promptTextEdit: + self._prompt = sender.toPlainText() + CONFIG_MANAGER.set_dalle_property("prompt", self._prompt) + + def _setSaveDirectory(self, directory: str): + super()._setSaveDirectory(directory) + CONFIG_MANAGER.set_dalle_property("directory", directory) + + def _saveChkBoxToggled(self, f: bool): + super()._saveChkBoxToggled(f) + CONFIG_MANAGER.set_dalle_property("is_save", str(f)) + + def _continueGenerationChkBoxToggled(self, f: bool): + super()._continueGenerationChkBoxToggled(f) + CONFIG_MANAGER.set_dalle_property("continue_generation", str(f)) + + def _savePromptAsTextChkBoxToggled(self, f: bool): + super()._savePromptAsTextChkBoxToggled(f) + CONFIG_MANAGER.set_dalle_property("save_prompt_as_text", str(f)) + + def _numberOfImagesToCreateSpinBoxValueChanged(self, value: int): + super()._numberOfImagesToCreateSpinBoxValueChanged(value) + CONFIG_MANAGER.set_dalle_property("number_of_images_to_create", str(value)) + + def __promptTypeToggled(self, f: bool): + sender = self.sender() + # Prompt type to show on the image + # 1 is normal, 2 is revised + if sender == self.__normalOne: + self.__prompt_type = 1 + CONFIG_MANAGER.set_dalle_property("prompt_type", str(self.__prompt_type)) + elif sender == self.__revisedOne: + self.__prompt_type = 2 + CONFIG_MANAGER.set_dalle_property("prompt_type", str(self.__prompt_type)) + + def _submit(self): + arg = self.getArgument() + number_of_images = ( + self._number_of_images_to_create if self._continue_generation else 1 + ) + random_prompt = ( + self._randomImagePromptGeneratorWidget.getRandomPromptSourceArr() + ) + + t = DallEThread(arg, number_of_images, random_prompt) + self._setThread(t) + super()._submit() + + def getArgument(self) -> dict[str, Any]: + obj = super().getArgument() + return { + **obj, + "model": OPENAI_DEFAULT_IMAGE_MODEL, + "prompt": self._promptTextEdit.toPlainText(), + "n": self.__n, + "size": f"{self.__width}x{self.__height}", + "quality": self.__quality, + "style": self.__style, + "response_format": self.__response_format, + } diff --git a/pyqt_openai/dalle_widget/dalleThread.py b/pyqt_openai/dalle_widget/dalleThread.py index 75b9dbfe..ac6a30cd 100644 --- a/pyqt_openai/dalle_widget/dalleThread.py +++ b/pyqt_openai/dalle_widget/dalleThread.py @@ -1,48 +1,50 @@ -import base64 - -from PySide6.QtCore import QThread, Signal - -from pyqt_openai.models import ImagePromptContainer -from pyqt_openai.globals import OPENAI_CLIENT -from pyqt_openai.util.common import generate_random_prompt - - -class DallEThread(QThread): - replyGenerated = Signal(ImagePromptContainer) - errorGenerated = Signal(str) - allReplyGenerated = Signal() - - def __init__( - self, input_args, number_of_images, randomizing_prompt_source_arr=None - ): - super().__init__() - self.__input_args = input_args - self.__stop = False - - self.__randomizing_prompt_source_arr = randomizing_prompt_source_arr - self.__number_of_images = number_of_images - - def stop(self): - self.__stop = True - - def run(self): - try: - for _ in range(self.__number_of_images): - if self.__stop: - break - if self.__randomizing_prompt_source_arr is not None: - self.__input_args["prompt"] = generate_random_prompt( - self.__randomizing_prompt_source_arr - ) - response = OPENAI_CLIENT.images.generate(**self.__input_args) - container = ImagePromptContainer(**self.__input_args) - for _ in response.data: - image_data = base64.b64decode(_.b64_json) - container.data = image_data - container.revised_prompt = _.revised_prompt - container.width = self.__input_args["size"].split("x")[0] - container.height = self.__input_args["size"].split("x")[1] - self.replyGenerated.emit(container) - self.allReplyGenerated.emit() - except Exception as e: - self.errorGenerated.emit(str(e)) +from __future__ import annotations + +import base64 + +from qtpy.QtCore import QThread, Signal + +from pyqt_openai.globals import OPENAI_CLIENT +from pyqt_openai.models import ImagePromptContainer +from pyqt_openai.util.common import generate_random_prompt + + +class DallEThread(QThread): + replyGenerated = Signal(ImagePromptContainer) + errorGenerated = Signal(str) + allReplyGenerated = Signal() + + def __init__( + self, input_args, number_of_images, randomizing_prompt_source_arr=None, + ): + super().__init__() + self.__input_args = input_args + self.__stop = False + + self.__randomizing_prompt_source_arr = randomizing_prompt_source_arr + self.__number_of_images = number_of_images + + def stop(self): + self.__stop = True + + def run(self): + try: + for _ in range(self.__number_of_images): + if self.__stop: + break + if self.__randomizing_prompt_source_arr is not None: + self.__input_args["prompt"] = generate_random_prompt( + self.__randomizing_prompt_source_arr, + ) + response = OPENAI_CLIENT.images.generate(**self.__input_args) + container = ImagePromptContainer(**self.__input_args) + for _ in response.data: + image_data = base64.b64decode(_.b64_json) + container.data = image_data + container.revised_prompt = _.revised_prompt + container.width = self.__input_args["size"].split("x")[0] + container.height = self.__input_args["size"].split("x")[1] + self.replyGenerated.emit(container) + self.allReplyGenerated.emit() + except Exception as e: + self.errorGenerated.emit(str(e)) diff --git a/pyqt_openai/diff.txt b/pyqt_openai/diff.txt new file mode 100644 index 0000000000000000000000000000000000000000..0cc589dd82d979c21b757ad2a8d831e02c5f44b3 GIT binary patch literal 888974 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≧&80{&iRQ4bs9`FU8W%kXrF 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

NvI_ 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%0AM=V!#6mZlfqdrmRYpQEnB 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 None: + super().__init__(parent) + do_not_ask_again_message: str = LangClass.TRANSLATIONS[ + "Would you like to exit the application? If you won't, it will be running in the background." + ] + do_not_ask_again_checkbox_message = LangClass.TRANSLATIONS["Do not ask again"] + self.__initVal( + do_not_ask_again, + do_not_ask_again_message, + do_not_ask_again_checkbox_message, + ) + self.__initUi() + + def __initVal( + self, + do_not_ask_again, + do_not_ask_again_message, + do_not_ask_again_checkbox_message, + ): + self.__is_cancel = False + self.__do_not_ask_again = do_not_ask_again + self.__do_not_ask_again_message = do_not_ask_again_message + self.__do_not_ask_again_checkbox_message = do_not_ask_again_checkbox_message + + def __initUi(self): + self.setWindowTitle(LangClass.TRANSLATIONS["Exit"]) + self.setModal(True) + self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) + + self.label: QLabel = QLabel(self.__do_not_ask_again_message) + self.label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.yesButton: QPushButton = QPushButton(LangClass.TRANSLATIONS["Yes"]) + self.yesButton.clicked.connect(self.accept) + + self.noButton: QPushButton = QPushButton(LangClass.TRANSLATIONS["No"]) + self.noButton.clicked.connect(self.reject) + + self.cancelButton: QPushButton = QPushButton(LangClass.TRANSLATIONS["Cancel"]) + self.cancelButton.clicked.connect(self.__cancel) + + self.doNotAskAgainCheckBox: QCheckBox = QCheckBox( + self.__do_not_ask_again_checkbox_message, + ) + self.doNotAskAgainCheckBox.setChecked(self.__do_not_ask_again) + self.doNotAskAgainCheckBox.stateChanged.connect(self.__onCheckBoxStateChanged) + + sep = getSeparator("horizontal") + + lay = QHBoxLayout() + lay.addWidget(self.doNotAskAgainCheckBox) + lay.addSpacerItem(QSpacerItem(10, 10, QSizePolicy.Policy.MinimumExpanding)) + lay.addWidget(self.yesButton) + lay.addWidget(self.noButton) + lay.addWidget(self.cancelButton) + lay.setAlignment(Qt.AlignmentFlag.AlignRight) + lay.setContentsMargins(0, 0, 0, 0) + btnWidget = QWidget() + btnWidget.setLayout(lay) + + lay = QVBoxLayout() + lay.addWidget(self.label) + lay.addWidget(sep) + lay.addWidget(btnWidget) + + self.setLayout(lay) + + def __cancel(self): + self.__is_cancel = True + self.reject() + + def __onCheckBoxStateChanged( + self, + state: int, + ) -> None: + self.__do_not_ask_again: bool = state == 2 + self.doNotAskAgainChanged.emit(self.__do_not_ask_again) + + def isCancel(self): + return self.__is_cancel diff --git a/pyqt_openai/fontWidget.py b/pyqt_openai/fontWidget.py index f518c69e..eb2a2981 100644 --- a/pyqt_openai/fontWidget.py +++ b/pyqt_openai/fontWidget.py @@ -1,324 +1,364 @@ -from PySide6.QtCore import Signal, Qt, QThread -from PySide6.QtGui import QFontDatabase, QFont -from PySide6.QtWidgets import ( - QListWidget, - QWidget, - QVBoxLayout, - QLabel, - QLineEdit, - QListWidgetItem, -) -from PySide6.QtWidgets import QSizePolicy, QTextEdit, QHBoxLayout - -from pyqt_openai import DEFAULT_FONT_FAMILY -from pyqt_openai.lang.translations import LangClass - - -class FontLoaderThread(QThread): - fonts_loaded = Signal(list) - afterFinished = Signal(QFont) - - def __init__(self, font: QFont): - super().__init__() - self.font = font - - def run(self): - fm = QFontDatabase.families(QFontDatabase.Any) - self.fonts_loaded.emit(fm) - self.afterFinished.emit(self.font) - - -class SizeWidget(QWidget): - sizeItemChanged = Signal(int) - - def __init__(self, font: QFont, parent=None): - super().__init__(parent) - self.__initUi(font=font) - - def __initUi(self, font: QFont): - self.__sizeLineEdit = QLineEdit() - self.__sizeLineEdit.textEdited.connect(self.__textEdited) - - self.__sizeListWidget = QListWidget() - self.__initSizes(font=font) - self.__sizeListWidget.itemSelectionChanged.connect(self.__sizeItemChanged) - - lay = QVBoxLayout() - lay.addWidget(self.__sizeLineEdit) - lay.addWidget(self.__sizeListWidget) - lay.setContentsMargins(0, 0, 0, 0) - lay.setSpacing(0) - - sizeBottomWidget = QWidget() - sizeBottomWidget.setLayout(lay) - - lay = QVBoxLayout() - lay.addWidget(QLabel(LangClass.TRANSLATIONS["Size"])) - lay.addWidget(sizeBottomWidget) - lay.setContentsMargins(0, 0, 0, 0) - lay.setSpacing(5) - - self.setLayout(lay) - - def __initSizes(self, font: QFont): - self.__initSizesList(font=font) - self.setCurrentSize(font=font) - - def __initSizesList(self, font: QFont): - font_name = font.family() - style_name = QFontDatabase.styles(font_name) - # In case of font is not in the font list - if style_name: - pass - else: - font_name = "Arial" - sizes = QFontDatabase.pointSizes(font_name) - sizes = list(map(str, sizes)) - self.__sizeListWidget.addItems(sizes) - - def setCurrentSize(self, font: QFont): - items = self.__sizeListWidget.findItems( - str(font.pointSize()), Qt.MatchFlag.MatchFixedString - ) - item = QListWidgetItem() - if items: - item = items[0] - else: - item = self.__sizeListWidget.item(0) - if item: - self.__sizeListWidget.setCurrentItem(item) - size_text = item.text() - self.__sizeLineEdit.setText(size_text) - else: - item = QListWidgetItem("10") - self.__sizeListWidget.setCurrentItem(item) - size_text = item.text() - self.__sizeLineEdit.setText(size_text) - - def __textEdited(self): - size_text = self.__sizeLineEdit.text() - items = self.__sizeListWidget.findItems( - size_text, Qt.MatchFlag.MatchFixedString - ) - if items: - self.__sizeListWidget.setCurrentItem(items[0]) - self.sizeItemChanged.emit(int(size_text)) - - def __sizeItemChanged(self): - size_text = self.__sizeListWidget.currentItem().text() - self.sizeItemChanged.emit(int(size_text)) - self.__sizeLineEdit.setText(size_text) - - def setSizes(self, sizes, prev_size=10): - sizes = list(map(str, sizes)) - self.__sizeListWidget.clear() - self.__sizeListWidget.addItems(sizes) - items = self.__sizeListWidget.findItems( - str(prev_size), Qt.MatchFlag.MatchFixedString - ) - if len(items) > 0: - item = items[0] - self.__sizeListWidget.setCurrentItem(item) - self.__sizeLineEdit.setText(item.text()) - else: - self.__sizeLineEdit.setText(str(prev_size)) - - def getSize(self): - return ( - self.__sizeListWidget.currentItem().text() - if self.__sizeListWidget.currentItem() - else 10 - ) - - -class FontItemWidget(QWidget): - fontItemChanged = Signal(str, list, list) - - def __init__(self, font, parent=None): - super().__init__(parent) - self.__font_families = [] - self.__initUi(font=font) - - def __initUi(self, font: QFont): - self.__fontLineEdit = QLineEdit() - self.__fontLineEdit.textEdited.connect(self.__textEdited) - - self.__fontListWidget = QListWidget() - self.__initFonts(font) - self.__fontListWidget.itemSelectionChanged.connect(self.__fontItemChanged) - - lay = QVBoxLayout() - lay.addWidget(self.__fontLineEdit) - lay.addWidget(self.__fontListWidget) - lay.setContentsMargins(0, 0, 0, 0) - lay.setSpacing(0) - - fontBottomWidget = QWidget() - fontBottomWidget.setLayout(lay) - - lay = QVBoxLayout() - lay.addWidget(QLabel(LangClass.TRANSLATIONS["Font"])) - lay.addWidget(fontBottomWidget) - lay.setContentsMargins(0, 0, 0, 0) - lay.setSpacing(5) - - self.setLayout(lay) - - def __initFonts(self, font: QFont): - self.loader_thread = FontLoaderThread(font) - self.loader_thread.fonts_loaded.connect(self.__onFontsLoaded) - self.loader_thread.start() - self.loader_thread.afterFinished.connect(self.setCurrentFont) - - def __onFontsLoaded(self, fm): - self.__font_families.extend(fm) - # Set each item to each font family - for f in fm: - item = QListWidgetItem(f) - # FIXME This makes the font list widget too slow - # item.setFont(QFont(f)) - self.__fontListWidget.addItem(item) - - def setCurrentFont(self, font: QFont): - items = self.__fontListWidget.findItems( - font.family(), Qt.MatchFlag.MatchFixedString - ) - item = QListWidgetItem() - if items: - item = items[0] - else: - item = self.__fontListWidget.item(0) - self.__fontListWidget.setCurrentItem(item) - font_name = item.text() - self.__fontLineEdit.setText(font_name) - - def __fontItemChanged(self): - font_name = self.__fontListWidget.currentItem().text() - self.__fontLineEdit.setText(font_name) - styles = QFontDatabase.styles(font_name) - pointSizes = QFontDatabase.pointSizes( - font_name, QFontDatabase.styles(font_name)[0] - ) - self.fontItemChanged.emit(font_name, styles, pointSizes) - - def __textEdited(self): - self.__fontListWidget.clear() - text = self.__fontLineEdit.text() - if text.strip() != "": - match_families = [] - for family in self.__font_families: - if family.startswith(text): - match_families.append(family) - if match_families: - self.__fontListWidget.addItems(match_families) - else: - pass - else: - self.__fontListWidget.addItems(self.__font_families) - - def getFontFamily(self): - item = self.__fontListWidget.currentItem() - if item: - return item.text() - else: - return DEFAULT_FONT_FAMILY - - -class FontWidget(QWidget): - fontChanged = Signal(QFont) - - def __init__(self, font, parent=None): - super().__init__(parent) - self.__current_font = font - self.__initUi(font=font) - - def __initUi(self, font: QFont): - self.__previewTextEdit = QTextEdit(self) - self.__previewTextEdit.textChanged.connect(self.__textChanged) - - self.__fontItemWidget = FontItemWidget(font) - self.__fontItemWidget.fontItemChanged.connect(self.__fontItemChangedExec) - - self.__sizeWidget = SizeWidget(font) - self.__sizeWidget.sizeItemChanged.connect(self.__sizeItemChangedExec) - - self.__initPreviewTextEdit() - - lay = QHBoxLayout() - lay.addWidget(self.__fontItemWidget) - lay.addWidget(self.__sizeWidget) - lay.setContentsMargins(0, 0, 0, 0) - lay.setSpacing(0) - - topWidget = QWidget() - topWidget.setLayout(lay) - - lay = QVBoxLayout() - lay.addWidget(QLabel(LangClass.TRANSLATIONS["Preview"])) - lay.addWidget(self.__previewTextEdit) - lay.setContentsMargins(0, 0, 0, 0) - lay.setSpacing(5) - - bottomWidget = QWidget() - bottomWidget.setLayout(lay) - bottomWidget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred) - - lay = QVBoxLayout() - lay.addWidget(topWidget) - lay.addWidget(bottomWidget) - self.setLayout(lay) - - def __initPreviewTextEdit(self): - font_family = self.__fontItemWidget.getFontFamily() - font_size = self.__sizeWidget.getSize() - font = self.__previewTextEdit.currentFont() - font.setFamily(font_family) - font.setPointSize(int(font_size)) - self.__previewTextEdit.setCurrentFont(font) - self.__previewTextEdit.setText(LangClass.TRANSLATIONS["Sample"]) - - def __sizeItemChangedExec(self, size): - self.__previewTextEdit.selectAll() - font = self.__previewTextEdit.currentFont() - font.setPointSize(size) - self.__previewTextEdit.setCurrentFont(font) - - self.__current_font = font - self.fontChanged.emit(self.__current_font) - - def __fontItemChangedExec(self, font_text, styles, sizes): - self.__previewTextEdit.selectAll() - font = self.__previewTextEdit.currentFont() - prev_size = font.pointSize() - - font.setFamily(font_text) - - sizes = list(filter(lambda x: x <= 20 and x >= 8, sizes)) - - if prev_size in sizes: - self.__sizeWidget.setSizes(sizes, prev_size) - font.setPointSize(prev_size) - else: - self.__sizeWidget.setSizes(sizes, prev_size) - - self.__previewTextEdit.setCurrentFont(font) - self.__current_font = font - self.fontChanged.emit(self.__current_font) - - def getFont(self): - return self.__previewTextEdit.currentFont() - - def setCurrentFont(self, font): - self.__fontItemWidget.setCurrentFont(font=font) - self.__sizeWidget.setCurrentSize(font=font) - - self.__previewTextEdit.setCurrentFont(font) - self.__current_font = font - self.fontChanged.emit(self.__current_font) - - def __textChanged(self): - text = self.__previewTextEdit.toPlainText() - if text.strip() != "": - pass - else: - self.__previewTextEdit.setCurrentFont(self.__current_font) +from __future__ import annotations + +from qtpy.QtCore import QThread, Qt, Signal +from qtpy.QtGui import QFont, QFontDatabase +from qtpy.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QSizePolicy, QTextEdit, QVBoxLayout, QWidget + +from pyqt_openai import DEFAULT_FONT_FAMILY +from pyqt_openai.lang.translations import LangClass + + +class FontLoaderThread(QThread): + fonts_loaded = Signal(list) + afterFinished = Signal(QFont) + + def __init__( + self, + font: QFont, + ) -> None: + super().__init__() + self.font: QFont = font + + def run(self): + fm: list[str] = QFontDatabase.families(QFontDatabase.WritingSystem.Any) + self.fonts_loaded.emit(fm) + self.afterFinished.emit(self.font) + + +class SizeWidget(QWidget): + sizeItemChanged: Signal = Signal(int) + + def __init__( + self, + font: QFont, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.__initUi(font=font) + + def __initUi( + self, + font: QFont, + ) -> None: + self.__sizeLineEdit: QLineEdit = QLineEdit() + self.__sizeLineEdit.textEdited.connect(self.__textEdited) + + self.__sizeListWidget: QListWidget = QListWidget() + self.__initSizes(font=font) + self.__sizeListWidget.itemSelectionChanged.connect(self.__sizeItemChanged) + + lay = QVBoxLayout() + lay.addWidget(self.__sizeLineEdit) + lay.addWidget(self.__sizeListWidget) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(0) + + sizeBottomWidget: QWidget = QWidget() + sizeBottomWidget.setLayout(lay) + + lay = QVBoxLayout() + lay.addWidget(QLabel(LangClass.TRANSLATIONS["Size"])) + lay.addWidget(sizeBottomWidget) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(5) + + self.setLayout(lay) + + def __initSizes( + self, + font: QFont, + ) -> None: + self.__initSizesList(font=font) + self.setCurrentSize(font=font) + + def __initSizesList( + self, + font: QFont, + ) -> None: + font_name: str = font.family() + style_name: list[str] = QFontDatabase.styles(font_name) + # In case of font is not in the font list + if style_name: + pass + else: + font_name = "Arial" + sizes = QFontDatabase.pointSizes(font_name) + sizes = list(map(str, sizes)) + self.__sizeListWidget.addItems(sizes) + + def setCurrentSize( + self, + font: QFont, + ) -> None: + items = self.__sizeListWidget.findItems( + str(font.pointSize()), Qt.MatchFlag.MatchFixedString, + ) + item = QListWidgetItem() + if items: + item = items[0] + else: + item = self.__sizeListWidget.item(0) + if item: + self.__sizeListWidget.setCurrentItem(item) + size_text = item.text() + self.__sizeLineEdit.setText(size_text) + else: + item = QListWidgetItem("10") + self.__sizeListWidget.setCurrentItem(item) + size_text = item.text() + self.__sizeLineEdit.setText(size_text) + + def __textEdited(self): + size_text = self.__sizeLineEdit.text() + items: list[QListWidgetItem] = self.__sizeListWidget.findItems( + size_text, Qt.MatchFlag.MatchFixedString, + ) + if items: + self.__sizeListWidget.setCurrentItem(items[0]) + self.sizeItemChanged.emit(int(size_text)) + + def __sizeItemChanged(self): + size_text = self.__sizeListWidget.currentItem().text() + self.sizeItemChanged.emit(int(size_text)) + self.__sizeLineEdit.setText(size_text) + + def setSizes(self, sizes, prev_size=10): + sizes = list(map(str, sizes)) + self.__sizeListWidget.clear() + self.__sizeListWidget.addItems(sizes) + items = self.__sizeListWidget.findItems( + str(prev_size), Qt.MatchFlag.MatchFixedString, + ) + if len(items) > 0: + item = items[0] + self.__sizeListWidget.setCurrentItem(item) + self.__sizeLineEdit.setText(item.text()) + else: + self.__sizeLineEdit.setText(str(prev_size)) + + def getSize(self): + return ( + self.__sizeListWidget.currentItem().text() + if self.__sizeListWidget.currentItem() + else 10 + ) + + +class FontItemWidget(QWidget): + fontItemChanged = Signal(str, list, list) + + def __init__(self, font, parent=None): + super().__init__(parent) + self.__font_families: list[str] = [] + self.__initUi(font=font) + + def __initUi( + self, + font: QFont, + ) -> None: + self.__fontLineEdit: QLineEdit = QLineEdit() + self.__fontLineEdit.textEdited.connect(self.__textEdited) + + self.__fontListWidget = QListWidget() + self.__initFonts(font) + self.__fontListWidget.itemSelectionChanged.connect(self.__fontItemChanged) + + lay = QVBoxLayout() + lay.addWidget(self.__fontLineEdit) + lay.addWidget(self.__fontListWidget) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(0) + + fontBottomWidget = QWidget() + fontBottomWidget.setLayout(lay) + + lay = QVBoxLayout() + lay.addWidget(QLabel(LangClass.TRANSLATIONS["Font"])) + lay.addWidget(fontBottomWidget) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(5) + + self.setLayout(lay) + + def __initFonts( + self, + font: QFont, + ) -> None: + self.loader_thread: FontLoaderThread = FontLoaderThread(font) + self.loader_thread.fonts_loaded.connect(self.__onFontsLoaded) + self.loader_thread.start() + self.loader_thread.afterFinished.connect(self.setCurrentFont) + + def __onFontsLoaded( + self, + fm: list[str], + ) -> None: + self.__font_families.extend(fm) + # Set each item to each font family + for f in fm: + item = QListWidgetItem(f) + # FIXME This makes the font list widget too slow + # item.setFont(QFont(f)) + self.__fontListWidget.addItem(item) + + def setCurrentFont(self, font: QFont): + items = self.__fontListWidget.findItems( + font.family(), Qt.MatchFlag.MatchFixedString, + ) + item = QListWidgetItem() + if items: + item = items[0] + else: + item = self.__fontListWidget.item(0) + self.__fontListWidget.setCurrentItem(item) + font_name = item.text() + self.__fontLineEdit.setText(font_name) + + def __fontItemChanged(self): + font_name: str = self.__fontListWidget.currentItem().text() + self.__fontLineEdit.setText(font_name) + styles: list[str] = QFontDatabase.styles(font_name) + pointSizes: list[int] = QFontDatabase.pointSizes( + font_name, + QFontDatabase.styles(font_name)[0], + ) + self.fontItemChanged.emit(font_name, styles, pointSizes) + + def __textEdited(self): + self.__fontListWidget.clear() + text = self.__fontLineEdit.text() + if text.strip() != "": + match_families: list[str] = [] + for family in self.__font_families: + if family.startswith(text): + match_families.append(family) + if match_families: + self.__fontListWidget.addItems(match_families) + else: + pass + else: + self.__fontListWidget.addItems(self.__font_families) + + def getFontFamily(self): + item = self.__fontListWidget.currentItem() + if item: + return item.text() + return DEFAULT_FONT_FAMILY + + +class FontWidget(QWidget): + fontChanged = Signal(QFont) + + def __init__( + self, + font: QFont, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.__current_font: QFont = font + self.__initUi(font=font) + + def __initUi( + self, + font: QFont, + ) -> None: + self.__previewTextEdit: QTextEdit = QTextEdit(self) + self.__previewTextEdit.textChanged.connect(self.__textChanged) + + self.__fontItemWidget: FontItemWidget = FontItemWidget(font) + self.__fontItemWidget.fontItemChanged.connect(self.__fontItemChangedExec) + + self.__sizeWidget: SizeWidget = SizeWidget(font) + self.__sizeWidget.sizeItemChanged.connect(self.__sizeItemChangedExec) + + self.__initPreviewTextEdit() + + lay = QHBoxLayout() + lay.addWidget(self.__fontItemWidget) + lay.addWidget(self.__sizeWidget) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(0) + + topWidget = QWidget() + topWidget.setLayout(lay) + + lay = QVBoxLayout() + lay.addWidget(QLabel(LangClass.TRANSLATIONS["Preview"])) + lay.addWidget(self.__previewTextEdit) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(5) + + bottomWidget = QWidget() + bottomWidget.setLayout(lay) + bottomWidget.setSizePolicy( + QSizePolicy.Policy.MinimumExpanding, + QSizePolicy.Policy.Preferred, + ) + + lay = QVBoxLayout() + lay.addWidget(topWidget) + lay.addWidget(bottomWidget) + self.setLayout(lay) + + def __initPreviewTextEdit(self) -> None: + font_family: str = self.__fontItemWidget.getFontFamily() + font_size: int = self.__sizeWidget.getSize() + font: QFont = self.__previewTextEdit.currentFont() + font.setFamily(font_family) + font.setPointSize(font_size) + self.__previewTextEdit.setCurrentFont(font) + self.__previewTextEdit.setText(LangClass.TRANSLATIONS["Sample"]) + + def __sizeItemChangedExec(self, size: int) -> None: + self.__previewTextEdit.selectAll() + font: QFont = self.__previewTextEdit.currentFont() + font.setPointSize(size) + self.__previewTextEdit.setCurrentFont(font) + + self.__current_font: QFont = font + self.fontChanged.emit(self.__current_font) + + def __fontItemChangedExec( + self, + font_text: str, + styles: list[str], + sizes: list[int], + ) -> None: + self.__previewTextEdit.selectAll() + font: QFont = self.__previewTextEdit.currentFont() + prev_size: int = font.pointSize() + + font.setFamily(font_text) + + sizes = list(filter(lambda x: x <= 20 and x >= 8, sizes)) + + if prev_size in sizes: + self.__sizeWidget.setSizes(sizes, prev_size) + font.setPointSize(prev_size) + else: + self.__sizeWidget.setSizes(sizes, prev_size) + + self.__previewTextEdit.setCurrentFont(font) + self.__current_font = font + self.fontChanged.emit(self.__current_font) + + def getFont(self): + return self.__previewTextEdit.currentFont() + + def setCurrentFont( + self, + font: QFont, + ) -> None: + self.__fontItemWidget.setCurrentFont(font=font) + self.__sizeWidget.setCurrentSize(font=font) + + self.__previewTextEdit.setCurrentFont(font) + self.__current_font = font + self.fontChanged.emit(self.__current_font) + + def __textChanged(self): + text = self.__previewTextEdit.toPlainText() + if text.strip() != "": + pass + else: + self.__previewTextEdit.setCurrentFont(self.__current_font) diff --git a/pyqt_openai/g4f_image_widget/g4fImageHome.py b/pyqt_openai/g4f_image_widget/g4fImageHome.py index b2d4e9a1..75d7287c 100644 --- a/pyqt_openai/g4f_image_widget/g4fImageHome.py +++ b/pyqt_openai/g4f_image_widget/g4fImageHome.py @@ -1,37 +1,39 @@ -from PySide6.QtCore import Qt -from PySide6.QtGui import QFont -from PySide6.QtWidgets import QLabel, QWidget, QVBoxLayout, QScrollArea - -from pyqt_openai import CONTEXT_DELIMITER, LARGE_LABEL_PARAM, MEDIUM_LABEL_PARAM - - -class G4FImageHome(QScrollArea): - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - # TODO LANGUAGE - title = QLabel("Welcome to GPT4Free\n" + "Image Generation Page !", self) - title.setFont(QFont(*LARGE_LABEL_PARAM)) - title.setAlignment(Qt.AlignmentFlag.AlignCenter) - - description = QLabel( - "Generate images for free with the power of G4F." + CONTEXT_DELIMITER - ) - - description.setFont(QFont(*MEDIUM_LABEL_PARAM)) - description.setAlignment(Qt.AlignmentFlag.AlignCenter) - - # TODO v2.x.0 "how does this work?" or "What is GPT4Free?" link (maybe) - - lay = QVBoxLayout() - lay.addWidget(title) - lay.addWidget(description) - lay.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.setLayout(lay) - - mainWidget = QWidget() - mainWidget.setLayout(lay) - self.setWidget(mainWidget) - self.setWidgetResizable(True) +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtGui import QFont +from qtpy.QtWidgets import QLabel, QScrollArea, QVBoxLayout, QWidget + +from pyqt_openai import CONTEXT_DELIMITER, LARGE_LABEL_PARAM, MEDIUM_LABEL_PARAM + + +class G4FImageHome(QScrollArea): + def __init__(self, parent=None): + super().__init__(parent) + self.__initUi() + + def __initUi(self): + # TODO LANGUAGE + title = QLabel("Welcome to GPT4Free\n" + "Image Generation Page !", self) + title.setFont(QFont(*LARGE_LABEL_PARAM)) + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + + description = QLabel( + "Generate images for free with the power of G4F." + CONTEXT_DELIMITER, + ) + + description.setFont(QFont(*MEDIUM_LABEL_PARAM)) + description.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # TODO v2.x.0 "how does this work?" or "What is GPT4Free?" link (maybe) + + lay = QVBoxLayout() + lay.addWidget(title) + lay.addWidget(description) + lay.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setLayout(lay) + + mainWidget = QWidget() + mainWidget.setLayout(lay) + self.setWidget(mainWidget) + self.setWidgetResizable(True) diff --git a/pyqt_openai/g4f_image_widget/g4fImageMainWidget.py b/pyqt_openai/g4f_image_widget/g4fImageMainWidget.py index ee6d97aa..24c05d49 100644 --- a/pyqt_openai/g4f_image_widget/g4fImageMainWidget.py +++ b/pyqt_openai/g4f_image_widget/g4fImageMainWidget.py @@ -1,26 +1,28 @@ -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.g4f_image_widget.g4fImageHome import G4FImageHome -from pyqt_openai.g4f_image_widget.g4fImageRightSideBar import G4FImageRightSideBarWidget -from pyqt_openai.widgets.imageMainWidget import ImageMainWidget - - -class G4FImageMainWidget(ImageMainWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - self._homePage = G4FImageHome() - self._rightSideBarWidget = G4FImageRightSideBarWidget() - - self._setHomeWidget(self._homePage) - self._setRightSideBarWidget(self._rightSideBarWidget) - self._completeUi() - - def toggleHistory(self, f): - super().toggleHistory(f) - CONFIG_MANAGER.set_g4f_image_property("show_history", f) - - def toggleSetting(self, f): - super().toggleSetting(f) - CONFIG_MANAGER.set_g4f_image_property("show_setting", f) +from __future__ import annotations + +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.g4f_image_widget.g4fImageHome import G4FImageHome +from pyqt_openai.g4f_image_widget.g4fImageRightSideBar import G4FImageRightSideBarWidget +from pyqt_openai.widgets.imageMainWidget import ImageMainWidget + + +class G4FImageMainWidget(ImageMainWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.__initUi() + + def __initUi(self): + self._homePage = G4FImageHome() + self._rightSideBarWidget = G4FImageRightSideBarWidget() + + self._setHomeWidget(self._homePage) + self._setRightSideBarWidget(self._rightSideBarWidget) + self._completeUi() + + def toggleHistory(self, f): + super().toggleHistory(f) + CONFIG_MANAGER.set_g4f_image_property("show_history", f) + + def toggleSetting(self, f): + super().toggleSetting(f) + CONFIG_MANAGER.set_g4f_image_property("show_setting", f) diff --git a/pyqt_openai/g4f_image_widget/g4fImageRightSideBar.py b/pyqt_openai/g4f_image_widget/g4fImageRightSideBar.py index 9eed38c5..46e09a67 100644 --- a/pyqt_openai/g4f_image_widget/g4fImageRightSideBar.py +++ b/pyqt_openai/g4f_image_widget/g4fImageRightSideBar.py @@ -1,185 +1,187 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QWidget, - QVBoxLayout, - QPlainTextEdit, - QFormLayout, - QLabel, - QSplitter, - QComboBox, -) - -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.g4f_image_widget.g4fImageThread import G4FImageThread -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.util.common import ( - get_g4f_image_providers, - get_g4f_image_models_from_provider, - get_g4f_image_models, -) -from pyqt_openai.widgets.imageControlWidget import ImageControlWidget - - -class G4FImageRightSideBarWidget(ImageControlWidget): - def __init__(self, parent=None): - super().__init__(parent) - self._initVal() - self._initUi() - - def _initVal(self): - super()._initVal() - - self._prompt = CONFIG_MANAGER.get_g4f_image_property("prompt") - self._continue_generation = CONFIG_MANAGER.get_g4f_image_property( - "continue_generation" - ) - self._save_prompt_as_text = CONFIG_MANAGER.get_g4f_image_property( - "save_prompt_as_text" - ) - self._is_save = CONFIG_MANAGER.get_g4f_image_property("is_save") - self._directory = CONFIG_MANAGER.get_g4f_image_property("directory") - self._number_of_images_to_create = CONFIG_MANAGER.get_g4f_image_property( - "number_of_images_to_create" - ) - - self.__model = CONFIG_MANAGER.get_g4f_image_property("model") - self.__provider = CONFIG_MANAGER.get_g4f_image_property("provider") - self.__negative_prompt = CONFIG_MANAGER.get_g4f_image_property( - "negative_prompt" - ) - - def _initUi(self): - super()._initUi() - - self.__providerCmbBox = QComboBox() - g4f_image_providers = get_g4f_image_providers(including_auto=True) - self.__providerCmbBox.addItems(g4f_image_providers) - self.__providerCmbBox.currentTextChanged.connect(self.__g4fProviderChanged) - - self.__modelCmbBox = QComboBox() - g4f_image_models = get_g4f_image_models() - self.__modelCmbBox.addItems(g4f_image_models) - self.__modelCmbBox.setCurrentText(self.__model) - self.__modelCmbBox.currentTextChanged.connect(self.__g4fModelChanged) - - self.__providerCmbBox.setCurrentText(self.__provider) - - lay = QVBoxLayout() - lay.addWidget(self._findPathWidget) - lay.addWidget(self._saveChkBox) - lay.addWidget(self._continueGenerationChkBox) - lay.addWidget(self._numberOfImagesToCreateSpinBox) - lay.addWidget(self._savePromptAsTextChkBox) - self._generalGrpBox.setLayout(lay) - - self._promptTextEdit.textChanged.connect(self.__replicateTextChanged) - - self._negativeTextEdit = QPlainTextEdit() - self._negativeTextEdit.setPlaceholderText( - "ugly, deformed, noisy, blurry, distorted" - ) - self._negativeTextEdit.setPlainText(self.__negative_prompt) - self._negativeTextEdit.textChanged.connect(self.__replicateTextChanged) - - lay = QVBoxLayout() - - lay.addWidget(self._randomImagePromptGeneratorWidget) - lay.addWidget(QLabel(LangClass.TRANSLATIONS["Prompt"])) - lay.addWidget(self._promptTextEdit) - - lay.addWidget(QLabel(LangClass.TRANSLATIONS["Negative Prompt"])) - lay.addWidget(self._negativeTextEdit) - promptWidget = QWidget() - promptWidget.setLayout(lay) - - lay = QFormLayout() - lay.addRow(LangClass.TRANSLATIONS["Provider"], self.__providerCmbBox) - lay.addRow(LangClass.TRANSLATIONS["Model"], self.__modelCmbBox) - otherParamWidget = QWidget() - otherParamWidget.setLayout(lay) - - splitter = QSplitter() - splitter.addWidget(otherParamWidget) - splitter.addWidget(promptWidget) - splitter.setHandleWidth(1) - splitter.setOrientation(Qt.Orientation.Vertical) - splitter.setChildrenCollapsible(False) - splitter.setSizes([500, 500]) - splitter.setStyleSheet("QSplitterHandle {background-color: lightgray;}") - - lay = QVBoxLayout() - lay.addWidget(splitter) - self._paramGrpBox.setLayout(lay) - - self._completeUi() - - def __g4fModelChanged(self, text): - self.__model = text - CONFIG_MANAGER.set_g4f_image_property("model", self.__model) - # - # g4f_image_providers = get_g4f_providers_by_model(self.__model, including_auto=True) - # self.__providerCmbBox.clear() - # self.__providerCmbBox.addItems(g4f_image_providers) - - def __g4fProviderChanged(self, text): - self.__provider = text - CONFIG_MANAGER.set_g4f_image_property("provider", self.__provider) - - image_models = get_g4f_image_models_from_provider(self.__provider) - self.__modelCmbBox.clear() - self.__modelCmbBox.addItems(image_models) - - def __replicateTextChanged(self): - sender = self.sender() - if isinstance(sender, QPlainTextEdit): - if sender == self._promptTextEdit: - self._prompt = sender.toPlainText() - CONFIG_MANAGER.set_g4f_image_property("prompt", self._prompt) - elif sender == self._negativeTextEdit: - self.__negative_prompt = sender.toPlainText() - CONFIG_MANAGER.set_g4f_image_property( - "negative_prompt", self.__negative_prompt - ) - - def _setSaveDirectory(self, directory): - super()._setSaveDirectory(directory) - CONFIG_MANAGER.set_g4f_image_property("directory", directory) - - def _saveChkBoxToggled(self, f): - super()._saveChkBoxToggled(f) - CONFIG_MANAGER.set_g4f_image_property("is_save", f) - - def _continueGenerationChkBoxToggled(self, f): - super()._continueGenerationChkBoxToggled(f) - CONFIG_MANAGER.set_g4f_image_property("continue_generation", f) - - def _savePromptAsTextChkBoxToggled(self, f): - super()._savePromptAsTextChkBoxToggled(f) - CONFIG_MANAGER.set_g4f_image_property("save_prompt_as_text", f) - - def _numberOfImagesToCreateSpinBoxValueChanged(self, value): - super()._numberOfImagesToCreateSpinBoxValueChanged(value) - CONFIG_MANAGER.set_g4f_image_property("number_of_images_to_create", value) - - def _submit(self): - arg = self.getArgument() - number_of_images = ( - self._number_of_images_to_create if self._continue_generation else 1 - ) - random_prompt = ( - self._randomImagePromptGeneratorWidget.getRandomPromptSourceArr() - ) - - t = G4FImageThread(arg, number_of_images, random_prompt) - self._setThread(t) - super()._submit() - - def getArgument(self): - obj = super().getArgument() - return { - **obj, - "model": self.__modelCmbBox.currentText(), - "provider": self.__providerCmbBox.currentText(), - "prompt": self._promptTextEdit.toPlainText(), - "negative_prompt": self._negativeTextEdit.toPlainText(), - } +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QComboBox, + QFormLayout, + QLabel, + QPlainTextEdit, + QSplitter, + QVBoxLayout, + QWidget, +) + +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.g4f_image_widget.g4fImageThread import G4FImageThread +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.util.common import ( + get_g4f_image_models, + get_g4f_image_models_from_provider, + get_g4f_image_providers, +) +from pyqt_openai.widgets.imageControlWidget import ImageControlWidget + + +class G4FImageRightSideBarWidget(ImageControlWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._initVal() + self._initUi() + + def _initVal(self): + super()._initVal() + + self._prompt = CONFIG_MANAGER.get_g4f_image_property("prompt") + self._continue_generation = CONFIG_MANAGER.get_g4f_image_property( + "continue_generation", + ) + self._save_prompt_as_text = CONFIG_MANAGER.get_g4f_image_property( + "save_prompt_as_text", + ) + self._is_save = CONFIG_MANAGER.get_g4f_image_property("is_save") + self._directory = CONFIG_MANAGER.get_g4f_image_property("directory") + self._number_of_images_to_create = CONFIG_MANAGER.get_g4f_image_property( + "number_of_images_to_create", + ) + + self.__model = CONFIG_MANAGER.get_g4f_image_property("model") + self.__provider = CONFIG_MANAGER.get_g4f_image_property("provider") + self.__negative_prompt = CONFIG_MANAGER.get_g4f_image_property( + "negative_prompt", + ) + + def _initUi(self): + super()._initUi() + + self.__providerCmbBox = QComboBox() + g4f_image_providers = get_g4f_image_providers(including_auto=True) + self.__providerCmbBox.addItems(g4f_image_providers) + self.__providerCmbBox.currentTextChanged.connect(self.__g4fProviderChanged) + + self.__modelCmbBox = QComboBox() + g4f_image_models = get_g4f_image_models() + self.__modelCmbBox.addItems(g4f_image_models) + self.__modelCmbBox.setCurrentText(self.__model) + self.__modelCmbBox.currentTextChanged.connect(self.__g4fModelChanged) + + self.__providerCmbBox.setCurrentText(self.__provider) + + lay = QVBoxLayout() + lay.addWidget(self._findPathWidget) + lay.addWidget(self._saveChkBox) + lay.addWidget(self._continueGenerationChkBox) + lay.addWidget(self._numberOfImagesToCreateSpinBox) + lay.addWidget(self._savePromptAsTextChkBox) + self._generalGrpBox.setLayout(lay) + + self._promptTextEdit.textChanged.connect(self.__replicateTextChanged) + + self._negativeTextEdit = QPlainTextEdit() + self._negativeTextEdit.setPlaceholderText( + "ugly, deformed, noisy, blurry, distorted", + ) + self._negativeTextEdit.setPlainText(self.__negative_prompt) + self._negativeTextEdit.textChanged.connect(self.__replicateTextChanged) + + lay = QVBoxLayout() + + lay.addWidget(self._randomImagePromptGeneratorWidget) + lay.addWidget(QLabel(LangClass.TRANSLATIONS["Prompt"])) + lay.addWidget(self._promptTextEdit) + + lay.addWidget(QLabel(LangClass.TRANSLATIONS["Negative Prompt"])) + lay.addWidget(self._negativeTextEdit) + promptWidget = QWidget() + promptWidget.setLayout(lay) + + lay = QFormLayout() + lay.addRow(LangClass.TRANSLATIONS["Provider"], self.__providerCmbBox) + lay.addRow(LangClass.TRANSLATIONS["Model"], self.__modelCmbBox) + otherParamWidget = QWidget() + otherParamWidget.setLayout(lay) + + splitter = QSplitter() + splitter.addWidget(otherParamWidget) + splitter.addWidget(promptWidget) + splitter.setHandleWidth(1) + splitter.setOrientation(Qt.Orientation.Vertical) + splitter.setChildrenCollapsible(False) + splitter.setSizes([500, 500]) + splitter.setStyleSheet("QSplitterHandle {background-color: lightgray;}") + + lay = QVBoxLayout() + lay.addWidget(splitter) + self._paramGrpBox.setLayout(lay) + + self._completeUi() + + def __g4fModelChanged(self, text): + self.__model = text + CONFIG_MANAGER.set_g4f_image_property("model", self.__model) + # + # g4f_image_providers = get_g4f_providers_by_model(self.__model, including_auto=True) + # self.__providerCmbBox.clear() + # self.__providerCmbBox.addItems(g4f_image_providers) + + def __g4fProviderChanged(self, text): + self.__provider = text + CONFIG_MANAGER.set_g4f_image_property("provider", self.__provider) + + image_models = get_g4f_image_models_from_provider(self.__provider) + self.__modelCmbBox.clear() + self.__modelCmbBox.addItems(image_models) + + def __replicateTextChanged(self): + sender = self.sender() + if isinstance(sender, QPlainTextEdit): + if sender == self._promptTextEdit: + self._prompt = sender.toPlainText() + CONFIG_MANAGER.set_g4f_image_property("prompt", self._prompt) + elif sender == self._negativeTextEdit: + self.__negative_prompt = sender.toPlainText() + CONFIG_MANAGER.set_g4f_image_property( + "negative_prompt", self.__negative_prompt, + ) + + def _setSaveDirectory(self, directory): + super()._setSaveDirectory(directory) + CONFIG_MANAGER.set_g4f_image_property("directory", directory) + + def _saveChkBoxToggled(self, f): + super()._saveChkBoxToggled(f) + CONFIG_MANAGER.set_g4f_image_property("is_save", f) + + def _continueGenerationChkBoxToggled(self, f): + super()._continueGenerationChkBoxToggled(f) + CONFIG_MANAGER.set_g4f_image_property("continue_generation", f) + + def _savePromptAsTextChkBoxToggled(self, f): + super()._savePromptAsTextChkBoxToggled(f) + CONFIG_MANAGER.set_g4f_image_property("save_prompt_as_text", f) + + def _numberOfImagesToCreateSpinBoxValueChanged(self, value): + super()._numberOfImagesToCreateSpinBoxValueChanged(value) + CONFIG_MANAGER.set_g4f_image_property("number_of_images_to_create", value) + + def _submit(self): + arg = self.getArgument() + number_of_images = ( + self._number_of_images_to_create if self._continue_generation else 1 + ) + random_prompt = ( + self._randomImagePromptGeneratorWidget.getRandomPromptSourceArr() + ) + + t = G4FImageThread(arg, number_of_images, random_prompt) + self._setThread(t) + super()._submit() + + def getArgument(self): + obj = super().getArgument() + return { + **obj, + "model": self.__modelCmbBox.currentText(), + "provider": self.__providerCmbBox.currentText(), + "prompt": self._promptTextEdit.toPlainText(), + "negative_prompt": self._negativeTextEdit.toPlainText(), + } diff --git a/pyqt_openai/g4f_image_widget/g4fImageThread.py b/pyqt_openai/g4f_image_widget/g4fImageThread.py index 778f0551..32feb6b4 100644 --- a/pyqt_openai/g4f_image_widget/g4fImageThread.py +++ b/pyqt_openai/g4f_image_widget/g4fImageThread.py @@ -1,70 +1,70 @@ -from abc import ABCMeta - -from PySide6.QtCore import QThread, Signal -from g4f.providers.retry_provider import IterListProvider - -from pyqt_openai import G4F_PROVIDER_DEFAULT -from pyqt_openai.globals import G4F_CLIENT -from pyqt_openai.models import ImagePromptContainer -from pyqt_openai.util.replicate import download_image_as_base64 -from pyqt_openai.util.common import generate_random_prompt, convert_to_provider - - -class G4FImageThread(QThread): - replyGenerated = Signal(ImagePromptContainer) - errorGenerated = Signal(str) - allReplyGenerated = Signal() - - def __init__( - self, input_args, number_of_images, randomizing_prompt_source_arr=None - ): - super().__init__() - self.__input_args = input_args - self.__stop = False - - self.__randomizing_prompt_source_arr = randomizing_prompt_source_arr - - self.__number_of_images = number_of_images - - def stop(self): - self.__stop = True - - def run(self): - try: - provider = G4F_PROVIDER_DEFAULT - if self.__input_args["provider"] != G4F_PROVIDER_DEFAULT: - provider = self.__input_args["provider"] - self.__input_args["provider"] = convert_to_provider( - self.__input_args["provider"] - ) - - for _ in range(self.__number_of_images): - if self.__stop: - break - if self.__randomizing_prompt_source_arr is not None: - self.__input_args["prompt"] = generate_random_prompt( - self.__randomizing_prompt_source_arr - ) - images = G4F_CLIENT.images - if provider != G4F_PROVIDER_DEFAULT: - images.provider = self.__input_args["provider"] - else: - del self.__input_args["provider"] - provider = images.models.get(self.__input_args['model'], images.provider) - if isinstance(provider, IterListProvider): - if provider.providers: - provider = provider.providers[0] - provider = provider.__name__ - - response = images.generate(**self.__input_args) - arg = { - **self.__input_args, - "provider": provider, - "data": download_image_as_base64(response.data[0].url), - } - - result = ImagePromptContainer(**arg) - self.replyGenerated.emit(result) - self.allReplyGenerated.emit() - except Exception as e: - self.errorGenerated.emit(str(e)) +from __future__ import annotations + +from g4f.providers.retry_provider import IterListProvider +from qtpy.QtCore import QThread, Signal + +from pyqt_openai import G4F_PROVIDER_DEFAULT +from pyqt_openai.globals import G4F_CLIENT +from pyqt_openai.models import ImagePromptContainer +from pyqt_openai.util.common import convert_to_provider, generate_random_prompt +from pyqt_openai.util.replicate import download_image_as_base64 + + +class G4FImageThread(QThread): + replyGenerated = Signal(ImagePromptContainer) + errorGenerated = Signal(str) + allReplyGenerated = Signal() + + def __init__( + self, input_args, number_of_images, randomizing_prompt_source_arr=None, + ): + super().__init__() + self.__input_args = input_args + self.__stop = False + + self.__randomizing_prompt_source_arr = randomizing_prompt_source_arr + + self.__number_of_images = number_of_images + + def stop(self): + self.__stop = True + + def run(self): + try: + provider = G4F_PROVIDER_DEFAULT + if self.__input_args["provider"] != G4F_PROVIDER_DEFAULT: + provider = self.__input_args["provider"] + self.__input_args["provider"] = convert_to_provider( + self.__input_args["provider"], + ) + + for _ in range(self.__number_of_images): + if self.__stop: + break + if self.__randomizing_prompt_source_arr is not None: + self.__input_args["prompt"] = generate_random_prompt( + self.__randomizing_prompt_source_arr, + ) + images = G4F_CLIENT.images + if provider != G4F_PROVIDER_DEFAULT: + images.provider = self.__input_args["provider"] + else: + del self.__input_args["provider"] + provider = images.models.get(self.__input_args["model"], images.provider) + if isinstance(provider, IterListProvider): + if provider.providers: + provider = provider.providers[0] + provider = provider.__name__ + + response = images.generate(**self.__input_args) + arg = { + **self.__input_args, + "provider": provider, + "data": download_image_as_base64(response.data[0].url), + } + + result = ImagePromptContainer(**arg) + self.replyGenerated.emit(result) + self.allReplyGenerated.emit() + except Exception as e: + self.errorGenerated.emit(str(e)) diff --git a/pyqt_openai/globals.py b/pyqt_openai/globals.py index 5cfe5074..afe2fb28 100644 --- a/pyqt_openai/globals.py +++ b/pyqt_openai/globals.py @@ -1,21 +1,20 @@ -""" -This is the file that contains the global variables that are used, or possibly used, throughout the application. -""" - -from g4f.client import Client -from openai import OpenAI - -from pyqt_openai.sqlite import SqliteDatabase -from pyqt_openai.util.llamaindex import LlamaIndexWrapper -from pyqt_openai.util.replicate import ReplicateWrapper - -DB = SqliteDatabase() - -LLAMAINDEX_WRAPPER = LlamaIndexWrapper() - -G4F_CLIENT = Client() - -# For Whisper -OPENAI_CLIENT = OpenAI(api_key="") - -REPLICATE_CLIENT = ReplicateWrapper(api_key="") +"""This is the file that contains the global variables that are used, or possibly used, throughout the application.""" +from __future__ import annotations + +from g4f.client import Client +from openai import OpenAI + +from pyqt_openai.sqlite import SqliteDatabase +from pyqt_openai.util.llamaindex import LlamaIndexWrapper +from pyqt_openai.util.replicate import ReplicateWrapper + +DB = SqliteDatabase() + +LLAMAINDEX_WRAPPER = LlamaIndexWrapper() + +G4F_CLIENT = Client() + +# For Whisper +OPENAI_CLIENT = OpenAI(api_key="") + +REPLICATE_CLIENT = ReplicateWrapper(api_key="") diff --git a/pyqt_openai/ico/add.svg b/pyqt_openai/ico/add.svg index cf03cf4d..0a396042 100644 --- a/pyqt_openai/ico/add.svg +++ b/pyqt_openai/ico/add.svg @@ -1,3 +1,3 @@ - - + + \ No newline at end of file diff --git a/pyqt_openai/ico/case.svg b/pyqt_openai/ico/case.svg index 90a740ab..52b75c8c 100644 --- a/pyqt_openai/ico/case.svg +++ b/pyqt_openai/ico/case.svg @@ -1,4 +1,4 @@ - - Svg Vector Icons : http://www.onlinewebfonts.com/icon - + + Svg Vector Icons : http://www.onlinewebfonts.com/icon + \ No newline at end of file diff --git a/pyqt_openai/ico/customize.svg b/pyqt_openai/ico/customize.svg index 16b0592a..a743c71f 100644 --- a/pyqt_openai/ico/customize.svg +++ b/pyqt_openai/ico/customize.svg @@ -1,11 +1,11 @@ - - - - - - - - - - + + + + + + + + + + \ No newline at end of file diff --git a/pyqt_openai/ico/delete.svg b/pyqt_openai/ico/delete.svg index 6da4853e..52630655 100644 --- a/pyqt_openai/ico/delete.svg +++ b/pyqt_openai/ico/delete.svg @@ -1,3 +1,3 @@ - - + + \ No newline at end of file diff --git a/pyqt_openai/ico/favorite_no.svg b/pyqt_openai/ico/favorite_no.svg index 705a138f..c53a99a7 100644 --- a/pyqt_openai/ico/favorite_no.svg +++ b/pyqt_openai/ico/favorite_no.svg @@ -1,8 +1,8 @@ - - - - - - - + + + + + + + \ No newline at end of file diff --git a/pyqt_openai/ico/favorite_yes.svg b/pyqt_openai/ico/favorite_yes.svg index 2f8bd177..7f06a552 100644 --- a/pyqt_openai/ico/favorite_yes.svg +++ b/pyqt_openai/ico/favorite_yes.svg @@ -1,8 +1,8 @@ - - - - - - - + + + + + + + \ No newline at end of file diff --git a/pyqt_openai/ico/file.svg b/pyqt_openai/ico/file.svg index 73c2acdc..67c8a784 100644 --- a/pyqt_openai/ico/file.svg +++ b/pyqt_openai/ico/file.svg @@ -1,9 +1,9 @@ - - - - - - - + + + + + + + diff --git a/pyqt_openai/ico/fullscreen.svg b/pyqt_openai/ico/fullscreen.svg index a663b37c..4dd75e5e 100644 --- a/pyqt_openai/ico/fullscreen.svg +++ b/pyqt_openai/ico/fullscreen.svg @@ -1,34 +1,34 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pyqt_openai/ico/import.svg b/pyqt_openai/ico/import.svg index fd6ae534..0128bc34 100644 --- a/pyqt_openai/ico/import.svg +++ b/pyqt_openai/ico/import.svg @@ -1,4 +1,4 @@ - - - + + + \ No newline at end of file diff --git a/pyqt_openai/ico/info.svg b/pyqt_openai/ico/info.svg index aaec85c5..50e203ed 100644 --- a/pyqt_openai/ico/info.svg +++ b/pyqt_openai/ico/info.svg @@ -1,5 +1,5 @@ - - - - + + + + \ No newline at end of file diff --git a/pyqt_openai/ico/next.svg b/pyqt_openai/ico/next.svg index c10942c9..b13478c3 100644 --- a/pyqt_openai/ico/next.svg +++ b/pyqt_openai/ico/next.svg @@ -1,35 +1,35 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pyqt_openai/ico/prev.svg b/pyqt_openai/ico/prev.svg index 300ed773..bc91c5fa 100644 --- a/pyqt_openai/ico/prev.svg +++ b/pyqt_openai/ico/prev.svg @@ -1,4 +1,4 @@ - - Svg Vector Icons : http://www.onlinewebfonts.com/icon - + + Svg Vector Icons : http://www.onlinewebfonts.com/icon + \ No newline at end of file diff --git a/pyqt_openai/ico/question.svg b/pyqt_openai/ico/question.svg index 9046a9e0..2f1ea406 100644 --- a/pyqt_openai/ico/question.svg +++ b/pyqt_openai/ico/question.svg @@ -1,2 +1,2 @@ - + \ No newline at end of file diff --git a/pyqt_openai/ico/record.svg b/pyqt_openai/ico/record.svg index 359c0d33..336b4155 100644 --- a/pyqt_openai/ico/record.svg +++ b/pyqt_openai/ico/record.svg @@ -1,18 +1,18 @@ - - - - - - - - - + + + + + + + + + \ No newline at end of file diff --git a/pyqt_openai/ico/refresh.svg b/pyqt_openai/ico/refresh.svg index 30334c17..b97452ac 100644 --- a/pyqt_openai/ico/refresh.svg +++ b/pyqt_openai/ico/refresh.svg @@ -1,13 +1,13 @@ - - - - - - - + + + + + + + \ No newline at end of file diff --git a/pyqt_openai/ico/search.svg b/pyqt_openai/ico/search.svg index 48f3feca..d747f2de 100644 --- a/pyqt_openai/ico/search.svg +++ b/pyqt_openai/ico/search.svg @@ -1,35 +1,35 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pyqt_openai/ico/send.svg b/pyqt_openai/ico/send.svg index 8f80ad5e..aceef79f 100644 --- a/pyqt_openai/ico/send.svg +++ b/pyqt_openai/ico/send.svg @@ -1,13 +1,13 @@ - - - - ic_fluent_send_28_filled - Created with Sketch. - - - - - - - + + + + ic_fluent_send_28_filled + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/pyqt_openai/ico/sidebar.svg b/pyqt_openai/ico/sidebar.svg index 50dd2cb2..b75f4e56 100644 --- a/pyqt_openai/ico/sidebar.svg +++ b/pyqt_openai/ico/sidebar.svg @@ -1,3 +1,3 @@ - - + + \ No newline at end of file diff --git a/pyqt_openai/ico/speaker.svg b/pyqt_openai/ico/speaker.svg index 66405633..9a4c1bd2 100644 --- a/pyqt_openai/ico/speaker.svg +++ b/pyqt_openai/ico/speaker.svg @@ -1,23 +1,23 @@ - - - - - - - - - - + + + + + + + + + + \ No newline at end of file diff --git a/pyqt_openai/ico/stackontop.svg b/pyqt_openai/ico/stackontop.svg index 544a0391..8451ecd7 100644 --- a/pyqt_openai/ico/stackontop.svg +++ b/pyqt_openai/ico/stackontop.svg @@ -1,22 +1,22 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pyqt_openai/ico/word.svg b/pyqt_openai/ico/word.svg index 1b2e3015..6b95e565 100644 --- a/pyqt_openai/ico/word.svg +++ b/pyqt_openai/ico/word.svg @@ -1,8 +1,8 @@ - - -Created by potrace 1.15, written by Peter Selinger 2001-2017 - - - - + + +Created by potrace 1.15, written by Peter Selinger 2001-2017 + + + + \ No newline at end of file diff --git a/pyqt_openai/lang/translations.py b/pyqt_openai/lang/translations.py index 0a0e7a3d..e3e0dc37 100644 --- a/pyqt_openai/lang/translations.py +++ b/pyqt_openai/lang/translations.py @@ -1,42 +1,42 @@ -import json - -from PySide6.QtCore import QLocale - -from pyqt_openai import DEFAULT_LANGUAGE, LANGUAGE_FILE, LANGUAGE_DICT - - -class WordsDict(dict): - """ - Only used for release version - to prevent KeyError - """ - - def __missing__(self, key): - return key - - -class LangClass: - """ - LangClass is the class that manages the language of the application. - It reads the language file and sets the language. - """ - - TRANSLATIONS = WordsDict() - - @classmethod - def lang_changed(cls, lang=None): - with open(LANGUAGE_FILE, "r", encoding="utf-8") as file: - translations_data = json.load(file) - - if not lang: - language = QLocale.system().name() - if language not in translations_data: - language = DEFAULT_LANGUAGE # Default language - else: - language = LANGUAGE_DICT[lang] - - cls.TRANSLATIONS = WordsDict(translations_data[language]) - - for k, v in LANGUAGE_DICT.items(): - if v == language: - return k +from __future__ import annotations + +import json + +from qtpy.QtCore import QLocale + +from pyqt_openai import DEFAULT_LANGUAGE, LANGUAGE_DICT, LANGUAGE_FILE + + +class WordsDict(dict): + """Only used for release version + to prevent KeyError. + """ + + def __missing__(self, key): + return key + + +class LangClass: + """LangClass is the class that manages the language of the application. + It reads the language file and sets the language. + """ + + TRANSLATIONS = WordsDict() + + @classmethod + def lang_changed(cls, lang=None): + with open(LANGUAGE_FILE, encoding="utf-8") as file: + translations_data = json.load(file) + + if not lang: + language = QLocale.system().name() + if language not in translations_data: + language = DEFAULT_LANGUAGE # Default language + else: + language = LANGUAGE_DICT[lang] + + cls.TRANSLATIONS = WordsDict(translations_data[language]) + + for k, v in LANGUAGE_DICT.items(): + if v == language: + return k diff --git a/pyqt_openai/main.py b/pyqt_openai/main.py index a5f90637..3c37595a 100644 --- a/pyqt_openai/main.py +++ b/pyqt_openai/main.py @@ -1,77 +1,78 @@ -import os -import sys - -# Get the absolute path of the current script file - -script_path = os.path.abspath(__file__) - -# Get the root directory by going up one level from the script directory -project_root = os.path.dirname(os.path.dirname(script_path)) - -sys.path.insert(0, project_root) -sys.path.insert(0, os.getcwd()) # Add the current directory as well - -# for testing pyside6 -# os.environ['QT_API'] = 'pyside6' - -# for testing pyqt6 -# os.environ['QT_API'] = 'pyqt6' - -from PySide6.QtGui import QFont, QIcon, QPixmap -from PySide6.QtWidgets import QApplication, QSplashScreen -from PySide6.QtSql import QSqlDatabase - -from pyqt_openai.config_loader import CONFIG_MANAGER - -from pyqt_openai.mainWindow import MainWindow -from pyqt_openai.util.common import handle_exception -from pyqt_openai.updateSoftwareDialog import update_software -from pyqt_openai.sqlite import get_db_filename - -from pyqt_openai import DEFAULT_APP_ICON - - -# Application -class App(QApplication): - def __init__(self, *args): - super().__init__(*args) - self.setQuitOnLastWindowClosed(False) - self.setWindowIcon(QIcon(DEFAULT_APP_ICON)) - self.splash = QSplashScreen(QPixmap(DEFAULT_APP_ICON)) - self.splash.show() - - self.__initQSqlDb() - self.__initFont() - - self.__showMainWindow() - self.splash.finish(self.main_window) - - update_software() - - def __initQSqlDb(self): - # Set up the database and table model (you'll need to configure this part based on your database) - self.__db = QSqlDatabase.addDatabase("QSQLITE") - self.__db.setDatabaseName(get_db_filename()) - self.__db.open() - - def __initFont(self): - font_family = CONFIG_MANAGER.get_general_property("font_family") - font_size = CONFIG_MANAGER.get_general_property("font_size") - QApplication.setFont(QFont(font_family, font_size)) - - def __showMainWindow(self): - self.main_window = MainWindow() - self.main_window.show() - - -# Set the global exception handler -sys.excepthook = handle_exception - - -def main(): - app = App(sys.argv) - sys.exit(app.exec()) - - -if __name__ == "__main__": - main() +from __future__ import annotations + +import os +import sys + +# Get the absolute path of the current script file + +if __name__ == "__main__": + script_path: str = os.path.abspath(__file__) + + # Get the root directory by going up one level from the script directory + project_root: str = os.path.dirname(os.path.dirname(script_path)) + + sys.path.insert(0, project_root) + sys.path.insert(0, os.getcwd()) # Add the current directory as well + +# for testing pyside6 +# os.environ['QT_API'] = 'pyside6' + +# for testing pyqt6 +# os.environ['QT_API'] = 'pyqt6' + +from qtpy.QtGui import QFont, QIcon, QPixmap +from qtpy.QtSql import QSqlDatabase +from qtpy.QtWidgets import QApplication, QSplashScreen + +from pyqt_openai import DEFAULT_APP_ICON +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.mainWindow import MainWindow +from pyqt_openai.sqlite import get_db_filename +from pyqt_openai.updateSoftwareDialog import update_software +from pyqt_openai.util.common import handle_exception + + +# Application +class App(QApplication): + def __init__(self, *args): + super().__init__(*args) + self.setQuitOnLastWindowClosed(False) + self.setWindowIcon(QIcon(DEFAULT_APP_ICON)) + self.splash: QSplashScreen = QSplashScreen(QPixmap(DEFAULT_APP_ICON)) + self.splash.show() + + self.__initQSqlDb() + self.__initFont() + + self.__showMainWindow() + self.splash.finish(self.main_window) + + update_software() + + def __initQSqlDb(self): + # Set up the database and table model (you'll need to configure this part based on your database) + self.__db: QSqlDatabase = QSqlDatabase.addDatabase("QSQLITE") + self.__db.setDatabaseName(get_db_filename()) + self.__db.open() + + def __initFont(self): + font_family: str = CONFIG_MANAGER.get_general_property("font_family") or "Arial" + font_size: int = int(CONFIG_MANAGER.get_general_property("font_size") or 12) + QApplication.setFont(QFont(font_family, font_size)) + + def __showMainWindow(self): + self.main_window: MainWindow = MainWindow() + self.main_window.show() + + +# Set the global exception handler +sys.excepthook = handle_exception + + +def main(): + app: App = App(sys.argv) + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/pyqt_openai/mainWindow.py b/pyqt_openai/mainWindow.py index b072031c..19583be8 100644 --- a/pyqt_openai/mainWindow.py +++ b/pyqt_openai/mainWindow.py @@ -1,518 +1,535 @@ -import webbrowser - -from PySide6.QtCore import Qt -from PySide6.QtGui import QAction, QIcon -from PySide6.QtWidgets import ( - QMainWindow, - QToolBar, - QHBoxLayout, - QDialog, - QWidgetAction, - QSpinBox, - QWidget, - QApplication, - QSizePolicy, - QStackedWidget, - QMenu, - QSystemTrayIcon, - QMessageBox, -) - -from pyqt_openai import ( - DEFAULT_SHORTCUT_FULL_SCREEN, - APP_INITIAL_WINDOW_SIZE, - DEFAULT_APP_NAME, - DEFAULT_APP_ICON, - ICON_STACKONTOP, - ICON_CUSTOMIZE, - ICON_FULLSCREEN, - ICON_CLOSE, - DEFAULT_SHORTCUT_SETTING, - TRANSPARENT_RANGE, - TRANSPARENT_INIT_VAL, - ICON_GITHUB, - ICON_DISCORD, - PAYPAL_URL, - KOFI_URL, - DISCORD_URL, - GITHUB_URL, - DEFAULT_SHORTCUT_FOCUS_MODE, - ICON_FOCUS_MODE, - ICON_SETTING, - DEFAULT_SHORTCUT_SHOW_SECONDARY_TOOLBAR, - DEFAULT_SHORTCUT_STACK_ON_TOP, - ICON_PAYPAL, - ICON_KOFI, - ICON_PATREON, - PATREON_URL, - ICON_UPDATE, - ICON_SHORTCUT, -) -from pyqt_openai.aboutDialog import AboutDialog -from pyqt_openai.chat_widget.chatMainWidget import ChatMainWidget -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.customizeDialog import CustomizeDialog -from pyqt_openai.dalle_widget.dalleMainWidget import DallEMainWidget -from pyqt_openai.doNotAskAgainDialog import DoNotAskAgainDialog -from pyqt_openai.g4f_image_widget.g4fImageMainWidget import G4FImageMainWidget -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.models import SettingsParamsContainer, CustomizeParamsContainer -from pyqt_openai.replicate_widget.replicateMainWidget import ReplicateMainWidget -from pyqt_openai.settings_dialog.settingsDialog import SettingsDialog -from pyqt_openai.shortcutDialog import ShortcutDialog -from pyqt_openai.updateSoftwareDialog import update_software -from pyqt_openai.util.common import ( - restart_app, - show_message_box_after_change_to_restart, - set_auto_start_windows, - init_llama, set_api_key, -) -from pyqt_openai.widgets.navWidget import NavBar - - -class MainWindow(QMainWindow): - def __init__(self, parent=None): - super().__init__(parent) - self.__initVal() - self.__initUi() - - def __initVal(self): - self.__settingsParamContainer = SettingsParamsContainer() - self.__customizeParamsContainer = CustomizeParamsContainer() - - self.__initContainer(self.__settingsParamContainer) - self.__initContainer(self.__customizeParamsContainer) - - def __initUi(self): - self.setWindowTitle(DEFAULT_APP_NAME) - - self.__chatMainWidget = ChatMainWidget(self) - self.__dallEWidget = DallEMainWidget(self) - self.__replicateWidget = ReplicateMainWidget(self) - self.__g4fImageWidget = G4FImageMainWidget(self) - - self.__mainWidget = QStackedWidget() - self.__mainWidget.addWidget(self.__chatMainWidget) - self.__mainWidget.addWidget(self.__dallEWidget) - self.__mainWidget.addWidget(self.__replicateWidget) - self.__mainWidget.addWidget(self.__g4fImageWidget) - - self.__setActions() - self.__setMenuBar() - self.__setTrayMenu() - self.__setToolBar() - - self.__loadApiKeys() - - self.setCentralWidget(self.__mainWidget) - self.resize(*APP_INITIAL_WINDOW_SIZE) - - self.__refreshColumns() - self.__chatMainWidget.refreshCustomizedInformation( - self.__customizeParamsContainer - ) - - def __loadApiKeys(self): - set_api_key('OPENAI_API_KEY', CONFIG_MANAGER.get_general_property('OPENAI_API_KEY')) - set_api_key('REPLICATE_API_KEY', CONFIG_MANAGER.get_general_property('REPLICATE_API_KEY')) - init_llama() - - def __setActions(self): - self.__langAction = QAction() - - # menu action - self.__exitAction = QAction(LangClass.TRANSLATIONS["Exit"], self) - self.__exitAction.triggered.connect(self.__beforeClose) - - self.__stackAction = QAction(LangClass.TRANSLATIONS["Stack on Top"], self) - self.__stackAction.setShortcut(DEFAULT_SHORTCUT_STACK_ON_TOP) - self.__stackAction.setIcon(QIcon(ICON_STACKONTOP)) - self.__stackAction.setCheckable(True) - self.__stackAction.toggled.connect(self.__stackToggle) - - self.__showSecondaryToolBarAction = QAction( - LangClass.TRANSLATIONS["Show Secondary Toolbar"], self - ) - self.__showSecondaryToolBarAction.setShortcut( - DEFAULT_SHORTCUT_SHOW_SECONDARY_TOOLBAR - ) - self.__showSecondaryToolBarAction.setCheckable(True) - self.__showSecondaryToolBarAction.setChecked( - CONFIG_MANAGER.get_general_property("show_secondary_toolbar") - ) - self.__showSecondaryToolBarAction.toggled.connect(self.__toggleSecondaryToolBar) - - self.__focusModeAction = QAction(LangClass.TRANSLATIONS["Focus Mode"], self) - self.__focusModeAction.setShortcut(DEFAULT_SHORTCUT_FOCUS_MODE) - self.__focusModeAction.setIcon(QIcon(ICON_FOCUS_MODE)) - self.__focusModeAction.setCheckable(True) - self.__focusModeAction.setChecked( - CONFIG_MANAGER.get_general_property("focus_mode") - ) - self.__focusModeAction.triggered.connect(self.__activateFocusMode) - - self.__fullScreenAction = QAction(LangClass.TRANSLATIONS["Full Screen"], self) - self.__fullScreenAction.setShortcut(DEFAULT_SHORTCUT_FULL_SCREEN) - self.__fullScreenAction.setIcon(QIcon(ICON_FULLSCREEN)) - self.__fullScreenAction.setCheckable(True) - self.__fullScreenAction.setChecked(False) - self.__fullScreenAction.triggered.connect(self.__fullScreenToggle) - - self.__aboutAction = QAction(LangClass.TRANSLATIONS["About..."], self) - self.__aboutAction.setIcon(QIcon(DEFAULT_APP_ICON)) - self.__aboutAction.triggered.connect(self.__showAboutDialog) - - # TODO LANGAUGE - self.__checkUpdateAction = QAction( - LangClass.TRANSLATIONS["Check for Updates..."], self - ) - self.__checkUpdateAction.setIcon(QIcon(ICON_UPDATE)) - self.__checkUpdateAction.triggered.connect(self.__checkUpdate) - - self.__viewShortcutsAction = QAction( - LangClass.TRANSLATIONS["View Shortcuts"], self - ) - self.__viewShortcutsAction.setIcon(QIcon(ICON_SHORTCUT)) - self.__viewShortcutsAction.triggered.connect(self.__showShortcutsDialog) - - self.__githubAction = QAction("Github", self) - self.__githubAction.setIcon(QIcon(ICON_GITHUB)) - self.__githubAction.triggered.connect(lambda: webbrowser.open(GITHUB_URL)) - - self.__discordAction = QAction("Discord", self) - self.__discordAction.setIcon(QIcon(ICON_DISCORD)) - self.__discordAction.triggered.connect(lambda: webbrowser.open(DISCORD_URL)) - - self.__paypalAction = QAction("Paypal", self) - self.__paypalAction.setIcon(QIcon(ICON_PAYPAL)) - self.__paypalAction.triggered.connect(lambda: webbrowser.open(PAYPAL_URL)) - - self.__kofiAction = QAction("Ko-fi ❤", self) - self.__kofiAction.setIcon(QIcon(ICON_KOFI)) - self.__kofiAction.triggered.connect(lambda: webbrowser.open(KOFI_URL)) - - self.__patreonAction = QAction("Patreon", self) - self.__patreonAction.setIcon(QIcon(ICON_PATREON)) - self.__patreonAction.triggered.connect(lambda: webbrowser.open(PATREON_URL)) - - self.__navBar = NavBar() - self.__navBar.add(LangClass.TRANSLATIONS["Chat"]) - self.__navBar.add("DALL-E") - self.__navBar.add("Replicate") - self.__navBar.add("G4F Image") - self.__navBar.setSizePolicy( - QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Preferred - ) - self.__navBar.itemClicked.connect(self.__aiTypeChanged) - - # Chat is the default widget - self.__navBar.setActiveButton(0) - - # toolbar action - self.__chooseAiAction = QWidgetAction(self) - self.__chooseAiAction.setDefaultWidget(self.__navBar) - - self.__customizeAction = QAction(self) - self.__customizeAction.setText(LangClass.TRANSLATIONS["Customize"]) - self.__customizeAction.setIcon(QIcon(ICON_CUSTOMIZE)) - self.__customizeAction.triggered.connect(self.__executeCustomizeDialog) - - self.__transparentAction = QWidgetAction(self) - self.__transparentSpinBox = QSpinBox() - self.__transparentSpinBox.setRange(*TRANSPARENT_RANGE) - self.__transparentSpinBox.setValue(TRANSPARENT_INIT_VAL) - self.__transparentSpinBox.valueChanged.connect(self.__setTransparency) - self.__transparentSpinBox.setToolTip( - LangClass.TRANSLATIONS["Set Transparency of Window"] - ) - self.__transparentSpinBox.setMinimumWidth(100) - - lay = QHBoxLayout() - lay.addWidget(self.__transparentSpinBox) - - transparencyActionWidget = QWidget(self) - transparencyActionWidget.setLayout(lay) - self.__transparentAction.setDefaultWidget(transparencyActionWidget) - - self.__settingsAction = QAction(self) - self.__settingsAction.setText(LangClass.TRANSLATIONS["Settings"]) - self.__settingsAction.setIcon(QIcon(ICON_SETTING)) - self.__settingsAction.setShortcut(DEFAULT_SHORTCUT_SETTING) - self.__settingsAction.triggered.connect(self.__showSettingsDialog) - - def __fullScreenToggle(self, f): - if f: - self.showFullScreen() - else: - self.showNormal() - - def __activateFocusMode(self, f): - f = not f - # Toggle GUI - for i in range(self.__mainWidget.count()): - currentWidget = self.__mainWidget.widget(i) - currentWidget.showSecondaryToolBar(f) - currentWidget.toggleButtons(f) - self.__toggleSecondaryToolBar(f) - - # Toggle container - self.__settingsParamContainer.show_secondary_toolbar = f - CONFIG_MANAGER.set_general_property("focus_mode", not f) - - def __setMenuBar(self): - menubar = self.menuBar() - - fileMenu = QMenu(LangClass.TRANSLATIONS["File"], self) - fileMenu.addAction(self.__settingsAction) - fileMenu.addAction(self.__exitAction) - - viewMenu = QMenu(LangClass.TRANSLATIONS["View"], self) - viewMenu.addAction(self.__focusModeAction) - viewMenu.addAction(self.__fullScreenAction) - viewMenu.addAction(self.__stackAction) - viewMenu.addAction(self.__showSecondaryToolBarAction) - - helpMenu = QMenu(LangClass.TRANSLATIONS["Help"], self) - helpMenu.addAction(self.__aboutAction) - helpMenu.addAction(self.__checkUpdateAction) - helpMenu.addAction(self.__viewShortcutsAction) - helpMenu.addAction(self.__githubAction) - helpMenu.addAction(self.__discordAction) - - donateMenu = QMenu(LangClass.TRANSLATIONS["Donate"], self) - donateMenu.addAction(self.__paypalAction) - donateMenu.addAction(self.__kofiAction) - donateMenu.addAction(self.__patreonAction) - - menubar.addMenu(fileMenu) - menubar.addMenu(viewMenu) - menubar.addMenu(helpMenu) - menubar.addMenu(donateMenu) - - def __setTrayMenu(self): - # background app - menu = QMenu() - app = QApplication.instance() - - action = QAction("Quit", self) - action.setIcon(QIcon(ICON_CLOSE)) - - action.triggered.connect(app.quit) - - menu.addAction(action) - - tray_icon = QSystemTrayIcon(app) - tray_icon.setIcon(QIcon(DEFAULT_APP_ICON)) - tray_icon.activated.connect(self.__activated) - - tray_icon.setContextMenu(menu) - - tray_icon.show() - - def __activated(self, reason): - if reason == QSystemTrayIcon.ActivationReason.DoubleClick: - self.show() - - def __setToolBar(self): - self.__toolbar = QToolBar() - self.__toolbar.addAction(self.__chooseAiAction) - self.__toolbar.addAction(self.__showSecondaryToolBarAction) - self.__toolbar.addAction(self.__fullScreenAction) - self.__toolbar.addAction(self.__stackAction) - self.__toolbar.addAction(self.__focusModeAction) - self.__toolbar.addAction(self.__settingsAction) - self.__toolbar.addAction(self.__checkUpdateAction) - self.__toolbar.addAction(self.__customizeAction) - self.__toolbar.addAction(self.__githubAction) - self.__toolbar.addAction(self.__discordAction) - self.__toolbar.addAction(self.__paypalAction) - self.__toolbar.addAction(self.__kofiAction) - self.__toolbar.addAction(self.__patreonAction) - self.__toolbar.addAction(self.__aboutAction) - self.__toolbar.addAction(self.__viewShortcutsAction) - self.__toolbar.addAction(self.__transparentAction) - self.__toolbar.setMovable(False) - - self.addToolBar(self.__toolbar) - - # QToolbar's layout can't be set spacing with lay.setSpacing so i've just did this instead - self.__toolbar.setStyleSheet("QToolBar { spacing: 2px; }") - - for i in range(self.__mainWidget.count()): - currentWidget = self.__mainWidget.widget(i) - currentWidget.showSecondaryToolBar( - self.__settingsParamContainer.show_secondary_toolbar - ) - - def __showAboutDialog(self): - aboutDialog = AboutDialog(self) - aboutDialog.exec() - - def __checkUpdate(self): - update_software() - - def __showShortcutsDialog(self): - shortcutListWidget = ShortcutDialog(self) - shortcutListWidget.exec() - - def __stackToggle(self, f): - if f: - self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowStaysOnTopHint) - else: - # Qt.WindowType.WindowCloseButtonHint is added to prevent the close button get deactivated - self.setWindowFlags( - self.windowFlags() & ~Qt.WindowType.WindowStaysOnTopHint - | Qt.WindowType.WindowCloseButtonHint - ) - self.show() - - def __setTransparency(self, v): - self.setWindowOpacity(v / 100) - - def __toggleSecondaryToolBar(self, f): - self.__showSecondaryToolBarAction.setChecked(f) - self.__mainWidget.currentWidget().showSecondaryToolBar(f) - self.__settingsParamContainer.show_secondary_toolbar = f - - def __executeCustomizeDialog(self): - dialog = CustomizeDialog(self.__customizeParamsContainer, parent=self) - reply = dialog.exec() - if reply == QDialog.DialogCode.Accepted: - container = dialog.getParam() - self.__customizeParamsContainer = container - self.__refreshContainer(container) - self.__chatMainWidget.refreshCustomizedInformation(container) - - def __aiTypeChanged(self, i): - self.__mainWidget.setCurrentIndex(i) - self.__navBar.setActiveButton(i) - widget = self.__mainWidget.currentWidget() - widget.showSecondaryToolBar( - self.__settingsParamContainer.show_secondary_toolbar - ) - - def __initContainer(self, container): - """ - Initialize the container with the values in the settings file - """ - for k, v in container.get_items(): - setattr(container, k, CONFIG_MANAGER.get_general_property(k)) - if isinstance(container, SettingsParamsContainer): - self.__lang = LangClass.lang_changed(container.lang) - set_auto_start_windows(container.run_at_startup) - - def __refreshContainer(self, container): - if isinstance(container, SettingsParamsContainer): - prev_db = CONFIG_MANAGER.get_general_property("db") - prev_show_secondary_toolbar = CONFIG_MANAGER.get_general_property( - "show_secondary_toolbar" - ) - prev_show_as_markdown = CONFIG_MANAGER.get_general_property( - "show_as_markdown" - ) - prev_run_at_startup = CONFIG_MANAGER.get_general_property("run_at_startup") - - for k, v in container.get_items(): - CONFIG_MANAGER.set_general_property(k, v) - - # If db name is changed - if container.db != prev_db: - QMessageBox.information( - self, - LangClass.TRANSLATIONS["Info"], - LangClass.TRANSLATIONS[ - "The name of the reference target database has been changed. The changes will take effect after a restart." - ], - ) - if container.run_at_startup != prev_run_at_startup: - set_auto_start_windows(container.run_at_startup) - # If show_secondary_toolbar is changed - if container.show_secondary_toolbar != prev_show_secondary_toolbar: - for i in range(self.__mainWidget.count()): - currentWidget = self.__mainWidget.widget(i) - currentWidget.showSecondaryToolBar(container.show_secondary_toolbar) - # If properties that require a restart are changed - if ( - container.lang != self.__lang - or container.show_as_markdown != prev_show_as_markdown - ): - change_list = [] - if container.lang != self.__lang: - change_list.append(LangClass.TRANSLATIONS["Language"]) - if container.show_as_markdown != prev_show_as_markdown: - change_list.append(LangClass.TRANSLATIONS["Show as Markdown"]) - result = show_message_box_after_change_to_restart(change_list) - if result == QMessageBox.StandardButton.Yes: - restart_app() - - elif isinstance(container, CustomizeParamsContainer): - prev_font_family = CONFIG_MANAGER.get_general_property("font_family") - prev_font_size = CONFIG_MANAGER.get_general_property("font_size") - - for k, v in container.get_items(): - CONFIG_MANAGER.set_general_property(k, v) - - if ( - container.font_family != prev_font_family - or container.font_size != prev_font_size - ): - change_list = [ - LangClass.TRANSLATIONS["Font Change"], - ] - result = show_message_box_after_change_to_restart(change_list) - if result == QMessageBox.StandardButton.Yes: - restart_app() - - def __refreshColumns(self): - self.__chatMainWidget.setColumns( - self.__settingsParamContainer.chat_column_to_show - ) - image_column_to_show = self.__settingsParamContainer.image_column_to_show - if image_column_to_show.__contains__("data"): - image_column_to_show.remove("data") - self.__dallEWidget.setColumns( - self.__settingsParamContainer.image_column_to_show - ) - self.__replicateWidget.setColumns( - self.__settingsParamContainer.image_column_to_show - ) - - def __showSettingsDialog(self): - dialog = SettingsDialog(parent=self) - reply = dialog.exec() - if reply == QDialog.DialogCode.Accepted: - container = dialog.getParam() - self.__settingsParamContainer = container - self.__refreshContainer(container) - self.__refreshColumns() - - def __doNotAskAgainChanged(self, value): - self.__settingsParamContainer.do_not_ask_again = value - self.__refreshContainer(self.__settingsParamContainer) - - def __beforeClose(self): - if self.__settingsParamContainer.do_not_ask_again: - app = QApplication.instance() - app.quit() - else: - # Show a message box to confirm the exit or cancel or running in the background - dialog = DoNotAskAgainDialog( - self.__settingsParamContainer.do_not_ask_again, parent=self - ) - dialog.doNotAskAgainChanged.connect(self.__doNotAskAgainChanged) - reply = dialog.exec() - if dialog.isCancel(): - return True - else: - if reply == QDialog.DialogCode.Accepted: - app = QApplication.instance() - app.quit() - elif reply == QDialog.DialogCode.Rejected: - self.close() - - def closeEvent(self, event): - f = self.__beforeClose() - if f: - event.ignore() - else: - return super().closeEvent(event) +from __future__ import annotations + +import webbrowser + +from typing import TYPE_CHECKING + +from qtpy.QtCore import Qt +from qtpy.QtGui import QIcon +from qtpy.QtWidgets import ( + QAction, # pyright: ignore[reportPrivateImportUsage] + QApplication, + QDialog, + QHBoxLayout, + QMainWindow, + QMenu, + QMessageBox, + QSizePolicy, + QSpinBox, + QStackedWidget, + QSystemTrayIcon, + QToolBar, + QWidget, + QWidgetAction, +) + +from pyqt_openai import ( + APP_INITIAL_WINDOW_SIZE, + DEFAULT_APP_ICON, + DEFAULT_APP_NAME, + DEFAULT_SHORTCUT_FOCUS_MODE, + DEFAULT_SHORTCUT_FULL_SCREEN, + DEFAULT_SHORTCUT_SETTING, + DEFAULT_SHORTCUT_SHOW_SECONDARY_TOOLBAR, + DEFAULT_SHORTCUT_STACK_ON_TOP, + DISCORD_URL, + GITHUB_URL, + ICON_CLOSE, + ICON_CUSTOMIZE, + ICON_DISCORD, + ICON_FOCUS_MODE, + ICON_FULLSCREEN, + ICON_GITHUB, + ICON_KOFI, + ICON_PATREON, + ICON_PAYPAL, + ICON_SETTING, + ICON_SHORTCUT, + ICON_STACKONTOP, + ICON_UPDATE, + KOFI_URL, + PATREON_URL, + PAYPAL_URL, + TRANSPARENT_INIT_VAL, + TRANSPARENT_RANGE, +) +from pyqt_openai.aboutDialog import AboutDialog +from pyqt_openai.chat_widget.chatMainWidget import ChatMainWidget +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.customizeDialog import CustomizeDialog +from pyqt_openai.dalle_widget.dalleMainWidget import DallEMainWidget +from pyqt_openai.doNotAskAgainDialog import DoNotAskAgainDialog +from pyqt_openai.g4f_image_widget.g4fImageMainWidget import G4FImageMainWidget +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.models import CustomizeParamsContainer, SettingsParamsContainer +from pyqt_openai.replicate_widget.replicateMainWidget import ReplicateMainWidget +from pyqt_openai.settings_dialog.settingsDialog import SettingsDialog +from pyqt_openai.shortcutDialog import ShortcutDialog +from pyqt_openai.updateSoftwareDialog import update_software +from pyqt_openai.util.common import init_llama, restart_app, set_api_key, set_auto_start_windows, show_message_box_after_change_to_restart +from pyqt_openai.widgets.navWidget import NavBar + +if TYPE_CHECKING: + from qtpy.QtCore import QCoreApplication + from qtpy.QtGui import QCloseEvent + + +class MainWindow(QMainWindow): + def __init__( + self, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.__initVal() + self.__initUi() + + def __initVal(self): + self.__settingsParamContainer: SettingsParamsContainer = SettingsParamsContainer() + self.__customizeParamsContainer: CustomizeParamsContainer = CustomizeParamsContainer() + + self.__initContainer(self.__settingsParamContainer) + self.__initContainer(self.__customizeParamsContainer) + + def __initUi(self): + self.setWindowTitle(DEFAULT_APP_NAME) + + self.__chatMainWidget: ChatMainWidget = ChatMainWidget(self) + self.__dallEWidget: DallEMainWidget = DallEMainWidget(self) + self.__replicateWidget: ReplicateMainWidget = ReplicateMainWidget(self) + self.__g4fImageWidget: G4FImageMainWidget = G4FImageMainWidget(self) + + self.__mainWidget: QStackedWidget = QStackedWidget() + self.__mainWidget.addWidget(self.__chatMainWidget) + self.__mainWidget.addWidget(self.__dallEWidget) + self.__mainWidget.addWidget(self.__replicateWidget) + self.__mainWidget.addWidget(self.__g4fImageWidget) + + self.__setActions() + self.__setMenuBar() + self.__setTrayMenu() + self.__setToolBar() + + self.__loadApiKeys() + + self.setCentralWidget(self.__mainWidget) + self.resize(*APP_INITIAL_WINDOW_SIZE) + + self.__refreshColumns() + self.__chatMainWidget.refreshCustomizedInformation(self.__customizeParamsContainer) + + def __loadApiKeys(self): + set_api_key("OPENAI_API_KEY", CONFIG_MANAGER.get_general_property("OPENAI_API_KEY")) + set_api_key("REPLICATE_API_KEY", CONFIG_MANAGER.get_general_property("REPLICATE_API_KEY")) + init_llama() + + def __setActions(self): + self.__langAction = QAction() + + # menu action + self.__exitAction = QAction(LangClass.TRANSLATIONS["Exit"], self) + self.__exitAction.triggered.connect(self.__beforeClose) + + self.__stackAction = QAction(LangClass.TRANSLATIONS["Stack on Top"], self) + self.__stackAction.setShortcut(DEFAULT_SHORTCUT_STACK_ON_TOP) + self.__stackAction.setIcon(QIcon(ICON_STACKONTOP)) + self.__stackAction.setCheckable(True) + self.__stackAction.toggled.connect(self.__stackToggle) + + self.__showSecondaryToolBarAction = QAction(LangClass.TRANSLATIONS["Show Secondary Toolbar"], self) + self.__showSecondaryToolBarAction.setShortcut(DEFAULT_SHORTCUT_SHOW_SECONDARY_TOOLBAR) + self.__showSecondaryToolBarAction.setCheckable(True) + self.__showSecondaryToolBarAction.setChecked(bool(CONFIG_MANAGER.get_general_property("show_secondary_toolbar"))) + self.__showSecondaryToolBarAction.toggled.connect(self.__toggleSecondaryToolBar) + + self.__focusModeAction = QAction(LangClass.TRANSLATIONS["Focus Mode"], self) + self.__focusModeAction.setShortcut(DEFAULT_SHORTCUT_FOCUS_MODE) + self.__focusModeAction.setIcon(QIcon(ICON_FOCUS_MODE)) + self.__focusModeAction.setCheckable(True) + self.__focusModeAction.setChecked(bool(CONFIG_MANAGER.get_general_property("focus_mode"))) + self.__focusModeAction.triggered.connect(self.__activateFocusMode) + + self.__fullScreenAction = QAction(LangClass.TRANSLATIONS["Full Screen"], self) + self.__fullScreenAction.setShortcut(DEFAULT_SHORTCUT_FULL_SCREEN) + self.__fullScreenAction.setIcon(QIcon(ICON_FULLSCREEN)) + self.__fullScreenAction.setCheckable(True) + self.__fullScreenAction.setChecked(False) + self.__fullScreenAction.triggered.connect(self.__fullScreenToggle) + + self.__aboutAction = QAction(LangClass.TRANSLATIONS["About..."], self) + self.__aboutAction.setIcon(QIcon(DEFAULT_APP_ICON)) + self.__aboutAction.triggered.connect(self.__showAboutDialog) + + # TODO LANGAUGE + self.__checkUpdateAction = QAction(LangClass.TRANSLATIONS["Check for Updates..."], self) + self.__checkUpdateAction.setIcon(QIcon(ICON_UPDATE)) + self.__checkUpdateAction.triggered.connect(self.__checkUpdate) + + self.__viewShortcutsAction = QAction(LangClass.TRANSLATIONS["View Shortcuts"], self) + self.__viewShortcutsAction.setIcon(QIcon(ICON_SHORTCUT)) + self.__viewShortcutsAction.triggered.connect(self.__showShortcutsDialog) + + self.__githubAction = QAction("Github", self) + self.__githubAction.setIcon(QIcon(ICON_GITHUB)) + self.__githubAction.triggered.connect(lambda: webbrowser.open(GITHUB_URL)) + + self.__discordAction = QAction("Discord", self) + self.__discordAction.setIcon(QIcon(ICON_DISCORD)) + self.__discordAction.triggered.connect(lambda: webbrowser.open(DISCORD_URL)) + + self.__paypalAction = QAction("Paypal", self) + self.__paypalAction.setIcon(QIcon(ICON_PAYPAL)) + self.__paypalAction.triggered.connect(lambda: webbrowser.open(PAYPAL_URL)) + + self.__kofiAction = QAction("Ko-fi ❤", self) + self.__kofiAction.setIcon(QIcon(ICON_KOFI)) + self.__kofiAction.triggered.connect(lambda: webbrowser.open(KOFI_URL)) + + self.__patreonAction = QAction("Patreon", self) + self.__patreonAction.setIcon(QIcon(ICON_PATREON)) + self.__patreonAction.triggered.connect(lambda: webbrowser.open(PATREON_URL)) + + self.__navBar = NavBar() + self.__navBar.add(LangClass.TRANSLATIONS["Chat"]) + self.__navBar.add("DALL-E") + self.__navBar.add("Replicate") + self.__navBar.add("G4F Image") + self.__navBar.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Preferred) + self.__navBar.itemClicked.connect(self.__aiTypeChanged) + + # Chat is the default widget + self.__navBar.setActiveButton(0) + + # toolbar action + self.__chooseAiAction: QWidgetAction = QWidgetAction(self) + self.__chooseAiAction.setDefaultWidget(self.__navBar) + + self.__customizeAction = QAction(self) + self.__customizeAction.setText(LangClass.TRANSLATIONS["Customize"]) + self.__customizeAction.setIcon(QIcon(ICON_CUSTOMIZE)) + self.__customizeAction.triggered.connect(self.__executeCustomizeDialog) + + self.__transparentAction: QWidgetAction = QWidgetAction(self) + self.__transparentSpinBox = QSpinBox() + self.__transparentSpinBox.setRange(*TRANSPARENT_RANGE) + self.__transparentSpinBox.setValue(TRANSPARENT_INIT_VAL) + self.__transparentSpinBox.valueChanged.connect(self.__setTransparency) + self.__transparentSpinBox.setToolTip(LangClass.TRANSLATIONS["Set Transparency of Window"]) + self.__transparentSpinBox.setMinimumWidth(100) + + lay = QHBoxLayout() + lay.addWidget(self.__transparentSpinBox) + + transparencyActionWidget = QWidget(self) + transparencyActionWidget.setLayout(lay) + self.__transparentAction.setDefaultWidget(transparencyActionWidget) + + self.__settingsAction = QAction(self) + self.__settingsAction.setText(LangClass.TRANSLATIONS["Settings"]) + self.__settingsAction.setIcon(QIcon(ICON_SETTING)) + self.__settingsAction.setShortcut(DEFAULT_SHORTCUT_SETTING) + self.__settingsAction.triggered.connect(self.__showSettingsDialog) + + def __fullScreenToggle( + self, + f: bool, + ): + if f: + self.showFullScreen() + else: + self.showNormal() + + def __activateFocusMode( + self, + f: bool, + ): + f = not f + # Toggle GUI + for i in range(self.__mainWidget.count()): + currentWidget: QWidget = self.__mainWidget.widget(i) + if not isinstance(currentWidget, ChatMainWidget): + QMessageBox.critical( + None, # pyright: ignore[reportArgumentType] + LangClass.TRANSLATIONS["Error"], + LangClass.TRANSLATIONS["This feature is not available for this AI type."], + QMessageBox.StandardButton.Ok, + QMessageBox.StandardButton.Cancel, + ) + return + currentWidget.showSecondaryToolBar(f) + currentWidget.toggleButtons(f) + self.__toggleSecondaryToolBar(f) + + # Toggle container + self.__settingsParamContainer.show_secondary_toolbar = f + CONFIG_MANAGER.set_general_property("focus_mode", str(not f)) + + def __setMenuBar(self): + menubar = self.menuBar() + + fileMenu = QMenu(LangClass.TRANSLATIONS["File"], self) + fileMenu.addAction(self.__settingsAction) + fileMenu.addAction(self.__exitAction) + + viewMenu = QMenu(LangClass.TRANSLATIONS["View"], self) + viewMenu.addAction(self.__focusModeAction) + viewMenu.addAction(self.__fullScreenAction) + viewMenu.addAction(self.__stackAction) + viewMenu.addAction(self.__showSecondaryToolBarAction) + + helpMenu = QMenu(LangClass.TRANSLATIONS["Help"], self) + helpMenu.addAction(self.__aboutAction) + helpMenu.addAction(self.__checkUpdateAction) + helpMenu.addAction(self.__viewShortcutsAction) + helpMenu.addAction(self.__githubAction) + helpMenu.addAction(self.__discordAction) + + donateMenu = QMenu(LangClass.TRANSLATIONS["Donate"], self) + donateMenu.addAction(self.__paypalAction) + donateMenu.addAction(self.__kofiAction) + donateMenu.addAction(self.__patreonAction) + + menubar.addMenu(fileMenu) + menubar.addMenu(viewMenu) + menubar.addMenu(helpMenu) + menubar.addMenu(donateMenu) + + def __setTrayMenu(self): + # background app + menu = QMenu() + app = QApplication.instance() + assert app is not None + + action = QAction("Quit", self) + action.setIcon(QIcon(ICON_CLOSE)) + + action.triggered.connect(app.quit) + + menu.addAction(action) + + tray_icon = QSystemTrayIcon(app) + tray_icon.setIcon(QIcon(DEFAULT_APP_ICON)) + tray_icon.activated.connect(self.__activated) + + tray_icon.setContextMenu(menu) + + tray_icon.show() + + def __activated(self, reason: QSystemTrayIcon.ActivationReason): + if reason == QSystemTrayIcon.ActivationReason.DoubleClick: + self.show() + + def __setToolBar(self): + self.__toolbar = QToolBar() + self.__toolbar.addAction(self.__chooseAiAction) + self.__toolbar.addAction(self.__showSecondaryToolBarAction) + self.__toolbar.addAction(self.__fullScreenAction) + self.__toolbar.addAction(self.__stackAction) + self.__toolbar.addAction(self.__focusModeAction) + self.__toolbar.addAction(self.__settingsAction) + self.__toolbar.addAction(self.__checkUpdateAction) + self.__toolbar.addAction(self.__customizeAction) + self.__toolbar.addAction(self.__githubAction) + self.__toolbar.addAction(self.__discordAction) + self.__toolbar.addAction(self.__paypalAction) + self.__toolbar.addAction(self.__kofiAction) + self.__toolbar.addAction(self.__patreonAction) + self.__toolbar.addAction(self.__aboutAction) + self.__toolbar.addAction(self.__viewShortcutsAction) + self.__toolbar.addAction(self.__transparentAction) + self.__toolbar.setMovable(False) + + self.addToolBar(self.__toolbar) + + # QToolbar's layout can't be set spacing with lay.setSpacing so i've just did this instead + self.__toolbar.setStyleSheet("QToolBar { spacing: 2px; }") + + for i in range(self.__mainWidget.count()): + currentWidget: QWidget = self.__mainWidget.widget(i) + if not isinstance(currentWidget, ChatMainWidget): + print(f"error: Current widget at index {i} is not a ChatMainWidget, skipping") + continue + currentWidget.showSecondaryToolBar(self.__settingsParamContainer.show_secondary_toolbar) + + def __showAboutDialog(self): + aboutDialog: AboutDialog = AboutDialog(self) + aboutDialog.exec() + + def __checkUpdate(self): + update_software() + + def __showShortcutsDialog(self): + shortcutListWidget: ShortcutDialog = ShortcutDialog(self) + shortcutListWidget.exec() + + def __stackToggle(self, f: bool): + if f: + self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowStaysOnTopHint) + else: + # Qt.WindowType.WindowCloseButtonHint is added to prevent the close button get deactivated + self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.WindowCloseButtonHint) + self.show() + + def __setTransparency(self, v: int): + self.setWindowOpacity(v / 100) + + def __toggleSecondaryToolBar(self, f: bool): + self.__showSecondaryToolBarAction.setChecked(f) + curWidget = self.__mainWidget.currentWidget() + if not isinstance(curWidget, ChatMainWidget): + QMessageBox.warning( + None, # pyright: ignore[reportArgumentType] + LangClass.TRANSLATIONS["Error"], + LangClass.TRANSLATIONS["This feature is not available for this AI type. Secondary toolbar is only available for Chat."], + QMessageBox.StandardButton.Ok, + QMessageBox.StandardButton.Cancel, + ) + return + curWidget.showSecondaryToolBar(f) + self.__settingsParamContainer.show_secondary_toolbar = f + + def __executeCustomizeDialog(self): + dialog = CustomizeDialog(self.__customizeParamsContainer, parent=self) + reply = dialog.exec() + if reply == QDialog.DialogCode.Accepted: + container: CustomizeParamsContainer = dialog.getParam() + self.__customizeParamsContainer = container + self.__refreshContainer(container) + self.__chatMainWidget.refreshCustomizedInformation(container) + + def __aiTypeChanged( + self, + i: int, + ): + self.__mainWidget.setCurrentIndex(i) + self.__navBar.setActiveButton(i) + widget: QWidget = self.__mainWidget.currentWidget() + if not isinstance(widget, ChatMainWidget): + QMessageBox.critical( + None, # pyright: ignore[reportArgumentType] + LangClass.TRANSLATIONS["Error"], + LangClass.TRANSLATIONS["This feature is not available for this AI type."], + QMessageBox.StandardButton.Ok, + QMessageBox.StandardButton.Cancel, + ) + return + widget.showSecondaryToolBar(self.__settingsParamContainer.show_secondary_toolbar) + + def __initContainer( + self, + container: SettingsParamsContainer | CustomizeParamsContainer, + ): + """Initialize the container with the values in the settings file.""" + for k, v in container.get_items(): + setattr(container, k, CONFIG_MANAGER.get_general_property(k)) + if isinstance(container, SettingsParamsContainer): + self.__lang: str | None = LangClass.lang_changed(container.lang) + set_auto_start_windows(container.run_at_startup) + + def __refreshContainer( + self, + container: SettingsParamsContainer | CustomizeParamsContainer, + ): + if isinstance(container, SettingsParamsContainer): + prev_db = CONFIG_MANAGER.get_general_property("db") + prev_show_secondary_toolbar = CONFIG_MANAGER.get_general_property("show_secondary_toolbar") + prev_show_as_markdown = CONFIG_MANAGER.get_general_property("show_as_markdown") + prev_run_at_startup = CONFIG_MANAGER.get_general_property("run_at_startup") + + for k, v in container.get_items(): + CONFIG_MANAGER.set_general_property(k, v) + + # If db name is changed + if container.db != prev_db: + QMessageBox.information( + self, + LangClass.TRANSLATIONS["Info"], + LangClass.TRANSLATIONS["The name of the reference target database has been changed. The changes will take effect after a restart."], + ) + if container.run_at_startup != prev_run_at_startup: + set_auto_start_windows(container.run_at_startup) + # If show_secondary_toolbar is changed + if container.show_secondary_toolbar != prev_show_secondary_toolbar: + for i in range(self.__mainWidget.count()): + currentWidget: QWidget = self.__mainWidget.widget(i) + if not isinstance(currentWidget, ChatMainWidget): + print(f"error: Current widget at index {i} is not a ChatMainWidget, skipping") + continue + currentWidget.showSecondaryToolBar(container.show_secondary_toolbar) + # If properties that require a restart are changed + if container.lang != self.__lang or container.show_as_markdown != prev_show_as_markdown: + change_list = [] + if container.lang != self.__lang: + change_list.append(LangClass.TRANSLATIONS["Language"]) + if container.show_as_markdown != prev_show_as_markdown: + change_list.append(LangClass.TRANSLATIONS["Show as Markdown"]) + result = show_message_box_after_change_to_restart(change_list) + if result == QMessageBox.StandardButton.Yes: + restart_app() + + elif isinstance(container, CustomizeParamsContainer): + prev_font_family = CONFIG_MANAGER.get_general_property("font_family") + prev_font_size = CONFIG_MANAGER.get_general_property("font_size") + + for k, v in container.get_items(): + CONFIG_MANAGER.set_general_property(k, v) + + if container.font_family != prev_font_family or container.font_size != prev_font_size: + change_list = [ + LangClass.TRANSLATIONS["Font Change"], + ] + result = show_message_box_after_change_to_restart(change_list) + if result == QMessageBox.StandardButton.Yes: + restart_app() + + def __refreshColumns(self): + self.__chatMainWidget.setColumns(self.__settingsParamContainer.chat_column_to_show) + image_column_to_show = self.__settingsParamContainer.image_column_to_show + if image_column_to_show.__contains__("data"): + image_column_to_show.remove("data") + self.__dallEWidget.setColumns(self.__settingsParamContainer.image_column_to_show) + self.__replicateWidget.setColumns(self.__settingsParamContainer.image_column_to_show) + + def __showSettingsDialog(self): + dialog = SettingsDialog(parent=self) + reply = dialog.exec() + if reply == QDialog.DialogCode.Accepted: + container = dialog.getParam() + self.__settingsParamContainer = container + self.__refreshContainer(container) + self.__refreshColumns() + + def __doNotAskAgainChanged( + self, + value: bool, + ): + self.__settingsParamContainer.do_not_ask_again = value + self.__refreshContainer(self.__settingsParamContainer) + + def __beforeClose(self): + if self.__settingsParamContainer.do_not_ask_again: + app: QCoreApplication | None = QApplication.instance() + assert app is not None + app.quit() + else: + # Show a message box to confirm the exit or cancel or running in the background + dialog = DoNotAskAgainDialog(self.__settingsParamContainer.do_not_ask_again, parent=self) + dialog.doNotAskAgainChanged.connect(self.__doNotAskAgainChanged) + reply = dialog.exec() + if dialog.isCancel(): + return True + if reply == QDialog.DialogCode.Accepted: + app: QCoreApplication | None = QApplication.instance() + assert app is not None + app.quit() + elif reply == QDialog.DialogCode.Rejected: + self.close() + + def closeEvent( + self, + event: QCloseEvent, + ): + f = self.__beforeClose() + if f: + event.ignore() + else: + return super().closeEvent(event) diff --git a/pyqt_openai/models.py b/pyqt_openai/models.py index f4ff4df9..f3d88fcf 100644 --- a/pyqt_openai/models.py +++ b/pyqt_openai/models.py @@ -1,182 +1,173 @@ -""" -This file is used to store the data classes that are used throughout the application. -""" - -from dataclasses import dataclass, fields, field -from typing import List - -from pyqt_openai import ( - DB_FILE_NAME, - DEFAULT_FONT_SIZE, - DEFAULT_FONT_FAMILY, - DEFAULT_USER_IMAGE_PATH, - DEFAULT_AI_IMAGE_PATH, - MAXIMUM_MESSAGES_IN_PARAMETER, - TTS_DEFAULT_VOICE, - TTS_DEFAULT_SPEED, - TTS_DEFAULT_AUTO_PLAY, - TTS_DEFAULT_AUTO_STOP_SILENCE_DURATION, - TTS_DEFAULT_PROVIDER, -) -from pyqt_openai.lang.translations import LangClass - - -@dataclass -class Container: - def __init__(self, **kwargs): - """ - You don't have to call this if you want to use default class variables - """ - for k in self.__annotations__: - setattr(self, k, kwargs.get(k, getattr(self, k, ""))) - for key, value in kwargs.items(): - if key in self.__annotations__: - setattr(self, key, value) - - @classmethod - def get_keys(cls, excludes: list = None): - """ - Function that returns the keys of the target data type as a list. - Exclude the keys in the "excludes" list. - """ - if excludes is None: - excludes = [] - arr = [field.name for field in fields(cls)] - for exclude in excludes: - if exclude in arr: - arr.remove(exclude) - return arr - - def get_values_for_insert(self, excludes: list = None): - """ - Function that returns the values of the target data type as a list. - """ - if excludes is None: - excludes = [] - arr = [getattr(self, key) for key in self.get_keys(excludes)] - return arr - - def get_items(self, excludes: list = None): - """ - Function that returns the items of the target data type as a list. - """ - if excludes is None: - excludes = [] - return {key: getattr(self, key) for key in self.get_keys(excludes)}.items() - - def create_insert_query(self, table_name: str, excludes: list = None): - if excludes is None: - excludes = [] - """ - Function to dynamically generate an SQLite insert statement. - Takes the table name as a parameter. - """ - field_names = self.get_keys(excludes) - columns = ", ".join(field_names) - placeholders = ", ".join(["?" for _ in field_names]) - query = f"INSERT INTO {table_name} ({columns}) VALUES ({placeholders})" - return query - - -@dataclass -class ChatThreadContainer(Container): - id: str = "" - name: str = "" - insert_dt: str = "" - update_dt: str = "" - - -@dataclass -class ChatMessageContainer(Container): - id: str = "" - thread_id: str = "" - role: str = "" - content: str = "" - insert_dt: str = "" - update_dt: str = "" - finish_reason: str = "" - model: str = "" - prompt_tokens: str = "" - completion_tokens: str = "" - total_tokens: str = "" - favorite: int = 0 - favorite_set_date: str = "" - is_json_response_available: str = 0 - is_g4f: int = 0 - provider: str = "" - - def __init__(self, **kwargs): - super().__init__(**kwargs) - - -@dataclass -class ImagePromptContainer(Container): - id: str = "" - model: str = "" - width: str = "" - height: str = "" - provider: str = "" - prompt: str = "" - negative_prompt: str = "" - n: str = "" - quality: str = "" - data: str = "" - style: str = "" - revised_prompt: str = "" - update_dt: str = "" - insert_dt: str = "" - - def __init__(self, **kwargs): - super().__init__(**kwargs) - - -@dataclass -class SettingsParamsContainer(Container): - lang: str = LangClass.lang_changed() - db: str = DB_FILE_NAME - do_not_ask_again: bool = False - notify_finish: bool = True - show_secondary_toolbar: bool = True - chat_column_to_show: List[str] = field(default_factory=ChatThreadContainer.get_keys) - image_column_to_show: List[str] = field( - default_factory=ImagePromptContainer.get_keys - ) - maximum_messages_in_parameter: int = MAXIMUM_MESSAGES_IN_PARAMETER - show_as_markdown: bool = True - apply_user_defined_styles: bool = False - run_at_startup: bool = True - manual_update: bool = True - - voice_provider: str = TTS_DEFAULT_PROVIDER - voice: str = TTS_DEFAULT_VOICE - voice_speed: int = TTS_DEFAULT_SPEED - auto_play_voice: bool = TTS_DEFAULT_AUTO_PLAY - auto_stop_silence_duration: int = TTS_DEFAULT_AUTO_STOP_SILENCE_DURATION - - -@dataclass -class CustomizeParamsContainer(Container): - background_image: str = "" - user_image: str = DEFAULT_USER_IMAGE_PATH - ai_image: str = DEFAULT_AI_IMAGE_PATH - font_size: int = DEFAULT_FONT_SIZE - font_family: str = DEFAULT_FONT_FAMILY - - -@dataclass -class PromptGroupContainer(Container): - id: str = "" - name: str = "" - insert_dt: str = "" - update_dt: str = "" - prompt_type: str = "" - - -@dataclass -class PromptEntryContainer(Container): - id: str = "" - group_id: str = "" - act: str = "" - prompt: str = "" - insert_dt: str = "" - update_dt: str = "" +"""This file is used to store the data classes that are used throughout the application.""" +from __future__ import annotations + +from dataclasses import dataclass, field, fields + +from pyqt_openai import ( + DB_FILE_NAME, + DEFAULT_AI_IMAGE_PATH, + DEFAULT_FONT_FAMILY, + DEFAULT_FONT_SIZE, + DEFAULT_USER_IMAGE_PATH, + MAXIMUM_MESSAGES_IN_PARAMETER, + TTS_DEFAULT_AUTO_PLAY, + TTS_DEFAULT_AUTO_STOP_SILENCE_DURATION, + TTS_DEFAULT_PROVIDER, + TTS_DEFAULT_SPEED, + TTS_DEFAULT_VOICE, +) +from pyqt_openai.lang.translations import LangClass + + +@dataclass +class Container: + def __init__(self, **kwargs): + """You don't have to call this if you want to use default class variables.""" + for k in self.__annotations__: + setattr(self, k, kwargs.get(k, getattr(self, k, ""))) + for key, value in kwargs.items(): + if key in self.__annotations__: + setattr(self, key, value) + + @classmethod + def get_keys(cls, excludes: list | None = None) -> list[str]: + """Function that returns the keys of the target data type as a list. + Exclude the keys in the "excludes" list. + """ + if excludes is None: + excludes = [] + arr = [field.name for field in fields(cls)] + for exclude in excludes: + if exclude in arr: + arr.remove(exclude) + return arr + + def get_values_for_insert(self, excludes: list | None = None) -> list[str]: + """Function that returns the values of the target data type as a list.""" + if excludes is None: + excludes = [] + arr = [getattr(self, key) for key in self.get_keys(excludes)] + return arr + + def get_items(self, excludes: list | None = None) -> list[tuple[str, str]]: + """Function that returns the items of the target data type as a list.""" + if excludes is None: + excludes = [] + return {key: getattr(self, key) for key in self.get_keys(excludes)}.items() + + def create_insert_query(self, table_name: str, excludes: list | None = None) -> str: + if excludes is None: + excludes = [] + """ + Function to dynamically generate an SQLite insert statement. + Takes the table name as a parameter. + """ + field_names: list[str] = self.get_keys(excludes) + columns: str = ", ".join(field_names) + placeholders: str = ", ".join(["?" for _ in field_names]) + query: str = f"INSERT INTO {table_name} ({columns}) VALUES ({placeholders})" + return query + + +@dataclass +class ChatThreadContainer(Container): + id: str = "" + name: str = "" + insert_dt: str = "" + update_dt: str = "" + + +@dataclass +class ChatMessageContainer(Container): + id: str = "" + thread_id: str = "" + role: str = "" + content: str = "" + insert_dt: str = "" + update_dt: str = "" + finish_reason: str = "" + model: str = "" + prompt_tokens: str = "" + completion_tokens: str = "" + total_tokens: str = "" + favorite: int = 0 + favorite_set_date: str = "" + is_json_response_available: str = "0" + is_g4f: int = 0 + provider: str = "" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + +@dataclass +class ImagePromptContainer(Container): + id: str = "" + model: str = "" + width: str = "" + height: str = "" + provider: str = "" + prompt: str = "" + negative_prompt: str = "" + n: str = "" + quality: str = "" + data: str = "" + style: str = "" + revised_prompt: str = "" + update_dt: str = "" + insert_dt: str = "" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + +@dataclass +class SettingsParamsContainer(Container): + lang: str = LangClass.lang_changed() or "" + db: str = DB_FILE_NAME + do_not_ask_again: bool = False + notify_finish: bool = True + show_secondary_toolbar: bool = True + chat_column_to_show: list[str] = field(default_factory=ChatThreadContainer.get_keys) + image_column_to_show: list[str] = field( + default_factory=ImagePromptContainer.get_keys, + ) + maximum_messages_in_parameter: int = MAXIMUM_MESSAGES_IN_PARAMETER + show_as_markdown: bool = True + apply_user_defined_styles: bool = False + run_at_startup: bool = True + manual_update: bool = True + + voice_provider: str = TTS_DEFAULT_PROVIDER + voice: str = TTS_DEFAULT_VOICE + voice_speed: float = TTS_DEFAULT_SPEED + auto_play_voice: bool = TTS_DEFAULT_AUTO_PLAY + auto_stop_silence_duration: int = TTS_DEFAULT_AUTO_STOP_SILENCE_DURATION + + +@dataclass +class CustomizeParamsContainer(Container): + background_image: str = "" + user_image: str = DEFAULT_USER_IMAGE_PATH + ai_image: str = DEFAULT_AI_IMAGE_PATH + font_size: int = DEFAULT_FONT_SIZE + font_family: str = DEFAULT_FONT_FAMILY + + +@dataclass +class PromptGroupContainer(Container): + id: str = "" + name: str = "" + insert_dt: str = "" + update_dt: str = "" + prompt_type: str = "" + + +@dataclass +class PromptEntryContainer(Container): + id: str = "" + group_id: str = "" + act: str = "" + prompt: str = "" + insert_dt: str = "" + update_dt: str = "" diff --git a/pyqt_openai/prompt_res/alex_brogan.json b/pyqt_openai/prompt_res/alex_brogan.json index b2f3ae16..b17433ba 100644 --- a/pyqt_openai/prompt_res/alex_brogan.json +++ b/pyqt_openai/prompt_res/alex_brogan.json @@ -1,47 +1,47 @@ -[ - { - "name": "alex_brogan", - "data": [ - { - "act": "sample_1", - "prompt": "Identify the 20% of [topic or skill] that will yield 80% of the desired results and provide a focused learning plan to master it." - }, - { - "act": "sample_2", - "prompt": "Explain [topic or skill] in the simplest terms possible as if teaching it to a complete beginner. Identify gaps in my understanding and suggest resources to fill them." - }, - { - "act": "sample_3", - "prompt": "Create a study plan that mixes different topics or skills within [subject area] to help me develop a more robust understanding and facilitate connections between them." - }, - { - "act": "sample_4", - "prompt": "Design a spaced repetition schedule for me to effectively review [topic or skill] over time, ensuring better retention and recall." - }, - { - "act": "sample_5", - "prompt": "Help me create mental models or analogies to better understand and remember key concepts in [topic or skill]." - }, - { - "act": "sample_6", - "prompt": "Suggest various learning resources (e.g., videos, books, podcasts, interactive exercises) for [topic or skill] that cater to different learning styles." - }, - { - "act": "sample_7", - "prompt": "Provide me with a series of challenging questions or problems related to [topic or skill] to test my understanding and improve long-term retention." - }, - { - "act": "sample_8", - "prompt": "Transform key concepts or lessons from [topic or skill] into engaging stories or narratives to help me better remember and understand the material." - }, - { - "act": "sample_9", - "prompt": "Design a deliberate practice routine for [topic or skill], focusing on my weaknesses and providing regular feedback for improvement." - }, - { - "act": "sample_10", - "prompt": "Guide me through a visualization exercise to help me internalize [topic or skill] and imagine myself succesfully applying it in real-life situations." - } - ] - } +[ + { + "name": "alex_brogan", + "data": [ + { + "act": "sample_1", + "prompt": "Identify the 20% of [topic or skill] that will yield 80% of the desired results and provide a focused learning plan to master it." + }, + { + "act": "sample_2", + "prompt": "Explain [topic or skill] in the simplest terms possible as if teaching it to a complete beginner. Identify gaps in my understanding and suggest resources to fill them." + }, + { + "act": "sample_3", + "prompt": "Create a study plan that mixes different topics or skills within [subject area] to help me develop a more robust understanding and facilitate connections between them." + }, + { + "act": "sample_4", + "prompt": "Design a spaced repetition schedule for me to effectively review [topic or skill] over time, ensuring better retention and recall." + }, + { + "act": "sample_5", + "prompt": "Help me create mental models or analogies to better understand and remember key concepts in [topic or skill]." + }, + { + "act": "sample_6", + "prompt": "Suggest various learning resources (e.g., videos, books, podcasts, interactive exercises) for [topic or skill] that cater to different learning styles." + }, + { + "act": "sample_7", + "prompt": "Provide me with a series of challenging questions or problems related to [topic or skill] to test my understanding and improve long-term retention." + }, + { + "act": "sample_8", + "prompt": "Transform key concepts or lessons from [topic or skill] into engaging stories or narratives to help me better remember and understand the material." + }, + { + "act": "sample_9", + "prompt": "Design a deliberate practice routine for [topic or skill], focusing on my weaknesses and providing regular feedback for improvement." + }, + { + "act": "sample_10", + "prompt": "Guide me through a visualization exercise to help me internalize [topic or skill] and imagine myself succesfully applying it in real-life situations." + } + ] + } ] \ No newline at end of file diff --git a/pyqt_openai/replicate_widget/replicateHome.py b/pyqt_openai/replicate_widget/replicateHome.py index 110c3e59..7a063be1 100644 --- a/pyqt_openai/replicate_widget/replicateHome.py +++ b/pyqt_openai/replicate_widget/replicateHome.py @@ -1,53 +1,55 @@ -from PySide6.QtCore import Qt -from PySide6.QtGui import QFont -from PySide6.QtWidgets import QLabel, QWidget, QVBoxLayout, QScrollArea - -from pyqt_openai import ( - CONTEXT_DELIMITER, - HOW_TO_REPLICATE, - LARGE_LABEL_PARAM, - MEDIUM_LABEL_PARAM, -) -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.widgets.linkLabel import LinkLabel - - -class ReplicateHome(QScrollArea): - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - title = QLabel("Welcome to Replicate Page !", self) - title.setFont(QFont(*LARGE_LABEL_PARAM)) - title.setAlignment(Qt.AlignmentFlag.AlignCenter) - - description = QLabel( - LangClass.TRANSLATIONS["Generate images with Replicate API."] - + "\n" - + LangClass.TRANSLATIONS[ - "You can use a lot of models to generate images, only you need to have an API key." - ] - + CONTEXT_DELIMITER - ) - - description.setFont(QFont(*MEDIUM_LABEL_PARAM)) - description.setAlignment(Qt.AlignmentFlag.AlignCenter) - - self.__manualLabel = LinkLabel() - self.__manualLabel.setText("What is the Replicate & How to use it?") - self.__manualLabel.setUrl(HOW_TO_REPLICATE) - self.__manualLabel.setFont(QFont(*MEDIUM_LABEL_PARAM)) - self.__manualLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) - - lay = QVBoxLayout() - lay.addWidget(title) - lay.addWidget(description) - lay.addWidget(self.__manualLabel) - lay.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignHCenter) - self.setLayout(lay) - - mainWidget = QWidget() - mainWidget.setLayout(lay) - self.setWidget(mainWidget) - self.setWidgetResizable(True) +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtGui import QFont +from qtpy.QtWidgets import QLabel, QScrollArea, QVBoxLayout, QWidget + +from pyqt_openai import ( + CONTEXT_DELIMITER, + HOW_TO_REPLICATE, + LARGE_LABEL_PARAM, + MEDIUM_LABEL_PARAM, +) +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.widgets.linkLabel import LinkLabel + + +class ReplicateHome(QScrollArea): + def __init__(self, parent=None): + super().__init__(parent) + self.__initUi() + + def __initUi(self): + title = QLabel("Welcome to Replicate Page !", self) + title.setFont(QFont(*LARGE_LABEL_PARAM)) + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + + description = QLabel( + LangClass.TRANSLATIONS["Generate images with Replicate API."] + + "\n" + + LangClass.TRANSLATIONS[ + "You can use a lot of models to generate images, only you need to have an API key." + ] + + CONTEXT_DELIMITER, + ) + + description.setFont(QFont(*MEDIUM_LABEL_PARAM)) + description.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.__manualLabel = LinkLabel() + self.__manualLabel.setText("What is the Replicate & How to use it?") + self.__manualLabel.setUrl(HOW_TO_REPLICATE) + self.__manualLabel.setFont(QFont(*MEDIUM_LABEL_PARAM)) + self.__manualLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) + + lay = QVBoxLayout() + lay.addWidget(title) + lay.addWidget(description) + lay.addWidget(self.__manualLabel) + lay.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignHCenter) + self.setLayout(lay) + + mainWidget = QWidget() + mainWidget.setLayout(lay) + self.setWidget(mainWidget) + self.setWidgetResizable(True) diff --git a/pyqt_openai/replicate_widget/replicateMainWidget.py b/pyqt_openai/replicate_widget/replicateMainWidget.py index 099d62f3..b31567b3 100644 --- a/pyqt_openai/replicate_widget/replicateMainWidget.py +++ b/pyqt_openai/replicate_widget/replicateMainWidget.py @@ -1,58 +1,30 @@ -import os - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QStackedWidget, - QHBoxLayout, - QVBoxLayout, - QWidget, - QSplitter, -) - -from pyqt_openai import ( - ICON_HISTORY, - ICON_SETTING, - DEFAULT_SHORTCUT_LEFT_SIDEBAR_WINDOW, - DEFAULT_SHORTCUT_RIGHT_SIDEBAR_WINDOW, -) -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.models import ImagePromptContainer -from pyqt_openai.globals import DB -from pyqt_openai.replicate_widget.replicateHome import ReplicateHome -from pyqt_openai.replicate_widget.replicateRightSideBar import ( - ReplicateRightSideBarWidget, -) -from pyqt_openai.util.common import ( - get_image_filename_for_saving, - open_directory, - get_image_prompt_filename_for_saving, - getSeparator, -) -from pyqt_openai.widgets.button import Button -from pyqt_openai.widgets.imageMainWidget import ImageMainWidget -from pyqt_openai.widgets.imageNavWidget import ImageNavWidget -from pyqt_openai.widgets.notifier import NotifierWidget -from pyqt_openai.widgets.thumbnailView import ThumbnailView - - -class ReplicateMainWidget(ImageMainWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - self._homePage = ReplicateHome() - self._rightSideBarWidget = ReplicateRightSideBarWidget() - - self._setHomeWidget(self._homePage) - self._setRightSideBarWidget(self._rightSideBarWidget) - self._completeUi() - - def toggleHistory(self, f): - super().toggleHistory(f) - CONFIG_MANAGER.set_replicate_property("show_history", f) - - def toggleSetting(self, f): - super().toggleSetting(f) - CONFIG_MANAGER.set_replicate_property("show_setting", f) +from __future__ import annotations + +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.replicate_widget.replicateHome import ReplicateHome +from pyqt_openai.replicate_widget.replicateRightSideBar import ( + ReplicateRightSideBarWidget, +) +from pyqt_openai.widgets.imageMainWidget import ImageMainWidget + + +class ReplicateMainWidget(ImageMainWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.__initUi() + + def __initUi(self): + self._homePage = ReplicateHome() + self._rightSideBarWidget = ReplicateRightSideBarWidget() + + self._setHomeWidget(self._homePage) + self._setRightSideBarWidget(self._rightSideBarWidget) + self._completeUi() + + def toggleHistory(self, f): + super().toggleHistory(f) + CONFIG_MANAGER.set_replicate_property("show_history", f) + + def toggleSetting(self, f): + super().toggleSetting(f) + CONFIG_MANAGER.set_replicate_property("show_setting", f) diff --git a/pyqt_openai/replicate_widget/replicateRightSideBar.py b/pyqt_openai/replicate_widget/replicateRightSideBar.py index 538bc346..241d8525 100644 --- a/pyqt_openai/replicate_widget/replicateRightSideBar.py +++ b/pyqt_openai/replicate_widget/replicateRightSideBar.py @@ -1,188 +1,190 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QWidget, - QSpinBox, - QVBoxLayout, - QPlainTextEdit, - QFormLayout, - QLabel, - QSplitter, -) - -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.replicate_widget.replicateThread import ReplicateThread -from pyqt_openai.widgets.imageControlWidget import ImageControlWidget -from pyqt_openai.widgets.APIInputButton import APIInputButton - - -class ReplicateRightSideBarWidget(ImageControlWidget): - def __init__(self, parent=None): - super().__init__(parent) - self._initVal() - self._initUi() - - def _initVal(self): - super()._initVal() - - self._prompt = CONFIG_MANAGER.get_replicate_property("prompt") - self._continue_generation = CONFIG_MANAGER.get_replicate_property( - "continue_generation" - ) - self._save_prompt_as_text = CONFIG_MANAGER.get_replicate_property( - "save_prompt_as_text" - ) - self._is_save = CONFIG_MANAGER.get_replicate_property("is_save") - self._directory = CONFIG_MANAGER.get_replicate_property("directory") - self._number_of_images_to_create = CONFIG_MANAGER.get_replicate_property( - "number_of_images_to_create" - ) - - self.__model = CONFIG_MANAGER.get_replicate_property("model") - self.__width = CONFIG_MANAGER.get_replicate_property("width") - self.__height = CONFIG_MANAGER.get_replicate_property("height") - self.__negative_prompt = CONFIG_MANAGER.get_replicate_property( - "negative_prompt" - ) - - def _initUi(self): - super()._initUi() - - # TODO LANGUAGE - self.__setApiBtn = APIInputButton() - self.__setApiBtn.setText("Set API Key") - - self.__modelTextEdit = QPlainTextEdit() - self.__modelTextEdit.setPlainText(self.__model) - self.__modelTextEdit.textChanged.connect(self.__replicateTextChanged) - - self.__widthSpinBox = QSpinBox() - self.__widthSpinBox.setRange(512, 1392) - self.__widthSpinBox.setSingleStep(8) - self.__widthSpinBox.setValue(self.__width) - self.__widthSpinBox.valueChanged.connect(self.__replicateChanged) - - self.__heightSpinBox = QSpinBox() - self.__heightSpinBox.setRange(512, 1392) - self.__heightSpinBox.setSingleStep(8) - self.__heightSpinBox.setValue(self.__height) - self.__heightSpinBox.valueChanged.connect(self.__replicateChanged) - - lay = QVBoxLayout() - lay.addWidget(self.__setApiBtn) - lay.addWidget(self._findPathWidget) - lay.addWidget(self._saveChkBox) - lay.addWidget(self._continueGenerationChkBox) - lay.addWidget(self._numberOfImagesToCreateSpinBox) - lay.addWidget(self._savePromptAsTextChkBox) - self._generalGrpBox.setLayout(lay) - - self._promptTextEdit.textChanged.connect(self.__replicateTextChanged) - - self._negativeTextEdit = QPlainTextEdit() - self._negativeTextEdit.setPlaceholderText( - "ugly, deformed, noisy, blurry, distorted" - ) - self._negativeTextEdit.setPlainText(self.__negative_prompt) - self._negativeTextEdit.textChanged.connect(self.__replicateTextChanged) - - lay = QVBoxLayout() - - lay.addWidget(self._randomImagePromptGeneratorWidget) - lay.addWidget(QLabel(LangClass.TRANSLATIONS["Prompt"])) - lay.addWidget(self._promptTextEdit) - - lay.addWidget(QLabel(LangClass.TRANSLATIONS["Negative Prompt"])) - lay.addWidget(self._negativeTextEdit) - promptWidget = QWidget() - promptWidget.setLayout(lay) - - lay = QFormLayout() - lay.addRow(LangClass.TRANSLATIONS["Model"], self.__modelTextEdit) - lay.addRow(LangClass.TRANSLATIONS["Width"], self.__widthSpinBox) - lay.addRow(LangClass.TRANSLATIONS["Height"], self.__heightSpinBox) - otherParamWidget = QWidget() - otherParamWidget.setLayout(lay) - - splitter = QSplitter() - splitter.addWidget(otherParamWidget) - splitter.addWidget(promptWidget) - splitter.setHandleWidth(1) - splitter.setOrientation(Qt.Orientation.Vertical) - splitter.setChildrenCollapsible(False) - splitter.setSizes([500, 500]) - splitter.setStyleSheet("QSplitterHandle {background-color: lightgray;}") - - lay = QVBoxLayout() - lay.addWidget(splitter) - self._paramGrpBox.setLayout(lay) - - self._completeUi() - - def __replicateChanged(self, v): - sender = self.sender() - if sender == self.__widthSpinBox: - self.__width = v - CONFIG_MANAGER.set_replicate_property("width", v) - elif sender == self.__heightSpinBox: - self.__height = v - CONFIG_MANAGER.set_replicate_property("height", v) - - def __replicateTextChanged(self): - sender = self.sender() - if isinstance(sender, QPlainTextEdit): - if sender == self.__modelTextEdit: - self.__model = sender.toPlainText() - CONFIG_MANAGER.set_replicate_property("model", self.__model) - elif sender == self._promptTextEdit: - self._prompt = sender.toPlainText() - CONFIG_MANAGER.set_replicate_property("prompt", self._prompt) - elif sender == self._negativeTextEdit: - self.__negative_prompt = sender.toPlainText() - CONFIG_MANAGER.set_replicate_property( - "negative_prompt", self.__negative_prompt - ) - - def _setSaveDirectory(self, directory): - super()._setSaveDirectory(directory) - CONFIG_MANAGER.set_replicate_property("directory", directory) - - def _saveChkBoxToggled(self, f): - super()._saveChkBoxToggled(f) - CONFIG_MANAGER.set_replicate_property("is_save", f) - - def _continueGenerationChkBoxToggled(self, f): - super()._continueGenerationChkBoxToggled(f) - CONFIG_MANAGER.set_replicate_property("continue_generation", f) - - def _savePromptAsTextChkBoxToggled(self, f): - super()._savePromptAsTextChkBoxToggled(f) - CONFIG_MANAGER.set_replicate_property("save_prompt_as_text", f) - - def _numberOfImagesToCreateSpinBoxValueChanged(self, value): - super()._numberOfImagesToCreateSpinBoxValueChanged(value) - CONFIG_MANAGER.set_replicate_property("number_of_images_to_create", value) - - def _submit(self): - arg = self.getArgument() - number_of_images = ( - self._number_of_images_to_create if self._continue_generation else 1 - ) - random_prompt = ( - self._randomImagePromptGeneratorWidget.getRandomPromptSourceArr() - ) - - t = ReplicateThread(arg, number_of_images, random_prompt) - self._setThread(t) - super()._submit() - - def getArgument(self): - obj = super().getArgument() - return { - **obj, - "model": self.__model, - "prompt": self._promptTextEdit.toPlainText(), - "negative_prompt": self._negativeTextEdit.toPlainText(), - "width": self.__width, - "height": self.__height, - } +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QFormLayout, + QLabel, + QPlainTextEdit, + QSpinBox, + QSplitter, + QVBoxLayout, + QWidget, +) + +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.replicate_widget.replicateThread import ReplicateThread +from pyqt_openai.widgets.APIInputButton import APIInputButton +from pyqt_openai.widgets.imageControlWidget import ImageControlWidget + + +class ReplicateRightSideBarWidget(ImageControlWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._initVal() + self._initUi() + + def _initVal(self): + super()._initVal() + + self._prompt = CONFIG_MANAGER.get_replicate_property("prompt") + self._continue_generation = CONFIG_MANAGER.get_replicate_property( + "continue_generation", + ) + self._save_prompt_as_text = CONFIG_MANAGER.get_replicate_property( + "save_prompt_as_text", + ) + self._is_save = CONFIG_MANAGER.get_replicate_property("is_save") + self._directory = CONFIG_MANAGER.get_replicate_property("directory") + self._number_of_images_to_create = CONFIG_MANAGER.get_replicate_property( + "number_of_images_to_create", + ) + + self.__model = CONFIG_MANAGER.get_replicate_property("model") + self.__width = CONFIG_MANAGER.get_replicate_property("width") + self.__height = CONFIG_MANAGER.get_replicate_property("height") + self.__negative_prompt = CONFIG_MANAGER.get_replicate_property( + "negative_prompt", + ) + + def _initUi(self): + super()._initUi() + + # TODO LANGUAGE + self.__setApiBtn = APIInputButton() + self.__setApiBtn.setText("Set API Key") + + self.__modelTextEdit = QPlainTextEdit() + self.__modelTextEdit.setPlainText(self.__model) + self.__modelTextEdit.textChanged.connect(self.__replicateTextChanged) + + self.__widthSpinBox = QSpinBox() + self.__widthSpinBox.setRange(512, 1392) + self.__widthSpinBox.setSingleStep(8) + self.__widthSpinBox.setValue(self.__width) + self.__widthSpinBox.valueChanged.connect(self.__replicateChanged) + + self.__heightSpinBox = QSpinBox() + self.__heightSpinBox.setRange(512, 1392) + self.__heightSpinBox.setSingleStep(8) + self.__heightSpinBox.setValue(self.__height) + self.__heightSpinBox.valueChanged.connect(self.__replicateChanged) + + lay = QVBoxLayout() + lay.addWidget(self.__setApiBtn) + lay.addWidget(self._findPathWidget) + lay.addWidget(self._saveChkBox) + lay.addWidget(self._continueGenerationChkBox) + lay.addWidget(self._numberOfImagesToCreateSpinBox) + lay.addWidget(self._savePromptAsTextChkBox) + self._generalGrpBox.setLayout(lay) + + self._promptTextEdit.textChanged.connect(self.__replicateTextChanged) + + self._negativeTextEdit = QPlainTextEdit() + self._negativeTextEdit.setPlaceholderText( + "ugly, deformed, noisy, blurry, distorted", + ) + self._negativeTextEdit.setPlainText(self.__negative_prompt) + self._negativeTextEdit.textChanged.connect(self.__replicateTextChanged) + + lay = QVBoxLayout() + + lay.addWidget(self._randomImagePromptGeneratorWidget) + lay.addWidget(QLabel(LangClass.TRANSLATIONS["Prompt"])) + lay.addWidget(self._promptTextEdit) + + lay.addWidget(QLabel(LangClass.TRANSLATIONS["Negative Prompt"])) + lay.addWidget(self._negativeTextEdit) + promptWidget = QWidget() + promptWidget.setLayout(lay) + + lay = QFormLayout() + lay.addRow(LangClass.TRANSLATIONS["Model"], self.__modelTextEdit) + lay.addRow(LangClass.TRANSLATIONS["Width"], self.__widthSpinBox) + lay.addRow(LangClass.TRANSLATIONS["Height"], self.__heightSpinBox) + otherParamWidget = QWidget() + otherParamWidget.setLayout(lay) + + splitter = QSplitter() + splitter.addWidget(otherParamWidget) + splitter.addWidget(promptWidget) + splitter.setHandleWidth(1) + splitter.setOrientation(Qt.Orientation.Vertical) + splitter.setChildrenCollapsible(False) + splitter.setSizes([500, 500]) + splitter.setStyleSheet("QSplitterHandle {background-color: lightgray;}") + + lay = QVBoxLayout() + lay.addWidget(splitter) + self._paramGrpBox.setLayout(lay) + + self._completeUi() + + def __replicateChanged(self, v): + sender = self.sender() + if sender == self.__widthSpinBox: + self.__width = v + CONFIG_MANAGER.set_replicate_property("width", v) + elif sender == self.__heightSpinBox: + self.__height = v + CONFIG_MANAGER.set_replicate_property("height", v) + + def __replicateTextChanged(self): + sender = self.sender() + if isinstance(sender, QPlainTextEdit): + if sender == self.__modelTextEdit: + self.__model = sender.toPlainText() + CONFIG_MANAGER.set_replicate_property("model", self.__model) + elif sender == self._promptTextEdit: + self._prompt = sender.toPlainText() + CONFIG_MANAGER.set_replicate_property("prompt", self._prompt) + elif sender == self._negativeTextEdit: + self.__negative_prompt = sender.toPlainText() + CONFIG_MANAGER.set_replicate_property( + "negative_prompt", self.__negative_prompt, + ) + + def _setSaveDirectory(self, directory): + super()._setSaveDirectory(directory) + CONFIG_MANAGER.set_replicate_property("directory", directory) + + def _saveChkBoxToggled(self, f): + super()._saveChkBoxToggled(f) + CONFIG_MANAGER.set_replicate_property("is_save", f) + + def _continueGenerationChkBoxToggled(self, f): + super()._continueGenerationChkBoxToggled(f) + CONFIG_MANAGER.set_replicate_property("continue_generation", f) + + def _savePromptAsTextChkBoxToggled(self, f): + super()._savePromptAsTextChkBoxToggled(f) + CONFIG_MANAGER.set_replicate_property("save_prompt_as_text", f) + + def _numberOfImagesToCreateSpinBoxValueChanged(self, value): + super()._numberOfImagesToCreateSpinBoxValueChanged(value) + CONFIG_MANAGER.set_replicate_property("number_of_images_to_create", value) + + def _submit(self): + arg = self.getArgument() + number_of_images = ( + self._number_of_images_to_create if self._continue_generation else 1 + ) + random_prompt = ( + self._randomImagePromptGeneratorWidget.getRandomPromptSourceArr() + ) + + t = ReplicateThread(arg, number_of_images, random_prompt) + self._setThread(t) + super()._submit() + + def getArgument(self): + obj = super().getArgument() + return { + **obj, + "model": self.__model, + "prompt": self._promptTextEdit.toPlainText(), + "negative_prompt": self._negativeTextEdit.toPlainText(), + "width": self.__width, + "height": self.__height, + } diff --git a/pyqt_openai/replicate_widget/replicateThread.py b/pyqt_openai/replicate_widget/replicateThread.py index 40085c18..43387f1d 100644 --- a/pyqt_openai/replicate_widget/replicateThread.py +++ b/pyqt_openai/replicate_widget/replicateThread.py @@ -1,42 +1,44 @@ -from PySide6.QtCore import QThread, Signal - -from pyqt_openai.globals import REPLICATE_CLIENT -from pyqt_openai.models import ImagePromptContainer -from pyqt_openai.util.common import generate_random_prompt - - -class ReplicateThread(QThread): - replyGenerated = Signal(ImagePromptContainer) - errorGenerated = Signal(str) - allReplyGenerated = Signal() - - def __init__( - self, input_args, number_of_images, randomizing_prompt_source_arr=None - ): - super().__init__() - self.__input_args = input_args - self.__stop = False - - self.__randomizing_prompt_source_arr = randomizing_prompt_source_arr - - self.__number_of_images = number_of_images - - def stop(self): - self.__stop = True - - def run(self): - try: - for _ in range(self.__number_of_images): - if self.__stop: - break - if self.__randomizing_prompt_source_arr is not None: - self.__input_args["prompt"] = generate_random_prompt( - self.__randomizing_prompt_source_arr - ) - result = REPLICATE_CLIENT.get_image_response( - model=self.__input_args['model'], input_args=self.__input_args - ) - self.replyGenerated.emit(result) - self.allReplyGenerated.emit() - except Exception as e: - self.errorGenerated.emit(str(e)) +from __future__ import annotations + +from qtpy.QtCore import QThread, Signal + +from pyqt_openai.globals import REPLICATE_CLIENT +from pyqt_openai.models import ImagePromptContainer +from pyqt_openai.util.common import generate_random_prompt + + +class ReplicateThread(QThread): + replyGenerated = Signal(ImagePromptContainer) + errorGenerated = Signal(str) + allReplyGenerated = Signal() + + def __init__( + self, input_args, number_of_images, randomizing_prompt_source_arr=None, + ): + super().__init__() + self.__input_args = input_args + self.__stop = False + + self.__randomizing_prompt_source_arr = randomizing_prompt_source_arr + + self.__number_of_images = number_of_images + + def stop(self): + self.__stop = True + + def run(self): + try: + for _ in range(self.__number_of_images): + if self.__stop: + break + if self.__randomizing_prompt_source_arr is not None: + self.__input_args["prompt"] = generate_random_prompt( + self.__randomizing_prompt_source_arr, + ) + result = REPLICATE_CLIENT.get_image_response( + model=self.__input_args["model"], input_args=self.__input_args, + ) + self.replyGenerated.emit(result) + self.allReplyGenerated.emit() + except Exception as e: + self.errorGenerated.emit(str(e)) diff --git a/pyqt_openai/settings_dialog/apiWidget.py b/pyqt_openai/settings_dialog/apiWidget.py index 3e283891..cddce1f8 100644 --- a/pyqt_openai/settings_dialog/apiWidget.py +++ b/pyqt_openai/settings_dialog/apiWidget.py @@ -1,94 +1,123 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QVBoxLayout, - QTableWidget, - QHeaderView, - QTableWidgetItem, - QLabel, - QLineEdit, - QWidget, - QPushButton, -) - -from pyqt_openai import ( - DEFAULT_API_CONFIGS, -) -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.util.common import set_api_key -from pyqt_openai.widgets.linkLabel import LinkLabel - - -class ApiWidget(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.__initVal() - self.__initUi() - - def __initVal(self): - self.__api_keys = [] - # Get the api keys from the conf file with the env var name - for conf in DEFAULT_API_CONFIGS: - _conf = { - "display_name": conf["display_name"], - "env_var_name": conf["env_var_name"], - "api_key": CONFIG_MANAGER.get_general_property(conf["env_var_name"]), - "manual_url": conf["manual_url"], - } - self.__api_keys.append(_conf) - - def __initUi(self): - self.setWindowTitle("API Key") - - columns = ["Provider", "API Key", "Manual URL"] - self.__tableWidget = QTableWidget() - self.__tableWidget.setColumnCount(len(columns)) - self.__tableWidget.setHorizontalHeaderLabels(columns) - self.__tableWidget.horizontalHeader().setSectionResizeMode( - 1, QHeaderView.ResizeMode.Stretch - ) - self.__tableWidget.verticalHeader().setVisible(False) - - for i, obj in enumerate(self.__api_keys): - self.__tableWidget.insertRow(i) - modelItem = QTableWidgetItem(obj["display_name"]) - # Make item not editable - modelItem.setFlags(modelItem.flags() & ~Qt.ItemFlag.ItemIsEditable) - self.__tableWidget.setItem(i, 0, modelItem) - - apiKeyLineEdit = QLineEdit(obj["api_key"]) - apiKeyLineEdit.setEchoMode(QLineEdit.EchoMode.Password) - self.__tableWidget.setCellWidget(i, 1, apiKeyLineEdit) - - getApiKeyLbl = LinkLabel() - getApiKeyLbl.setText("Link") - getApiKeyLbl.setAlignment(Qt.AlignmentFlag.AlignCenter) - getApiKeyLbl.setUrl(obj["manual_url"]) - self.__tableWidget.setCellWidget(i, 2, getApiKeyLbl) - - saveBtn = QPushButton("Save") - saveBtn.clicked.connect(self.setApiKeys) - - lay = QVBoxLayout() - lay.addWidget(QLabel("API Key")) - lay.addWidget(self.__tableWidget) - lay.addWidget(saveBtn) - lay.setContentsMargins(0, 0, 0, 0) - - self.setLayout(lay) - - self.setMinimumHeight(150) - - def setApiKeys(self): - """ - Dynamically get the api keys from the table widget - """ - api_keys = { - self.__api_keys[i]["env_var_name"]: self.__tableWidget.cellWidget( - i, 1 - ).text() - for i in range(self.__tableWidget.rowCount()) - } - # Save the api keys to the conf file - for k, v in api_keys.items(): - CONFIG_MANAGER.set_general_property(k, v) - set_api_key(k, v) +from __future__ import annotations + +import os +import logging + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget + +from pyqt_openai import DEFAULT_API_CONFIGS +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.util.common import set_api_key +from pyqt_openai.widgets.linkLabel import LinkLabel + + +class ApiWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.__initVal() + self.__initUi() + + def __initVal(self): + self.__api_keys = [] + # Get the api keys from the conf file with the env var name + for conf in DEFAULT_API_CONFIGS: + _conf = { + "display_name": conf["display_name"], + "env_var_name": conf["env_var_name"], + "api_key": CONFIG_MANAGER.get_general_property(conf["env_var_name"]), + "manual_url": conf["manual_url"], + } + self.__api_keys.append(_conf) + + def __initUi(self): + self.setWindowTitle("API Key") + + columns = ["Provider", "API Key", "Manual URL"] + self.__tableWidget = QTableWidget() + self.__tableWidget.setColumnCount(len(columns)) + self.__tableWidget.setHorizontalHeaderLabels(columns) + self.__tableWidget.horizontalHeader().setSectionResizeMode( + 1, QHeaderView.ResizeMode.Stretch + ) + self.__tableWidget.verticalHeader().setVisible(False) + + for i, obj in enumerate(self.__api_keys): + self.__tableWidget.insertRow(i) + modelItem = QTableWidgetItem(obj["display_name"]) + # Make item not editable + modelItem.setFlags(modelItem.flags() & ~Qt.ItemFlag.ItemIsEditable) + self.__tableWidget.setItem(i, 0, modelItem) + + apiKeyLineEdit = QLineEdit(obj["api_key"]) + apiKeyLineEdit.setEchoMode(QLineEdit.EchoMode.Password) + self.__tableWidget.setCellWidget(i, 1, apiKeyLineEdit) + + getApiKeyLbl = LinkLabel() + getApiKeyLbl.setText("Link") + getApiKeyLbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + getApiKeyLbl.setUrl(obj["manual_url"]) + self.__tableWidget.setCellWidget(i, 2, getApiKeyLbl) + + saveBtn = QPushButton("Save") + saveBtn.clicked.connect(self.setApiKeys) + + loadEnvBtn = QPushButton("Load from ENV") + loadEnvBtn.clicked.connect(self.loadFromEnvironment) + + # Create horizontal layout for buttons + buttonLayout = QHBoxLayout() + buttonLayout.addWidget(loadEnvBtn) + buttonLayout.addWidget(saveBtn) + + lay = QVBoxLayout() + lay.addWidget(QLabel("API Key")) + lay.addWidget(self.__tableWidget) + lay.addLayout(buttonLayout) + lay.setContentsMargins(0, 0, 0, 0) + + self.setLayout(lay) + + self.setMinimumHeight(150) + + def loadFromEnvironment(self): + """Load API keys from environment variables if fields are empty.""" + logging.info("Starting loadFromEnvironment") + + for i, conf in enumerate(self.__api_keys): + env_var_name = conf["env_var_name"] + line_edit = self.__tableWidget.cellWidget(i, 1) + assert isinstance(line_edit, QLineEdit) # Type check to ensure it's a QLineEdit + + current_value = line_edit.text().strip() + env_value = os.environ.get(env_var_name, '') + + logging.info(f"Checking {env_var_name}:") + logging.info(f" Current field value: {'' if not current_value else ''}") + logging.info(f" Environment value: {repr(env_value)}") # Use repr to show empty strings + + # Only fill if current field is empty and env var exists and has content + if not current_value and env_value: + logging.info(f" Setting field for {env_var_name} with value: {repr(env_value)}") + line_edit.setText(env_value) + else: + reason = 'Field not empty' if current_value else 'No env var or empty value' + logging.info(f" Skipping {env_var_name}: {reason}") + + def setApiKeys(self): + """Dynamically get the api keys from the table widget.""" + logging.info("Starting setApiKeys") + api_keys = {} + for i in range(self.__tableWidget.rowCount()): + line_edit = self.__tableWidget.cellWidget(i, 1) + assert isinstance(line_edit, QLineEdit) # Type check to ensure it's a QLineEdit + env_var_name = self.__api_keys[i]["env_var_name"] + value = line_edit.text().strip() + api_keys[env_var_name] = value + logging.info(f"Setting {env_var_name} with value: {repr(value)}") + + # Save the api keys to the conf file + for k, v in api_keys.items(): + CONFIG_MANAGER.set_general_property(k, v) + set_api_key(k, v) + logging.info(f"Saved {k} to config and environment") diff --git a/pyqt_openai/settings_dialog/generalSettingsWidget.py b/pyqt_openai/settings_dialog/generalSettingsWidget.py index 1f0803ef..3e692602 100644 --- a/pyqt_openai/settings_dialog/generalSettingsWidget.py +++ b/pyqt_openai/settings_dialog/generalSettingsWidget.py @@ -1,253 +1,256 @@ -from PySide6.QtCore import QRegularExpression -from PySide6.QtGui import QRegularExpressionValidator -from PySide6.QtWidgets import ( - QComboBox, - QFormLayout, - QLineEdit, - QCheckBox, - QSizePolicy, - QVBoxLayout, - QHBoxLayout, - QGroupBox, - QSplitter, - QLabel, - QWidget, - QSpinBox, - QScrollArea, -) - -from pyqt_openai import ( - COLUMN_TO_EXCLUDE_FROM_SHOW_HIDE_CHAT, - COLUMN_TO_EXCLUDE_FROM_SHOW_HIDE_IMAGE, - LANGUAGE_DICT, - DB_NAME_REGEX, - MAXIMUM_MESSAGES_IN_PARAMETER_RANGE, DEFAULT_WARNING_COLOR, -) -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.models import ImagePromptContainer, ChatThreadContainer -from pyqt_openai.widgets.checkBoxListWidget import CheckBoxListWidget - - -class GeneralSettingsWidget(QScrollArea): - def __init__(self, parent=None): - super().__init__(parent) - self.__initVal() - self.__initUi() - - def __initVal(self): - self.lang = CONFIG_MANAGER.get_general_property("lang") - self.db = CONFIG_MANAGER.get_general_property("db") - self.do_not_ask_again = CONFIG_MANAGER.get_general_property("do_not_ask_again") - self.notify_finish = CONFIG_MANAGER.get_general_property("notify_finish") - self.show_secondary_toolbar = CONFIG_MANAGER.get_general_property( - "show_secondary_toolbar" - ) - self.chat_column_to_show = CONFIG_MANAGER.get_general_property( - "chat_column_to_show" - ) - self.image_column_to_show = CONFIG_MANAGER.get_general_property( - "image_column_to_show" - ) - self.maximum_messages_in_parameter = CONFIG_MANAGER.get_general_property( - "maximum_messages_in_parameter" - ) - self.show_as_markdown = CONFIG_MANAGER.get_general_property("show_as_markdown") - self.run_at_startup = CONFIG_MANAGER.get_general_property("run_at_startup") - self.manual_update = CONFIG_MANAGER.get_general_property("manual_update") - - def __initUi(self): - # Language setting - self.__langCmbBox = QComboBox() - self.__langCmbBox.addItems(list(LANGUAGE_DICT.keys())) - self.__langCmbBox.setCurrentText(self.lang) - self.__langCmbBox.setSizePolicy( - QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.MinimumExpanding - ) - - lay = QHBoxLayout() - lay.addWidget(QLabel(LangClass.TRANSLATIONS["Language"])) - lay.addWidget(self.__langCmbBox) - lay.setContentsMargins(0, 0, 0, 0) - - langWidget = QWidget() - langWidget.setLayout(lay) - - # Database setting - dbLayout = QHBoxLayout() - self.__dbLineEdit = QLineEdit(self.db) - self.__validator = QRegularExpressionValidator() - re = QRegularExpression(DB_NAME_REGEX) - self.__validator.setRegularExpression(re) - self.__dbLineEdit.setValidator(self.__validator) - dbLayout.addWidget( - QLabel( - LangClass.TRANSLATIONS["Name of target database (without extension)"] - ) - ) - dbLayout.addWidget(self.__dbLineEdit) - - # Checkboxes - self.__doNotAskAgainCheckBox = QCheckBox( - f'{LangClass.TRANSLATIONS["Do not ask again when closing"]} ({LangClass.TRANSLATIONS["Always close the application"]})' - ) - self.__doNotAskAgainCheckBox.setChecked(self.do_not_ask_again) - - self.__notifyFinishCheckBox = QCheckBox( - LangClass.TRANSLATIONS[ - "Notify when finish processing any task (Conversion, etc.)" - ] - ) - self.__notifyFinishCheckBox.setChecked(self.notify_finish) - self.__showSecondaryToolBarChkBox = QCheckBox( - LangClass.TRANSLATIONS["Show Secondary Toolbar"] - ) - self.__showSecondaryToolBarChkBox.setChecked(self.show_secondary_toolbar) - # TODO LANGUAGE - self.__runAtStartupCheckBox = QCheckBox( - LangClass.TRANSLATIONS["Run at startup (Windows only)"] - ) - self.__runAtStartupCheckBox.setChecked(self.run_at_startup) - - self.__manual_updateCheckBox = QCheckBox( - LangClass.TRANSLATIONS["Manual Update (<-> Auto Update)"] - ) - self.__manual_updateCheckBox.setChecked(self.manual_update) - - self.__manual_UpdateWarning = QLabel( - LangClass.TRANSLATIONS["Auto-update is supported on Windows only."] - ) - self.__manual_UpdateWarning.setStyleSheet(f"color: {DEFAULT_WARNING_COLOR};") - - lay = QVBoxLayout() - lay.addWidget(langWidget) - lay.addLayout(dbLayout) - lay.addWidget(self.__doNotAskAgainCheckBox) - lay.addWidget(self.__notifyFinishCheckBox) - lay.addWidget(self.__showSecondaryToolBarChkBox) - lay.addWidget(self.__runAtStartupCheckBox) - lay.addWidget(self.__manual_updateCheckBox) - lay.addWidget(self.__manual_UpdateWarning) - - generalGrpBox = QGroupBox(LangClass.TRANSLATIONS["General"]) - generalGrpBox.setLayout(lay) - - self.__maximumMessagesInParameterSpinBox = QSpinBox() - self.__maximumMessagesInParameterSpinBox.setRange( - *MAXIMUM_MESSAGES_IN_PARAMETER_RANGE - ) - self.__maximumMessagesInParameterSpinBox.setValue( - self.maximum_messages_in_parameter - ) - - self.__show_as_markdown = QCheckBox(LangClass.TRANSLATIONS["Show as Markdown"]) - self.__show_as_markdown.setChecked(self.show_as_markdown) - - lay = QFormLayout() - lay.addRow( - LangClass.TRANSLATIONS["Maximum Messages in Parameter"], - self.__maximumMessagesInParameterSpinBox, - ) - lay.addRow(self.__show_as_markdown) - - chatBrowserGrpBox = QGroupBox(LangClass.TRANSLATIONS["Chat Browser"]) - chatBrowserGrpBox.setLayout(lay) - - chatColAllCheckBox = QCheckBox(LangClass.TRANSLATIONS["Check All"]) - self.__chatColCheckBoxListWidget = CheckBoxListWidget() - for k in ChatThreadContainer.get_keys( - excludes=COLUMN_TO_EXCLUDE_FROM_SHOW_HIDE_CHAT - ): - self.__chatColCheckBoxListWidget.addItem( - k, checked=k in self.chat_column_to_show - ) - - chatColAllCheckBox.stateChanged.connect( - self.__chatColCheckBoxListWidget.toggleState - ) - - lay = QVBoxLayout() - lay.addWidget( - QLabel( - LangClass.TRANSLATIONS[ - "Select the columns you want to show in the chat list." - ] - ) - ) - lay.addWidget(chatColAllCheckBox) - lay.addWidget(self.__chatColCheckBoxListWidget) - - chatColWidget = QWidget() - chatColWidget.setLayout(lay) - - imageColAllCheckBox = QCheckBox(LangClass.TRANSLATIONS["Check all"]) - self.__imageColCheckBoxListWidget = CheckBoxListWidget() - for k in ImagePromptContainer.get_keys( - excludes=COLUMN_TO_EXCLUDE_FROM_SHOW_HIDE_IMAGE - ): - self.__imageColCheckBoxListWidget.addItem( - k, checked=k in self.image_column_to_show - ) - - imageColAllCheckBox.stateChanged.connect( - self.__imageColCheckBoxListWidget.toggleState - ) - - lay = QVBoxLayout() - lay.addWidget( - QLabel( - LangClass.TRANSLATIONS[ - "Select the columns you want to show in the image list." - ] - ) - ) - lay.addWidget(imageColAllCheckBox) - lay.addWidget(self.__imageColCheckBoxListWidget) - - imageColWidget = QWidget() - imageColWidget.setLayout(lay) - - self.__splitter = QSplitter() - self.__splitter.addWidget(chatColWidget) - self.__splitter.addWidget(imageColWidget) - self.__splitter.setHandleWidth(1) - self.__splitter.setChildrenCollapsible(False) - self.__splitter.setSizes([500, 500]) - self.__splitter.setStyleSheet("QSplitterHandle {background-color: lightgray;}") - self.__splitter.setSizePolicy( - QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.MinimumExpanding - ) - - lay = QVBoxLayout() - lay.addWidget(self.__splitter) - - columnGrpBox = QGroupBox(LangClass.TRANSLATIONS["Show/hide columns"]) - columnGrpBox.setLayout(lay) - - lay = QVBoxLayout() - lay.addWidget(generalGrpBox) - lay.addWidget(chatBrowserGrpBox) - lay.addWidget(columnGrpBox) - - mainWidget = QWidget() - mainWidget.setLayout(lay) - - self.setWidget(mainWidget) - self.setWidgetResizable(True) - - def getParam(self): - return { - "lang": self.__langCmbBox.currentText(), - "db": self.__dbLineEdit.text(), - "do_not_ask_again": self.__doNotAskAgainCheckBox.isChecked(), - "notify_finish": self.__notifyFinishCheckBox.isChecked(), - "show_secondary_toolbar": self.__showSecondaryToolBarChkBox.isChecked(), - "chat_column_to_show": COLUMN_TO_EXCLUDE_FROM_SHOW_HIDE_CHAT - + self.__chatColCheckBoxListWidget.getCheckedItemsText(), - "image_column_to_show": COLUMN_TO_EXCLUDE_FROM_SHOW_HIDE_IMAGE - + self.__imageColCheckBoxListWidget.getCheckedItemsText(), - "maximum_messages_in_parameter": self.__maximumMessagesInParameterSpinBox.value(), - "show_as_markdown": self.__show_as_markdown.isChecked(), - "run_at_startup": self.__runAtStartupCheckBox.isChecked(), - "manual_update": self.__manual_updateCheckBox.isChecked(), - } +from __future__ import annotations + +from qtpy.QtCore import QRegularExpression +from qtpy.QtGui import QRegularExpressionValidator +from qtpy.QtWidgets import ( + QCheckBox, + QComboBox, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QScrollArea, + QSizePolicy, + QSpinBox, + QSplitter, + QVBoxLayout, + QWidget, +) + +from pyqt_openai import ( + COLUMN_TO_EXCLUDE_FROM_SHOW_HIDE_CHAT, + COLUMN_TO_EXCLUDE_FROM_SHOW_HIDE_IMAGE, + DB_NAME_REGEX, + DEFAULT_WARNING_COLOR, + LANGUAGE_DICT, + MAXIMUM_MESSAGES_IN_PARAMETER_RANGE, +) +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.models import ChatThreadContainer, ImagePromptContainer +from pyqt_openai.widgets.checkBoxListWidget import CheckBoxListWidget + + +class GeneralSettingsWidget(QScrollArea): + def __init__(self, parent=None): + super().__init__(parent) + self.__initVal() + self.__initUi() + + def __initVal(self): + self.lang = CONFIG_MANAGER.get_general_property("lang") + self.db = CONFIG_MANAGER.get_general_property("db") + self.do_not_ask_again = CONFIG_MANAGER.get_general_property("do_not_ask_again") + self.notify_finish = CONFIG_MANAGER.get_general_property("notify_finish") + self.show_secondary_toolbar = CONFIG_MANAGER.get_general_property( + "show_secondary_toolbar", + ) + self.chat_column_to_show = CONFIG_MANAGER.get_general_property( + "chat_column_to_show", + ) + self.image_column_to_show = CONFIG_MANAGER.get_general_property( + "image_column_to_show", + ) + self.maximum_messages_in_parameter = CONFIG_MANAGER.get_general_property( + "maximum_messages_in_parameter", + ) + self.show_as_markdown = CONFIG_MANAGER.get_general_property("show_as_markdown") + self.run_at_startup = CONFIG_MANAGER.get_general_property("run_at_startup") + self.manual_update = CONFIG_MANAGER.get_general_property("manual_update") + + def __initUi(self): + # Language setting + self.__langCmbBox = QComboBox() + self.__langCmbBox.addItems(list(LANGUAGE_DICT.keys())) + self.__langCmbBox.setCurrentText(self.lang) + self.__langCmbBox.setSizePolicy( + QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.MinimumExpanding, + ) + + lay = QHBoxLayout() + lay.addWidget(QLabel(LangClass.TRANSLATIONS["Language"])) + lay.addWidget(self.__langCmbBox) + lay.setContentsMargins(0, 0, 0, 0) + + langWidget = QWidget() + langWidget.setLayout(lay) + + # Database setting + dbLayout = QHBoxLayout() + self.__dbLineEdit = QLineEdit(self.db) + self.__validator = QRegularExpressionValidator() + re = QRegularExpression(DB_NAME_REGEX) + self.__validator.setRegularExpression(re) + self.__dbLineEdit.setValidator(self.__validator) + dbLayout.addWidget( + QLabel( + LangClass.TRANSLATIONS["Name of target database (without extension)"], + ), + ) + dbLayout.addWidget(self.__dbLineEdit) + + # Checkboxes + self.__doNotAskAgainCheckBox = QCheckBox( + f'{LangClass.TRANSLATIONS["Do not ask again when closing"]} ({LangClass.TRANSLATIONS["Always close the application"]})', + ) + self.__doNotAskAgainCheckBox.setChecked(self.do_not_ask_again) + + self.__notifyFinishCheckBox = QCheckBox( + LangClass.TRANSLATIONS[ + "Notify when finish processing any task (Conversion, etc.)" + ], + ) + self.__notifyFinishCheckBox.setChecked(self.notify_finish) + self.__showSecondaryToolBarChkBox = QCheckBox( + LangClass.TRANSLATIONS["Show Secondary Toolbar"], + ) + self.__showSecondaryToolBarChkBox.setChecked(self.show_secondary_toolbar) + # TODO LANGUAGE + self.__runAtStartupCheckBox = QCheckBox( + LangClass.TRANSLATIONS["Run at startup (Windows only)"], + ) + self.__runAtStartupCheckBox.setChecked(self.run_at_startup) + + self.__manual_updateCheckBox = QCheckBox( + LangClass.TRANSLATIONS["Manual Update (<-> Auto Update)"], + ) + self.__manual_updateCheckBox.setChecked(self.manual_update) + + self.__manual_UpdateWarning = QLabel( + LangClass.TRANSLATIONS["Auto-update is supported on Windows only."], + ) + self.__manual_UpdateWarning.setStyleSheet(f"color: {DEFAULT_WARNING_COLOR};") + + lay = QVBoxLayout() + lay.addWidget(langWidget) + lay.addLayout(dbLayout) + lay.addWidget(self.__doNotAskAgainCheckBox) + lay.addWidget(self.__notifyFinishCheckBox) + lay.addWidget(self.__showSecondaryToolBarChkBox) + lay.addWidget(self.__runAtStartupCheckBox) + lay.addWidget(self.__manual_updateCheckBox) + lay.addWidget(self.__manual_UpdateWarning) + + generalGrpBox = QGroupBox(LangClass.TRANSLATIONS["General"]) + generalGrpBox.setLayout(lay) + + self.__maximumMessagesInParameterSpinBox = QSpinBox() + self.__maximumMessagesInParameterSpinBox.setRange( + *MAXIMUM_MESSAGES_IN_PARAMETER_RANGE, + ) + self.__maximumMessagesInParameterSpinBox.setValue( + self.maximum_messages_in_parameter, + ) + + self.__show_as_markdown = QCheckBox(LangClass.TRANSLATIONS["Show as Markdown"]) + self.__show_as_markdown.setChecked(self.show_as_markdown) + + lay = QFormLayout() + lay.addRow( + LangClass.TRANSLATIONS["Maximum Messages in Parameter"], + self.__maximumMessagesInParameterSpinBox, + ) + lay.addRow(self.__show_as_markdown) + + chatBrowserGrpBox = QGroupBox(LangClass.TRANSLATIONS["Chat Browser"]) + chatBrowserGrpBox.setLayout(lay) + + chatColAllCheckBox = QCheckBox(LangClass.TRANSLATIONS["Check All"]) + self.__chatColCheckBoxListWidget = CheckBoxListWidget() + for k in ChatThreadContainer.get_keys( + excludes=COLUMN_TO_EXCLUDE_FROM_SHOW_HIDE_CHAT, + ): + self.__chatColCheckBoxListWidget.addItem( + k, checked=k in self.chat_column_to_show, + ) + + chatColAllCheckBox.stateChanged.connect( + self.__chatColCheckBoxListWidget.toggleState, + ) + + lay = QVBoxLayout() + lay.addWidget( + QLabel( + LangClass.TRANSLATIONS[ + "Select the columns you want to show in the chat list." + ], + ), + ) + lay.addWidget(chatColAllCheckBox) + lay.addWidget(self.__chatColCheckBoxListWidget) + + chatColWidget = QWidget() + chatColWidget.setLayout(lay) + + imageColAllCheckBox = QCheckBox(LangClass.TRANSLATIONS["Check all"]) + self.__imageColCheckBoxListWidget = CheckBoxListWidget() + for k in ImagePromptContainer.get_keys( + excludes=COLUMN_TO_EXCLUDE_FROM_SHOW_HIDE_IMAGE, + ): + self.__imageColCheckBoxListWidget.addItem( + k, checked=k in self.image_column_to_show, + ) + + imageColAllCheckBox.stateChanged.connect( + self.__imageColCheckBoxListWidget.toggleState, + ) + + lay = QVBoxLayout() + lay.addWidget( + QLabel( + LangClass.TRANSLATIONS[ + "Select the columns you want to show in the image list." + ], + ), + ) + lay.addWidget(imageColAllCheckBox) + lay.addWidget(self.__imageColCheckBoxListWidget) + + imageColWidget = QWidget() + imageColWidget.setLayout(lay) + + self.__splitter = QSplitter() + self.__splitter.addWidget(chatColWidget) + self.__splitter.addWidget(imageColWidget) + self.__splitter.setHandleWidth(1) + self.__splitter.setChildrenCollapsible(False) + self.__splitter.setSizes([500, 500]) + self.__splitter.setStyleSheet("QSplitterHandle {background-color: lightgray;}") + self.__splitter.setSizePolicy( + QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.MinimumExpanding, + ) + + lay = QVBoxLayout() + lay.addWidget(self.__splitter) + + columnGrpBox = QGroupBox(LangClass.TRANSLATIONS["Show/hide columns"]) + columnGrpBox.setLayout(lay) + + lay = QVBoxLayout() + lay.addWidget(generalGrpBox) + lay.addWidget(chatBrowserGrpBox) + lay.addWidget(columnGrpBox) + + mainWidget = QWidget() + mainWidget.setLayout(lay) + + self.setWidget(mainWidget) + self.setWidgetResizable(True) + + def getParam(self): + return { + "lang": self.__langCmbBox.currentText(), + "db": self.__dbLineEdit.text(), + "do_not_ask_again": self.__doNotAskAgainCheckBox.isChecked(), + "notify_finish": self.__notifyFinishCheckBox.isChecked(), + "show_secondary_toolbar": self.__showSecondaryToolBarChkBox.isChecked(), + "chat_column_to_show": COLUMN_TO_EXCLUDE_FROM_SHOW_HIDE_CHAT + + self.__chatColCheckBoxListWidget.getCheckedItemsText(), + "image_column_to_show": COLUMN_TO_EXCLUDE_FROM_SHOW_HIDE_IMAGE + + self.__imageColCheckBoxListWidget.getCheckedItemsText(), + "maximum_messages_in_parameter": self.__maximumMessagesInParameterSpinBox.value(), + "show_as_markdown": self.__show_as_markdown.isChecked(), + "run_at_startup": self.__runAtStartupCheckBox.isChecked(), + "manual_update": self.__manual_updateCheckBox.isChecked(), + } diff --git a/pyqt_openai/settings_dialog/markdownSettingsWidget.py b/pyqt_openai/settings_dialog/markdownSettingsWidget.py index e8e29f1c..20651f59 100644 --- a/pyqt_openai/settings_dialog/markdownSettingsWidget.py +++ b/pyqt_openai/settings_dialog/markdownSettingsWidget.py @@ -1,124 +1,124 @@ -# TODO WILL_BE_IMPLEMENTED AFTER v2.x.0 - -# from PySide6.QtWidgets import (QWidget, QVBoxLayout, QCheckBox, QLabel, QComboBox, -# QPushButton, QColorDialog, QFormLayout, QScrollArea) -# -# from pyqt_openai.lang.translations import LangClass -# from pyqt_openai.models import SettingsParamsContainer -# from pyqt_openai.util.script import getSeparator -# -# -# class MarkdownSettingsWidget(QWidget): -# def __init__(self, args: SettingsParamsContainer, parent=None): -# super().__init__(parent) -# self.__initVal(args) -# self.__initUi() -# -# def __initVal(self, args): -# self.__show_as_markdown = args.show_as_markdown -# self.__apply_user_defined_styles = args.apply_user_defined_styles -# self.__span_font = args.span_font -# self.__span_color = args.span_color -# self.__ul_color = args.ul_color -# self.__h1_color = args.h1_color -# self.__h2_color = args.h2_color -# self.__h3_color = args.h3_color -# self.__h4_color = args.h4_color -# self.__h5_color = args.h5_color -# self.__h6_color = args.h6_color -# self.__a_color = args.a_color -# -# def __initUi(self): -# self.__showAsMarkdownCheckBox = QCheckBox(LangClass.TRANSLATIONS['Show as Markdown']) -# -# self.__applyUserDefinedStylesCheckBox = QCheckBox(LangClass.TRANSLATIONS['Apply User-Defined HTML Styles']) -# -# markdownLbl = QLabel(LangClass.TRANSLATIONS['Details']) -# markdownWidget = QWidget() -# -# form_layout = QFormLayout() -# -# # Helper function to create color picker buttons -# def create_color_picker(label_text): -# color_button = QPushButton('Choose Color') -# color_button.setObjectName(label_text) -# color_button.clicked.connect(self.open_color_dialog) -# combo_box_font = None -# if label_text == 'span': -# combo_box_font = QComboBox() -# # Add frequently used font families to the combo box -# font_families = ["Arial", "Courier New", "Times New Roman", "Verdana", "Helvetica"] -# combo_box_font.addItems(font_families) -# form_layout.addRow(QLabel(label_text + ' Font:'), combo_box_font) -# form_layout.addRow(QLabel(label_text + ' Color:'), color_button) -# form_layout.addRow(getSeparator()) -# return combo_box_font, color_button -# -# # Adding form fields for each tag and attribute -# self.font_editors = {} -# self.color_buttons = {} -# self.__tags_attributes = [ -# ('span', 'Font, Color'), -# ('ol', 'Color'), -# ('li', 'Color'), -# ('ul', 'Color'), -# ('h1', 'Color'), -# ('h2', 'Color'), -# ('h3', 'Color'), -# ('h4', 'Color'), -# ('h5', 'Color'), -# ('h6', 'Color'), -# ('a', 'Color') -# ] -# -# for tag, attributes in self.__tags_attributes: -# if 'Font' in attributes: -# self.font_editors[tag], self.color_buttons[tag] = create_color_picker(tag) -# else: -# _, self.color_buttons[tag] = create_color_picker(tag) -# -# # Set the form layout to the widget -# markdownWidget.setLayout(form_layout) -# -# # Create a QScrollArea and set markdownWidget as its widget -# scroll_area = QScrollArea() -# scroll_area.setWidget(markdownWidget) -# scroll_area.setWidgetResizable(True) -# -# layout = QVBoxLayout() -# layout.addWidget(self.__showAsMarkdownCheckBox) -# layout.addWidget(self.__applyUserDefinedStylesCheckBox) -# layout.addWidget(markdownLbl) -# layout.addWidget(scroll_area) -# -# self.setLayout(layout) -# -# self.__showAsMarkdownCheckBox.toggled.connect( -# lambda: self.__applyUserDefinedStylesCheckBox.setEnabled(self.__showAsMarkdownCheckBox.isChecked())) -# self.__applyUserDefinedStylesCheckBox.toggled.connect( -# lambda: markdownWidget.setEnabled(self.__applyUserDefinedStylesCheckBox.isChecked() and self.__showAsMarkdownCheckBox.isChecked())) -# -# self.__showAsMarkdownCheckBox.setChecked(self.__show_as_markdown) -# self.__applyUserDefinedStylesCheckBox.setChecked(self.__apply_user_defined_styles) -# -# self.__applyUserDefinedStylesCheckBox.setEnabled(self.__showAsMarkdownCheckBox.isChecked()) -# markdownWidget.setEnabled(self.__applyUserDefinedStylesCheckBox.isChecked() and self.__showAsMarkdownCheckBox.isChecked()) -# -# def open_color_dialog(self): -# sender = self.sender() -# color = QColorDialog.getColor() -# if color.isValid(): -# sender.setStyleSheet(f'background-color: {color.name()};') -# -# def getParam(self): -# params = { -# 'show_as_markdown': self.__showAsMarkdownCheckBox.isChecked(), -# 'apply_user_defined_styles': self.__applyUserDefinedStylesCheckBox.isChecked(), -# } -# for tag, editor in self.font_editors.items(): -# if editor: # editor will be None for tags without a font combo box -# params[f'{tag}_font'] = editor.currentText() -# for tag, button in self.color_buttons.items(): -# color_name = button.palette().button().color().name() -# params[f'{tag}_color'] = color_name -# return params +# TODO WILL_BE_IMPLEMENTED AFTER v2.x.0 + +# from qtpy.QtWidgets import (QWidget, QVBoxLayout, QCheckBox, QLabel, QComboBox, +# QPushButton, QColorDialog, QFormLayout, QScrollArea) +# +# from pyqt_openai.lang.translations import LangClass +# from pyqt_openai.models import SettingsParamsContainer +# from pyqt_openai.util.script import getSeparator +# +# +# class MarkdownSettingsWidget(QWidget): +# def __init__(self, args: SettingsParamsContainer, parent=None): +# super().__init__(parent) +# self.__initVal(args) +# self.__initUi() +# +# def __initVal(self, args): +# self.__show_as_markdown = args.show_as_markdown +# self.__apply_user_defined_styles = args.apply_user_defined_styles +# self.__span_font = args.span_font +# self.__span_color = args.span_color +# self.__ul_color = args.ul_color +# self.__h1_color = args.h1_color +# self.__h2_color = args.h2_color +# self.__h3_color = args.h3_color +# self.__h4_color = args.h4_color +# self.__h5_color = args.h5_color +# self.__h6_color = args.h6_color +# self.__a_color = args.a_color +# +# def __initUi(self): +# self.__showAsMarkdownCheckBox = QCheckBox(LangClass.TRANSLATIONS['Show as Markdown']) +# +# self.__applyUserDefinedStylesCheckBox = QCheckBox(LangClass.TRANSLATIONS['Apply User-Defined HTML Styles']) +# +# markdownLbl = QLabel(LangClass.TRANSLATIONS['Details']) +# markdownWidget = QWidget() +# +# form_layout = QFormLayout() +# +# # Helper function to create color picker buttons +# def create_color_picker(label_text): +# color_button = QPushButton('Choose Color') +# color_button.setObjectName(label_text) +# color_button.clicked.connect(self.open_color_dialog) +# combo_box_font = None +# if label_text == 'span': +# combo_box_font = QComboBox() +# # Add frequently used font families to the combo box +# font_families = ["Arial", "Courier New", "Times New Roman", "Verdana", "Helvetica"] +# combo_box_font.addItems(font_families) +# form_layout.addRow(QLabel(label_text + ' Font:'), combo_box_font) +# form_layout.addRow(QLabel(label_text + ' Color:'), color_button) +# form_layout.addRow(getSeparator()) +# return combo_box_font, color_button +# +# # Adding form fields for each tag and attribute +# self.font_editors = {} +# self.color_buttons = {} +# self.__tags_attributes = [ +# ('span', 'Font, Color'), +# ('ol', 'Color'), +# ('li', 'Color'), +# ('ul', 'Color'), +# ('h1', 'Color'), +# ('h2', 'Color'), +# ('h3', 'Color'), +# ('h4', 'Color'), +# ('h5', 'Color'), +# ('h6', 'Color'), +# ('a', 'Color') +# ] +# +# for tag, attributes in self.__tags_attributes: +# if 'Font' in attributes: +# self.font_editors[tag], self.color_buttons[tag] = create_color_picker(tag) +# else: +# _, self.color_buttons[tag] = create_color_picker(tag) +# +# # Set the form layout to the widget +# markdownWidget.setLayout(form_layout) +# +# # Create a QScrollArea and set markdownWidget as its widget +# scroll_area = QScrollArea() +# scroll_area.setWidget(markdownWidget) +# scroll_area.setWidgetResizable(True) +# +# layout = QVBoxLayout() +# layout.addWidget(self.__showAsMarkdownCheckBox) +# layout.addWidget(self.__applyUserDefinedStylesCheckBox) +# layout.addWidget(markdownLbl) +# layout.addWidget(scroll_area) +# +# self.setLayout(layout) +# +# self.__showAsMarkdownCheckBox.toggled.connect( +# lambda: self.__applyUserDefinedStylesCheckBox.setEnabled(self.__showAsMarkdownCheckBox.isChecked())) +# self.__applyUserDefinedStylesCheckBox.toggled.connect( +# lambda: markdownWidget.setEnabled(self.__applyUserDefinedStylesCheckBox.isChecked() and self.__showAsMarkdownCheckBox.isChecked())) +# +# self.__showAsMarkdownCheckBox.setChecked(self.__show_as_markdown) +# self.__applyUserDefinedStylesCheckBox.setChecked(self.__apply_user_defined_styles) +# +# self.__applyUserDefinedStylesCheckBox.setEnabled(self.__showAsMarkdownCheckBox.isChecked()) +# markdownWidget.setEnabled(self.__applyUserDefinedStylesCheckBox.isChecked() and self.__showAsMarkdownCheckBox.isChecked()) +# +# def open_color_dialog(self): +# sender = self.sender() +# color = QColorDialog.getColor() +# if color.isValid(): +# sender.setStyleSheet(f'background-color: {color.name()};') +# +# def getParam(self): +# params = { +# 'show_as_markdown': self.__showAsMarkdownCheckBox.isChecked(), +# 'apply_user_defined_styles': self.__applyUserDefinedStylesCheckBox.isChecked(), +# } +# for tag, editor in self.font_editors.items(): +# if editor: # editor will be None for tags without a font combo box +# params[f'{tag}_font'] = editor.currentText() +# for tag, button in self.color_buttons.items(): +# color_name = button.palette().button().color().name() +# params[f'{tag}_color'] = color_name +# return params diff --git a/pyqt_openai/settings_dialog/settingsDialog.py b/pyqt_openai/settings_dialog/settingsDialog.py index d1118c1f..cfde6be4 100644 --- a/pyqt_openai/settings_dialog/settingsDialog.py +++ b/pyqt_openai/settings_dialog/settingsDialog.py @@ -1,90 +1,92 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QDialogButtonBox, - QMessageBox, - QStackedWidget, - QWidget, - QHBoxLayout, -) - -from pyqt_openai.settings_dialog.apiWidget import ApiWidget -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.models import SettingsParamsContainer -from pyqt_openai.settings_dialog.generalSettingsWidget import GeneralSettingsWidget -from pyqt_openai.settings_dialog.voiceSettingsWidget import VoiceSettingsWidget -from pyqt_openai.widgets.navWidget import NavBar - - -class SettingsDialog(QDialog): - def __init__(self, default_index=0, parent=None): - super().__init__(parent) - self.__initVal(default_index) - self.__initUi() - - def __initVal(self, default_index): - self.__default_index = default_index - - def __initUi(self): - self.setWindowTitle(LangClass.TRANSLATIONS["Settings"]) - self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) - - self.__generalSettingsWidget = GeneralSettingsWidget() - self.__apiWidget = ApiWidget() - self.__voiceSettingsWidget = VoiceSettingsWidget() - - # Dialog buttons - buttonBox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - buttonBox.accepted.connect(self.__accept) - buttonBox.rejected.connect(self.reject) - - self.__stackedWidget = QStackedWidget() - - self.__navBar = NavBar(orientation=Qt.Orientation.Vertical) - self.__navBar.add(LangClass.TRANSLATIONS["General"]) - self.__navBar.add(LangClass.TRANSLATIONS["API Key"]) - self.__navBar.add(LangClass.TRANSLATIONS["TTS-STT Settings"]) - self.__navBar.itemClicked.connect(self.__currentWidgetChanged) - - self.__stackedWidget.addWidget(self.__generalSettingsWidget) - self.__stackedWidget.addWidget(self.__apiWidget) - self.__stackedWidget.addWidget(self.__voiceSettingsWidget) - - self.__stackedWidget.setCurrentIndex(self.__default_index) - self.__navBar.setActiveButton(self.__default_index) - - lay = QHBoxLayout() - lay.addWidget(self.__navBar) - lay.addWidget(self.__stackedWidget) - lay.setContentsMargins(0, 0, 0, 0) - - self.__mainWidget = QWidget() - self.__mainWidget.setLayout(lay) - - lay = QVBoxLayout() - lay.addWidget(self.__mainWidget) - lay.addWidget(buttonBox) - - self.setLayout(lay) - - def __accept(self): - # If DB file name is empty - if self.__generalSettingsWidget.db.strip() == "": - QMessageBox.critical( - self, - LangClass.TRANSLATIONS["Error"], - LangClass.TRANSLATIONS["Database name cannot be empty."], - ) - else: - self.accept() - - def getParam(self): - return SettingsParamsContainer( - **self.__generalSettingsWidget.getParam(), - **self.__voiceSettingsWidget.getParam(), - ) - - def __currentWidgetChanged(self, i): - self.__stackedWidget.setCurrentIndex(i) - self.__navBar.setActiveButton(i) +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QDialog, + QDialogButtonBox, + QHBoxLayout, + QMessageBox, + QStackedWidget, + QVBoxLayout, + QWidget, +) + +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.models import SettingsParamsContainer +from pyqt_openai.settings_dialog.apiWidget import ApiWidget +from pyqt_openai.settings_dialog.generalSettingsWidget import GeneralSettingsWidget +from pyqt_openai.settings_dialog.voiceSettingsWidget import VoiceSettingsWidget +from pyqt_openai.widgets.navWidget import NavBar + + +class SettingsDialog(QDialog): + def __init__(self, default_index=0, parent=None): + super().__init__(parent) + self.__initVal(default_index) + self.__initUi() + + def __initVal(self, default_index): + self.__default_index = default_index + + def __initUi(self): + self.setWindowTitle(LangClass.TRANSLATIONS["Settings"]) + self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) + + self.__generalSettingsWidget = GeneralSettingsWidget() + self.__apiWidget = ApiWidget() + self.__voiceSettingsWidget = VoiceSettingsWidget() + + # Dialog buttons + buttonBox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttonBox.accepted.connect(self.__accept) + buttonBox.rejected.connect(self.reject) + + self.__stackedWidget = QStackedWidget() + + self.__navBar = NavBar(orientation=Qt.Orientation.Vertical) + self.__navBar.add(LangClass.TRANSLATIONS["General"]) + self.__navBar.add(LangClass.TRANSLATIONS["API Key"]) + self.__navBar.add(LangClass.TRANSLATIONS["TTS-STT Settings"]) + self.__navBar.itemClicked.connect(self.__currentWidgetChanged) + + self.__stackedWidget.addWidget(self.__generalSettingsWidget) + self.__stackedWidget.addWidget(self.__apiWidget) + self.__stackedWidget.addWidget(self.__voiceSettingsWidget) + + self.__stackedWidget.setCurrentIndex(self.__default_index) + self.__navBar.setActiveButton(self.__default_index) + + lay = QHBoxLayout() + lay.addWidget(self.__navBar) + lay.addWidget(self.__stackedWidget) + lay.setContentsMargins(0, 0, 0, 0) + + self.__mainWidget = QWidget() + self.__mainWidget.setLayout(lay) + + lay = QVBoxLayout() + lay.addWidget(self.__mainWidget) + lay.addWidget(buttonBox) + + self.setLayout(lay) + + def __accept(self): + # If DB file name is empty + if self.__generalSettingsWidget.db.strip() == "": + QMessageBox.critical( + self, + LangClass.TRANSLATIONS["Error"], + LangClass.TRANSLATIONS["Database name cannot be empty."], + ) + else: + self.accept() + + def getParam(self): + return SettingsParamsContainer( + **self.__generalSettingsWidget.getParam(), + **self.__voiceSettingsWidget.getParam(), + ) + + def __currentWidgetChanged(self, i): + self.__stackedWidget.setCurrentIndex(i) + self.__navBar.setActiveButton(i) diff --git a/pyqt_openai/settings_dialog/voiceSettingsWidget.py b/pyqt_openai/settings_dialog/voiceSettingsWidget.py index 0f5e5659..f6e12e6a 100644 --- a/pyqt_openai/settings_dialog/voiceSettingsWidget.py +++ b/pyqt_openai/settings_dialog/voiceSettingsWidget.py @@ -1,130 +1,132 @@ -from PySide6.QtWidgets import ( - QWidget, - QComboBox, - QFormLayout, - QDoubleSpinBox, - QGroupBox, - QVBoxLayout, - QSpinBox, - QCheckBox, - QLabel, -) - -from pyqt_openai import ( - WHISPER_TTS_VOICE_TYPE, - WHISPER_TTS_VOICE_SPEED_RANGE, - EDGE_TTS_VOICE_TYPE, - DEFAULT_HIGHLIGHT_TEXT_COLOR, -) -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.lang.translations import LangClass - - -class VoiceSettingsWidget(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.__initVal() - self.__initUi() - - def __initVal(self): - self.voice_provider = CONFIG_MANAGER.get_general_property("voice_provider") - self.voice = CONFIG_MANAGER.get_general_property("voice") - self.speed = CONFIG_MANAGER.get_general_property("voice_speed") - self.auto_play = CONFIG_MANAGER.get_general_property("auto_play_voice") - self.auto_stop_silence_duration = CONFIG_MANAGER.get_general_property( - "auto_stop_silence_duration" - ) - - def __initUi(self): - ttsGrpBox = QGroupBox("Text to Speech") - - self.__voiceProviderCmbBox = QComboBox() - self.__voiceProviderCmbBox.addItems(["OpenAI", "edge-tts"]) - self.__voiceProviderCmbBox.setCurrentText(self.voice_provider) - self.__voiceProviderCmbBox.currentTextChanged.connect( - self.__voiceProviderChanged - ) - - # TODO LANGUAGE - self.__warningLbl = QLabel( - "You need to install mpv to use edge-tts. " - "Link" - "
Also edge-tts can only be used when run with python." - ) - self.__warningLbl.setOpenExternalLinks(True) - self.__warningLbl.setStyleSheet(f"color: {DEFAULT_HIGHLIGHT_TEXT_COLOR};") - self.__warningLbl.setVisible(self.voice_provider == "edge-tts") - - detailsGroupBox = QGroupBox("Details") - - self.__voiceCmbBox = QComboBox() - self.__voiceProviderChanged(self.voice_provider) - self.__voiceCmbBox.setCurrentText(self.voice) - - self.__speedSpinBox = QDoubleSpinBox() - self.__speedSpinBox.setRange(*WHISPER_TTS_VOICE_SPEED_RANGE) - self.__speedSpinBox.setSingleStep(0.1) - self.__speedSpinBox.setValue(float(self.speed)) - - # Auto-Play voice when response is received - self.__autoPlayChkBox = QCheckBox( - "Auto-Play Voice when Response is Received (Work in Progress)" - ) - self.__autoPlayChkBox.setChecked(self.auto_play) - # TODO implement auto-play voice in v1.8.0 - self.__autoPlayChkBox.setEnabled(False) - - lay = QFormLayout() - lay.addRow(LangClass.TRANSLATIONS["Voice"], self.__voiceCmbBox) - lay.addRow(LangClass.TRANSLATIONS["Voice Speed"], self.__speedSpinBox) - lay.addRow(self.__autoPlayChkBox) - detailsGroupBox.setLayout(lay) - - lay = QFormLayout() - lay.addRow(LangClass.TRANSLATIONS["Voice Provider"], self.__voiceProviderCmbBox) - lay.addRow(self.__warningLbl) - lay.addRow(detailsGroupBox) - - ttsGrpBox.setLayout(lay) - - sttGrpBox = QGroupBox("Speech to Text") - - # Allow user to determine Auto-Stop Silence Duration - self.__autoStopSilenceDurationSpinBox = QSpinBox() - self.__autoStopSilenceDurationSpinBox.setRange(3, 10) - self.__autoStopSilenceDurationSpinBox.setValue(self.auto_stop_silence_duration) - # TODO implement auto-play voice in v1.8.0 - self.__autoStopSilenceDurationSpinBox.setEnabled(False) - - lay = QFormLayout() - lay.addRow( - "Auto-Stop Silence Duration (Work in Progress)", - self.__autoStopSilenceDurationSpinBox, - ) - - sttGrpBox.setLayout(lay) - - lay = QVBoxLayout() - lay.addWidget(ttsGrpBox) - lay.addWidget(sttGrpBox) - - self.setLayout(lay) - - def getParam(self): - return { - "voice_provider": self.__voiceProviderCmbBox.currentText(), - "voice": self.__voiceCmbBox.currentText(), - "voice_speed": self.__speedSpinBox.value(), - "auto_play_voice": self.__autoPlayChkBox.isChecked(), - "auto_stop_silence_duration": self.__autoStopSilenceDurationSpinBox.value(), - } - - def __voiceProviderChanged(self, text): - f = text == "OpenAI" - if f: - self.__voiceCmbBox.clear() - self.__voiceCmbBox.addItems(WHISPER_TTS_VOICE_TYPE) - else: - self.__voiceCmbBox.clear() - self.__voiceCmbBox.addItems(EDGE_TTS_VOICE_TYPE) - self.__warningLbl.setVisible(not f) +from __future__ import annotations + +from qtpy.QtWidgets import ( + QCheckBox, + QComboBox, + QDoubleSpinBox, + QFormLayout, + QGroupBox, + QLabel, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from pyqt_openai import ( + DEFAULT_HIGHLIGHT_TEXT_COLOR, + EDGE_TTS_VOICE_TYPE, + WHISPER_TTS_VOICE_SPEED_RANGE, + WHISPER_TTS_VOICE_TYPE, +) +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.lang.translations import LangClass + + +class VoiceSettingsWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.__initVal() + self.__initUi() + + def __initVal(self): + self.voice_provider = CONFIG_MANAGER.get_general_property("voice_provider") + self.voice = CONFIG_MANAGER.get_general_property("voice") + self.speed = CONFIG_MANAGER.get_general_property("voice_speed") + self.auto_play = CONFIG_MANAGER.get_general_property("auto_play_voice") + self.auto_stop_silence_duration = CONFIG_MANAGER.get_general_property( + "auto_stop_silence_duration", + ) + + def __initUi(self): + ttsGrpBox = QGroupBox("Text to Speech") + + self.__voiceProviderCmbBox = QComboBox() + self.__voiceProviderCmbBox.addItems(["OpenAI", "edge-tts"]) + self.__voiceProviderCmbBox.setCurrentText(self.voice_provider) + self.__voiceProviderCmbBox.currentTextChanged.connect( + self.__voiceProviderChanged, + ) + + # TODO LANGUAGE + self.__warningLbl = QLabel( + "You need to install mpv to use edge-tts. " + "
Link" + "
Also edge-tts can only be used when run with python.", + ) + self.__warningLbl.setOpenExternalLinks(True) + self.__warningLbl.setStyleSheet(f"color: {DEFAULT_HIGHLIGHT_TEXT_COLOR};") + self.__warningLbl.setVisible(self.voice_provider == "edge-tts") + + detailsGroupBox = QGroupBox("Details") + + self.__voiceCmbBox = QComboBox() + self.__voiceProviderChanged(self.voice_provider) + self.__voiceCmbBox.setCurrentText(self.voice) + + self.__speedSpinBox = QDoubleSpinBox() + self.__speedSpinBox.setRange(*WHISPER_TTS_VOICE_SPEED_RANGE) + self.__speedSpinBox.setSingleStep(0.1) + self.__speedSpinBox.setValue(float(self.speed)) + + # Auto-Play voice when response is received + self.__autoPlayChkBox = QCheckBox( + "Auto-Play Voice when Response is Received (Work in Progress)", + ) + self.__autoPlayChkBox.setChecked(self.auto_play) + # TODO implement auto-play voice in v1.8.0 + self.__autoPlayChkBox.setEnabled(False) + + lay = QFormLayout() + lay.addRow(LangClass.TRANSLATIONS["Voice"], self.__voiceCmbBox) + lay.addRow(LangClass.TRANSLATIONS["Voice Speed"], self.__speedSpinBox) + lay.addRow(self.__autoPlayChkBox) + detailsGroupBox.setLayout(lay) + + lay = QFormLayout() + lay.addRow(LangClass.TRANSLATIONS["Voice Provider"], self.__voiceProviderCmbBox) + lay.addRow(self.__warningLbl) + lay.addRow(detailsGroupBox) + + ttsGrpBox.setLayout(lay) + + sttGrpBox = QGroupBox("Speech to Text") + + # Allow user to determine Auto-Stop Silence Duration + self.__autoStopSilenceDurationSpinBox = QSpinBox() + self.__autoStopSilenceDurationSpinBox.setRange(3, 10) + self.__autoStopSilenceDurationSpinBox.setValue(self.auto_stop_silence_duration) + # TODO implement auto-play voice in v1.8.0 + self.__autoStopSilenceDurationSpinBox.setEnabled(False) + + lay = QFormLayout() + lay.addRow( + "Auto-Stop Silence Duration (Work in Progress)", + self.__autoStopSilenceDurationSpinBox, + ) + + sttGrpBox.setLayout(lay) + + lay = QVBoxLayout() + lay.addWidget(ttsGrpBox) + lay.addWidget(sttGrpBox) + + self.setLayout(lay) + + def getParam(self): + return { + "voice_provider": self.__voiceProviderCmbBox.currentText(), + "voice": self.__voiceCmbBox.currentText(), + "voice_speed": self.__speedSpinBox.value(), + "auto_play_voice": self.__autoPlayChkBox.isChecked(), + "auto_stop_silence_duration": self.__autoStopSilenceDurationSpinBox.value(), + } + + def __voiceProviderChanged(self, text): + f = text == "OpenAI" + if f: + self.__voiceCmbBox.clear() + self.__voiceCmbBox.addItems(WHISPER_TTS_VOICE_TYPE) + else: + self.__voiceCmbBox.clear() + self.__voiceCmbBox.addItems(EDGE_TTS_VOICE_TYPE) + self.__warningLbl.setVisible(not f) diff --git a/pyqt_openai/shortcutDialog.py b/pyqt_openai/shortcutDialog.py index 3872f1e3..14f3e32f 100644 --- a/pyqt_openai/shortcutDialog.py +++ b/pyqt_openai/shortcutDialog.py @@ -1,124 +1,126 @@ -from PySide6.QtWidgets import QLabel, QFormLayout, QGroupBox, QDialog - -from pyqt_openai import ( - DEFAULT_SHORTCUT_FIND_PREV, - DEFAULT_SHORTCUT_FIND_NEXT, - DEFAULT_SHORTCUT_PROMPT_BEGINNING, - DEFAULT_SHORTCUT_PROMPT_ENDING, - DEFAULT_SHORTCUT_SUPPORT_PROMPT_COMMAND, - DEFAULT_SHORTCUT_FULL_SCREEN, - DEFAULT_SHORTCUT_FIND, - DEFAULT_SHORTCUT_JSON_MODE, - DEFAULT_SHORTCUT_LEFT_SIDEBAR_WINDOW, - DEFAULT_SHORTCUT_RIGHT_SIDEBAR_WINDOW, - DEFAULT_SHORTCUT_CONTROL_PROMPT_WINDOW, - DEFAULT_SHORTCUT_SETTING, - DEFAULT_SHORTCUT_SEND, - DEFAULT_SHORTCUT_SHOW_SECONDARY_TOOLBAR, - DEFAULT_SHORTCUT_FOCUS_MODE, - DEFAULT_SWITCH_PROMPT_UP, - DEFAULT_SWITCH_PROMPT_DOWN, - DEFAULT_SHORTCUT_RECORD, - DEFAULT_SHORTCUT_STACK_ON_TOP, -) -from pyqt_openai.lang.translations import LangClass - - -class ShortcutDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.__shortcuts = { - "SHORTCUT_FIND_PREV": { - "label": f"{LangClass.TRANSLATIONS['Find']} - {LangClass.TRANSLATIONS['Previous']}", - "value": DEFAULT_SHORTCUT_FIND_PREV, - }, - "SHORTCUT_FIND_NEXT": { - "label": f"{LangClass.TRANSLATIONS['Find']} - {LangClass.TRANSLATIONS['Next']}", - "value": DEFAULT_SHORTCUT_FIND_NEXT, - }, - "SHORTCUT_PROMPT_BEGINNING": { - "label": LangClass.TRANSLATIONS["Prompt Beginning"], - "value": DEFAULT_SHORTCUT_PROMPT_BEGINNING, - }, - "SHORTCUT_PROMPT_ENDING": { - "label": LangClass.TRANSLATIONS["Prompt Ending"], - "value": DEFAULT_SHORTCUT_PROMPT_ENDING, - }, - "SHORTCUT_SUPPORT_PROMPT_COMMAND": { - "label": LangClass.TRANSLATIONS["Support Prompt Command"], - "value": DEFAULT_SHORTCUT_SUPPORT_PROMPT_COMMAND, - }, - "SHOW_SECONDARY_TOOLBAR": { - "label": LangClass.TRANSLATIONS["Show Secondary Toolbar"], - "value": DEFAULT_SHORTCUT_SHOW_SECONDARY_TOOLBAR, - }, - "SHORTCUT_FOCUS_MODE": { - "label": LangClass.TRANSLATIONS["Focus Mode"], - "value": DEFAULT_SHORTCUT_FOCUS_MODE, - }, - "SHORTCUT_FULL_SCREEN": { - "label": LangClass.TRANSLATIONS["Full Screen"], - "value": DEFAULT_SHORTCUT_FULL_SCREEN, - }, - "SHORTCUT_STACK_ON_TOP": { - "label": LangClass.TRANSLATIONS["Stack On Top"], - "value": DEFAULT_SHORTCUT_STACK_ON_TOP, - }, - "SHORTCUT_FIND": { - "label": LangClass.TRANSLATIONS["Find"], - "value": DEFAULT_SHORTCUT_FIND, - }, - "SHORTCUT_JSON_MODE": { - "label": LangClass.TRANSLATIONS["JSON Mode"], - "value": DEFAULT_SHORTCUT_JSON_MODE, - }, - "SHORTCUT_LEFT_SIDEBAR_WINDOW": { - "label": LangClass.TRANSLATIONS["Left Sidebar Window"], - "value": DEFAULT_SHORTCUT_LEFT_SIDEBAR_WINDOW, - }, - "SHORTCUT_RIGHT_SIDEBAR_WINDOW": { - "label": LangClass.TRANSLATIONS["Right Sidebar Window"], - "value": DEFAULT_SHORTCUT_RIGHT_SIDEBAR_WINDOW, - }, - "SHORTCUT_CONTROL_PROMPT_WINDOW": { - "label": LangClass.TRANSLATIONS["Control Prompt Window"], - "value": DEFAULT_SHORTCUT_CONTROL_PROMPT_WINDOW, - }, - "SHORTCUT_SETTING": { - "label": LangClass.TRANSLATIONS["Setting"], - "value": DEFAULT_SHORTCUT_SETTING, - }, - "SHORTCUT_SEND": { - "label": LangClass.TRANSLATIONS["Send"], - "value": DEFAULT_SHORTCUT_SEND, - }, - "SWITCH_PROMPT_UP": { - "label": LangClass.TRANSLATIONS["Switch Prompt Up"], - "value": DEFAULT_SWITCH_PROMPT_UP, - }, - "SWITCH_PROMPT_DOWN": { - "label": LangClass.TRANSLATIONS["Switch Prompt Down"], - "value": DEFAULT_SWITCH_PROMPT_DOWN, - }, - "SHORTCUT_RECORD": { - "label": LangClass.TRANSLATIONS["Record"], - "value": DEFAULT_SHORTCUT_RECORD, - }, - } - self.__initUi() - - def __initUi(self): - self.setWindowTitle(LangClass.TRANSLATIONS["Shortcuts"]) - - lay = QFormLayout() - - shortcutGroupBox = QGroupBox(LangClass.TRANSLATIONS["Shortcuts"]) - shortcutGroupBox.setLayout(lay) - - for key, shortcut in self.__shortcuts.items(): - lineEdit = QLabel() - lineEdit.setText(shortcut["value"]) - shortcut["lineEdit"] = lineEdit - lay.addRow(shortcut["label"], lineEdit) - - self.setLayout(lay) +from __future__ import annotations + +from qtpy.QtWidgets import QDialog, QFormLayout, QGroupBox, QLabel + +from pyqt_openai import ( + DEFAULT_SHORTCUT_CONTROL_PROMPT_WINDOW, + DEFAULT_SHORTCUT_FIND, + DEFAULT_SHORTCUT_FIND_NEXT, + DEFAULT_SHORTCUT_FIND_PREV, + DEFAULT_SHORTCUT_FOCUS_MODE, + DEFAULT_SHORTCUT_FULL_SCREEN, + DEFAULT_SHORTCUT_JSON_MODE, + DEFAULT_SHORTCUT_LEFT_SIDEBAR_WINDOW, + DEFAULT_SHORTCUT_PROMPT_BEGINNING, + DEFAULT_SHORTCUT_PROMPT_ENDING, + DEFAULT_SHORTCUT_RECORD, + DEFAULT_SHORTCUT_RIGHT_SIDEBAR_WINDOW, + DEFAULT_SHORTCUT_SEND, + DEFAULT_SHORTCUT_SETTING, + DEFAULT_SHORTCUT_SHOW_SECONDARY_TOOLBAR, + DEFAULT_SHORTCUT_STACK_ON_TOP, + DEFAULT_SHORTCUT_SUPPORT_PROMPT_COMMAND, + DEFAULT_SWITCH_PROMPT_DOWN, + DEFAULT_SWITCH_PROMPT_UP, +) +from pyqt_openai.lang.translations import LangClass + + +class ShortcutDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.__shortcuts = { + "SHORTCUT_FIND_PREV": { + "label": f"{LangClass.TRANSLATIONS['Find']} - {LangClass.TRANSLATIONS['Previous']}", + "value": DEFAULT_SHORTCUT_FIND_PREV, + }, + "SHORTCUT_FIND_NEXT": { + "label": f"{LangClass.TRANSLATIONS['Find']} - {LangClass.TRANSLATIONS['Next']}", + "value": DEFAULT_SHORTCUT_FIND_NEXT, + }, + "SHORTCUT_PROMPT_BEGINNING": { + "label": LangClass.TRANSLATIONS["Prompt Beginning"], + "value": DEFAULT_SHORTCUT_PROMPT_BEGINNING, + }, + "SHORTCUT_PROMPT_ENDING": { + "label": LangClass.TRANSLATIONS["Prompt Ending"], + "value": DEFAULT_SHORTCUT_PROMPT_ENDING, + }, + "SHORTCUT_SUPPORT_PROMPT_COMMAND": { + "label": LangClass.TRANSLATIONS["Support Prompt Command"], + "value": DEFAULT_SHORTCUT_SUPPORT_PROMPT_COMMAND, + }, + "SHOW_SECONDARY_TOOLBAR": { + "label": LangClass.TRANSLATIONS["Show Secondary Toolbar"], + "value": DEFAULT_SHORTCUT_SHOW_SECONDARY_TOOLBAR, + }, + "SHORTCUT_FOCUS_MODE": { + "label": LangClass.TRANSLATIONS["Focus Mode"], + "value": DEFAULT_SHORTCUT_FOCUS_MODE, + }, + "SHORTCUT_FULL_SCREEN": { + "label": LangClass.TRANSLATIONS["Full Screen"], + "value": DEFAULT_SHORTCUT_FULL_SCREEN, + }, + "SHORTCUT_STACK_ON_TOP": { + "label": LangClass.TRANSLATIONS["Stack On Top"], + "value": DEFAULT_SHORTCUT_STACK_ON_TOP, + }, + "SHORTCUT_FIND": { + "label": LangClass.TRANSLATIONS["Find"], + "value": DEFAULT_SHORTCUT_FIND, + }, + "SHORTCUT_JSON_MODE": { + "label": LangClass.TRANSLATIONS["JSON Mode"], + "value": DEFAULT_SHORTCUT_JSON_MODE, + }, + "SHORTCUT_LEFT_SIDEBAR_WINDOW": { + "label": LangClass.TRANSLATIONS["Left Sidebar Window"], + "value": DEFAULT_SHORTCUT_LEFT_SIDEBAR_WINDOW, + }, + "SHORTCUT_RIGHT_SIDEBAR_WINDOW": { + "label": LangClass.TRANSLATIONS["Right Sidebar Window"], + "value": DEFAULT_SHORTCUT_RIGHT_SIDEBAR_WINDOW, + }, + "SHORTCUT_CONTROL_PROMPT_WINDOW": { + "label": LangClass.TRANSLATIONS["Control Prompt Window"], + "value": DEFAULT_SHORTCUT_CONTROL_PROMPT_WINDOW, + }, + "SHORTCUT_SETTING": { + "label": LangClass.TRANSLATIONS["Setting"], + "value": DEFAULT_SHORTCUT_SETTING, + }, + "SHORTCUT_SEND": { + "label": LangClass.TRANSLATIONS["Send"], + "value": DEFAULT_SHORTCUT_SEND, + }, + "SWITCH_PROMPT_UP": { + "label": LangClass.TRANSLATIONS["Switch Prompt Up"], + "value": DEFAULT_SWITCH_PROMPT_UP, + }, + "SWITCH_PROMPT_DOWN": { + "label": LangClass.TRANSLATIONS["Switch Prompt Down"], + "value": DEFAULT_SWITCH_PROMPT_DOWN, + }, + "SHORTCUT_RECORD": { + "label": LangClass.TRANSLATIONS["Record"], + "value": DEFAULT_SHORTCUT_RECORD, + }, + } + self.__initUi() + + def __initUi(self): + self.setWindowTitle(LangClass.TRANSLATIONS["Shortcuts"]) + + lay = QFormLayout() + + shortcutGroupBox = QGroupBox(LangClass.TRANSLATIONS["Shortcuts"]) + shortcutGroupBox.setLayout(lay) + + for key, shortcut in self.__shortcuts.items(): + lineEdit = QLabel() + lineEdit.setText(shortcut["value"]) + shortcut["lineEdit"] = lineEdit + lay.addRow(shortcut["label"], lineEdit) + + self.setLayout(lay) diff --git a/pyqt_openai/sqlite.py b/pyqt_openai/sqlite.py index 062179ed..9d4f440c 100644 --- a/pyqt_openai/sqlite.py +++ b/pyqt_openai/sqlite.py @@ -1,752 +1,740 @@ -import json -import os -import sqlite3 -from datetime import datetime -from typing import List - -from pyqt_openai import ( - THREAD_TABLE_NAME, - THREAD_TRIGGER_NAME, - MESSAGE_TABLE_NAME, - THREAD_MESSAGE_INSERTED_TR_NAME, - THREAD_MESSAGE_UPDATED_TR_NAME, - THREAD_MESSAGE_DELETED_TR_NAME, - IMAGE_TABLE_NAME, - PROMPT_GROUP_TABLE_NAME, - PROMPT_ENTRY_TABLE_NAME, - get_config_directory, - DEFAULT_DATETIME_FORMAT, - CHAT_FILE_TABLE_NAME, -) -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.models import ( - ImagePromptContainer, - ChatMessageContainer, - PromptEntryContainer, - PromptGroupContainer, -) - - -def get_db_filename(): - """ - Get the database file's name from the settings. - """ - db_filename = CONFIG_MANAGER.get_general_property("db") + ".db" - config_dir = get_config_directory() - db_path = os.path.join(config_dir, db_filename) - return db_path - - -class SqliteDatabase: - """ - Functions which only meant to be used frequently are defined. - If there is no functions you want to use, use ``getCursor`` instead. - """ - - def __init__(self, db_filename=get_db_filename()): - super().__init__() - self.__initVal(db_filename) - self.__initDb() - - def __initVal(self, db_filename): - # DB file name - self.__db_filename = db_filename or get_db_filename() - - def __initDb(self): - try: - # Connect to the database (create a new file if it doesn't exist) - self.__conn = sqlite3.connect(self.__db_filename) - self.__conn.row_factory = sqlite3.Row - self.__conn.execute("PRAGMA foreign_keys = ON;") - self.__conn.commit() - - # create cursor - self.__c = self.__conn.cursor() - - # create conversation tables - self.__createThread() - - # create prompt tables - self.__createPromptGroup() - - # create image tables - self.__createImage() - except sqlite3.Error as e: - print(f"An error occurred while connecting to the database: {e}") - raise - - def __createPromptGroup(self): - try: - self.__c.execute( - f"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='{PROMPT_GROUP_TABLE_NAME}'" - ) - if self.__c.fetchone()[0] == 1: - # TODO WILL_REMOVED_IN_FUTURE AFTER v2.0.0 - self.__createPromptEntry() - else: - self.__c.execute( - f"""CREATE TABLE {PROMPT_GROUP_TABLE_NAME} - (id INTEGER PRIMARY KEY, - name VARCHAR(255) UNIQUE, - prompt_type VARCHAR(255), - update_dt DATETIME DEFAULT CURRENT_TIMESTAMP, - insert_dt DATETIME DEFAULT CURRENT_TIMESTAMP)""" - ) - # Create prompt entry - self.__createPromptEntry() - - # Commit the transaction - self.__conn.commit() - except sqlite3.Error as e: - print(f"An error occurred while creating the table: {e}") - raise - - def insertPromptGroup(self, name, prompt_type): - try: - # Insert a row into the table - self.__c.execute( - f"INSERT INTO {PROMPT_GROUP_TABLE_NAME} (name, prompt_type) VALUES (?, ?)", - (name, prompt_type), - ) - new_id = self.__c.lastrowid - # Commit the transaction - self.__conn.commit() - return new_id - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def selectPromptGroup(self, prompt_type=None): - try: - query = f"SELECT * FROM {PROMPT_GROUP_TABLE_NAME}" - if prompt_type == "form": - query += f' WHERE prompt_type="form"' - elif prompt_type == "sentence": - query += f' WHERE prompt_type="sentence"' - self.__c.execute(query) - return [PromptGroupContainer(**elem) for elem in self.__c.fetchall()] - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def selectCertainPromptGroup(self, id=None, name=None): - """ - Select specific prompt group by id or name - """ - try: - query = f"SELECT * FROM {PROMPT_GROUP_TABLE_NAME}" - if id or name: - query += " WHERE" - if id: - query += f" id={id}" - if name: - query += " AND" - if name: - query += f' name="{name}"' - result = self.__c.execute(query).fetchone() - if result: - return PromptGroupContainer(**result) - else: - return None - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def updatePromptGroup(self, id, name): - try: - self.__c.execute( - f"UPDATE {PROMPT_GROUP_TABLE_NAME} SET name=? WHERE id={id}", (name,) - ) - self.__conn.commit() - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def deletePromptGroup(self, id=None): - try: - query = f"DELETE FROM {PROMPT_GROUP_TABLE_NAME}" - if id: - query += f" WHERE id = {id}" - self.__c.execute(query) - self.__conn.commit() - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def __createPromptEntry(self): - try: - self.__c.execute( - f"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='{PROMPT_ENTRY_TABLE_NAME}'" - ) - if self.__c.fetchone()[0] == 1: - # TODO WILL_REMOVED_IN_FUTURE AFTER v2.0.0 - # Update name->act and content->prompt if the table exists - self.__c.execute(f"PRAGMA table_info({PROMPT_ENTRY_TABLE_NAME})") - existing_columns = {row[1]: row for row in self.__c.fetchall()} # Map column names to info - - # Check if 'name' or 'content' exists - if "name" in existing_columns or "content" in existing_columns: - try: - self.__c.execute("PRAGMA foreign_keys=OFF") # Disable foreign key constraints temporarily - - # Rename table to a temporary name - temp_table = f"{PROMPT_ENTRY_TABLE_NAME}_backup" - self.__c.execute(f"ALTER TABLE {PROMPT_ENTRY_TABLE_NAME} RENAME TO {temp_table}") - - # Create the updated table structure - self.__c.execute( - f"""CREATE TABLE {PROMPT_ENTRY_TABLE_NAME} ( - id INTEGER PRIMARY KEY, - group_id INTEGER NOT NULL, - act VARCHAR(255) NOT NULL, - prompt TEXT NOT NULL, - insert_dt DATETIME DEFAULT CURRENT_TIMESTAMP, - update_dt DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (group_id) REFERENCES {PROMPT_GROUP_TABLE_NAME}(id) - ON DELETE CASCADE) - """ - ) - - # Copy data from the old table to the new table, renaming columns - self.__c.execute( - f"""INSERT INTO {PROMPT_ENTRY_TABLE_NAME} (id, group_id, act, prompt, insert_dt, update_dt) - SELECT id, group_id, - name AS act, content AS prompt, - insert_dt, update_dt - FROM {temp_table} - """ - ) - - # Drop the temporary table - self.__c.execute(f"DROP TABLE {temp_table}") - self.__c.execute("PRAGMA foreign_keys=ON") # Re-enable foreign key constraints - self.__conn.commit() - except Exception as e: - print("Error during column rename:", e) - self.__conn.rollback() - self.__c.execute("PRAGMA foreign_keys=ON") # Ensure foreign keys are re-enabled - else: - print(f"Table {PROMPT_ENTRY_TABLE_NAME} already updated.") - else: - self.__c.execute( - f"""CREATE TABLE {PROMPT_ENTRY_TABLE_NAME} ( - id INTEGER PRIMARY KEY, - group_id INTEGER NOT NULL, - act VARCHAR(255) NOT NULL, - prompt TEXT NOT NULL, - insert_dt DATETIME DEFAULT CURRENT_TIMESTAMP, - update_dt DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (group_id) REFERENCES {PROMPT_GROUP_TABLE_NAME}(id) - ON DELETE CASCADE) - """ - ) - self.__conn.commit() - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def insertPromptEntry(self, group_id, act, prompt=""): - try: - # Insert a row into the table - self.__c.execute( - f"INSERT INTO {PROMPT_ENTRY_TABLE_NAME} (group_id, act, prompt) VALUES (?, ?, ?)", - (group_id, act, prompt), - ) - new_id = self.__c.lastrowid - # Commit the transaction - self.__conn.commit() - return new_id - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def selectPromptEntry( - self, group_id, id=None, act=None - ) -> List[PromptEntryContainer]: - try: - query = f"SELECT * FROM {PROMPT_ENTRY_TABLE_NAME} WHERE group_id={group_id}" - if id: - query += f" AND id={id}" - if act: - query += f' AND act="{act}"' - - # Fetch rows only once - rows = self.__c.execute(query).fetchall() - - # Convert to PromptEntryContainer list - return [PromptEntryContainer(**dict(row)) for row in rows] - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def updatePromptEntry(self, id, act, prompt): - try: - self.__c.execute( - f"UPDATE {PROMPT_ENTRY_TABLE_NAME} SET act=?, prompt=? WHERE id={id}", - (act, prompt), - ) - self.__conn.commit() - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def deletePromptEntry(self, group_id, id=None): - try: - query = f"DELETE FROM {PROMPT_ENTRY_TABLE_NAME} WHERE group_id={group_id}" - if id: - query += f" AND id={id}" - self.__c.execute(query) - self.__conn.commit() - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def __createThread(self): - try: - # Create thread table if not exists - thread_tb_exists = ( - self.__c.execute( - f"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='{THREAD_TABLE_NAME}'" - ).fetchone()[0] - == 1 - ) - if thread_tb_exists: - pass - else: - # If user uses app for the first time, create a table - # Create a table with update_dt and insert_dt columns - self.__c.execute( - f"""CREATE TABLE {THREAD_TABLE_NAME} - (id INTEGER PRIMARY KEY, - name TEXT, - update_dt DATETIME, - insert_dt DATETIME DEFAULT CURRENT_TIMESTAMP)""" - ) - - # Create message table - self.__createMessage() - - # Create trigger if not exists - thread_trigger_exists = ( - self.__c.execute( - f"SELECT count(*) FROM sqlite_master WHERE type='trigger' AND name='{THREAD_TRIGGER_NAME}'" - ).fetchone()[0] - == 1 - ) - if thread_trigger_exists: - pass - else: - # Create a trigger to update the update_dt column with the current timestamp - self.__c.execute( - f"""CREATE TRIGGER {THREAD_TRIGGER_NAME} - AFTER UPDATE ON {THREAD_TABLE_NAME} - FOR EACH ROW - BEGIN - UPDATE {THREAD_TABLE_NAME} - SET update_dt=CURRENT_TIMESTAMP - WHERE id=OLD.id; - END;""" - ) - # Commit the transaction - self.__conn.commit() - except sqlite3.Error as e: - print(f"An error occurred while creating the table: {e}") - raise - - def selectAllThread(self, id_arr=None): - """ - Select all thread - id_arr: list of thread id - """ - try: - query = f"SELECT * FROM {THREAD_TABLE_NAME}" - if id_arr: - query += f' WHERE id IN ({",".join(map(str, id_arr))})' - self.__c.execute(query) - return self.__c.fetchall() - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def selectThread(self, id): - """ - Select specific thread - """ - try: - self.__c.execute(f"SELECT * FROM {THREAD_TABLE_NAME} WHERE id={id}") - return self.__c.fetchone() - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def insertThread(self, name, insert_dt=None, update_dt=None): - try: - query = f"INSERT INTO {THREAD_TABLE_NAME} (name) VALUES (?)" - params = (name,) - - if insert_dt and update_dt: - query = f"INSERT INTO {THREAD_TABLE_NAME} (name, insert_dt, update_dt) VALUES (?, ?, ?)" - params = (name, insert_dt, update_dt) - elif insert_dt: - query = ( - f"INSERT INTO {THREAD_TABLE_NAME} (name, insert_dt) VALUES (?, ?)" - ) - params = (name, insert_dt) - - # Insert a row into the table - self.__c.execute(query, params) - new_id = self.__c.lastrowid - # Commit the transaction - self.__conn.commit() - return new_id - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def updateThread(self, id, name): - try: - self.__c.execute( - f"UPDATE {THREAD_TABLE_NAME} SET name=(?) WHERE id={id}", (name,) - ) - self.__conn.commit() - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def deleteThread(self, id=None): - try: - query = f"DELETE FROM {THREAD_TABLE_NAME}" - if id: - query += f" WHERE id = {id}" - self.__c.execute(query) - self.__conn.commit() - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def __createMessageTrigger( - self, insert_trigger=True, update_trigger=True, delete_trigger=True - ): - """ - Create message trigger - """ - if insert_trigger: - # Create insert trigger - self.__c.execute( - f""" - CREATE TRIGGER {THREAD_MESSAGE_INSERTED_TR_NAME} - AFTER INSERT ON {MESSAGE_TABLE_NAME} - BEGIN - UPDATE {THREAD_TABLE_NAME} SET update_dt = CURRENT_TIMESTAMP WHERE id = NEW.thread_id; - END - """ - ) - - if update_trigger: - # Create update trigger - self.__c.execute( - f""" - CREATE TRIGGER {THREAD_MESSAGE_UPDATED_TR_NAME} - AFTER UPDATE ON {MESSAGE_TABLE_NAME} - BEGIN - UPDATE {THREAD_TABLE_NAME} SET update_dt = CURRENT_TIMESTAMP WHERE id = NEW.thread_id; - END - """ - ) - - if delete_trigger: - # Create delete trigger - self.__c.execute( - f""" - CREATE TRIGGER {THREAD_MESSAGE_DELETED_TR_NAME} - AFTER DELETE ON {MESSAGE_TABLE_NAME} - BEGIN - UPDATE {THREAD_TABLE_NAME} SET update_dt = CURRENT_TIMESTAMP WHERE id = OLD.thread_id; - END - """ - ) - - # Commit the transaction - self.__conn.commit() - - def __createMessage(self): - """ - Create message table - """ - try: - # Check if the table exists - self.__c.execute( - f"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='{MESSAGE_TABLE_NAME}'" - ) - if self.__c.fetchone()[0] == 1: - pass - else: - # Create message table and triggers - self.__c.execute( - f"""CREATE TABLE {MESSAGE_TABLE_NAME} - (id INTEGER PRIMARY KEY, - thread_id INTEGER, - role VARCHAR(255), - content TEXT, - finish_reason VARCHAR(255), - model VARCHAR(255), - prompt_tokens INTEGER, - completion_tokens INTEGER, - total_tokens INTEGER, - favorite INTEGER DEFAULT 0, - update_dt DATETIME DEFAULT CURRENT_TIMESTAMP, - insert_dt DATETIME DEFAULT CURRENT_TIMESTAMP, - favorite_set_date DATETIME, - is_json_response_available INT DEFAULT 0, - is_g4f INT DEFAULT 0, - provider VARCHAR(255), - FOREIGN KEY (thread_id) REFERENCES {THREAD_TABLE_NAME}(id) - ON DELETE CASCADE)""" - ) - - self.__createMessageTrigger() - self.__conn.commit() - except sqlite3.Error as e: - print(f"An error occurred while creating the table: {e}") - raise - - def selectCertainThreadMessagesRaw(self, thread_id, content_to_select=None): - """ - This is for selecting all messages in a thread with a specific thread_id. - The format of the result is a list of sqlite Rows. - """ - # Begin the query with the thread_id filter - query = f"SELECT * FROM {MESSAGE_TABLE_NAME} WHERE thread_id = ?" - params = [thread_id] # Start the parameter list with the thread_id - - # If content_to_select is provided, append to the query - if content_to_select: - query += " AND LOWER(content) LIKE LOWER(?)" # Modify for case-insensitive - params.append(f"%{content_to_select}%") # Use parameterized placeholder - - # Execute the query with parameters - self.__c.execute(query, params) - - # Fetch all results and return - return self.__c.fetchall() - - def selectCertainThreadMessages( - self, thread_id, content_to_select=None - ) -> List[ChatMessageContainer]: - """ - This is for selecting all messages in a thread with a specific thread_id. - The format of the result is a list of ChatMessageContainer. - """ - result = [ - ChatMessageContainer(**elem) - for elem in self.selectCertainThreadMessagesRaw( - thread_id, content_to_select=content_to_select - ) - ] - return result - - def selectAllContentOfThread(self, content_to_select=None): - """ - This is for selecting all messages in all threads which include the content_to_select. - """ - arr = [] - for _id in [conv[0] for conv in self.selectAllThread()]: - result = self.selectCertainThreadMessages(_id, content_to_select) - if result: - arr.append((_id, result)) - return arr - - def insertMessage(self, arg: ChatMessageContainer, deactivate_trigger=False): - try: - if deactivate_trigger: - # Remove the trigger - self.__c.execute(f"DROP TRIGGER {THREAD_MESSAGE_INSERTED_TR_NAME}") - excludes = ["id", "update_dt", "insert_dt"] - insert_query = arg.create_insert_query( - table_name=MESSAGE_TABLE_NAME, excludes=excludes - ) - self.__c.execute(insert_query, arg.get_values_for_insert(excludes=excludes)) - new_id = self.__c.lastrowid - if deactivate_trigger: - # Create the trigger - self.__createMessageTrigger( - insert_trigger=True, update_trigger=False, delete_trigger=False - ) - - # Commit the transaction - self.__conn.commit() - return new_id - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def updateMessage(self, id, favorite): - """ - Update message favorite - """ - try: - current_date = datetime.now().strftime(DEFAULT_DATETIME_FORMAT) - self.__c.execute( - f""" - UPDATE {MESSAGE_TABLE_NAME} - SET favorite = ?, - favorite_set_date = CASE - WHEN ? = 1 THEN ? - ELSE NULL - END - WHERE id = ? - """, - (favorite, favorite, current_date, id), - ) - self.__conn.commit() - return current_date - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def __createChatFile(self): - - try: - # Check if the table exists - self.__c.execute( - f"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='{CHAT_FILE_TABLE_NAME}'" - ) - if self.__c.fetchone()[0] == 1: - # Add provider column if not exists - self.__c.execute(f"PRAGMA table_info({CHAT_FILE_TABLE_NAME})") - columns = self.__c.fetchall() - if not any([col[1] == "provider" for col in columns]): - self.__c.execute( - f"ALTER TABLE {CHAT_FILE_TABLE_NAME} ADD COLUMN provider VARCHAR(255)" - ) - else: - self.__c.execute( - f"""CREATE TABLE {CHAT_FILE_TABLE_NAME} - (id INTEGER PRIMARY KEY, - thread_id INTEGER, - message_id INTEGER, - name TEXT, - data BLOB, - update_dt DATETIME DEFAULT CURRENT_TIMESTAMP, - insert_dt DATETIME DEFAULT CURRENT_TIMESTAMP)""" - ) - # Commit the transaction - self.__conn.commit() - except sqlite3.Error as e: - print(f"An error occurred while creating the table: {e}") - raise - - def __createImage(self): - try: - # Check if the table exists - self.__c.execute( - f"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='{IMAGE_TABLE_NAME}'" - ) - if self.__c.fetchone()[0] == 1: - # Add provider column if not exists - self.__c.execute(f"PRAGMA table_info({IMAGE_TABLE_NAME})") - columns = self.__c.fetchall() - if not any([col[1] == "provider" for col in columns]): - self.__c.execute( - f"ALTER TABLE {IMAGE_TABLE_NAME} ADD COLUMN provider VARCHAR(255)" - ) - else: - self.__c.execute( - f"""CREATE TABLE {IMAGE_TABLE_NAME} - (id INTEGER PRIMARY KEY, - model VARCHAR(255), - prompt TEXT, - n INT, - quality VARCHAR(255), - data BLOB, - style VARCHAR(255), - revised_prompt TEXT, - width INT, - height INT, - negative_prompt TEXT, - provider VARCHAR(255), - update_dt DATETIME DEFAULT CURRENT_TIMESTAMP, - insert_dt DATETIME DEFAULT CURRENT_TIMESTAMP)""" - ) - # Commit the transaction - self.__conn.commit() - except sqlite3.Error as e: - print(f"An error occurred while creating the table: {e}") - raise - - def insertImage(self, arg: ImagePromptContainer): - try: - excludes = ["id", "insert_dt", "update_dt"] - query = arg.create_insert_query(IMAGE_TABLE_NAME, excludes) - values = arg.get_values_for_insert(excludes) - self.__c.execute(query, values) - new_id = self.__c.lastrowid - self.__conn.commit() - return new_id - except sqlite3.Error as e: - print(f"An error occurred..") - raise - - def selectImage(self): - try: - self.__c.execute(f"SELECT * FROM {IMAGE_TABLE_NAME}") - return self.__c.fetchall() - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def selectCertainImage(self, id): - try: - self.__c.execute(f"SELECT * FROM {IMAGE_TABLE_NAME} WHERE id={id}") - return self.__c.fetchone() - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def removeImage(self, id=None): - try: - query = f"DELETE FROM {IMAGE_TABLE_NAME}" - if id: - query += f" WHERE id = {id}" - self.__c.execute(query) - self.__conn.commit() - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def selectFavorite(self): - try: - self.__c.execute( - f"SELECT * FROM {MESSAGE_TABLE_NAME} WHERE favorite=1 order by favorite_set_date" - ) - return self.__c.fetchall() - except sqlite3.Error as e: - print(f"An error occurred: {e}") - raise - - def export(self, ids, filename): - # Get the records of the threads of the given ids - thread_records = self.selectAllThread(ids) - data = [dict(record) for record in thread_records] - # Convert it into dictionary - for d in data: - d["messages"] = list( - map(lambda x: x.__dict__, self.selectCertainThreadMessages(d["id"])) - ) - - # Save the JSON - with open(filename, "w") as f: - json.dump(data, f) - - def getCursor(self): - return self.__c - - def close(self): - self.__conn.close() - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - # Close the connection - self.__conn.close() +from __future__ import annotations + +import json +import os +import sqlite3 + +from datetime import datetime +from typing import TYPE_CHECKING + +from pyqt_openai import ( + CHAT_FILE_TABLE_NAME, + DEFAULT_DATETIME_FORMAT, + IMAGE_TABLE_NAME, + MESSAGE_TABLE_NAME, + PROMPT_ENTRY_TABLE_NAME, + PROMPT_GROUP_TABLE_NAME, + THREAD_MESSAGE_DELETED_TR_NAME, + THREAD_MESSAGE_INSERTED_TR_NAME, + THREAD_MESSAGE_UPDATED_TR_NAME, + THREAD_TABLE_NAME, + THREAD_TRIGGER_NAME, + get_config_directory, +) +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.models import ( + ChatMessageContainer, + PromptEntryContainer, + PromptGroupContainer, +) + +if TYPE_CHECKING: + from pyqt_openai.models import ( + ImagePromptContainer, + ) + + +def get_db_filename(): + """Get the database file's name from the settings.""" + db_filename = CONFIG_MANAGER.get_general_property("db") + ".db" + config_dir = get_config_directory() + db_path = os.path.join(config_dir, db_filename) + return db_path + + +class SqliteDatabase: + """Functions which only meant to be used frequently are defined. + If there is no functions you want to use, use ``getCursor`` instead. + """ + + def __init__(self, db_filename=get_db_filename()): + super().__init__() + self.__initVal(db_filename) + self.__initDb() + + def __initVal(self, db_filename): + # DB file name + self.__db_filename = db_filename or get_db_filename() + + def __initDb(self): + try: + # Connect to the database (create a new file if it doesn't exist) + self.__conn = sqlite3.connect(self.__db_filename) + self.__conn.row_factory = sqlite3.Row + self.__conn.execute("PRAGMA foreign_keys = ON;") + self.__conn.commit() + + # create cursor + self.__c = self.__conn.cursor() + + # create conversation tables + self.__createThread() + + # create prompt tables + self.__createPromptGroup() + + # create image tables + self.__createImage() + except sqlite3.Error as e: + print(f"An error occurred while connecting to the database: {e}") + raise + + def __createPromptGroup(self): + try: + self.__c.execute( + f"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='{PROMPT_GROUP_TABLE_NAME}'", + ) + if self.__c.fetchone()[0] == 1: + # TODO WILL_REMOVED_IN_FUTURE AFTER v2.0.0 + self.__createPromptEntry() + else: + self.__c.execute( + f"""CREATE TABLE {PROMPT_GROUP_TABLE_NAME} + (id INTEGER PRIMARY KEY, + name VARCHAR(255) UNIQUE, + prompt_type VARCHAR(255), + update_dt DATETIME DEFAULT CURRENT_TIMESTAMP, + insert_dt DATETIME DEFAULT CURRENT_TIMESTAMP)""", + ) + # Create prompt entry + self.__createPromptEntry() + + # Commit the transaction + self.__conn.commit() + except sqlite3.Error as e: + print(f"An error occurred while creating the table: {e}") + raise + + def insertPromptGroup(self, name, prompt_type): + try: + # Insert a row into the table + self.__c.execute( + f"INSERT INTO {PROMPT_GROUP_TABLE_NAME} (name, prompt_type) VALUES (?, ?)", + (name, prompt_type), + ) + new_id = self.__c.lastrowid + # Commit the transaction + self.__conn.commit() + return new_id + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def selectPromptGroup(self, prompt_type=None): + try: + query = f"SELECT * FROM {PROMPT_GROUP_TABLE_NAME}" + if prompt_type == "form": + query += ' WHERE prompt_type="form"' + elif prompt_type == "sentence": + query += ' WHERE prompt_type="sentence"' + self.__c.execute(query) + return [PromptGroupContainer(**elem) for elem in self.__c.fetchall()] + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def selectCertainPromptGroup(self, id=None, name=None): + """Select specific prompt group by id or name.""" + try: + query = f"SELECT * FROM {PROMPT_GROUP_TABLE_NAME}" + if id or name: + query += " WHERE" + if id: + query += f" id={id}" + if name: + query += " AND" + if name: + query += f' name="{name}"' + result = self.__c.execute(query).fetchone() + if result: + return PromptGroupContainer(**result) + return None + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def updatePromptGroup(self, id, name): + try: + self.__c.execute( + f"UPDATE {PROMPT_GROUP_TABLE_NAME} SET name=? WHERE id={id}", (name,), + ) + self.__conn.commit() + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def deletePromptGroup(self, id=None): + try: + query = f"DELETE FROM {PROMPT_GROUP_TABLE_NAME}" + if id: + query += f" WHERE id = {id}" + self.__c.execute(query) + self.__conn.commit() + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def __createPromptEntry(self): + try: + self.__c.execute( + f"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='{PROMPT_ENTRY_TABLE_NAME}'", + ) + if self.__c.fetchone()[0] == 1: + # TODO WILL_REMOVED_IN_FUTURE AFTER v2.0.0 + # Update name->act and content->prompt if the table exists + self.__c.execute(f"PRAGMA table_info({PROMPT_ENTRY_TABLE_NAME})") + existing_columns = {row[1]: row for row in self.__c.fetchall()} # Map column names to info + + # Check if 'name' or 'content' exists + if "name" in existing_columns or "content" in existing_columns: + try: + self.__c.execute("PRAGMA foreign_keys=OFF") # Disable foreign key constraints temporarily + + # Rename table to a temporary name + temp_table = f"{PROMPT_ENTRY_TABLE_NAME}_backup" + self.__c.execute(f"ALTER TABLE {PROMPT_ENTRY_TABLE_NAME} RENAME TO {temp_table}") + + # Create the updated table structure + self.__c.execute( + f"""CREATE TABLE {PROMPT_ENTRY_TABLE_NAME} ( + id INTEGER PRIMARY KEY, + group_id INTEGER NOT NULL, + act VARCHAR(255) NOT NULL, + prompt TEXT NOT NULL, + insert_dt DATETIME DEFAULT CURRENT_TIMESTAMP, + update_dt DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (group_id) REFERENCES {PROMPT_GROUP_TABLE_NAME}(id) + ON DELETE CASCADE) + """, + ) + + # Copy data from the old table to the new table, renaming columns + self.__c.execute( + f"""INSERT INTO {PROMPT_ENTRY_TABLE_NAME} (id, group_id, act, prompt, insert_dt, update_dt) + SELECT id, group_id, + name AS act, content AS prompt, + insert_dt, update_dt + FROM {temp_table} + """, + ) + + # Drop the temporary table + self.__c.execute(f"DROP TABLE {temp_table}") + self.__c.execute("PRAGMA foreign_keys=ON") # Re-enable foreign key constraints + self.__conn.commit() + except Exception as e: + print("Error during column rename:", e) + self.__conn.rollback() + self.__c.execute("PRAGMA foreign_keys=ON") # Ensure foreign keys are re-enabled + else: + print(f"Table {PROMPT_ENTRY_TABLE_NAME} already updated.") + else: + self.__c.execute( + f"""CREATE TABLE {PROMPT_ENTRY_TABLE_NAME} ( + id INTEGER PRIMARY KEY, + group_id INTEGER NOT NULL, + act VARCHAR(255) NOT NULL, + prompt TEXT NOT NULL, + insert_dt DATETIME DEFAULT CURRENT_TIMESTAMP, + update_dt DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (group_id) REFERENCES {PROMPT_GROUP_TABLE_NAME}(id) + ON DELETE CASCADE) + """, + ) + self.__conn.commit() + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def insertPromptEntry(self, group_id, act, prompt=""): + try: + # Insert a row into the table + self.__c.execute( + f"INSERT INTO {PROMPT_ENTRY_TABLE_NAME} (group_id, act, prompt) VALUES (?, ?, ?)", + (group_id, act, prompt), + ) + new_id = self.__c.lastrowid + # Commit the transaction + self.__conn.commit() + return new_id + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def selectPromptEntry( + self, group_id, id=None, act=None, + ) -> list[PromptEntryContainer]: + try: + query = f"SELECT * FROM {PROMPT_ENTRY_TABLE_NAME} WHERE group_id={group_id}" + if id: + query += f" AND id={id}" + if act: + query += f' AND act="{act}"' + + # Fetch rows only once + rows = self.__c.execute(query).fetchall() + + # Convert to PromptEntryContainer list + return [PromptEntryContainer(**dict(row)) for row in rows] + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def updatePromptEntry(self, id, act, prompt): + try: + self.__c.execute( + f"UPDATE {PROMPT_ENTRY_TABLE_NAME} SET act=?, prompt=? WHERE id={id}", + (act, prompt), + ) + self.__conn.commit() + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def deletePromptEntry(self, group_id, id=None): + try: + query = f"DELETE FROM {PROMPT_ENTRY_TABLE_NAME} WHERE group_id={group_id}" + if id: + query += f" AND id={id}" + self.__c.execute(query) + self.__conn.commit() + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def __createThread(self): + try: + # Create thread table if not exists + thread_tb_exists = ( + self.__c.execute( + f"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='{THREAD_TABLE_NAME}'", + ).fetchone()[0] + == 1 + ) + if thread_tb_exists: + pass + else: + # If user uses app for the first time, create a table + # Create a table with update_dt and insert_dt columns + self.__c.execute( + f"""CREATE TABLE {THREAD_TABLE_NAME} + (id INTEGER PRIMARY KEY, + name TEXT, + update_dt DATETIME, + insert_dt DATETIME DEFAULT CURRENT_TIMESTAMP)""", + ) + + # Create message table + self.__createMessage() + + # Create trigger if not exists + thread_trigger_exists = ( + self.__c.execute( + f"SELECT count(*) FROM sqlite_master WHERE type='trigger' AND name='{THREAD_TRIGGER_NAME}'", + ).fetchone()[0] + == 1 + ) + if thread_trigger_exists: + pass + else: + # Create a trigger to update the update_dt column with the current timestamp + self.__c.execute( + f"""CREATE TRIGGER {THREAD_TRIGGER_NAME} + AFTER UPDATE ON {THREAD_TABLE_NAME} + FOR EACH ROW + BEGIN + UPDATE {THREAD_TABLE_NAME} + SET update_dt=CURRENT_TIMESTAMP + WHERE id=OLD.id; + END;""", + ) + # Commit the transaction + self.__conn.commit() + except sqlite3.Error as e: + print(f"An error occurred while creating the table: {e}") + raise + + def selectAllThread(self, id_arr=None): + """Select all thread + id_arr: list of thread id. + """ + try: + query = f"SELECT * FROM {THREAD_TABLE_NAME}" + if id_arr: + query += f' WHERE id IN ({",".join(map(str, id_arr))})' + self.__c.execute(query) + return self.__c.fetchall() + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def selectThread(self, id): + """Select specific thread.""" + try: + self.__c.execute(f"SELECT * FROM {THREAD_TABLE_NAME} WHERE id={id}") + return self.__c.fetchone() + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def insertThread(self, name, insert_dt=None, update_dt=None): + try: + query = f"INSERT INTO {THREAD_TABLE_NAME} (name) VALUES (?)" + params = (name,) + + if insert_dt and update_dt: + query = f"INSERT INTO {THREAD_TABLE_NAME} (name, insert_dt, update_dt) VALUES (?, ?, ?)" + params = (name, insert_dt, update_dt) + elif insert_dt: + query = ( + f"INSERT INTO {THREAD_TABLE_NAME} (name, insert_dt) VALUES (?, ?)" + ) + params = (name, insert_dt) + + # Insert a row into the table + self.__c.execute(query, params) + new_id = self.__c.lastrowid + # Commit the transaction + self.__conn.commit() + return new_id + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def updateThread(self, id, name): + try: + self.__c.execute( + f"UPDATE {THREAD_TABLE_NAME} SET name=(?) WHERE id={id}", (name,), + ) + self.__conn.commit() + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def deleteThread(self, id=None): + try: + query = f"DELETE FROM {THREAD_TABLE_NAME}" + if id: + query += f" WHERE id = {id}" + self.__c.execute(query) + self.__conn.commit() + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def __createMessageTrigger( + self, insert_trigger=True, update_trigger=True, delete_trigger=True, + ): + """Create message trigger.""" + if insert_trigger: + # Create insert trigger + self.__c.execute( + f""" + CREATE TRIGGER {THREAD_MESSAGE_INSERTED_TR_NAME} + AFTER INSERT ON {MESSAGE_TABLE_NAME} + BEGIN + UPDATE {THREAD_TABLE_NAME} SET update_dt = CURRENT_TIMESTAMP WHERE id = NEW.thread_id; + END + """, + ) + + if update_trigger: + # Create update trigger + self.__c.execute( + f""" + CREATE TRIGGER {THREAD_MESSAGE_UPDATED_TR_NAME} + AFTER UPDATE ON {MESSAGE_TABLE_NAME} + BEGIN + UPDATE {THREAD_TABLE_NAME} SET update_dt = CURRENT_TIMESTAMP WHERE id = NEW.thread_id; + END + """, + ) + + if delete_trigger: + # Create delete trigger + self.__c.execute( + f""" + CREATE TRIGGER {THREAD_MESSAGE_DELETED_TR_NAME} + AFTER DELETE ON {MESSAGE_TABLE_NAME} + BEGIN + UPDATE {THREAD_TABLE_NAME} SET update_dt = CURRENT_TIMESTAMP WHERE id = OLD.thread_id; + END + """, + ) + + # Commit the transaction + self.__conn.commit() + + def __createMessage(self): + """Create message table.""" + try: + # Check if the table exists + self.__c.execute( + f"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='{MESSAGE_TABLE_NAME}'", + ) + if self.__c.fetchone()[0] == 1: + pass + else: + # Create message table and triggers + self.__c.execute( + f"""CREATE TABLE {MESSAGE_TABLE_NAME} + (id INTEGER PRIMARY KEY, + thread_id INTEGER, + role VARCHAR(255), + content TEXT, + finish_reason VARCHAR(255), + model VARCHAR(255), + prompt_tokens INTEGER, + completion_tokens INTEGER, + total_tokens INTEGER, + favorite INTEGER DEFAULT 0, + update_dt DATETIME DEFAULT CURRENT_TIMESTAMP, + insert_dt DATETIME DEFAULT CURRENT_TIMESTAMP, + favorite_set_date DATETIME, + is_json_response_available INT DEFAULT 0, + is_g4f INT DEFAULT 0, + provider VARCHAR(255), + FOREIGN KEY (thread_id) REFERENCES {THREAD_TABLE_NAME}(id) + ON DELETE CASCADE)""", + ) + + self.__createMessageTrigger() + self.__conn.commit() + except sqlite3.Error as e: + print(f"An error occurred while creating the table: {e}") + raise + + def selectCertainThreadMessagesRaw(self, thread_id, content_to_select=None): + """This is for selecting all messages in a thread with a specific thread_id. + The format of the result is a list of sqlite Rows. + """ + # Begin the query with the thread_id filter + query = f"SELECT * FROM {MESSAGE_TABLE_NAME} WHERE thread_id = ?" + params = [thread_id] # Start the parameter list with the thread_id + + # If content_to_select is provided, append to the query + if content_to_select: + query += " AND LOWER(content) LIKE LOWER(?)" # Modify for case-insensitive + params.append(f"%{content_to_select}%") # Use parameterized placeholder + + # Execute the query with parameters + self.__c.execute(query, params) + + # Fetch all results and return + return self.__c.fetchall() + + def selectCertainThreadMessages( + self, thread_id, content_to_select=None, + ) -> list[ChatMessageContainer]: + """This is for selecting all messages in a thread with a specific thread_id. + The format of the result is a list of ChatMessageContainer. + """ + result = [ + ChatMessageContainer(**elem) + for elem in self.selectCertainThreadMessagesRaw( + thread_id, content_to_select=content_to_select, + ) + ] + return result + + def selectAllContentOfThread(self, content_to_select=None): + """This is for selecting all messages in all threads which include the content_to_select.""" + arr = [] + for _id in [conv[0] for conv in self.selectAllThread()]: + result = self.selectCertainThreadMessages(_id, content_to_select) + if result: + arr.append((_id, result)) + return arr + + def insertMessage(self, arg: ChatMessageContainer, deactivate_trigger=False): + try: + if deactivate_trigger: + # Remove the trigger + self.__c.execute(f"DROP TRIGGER {THREAD_MESSAGE_INSERTED_TR_NAME}") + excludes = ["id", "update_dt", "insert_dt"] + insert_query = arg.create_insert_query( + table_name=MESSAGE_TABLE_NAME, excludes=excludes, + ) + self.__c.execute(insert_query, arg.get_values_for_insert(excludes=excludes)) + new_id = self.__c.lastrowid + if deactivate_trigger: + # Create the trigger + self.__createMessageTrigger( + insert_trigger=True, update_trigger=False, delete_trigger=False, + ) + + # Commit the transaction + self.__conn.commit() + return new_id + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def updateMessage(self, id, favorite): + """Update message favorite.""" + try: + current_date = datetime.now().strftime(DEFAULT_DATETIME_FORMAT) + self.__c.execute( + f""" + UPDATE {MESSAGE_TABLE_NAME} + SET favorite = ?, + favorite_set_date = CASE + WHEN ? = 1 THEN ? + ELSE NULL + END + WHERE id = ? + """, + (favorite, favorite, current_date, id), + ) + self.__conn.commit() + return current_date + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def __createChatFile(self): + + try: + # Check if the table exists + self.__c.execute( + f"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='{CHAT_FILE_TABLE_NAME}'", + ) + if self.__c.fetchone()[0] == 1: + # Add provider column if not exists + self.__c.execute(f"PRAGMA table_info({CHAT_FILE_TABLE_NAME})") + columns = self.__c.fetchall() + if not any([col[1] == "provider" for col in columns]): + self.__c.execute( + f"ALTER TABLE {CHAT_FILE_TABLE_NAME} ADD COLUMN provider VARCHAR(255)", + ) + else: + self.__c.execute( + f"""CREATE TABLE {CHAT_FILE_TABLE_NAME} + (id INTEGER PRIMARY KEY, + thread_id INTEGER, + message_id INTEGER, + name TEXT, + data BLOB, + update_dt DATETIME DEFAULT CURRENT_TIMESTAMP, + insert_dt DATETIME DEFAULT CURRENT_TIMESTAMP)""", + ) + # Commit the transaction + self.__conn.commit() + except sqlite3.Error as e: + print(f"An error occurred while creating the table: {e}") + raise + + def __createImage(self): + try: + # Check if the table exists + self.__c.execute( + f"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='{IMAGE_TABLE_NAME}'", + ) + if self.__c.fetchone()[0] == 1: + # Add provider column if not exists + self.__c.execute(f"PRAGMA table_info({IMAGE_TABLE_NAME})") + columns = self.__c.fetchall() + if not any([col[1] == "provider" for col in columns]): + self.__c.execute( + f"ALTER TABLE {IMAGE_TABLE_NAME} ADD COLUMN provider VARCHAR(255)", + ) + else: + self.__c.execute( + f"""CREATE TABLE {IMAGE_TABLE_NAME} + (id INTEGER PRIMARY KEY, + model VARCHAR(255), + prompt TEXT, + n INT, + quality VARCHAR(255), + data BLOB, + style VARCHAR(255), + revised_prompt TEXT, + width INT, + height INT, + negative_prompt TEXT, + provider VARCHAR(255), + update_dt DATETIME DEFAULT CURRENT_TIMESTAMP, + insert_dt DATETIME DEFAULT CURRENT_TIMESTAMP)""", + ) + # Commit the transaction + self.__conn.commit() + except sqlite3.Error as e: + print(f"An error occurred while creating the table: {e}") + raise + + def insertImage(self, arg: ImagePromptContainer): + try: + excludes = ["id", "insert_dt", "update_dt"] + query = arg.create_insert_query(IMAGE_TABLE_NAME, excludes) + values = arg.get_values_for_insert(excludes) + self.__c.execute(query, values) + new_id = self.__c.lastrowid + self.__conn.commit() + return new_id + except sqlite3.Error as e: + print("An error occurred..") + raise + + def selectImage(self): + try: + self.__c.execute(f"SELECT * FROM {IMAGE_TABLE_NAME}") + return self.__c.fetchall() + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def selectCertainImage(self, id): + try: + self.__c.execute(f"SELECT * FROM {IMAGE_TABLE_NAME} WHERE id={id}") + return self.__c.fetchone() + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def removeImage(self, id=None): + try: + query = f"DELETE FROM {IMAGE_TABLE_NAME}" + if id: + query += f" WHERE id = {id}" + self.__c.execute(query) + self.__conn.commit() + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def selectFavorite(self): + try: + self.__c.execute( + f"SELECT * FROM {MESSAGE_TABLE_NAME} WHERE favorite=1 order by favorite_set_date", + ) + return self.__c.fetchall() + except sqlite3.Error as e: + print(f"An error occurred: {e}") + raise + + def export(self, ids, filename): + # Get the records of the threads of the given ids + thread_records = self.selectAllThread(ids) + data = [dict(record) for record in thread_records] + # Convert it into dictionary + for d in data: + d["messages"] = list( + map(lambda x: x.__dict__, self.selectCertainThreadMessages(d["id"])), + ) + + # Save the JSON + with open(filename, "w") as f: + json.dump(data, f) + + def getCursor(self): + return self.__c + + def close(self): + self.__conn.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + # Close the connection + self.__conn.close() diff --git a/pyqt_openai/updateSoftwareDialog.py b/pyqt_openai/updateSoftwareDialog.py index 4297651b..8d02555f 100644 --- a/pyqt_openai/updateSoftwareDialog.py +++ b/pyqt_openai/updateSoftwareDialog.py @@ -1,154 +1,171 @@ -import subprocess -import sys - -import requests -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QLabel, - QTextBrowser, - QDialogButtonBox, - QMessageBox, -) - -from pyqt_openai import ( - __version__, - OWNER, - PACKAGE_NAME, - BIN_DIR, - CURRENT_FILENAME, - UPDATER_PATH, - is_frozen, -) -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.lang.translations import LangClass - - -class UpdateSoftwareDialog(QDialog): - def __init__(self, owner, repo, recent_version, parent=None): - super().__init__(parent) - self.__initVal(owner, repo, recent_version) - self.__initUi() - - def __initVal(self, owner, repo, recent_version): - self.__owner = owner - self.__repo = repo - self.__recent_version = f"v{recent_version}" - - def __initUi(self): - # TODO LANGUAGE - self.setWindowTitle("Update Software") - self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) - self.setModal(True) - - lay = QVBoxLayout() - - self.setLayout(lay) - - self.__lbl = QLabel("A new version of the software is available.") - lay.addWidget(self.__lbl) - - self.releaseNoteBrowser = QTextBrowser() - self.releaseNoteBrowser.setOpenExternalLinks(True) - - self.__updateManualLbl = QLabel() - - lay.addWidget(self.releaseNoteBrowser) - if sys.platform == "win32" and not CONFIG_MANAGER.get_general_property( - "manual_update" - ): - update_url = f"https://github.com/{self.__owner}/{self.__repo}/releases/download/{self.__recent_version}/VividNode.zip" - - buttonBox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - buttonBox.accepted.connect(lambda: run_updater(update_url)) - buttonBox.rejected.connect(self.reject) - - askLbl = QLabel("Do you want to update?") - - lay.addWidget(askLbl) - lay.addWidget(buttonBox) - else: - self.__updateManualLbl.setText( - f'{LangClass.TRANSLATIONS["Update Available"]}' - + f"""
- For manual updates, please click the link for the latest version and install the file appropriate for your operating system.

- Windows - Install via exe or zip
- Linux - Install via tar.gz
- macOS - Install via dmg
-
- If you want to enable automatic updates, please go to the settings and enable the option. - """ - ) - self.__updateManualLbl.setWordWrap(True) - lay.addWidget(self.__updateManualLbl) - - -def check_for_updates(current_version, owner, repo): - try: - url = f"https://api.github.com/repos/{owner}/{repo}/releases" - - response = requests.get(url) - releases = response.json() - - update_available = False - release_notes_html = "

    " - recent_version = current_version - for release in releases: - release_version = release["tag_name"].lstrip("v") - if release_version > current_version: - recent_version = ( - release_version - if recent_version < release_version - else recent_version - ) - update_available = True - release_notes_html += f'
  • {release["tag_name"]}
  • ' - release_notes_html += "
" - - if update_available: - return { - "release_notes": release_notes_html, - "recent_version": recent_version, - } - else: - return None - - except Exception as e: - QMessageBox.critical(None, "Error", f"Error fetching release notes: {str(e)}") - return None - - -def check_for_updates_and_show_dialog(current_version, owner, repo): - result_dict = check_for_updates(current_version, owner, repo) - if result_dict: - release_notes = result_dict["release_notes"] - recent_version = result_dict["recent_version"] - if release_notes: - # If updates are available, show the update dialog - update_dialog = UpdateSoftwareDialog(owner, repo, recent_version) - update_dialog.releaseNoteBrowser.setHtml(release_notes) - update_dialog.exec() - - -def update_software(): - # Replace with actual values - current_version = __version__ - owner = OWNER - repo = PACKAGE_NAME - - if not is_frozen(): - return - - # Check for updates and show dialog if available (Windows only) - if sys.platform == "win32": - check_for_updates_and_show_dialog(current_version, owner, repo) - - -def run_updater(update_url): - if sys.platform == "win32": - subprocess.Popen( - [UPDATER_PATH, update_url, BIN_DIR, CURRENT_FILENAME], shell=True - ) - sys.exit(0) - pass +from __future__ import annotations + +import subprocess +import sys + +from typing import TYPE_CHECKING + +import requests + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QDialog, QDialogButtonBox, QLabel, QMessageBox, QTextBrowser, QVBoxLayout + +from pyqt_openai import BIN_DIR, CURRENT_FILENAME, OWNER, PACKAGE_NAME, UPDATER_PATH, __version__, is_frozen +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.lang.translations import LangClass + +if TYPE_CHECKING: + from qtpy.QtWidgets import QWidget + + +class UpdateSoftwareDialog(QDialog): + def __init__( + self, + owner: str, + repo: str, + recent_version: str, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.__initVal(owner, repo, recent_version) + self.__initUi() + + def __initVal( + self, + owner: str, + repo: str, + recent_version: str, + ) -> None: + self.__owner: str = owner + self.__repo: str = repo + self.__recent_version: str = f"v{recent_version}" + + def __initUi(self): + # TODO LANGUAGE + self.setWindowTitle("Update Software") + self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) + self.setModal(True) + + lay = QVBoxLayout() + + self.setLayout(lay) + + self.__lbl: QLabel = QLabel("A new version of the software is available.") + lay.addWidget(self.__lbl) + + self.releaseNoteBrowser: QTextBrowser = QTextBrowser() + self.releaseNoteBrowser.setOpenExternalLinks(True) + + self.__updateManualLbl: QLabel = QLabel() + + lay.addWidget(self.releaseNoteBrowser) + if sys.platform == "win32" and not CONFIG_MANAGER.get_general_property( + "manual_update", + ): + update_url = f"https://github.com/{self.__owner}/{self.__repo}/releases/download/{self.__recent_version}/VividNode.zip" + + buttonBox: QDialogButtonBox = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, + ) + buttonBox.accepted.connect(lambda: run_updater(update_url)) + buttonBox.rejected.connect(self.reject) + + askLbl: QLabel = QLabel("Do you want to update?") + + lay.addWidget(askLbl) + lay.addWidget(buttonBox) + else: + self.__updateManualLbl.setText( + f'{LangClass.TRANSLATIONS["Update Available"]}' + + """
+ For manual updates, please click the link for the latest version and install the file appropriate for your operating system.

+ Windows - Install via exe or zip
+ Linux - Install via tar.gz
+ macOS - Install via dmg
+
+ If you want to enable automatic updates, please go to the settings and enable the option. + """, + ) + self.__updateManualLbl.setWordWrap(True) + lay.addWidget(self.__updateManualLbl) + + +def check_for_updates( + current_version: str, + owner: str, + repo: str, +) -> dict[str, str] | None: + try: + url: str = f"https://api.github.com/repos/{owner}/{repo}/releases" + + response: requests.Response = requests.get(url) + releases: list[dict[str, str]] = response.json() + + update_available: bool = False + release_notes_html: str = "
    " + recent_version: str = current_version + for release in releases: + release_version: str = release["tag_name"].lstrip("v") + if release_version > current_version: + recent_version: str = ( + max(recent_version, release_version) + ) + update_available = True + release_notes_html += f'
  • {release["tag_name"]}
  • ' + release_notes_html += "
" + + if update_available: + return { + "release_notes": release_notes_html, + "recent_version": recent_version, + } + return None + + except Exception as e: + QMessageBox.critical( + None, # pyright: ignore[reportArgumentType] + "Error", + f"Error fetching release notes: {e!s}", + QMessageBox.StandardButton.Ok, + QMessageBox.StandardButton.Ok, + ) + return None + + +def check_for_updates_and_show_dialog( + current_version: str, + owner: str, + repo: str, +) -> None: + result_dict: dict[str, str] | None = check_for_updates(current_version, owner, repo) + if result_dict: + release_notes: str = result_dict["release_notes"] + recent_version: str = result_dict["recent_version"] + if release_notes: + # If updates are available, show the update dialog + update_dialog = UpdateSoftwareDialog(owner, repo, recent_version) + update_dialog.releaseNoteBrowser.setHtml(release_notes) + update_dialog.exec() + + +def update_software(): + # Replace with actual values + current_version = __version__ + owner = OWNER + repo = PACKAGE_NAME + + if not is_frozen(): + return + + # Check for updates and show dialog if available (Windows only) + if sys.platform == "win32": + check_for_updates_and_show_dialog(current_version, owner, repo) + + +def run_updater(update_url: str) -> None: + if sys.platform == "win32": + subprocess.Popen( + [UPDATER_PATH, update_url, BIN_DIR, CURRENT_FILENAME], + shell=True, + ) + sys.exit(0) diff --git a/pyqt_openai/util/button_style_helper.py b/pyqt_openai/util/button_style_helper.py index aad6f74c..971b74c3 100644 --- a/pyqt_openai/util/button_style_helper.py +++ b/pyqt_openai/util/button_style_helper.py @@ -1,124 +1,128 @@ -from PySide6.QtGui import QColor, QPalette, qGray, QIcon -from PySide6.QtWidgets import ( - QGraphicsColorizeEffect, - QWidget, - QApplication, - QToolButton, - QPushButton, -) - -from pyqt_openai import ( - DEFAULT_BUTTON_HOVER_COLOR, - DEFAULT_BUTTON_PRESSED_COLOR, - DEFAULT_BUTTON_CHECKED_COLOR, -) - - -class ButtonStyleHelper: - def __init__(self, base_widget: QWidget = None): - self.__baseWidget = base_widget - self.__initVal() - - def __initVal(self): - # to set size accordance with scale - sc = QApplication.screens()[0] - sc.logicalDotsPerInchChanged.connect(self.__scaleChanged) - self.__size = sc.logicalDotsPerInch() // 4 - self.__padding = self.__border_radius = self.__size // 10 - self.__background_color = "transparent" - self.__icon = "" - self.__animation = "" - if self.__baseWidget: - self.__initColorByBaseWidget() - else: - self.__hover_color = DEFAULT_BUTTON_HOVER_COLOR - self.__pressed_color = DEFAULT_BUTTON_PRESSED_COLOR - self.__checked_color = DEFAULT_BUTTON_CHECKED_COLOR - - def __initColorByBaseWidget(self): - self.__base_color = self.__baseWidget.palette().color(QPalette.ColorRole.Base) - self.__hover_color = self.__getHoverColor(self.__base_color) - self.__pressed_color = self.__getPressedColor(self.__base_color) - self.__checked_color = self.__getPressedColor(self.__base_color) - - def __getColorByFactor(self, base_color, factor): - r, g, b = base_color.red(), base_color.green(), base_color.blue() - gray = qGray(r, g, b) - if gray > 255 // 2: - color = base_color.darker(factor) - else: - color = base_color.lighter(factor) - return color - - def __getHoverColor(self, base_color): - hover_factor = 120 - hover_color = self.__getColorByFactor(base_color, hover_factor) - return hover_color.name() - - def __getPressedColor(self, base_color): - pressed_factor = 130 - pressed_color = self.__getColorByFactor(base_color, pressed_factor) - return pressed_color.name() - - def __getCheckedColor(self, base_color): - return self.__getPressedColor(self.__base_color) - - def __getButtonTextColor(self, base_color): - r, g, b = ( - base_color.red() ^ 255, - base_color.green() ^ 255, - base_color.blue() ^ 255, - ) - if r == g == b: - text_color = QColor(r, g, b) - else: - if qGray(r, g, b) > 255 // 2: - text_color = QColor(255, 255, 255) - else: - text_color = QColor(0, 0, 0) - return text_color.name() - - def styleInit(self): - self.__btn_style = f""" - QAbstractButton - {{ - border: 0; - width: {self.__size}px; - height: {self.__size}px; - background-color: {self.__background_color}; - border-radius: {self.__border_radius}px; - padding: {self.__padding}px; - }} - QAbstractButton:hover - {{ - background-color: {self.__hover_color}; - }} - QAbstractButton:pressed - {{ - background-color: {self.__pressed_color}; - }} - QAbstractButton:checked - {{ - background-color: {self.__checked_color}; - border: none; - }} - """ - return self.__btn_style - - def setPadding(self, padding: int): - self.__padding = padding - - def setBorderRadius(self, border_radius: int): - self.__border_radius = border_radius - - def setBackground(self, background=None): - if background: - self.__background_color = background - else: - self.__background_color = self.__base_color.name() - - def setAsCircle(self, height): - self.__border_radius = height // 2 - - def __scaleChanged(self, dpi): - self.__size = dpi // 4 +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtGui import QColor, QPalette, qGray +from qtpy.QtWidgets import ( + QApplication, +) + +from pyqt_openai import ( + DEFAULT_BUTTON_CHECKED_COLOR, + DEFAULT_BUTTON_HOVER_COLOR, + DEFAULT_BUTTON_PRESSED_COLOR, +) + +if TYPE_CHECKING: + from qtpy.QtWidgets import ( + QWidget, + ) + + +class ButtonStyleHelper: + def __init__(self, base_widget: QWidget = None): + self.__baseWidget = base_widget + self.__initVal() + + def __initVal(self): + # to set size accordance with scale + sc = QApplication.screens()[0] + sc.logicalDotsPerInchChanged.connect(self.__scaleChanged) + self.__size = sc.logicalDotsPerInch() // 4 + self.__padding = self.__border_radius = self.__size // 10 + self.__background_color = "transparent" + self.__icon = "" + self.__animation = "" + if self.__baseWidget: + self.__initColorByBaseWidget() + else: + self.__hover_color = DEFAULT_BUTTON_HOVER_COLOR + self.__pressed_color = DEFAULT_BUTTON_PRESSED_COLOR + self.__checked_color = DEFAULT_BUTTON_CHECKED_COLOR + + def __initColorByBaseWidget(self): + self.__base_color = self.__baseWidget.palette().color(QPalette.ColorRole.Base) + self.__hover_color = self.__getHoverColor(self.__base_color) + self.__pressed_color = self.__getPressedColor(self.__base_color) + self.__checked_color = self.__getPressedColor(self.__base_color) + + def __getColorByFactor(self, base_color, factor): + r, g, b = base_color.red(), base_color.green(), base_color.blue() + gray = qGray(r, g, b) + if gray > 255 // 2: + color = base_color.darker(factor) + else: + color = base_color.lighter(factor) + return color + + def __getHoverColor(self, base_color): + hover_factor = 120 + hover_color = self.__getColorByFactor(base_color, hover_factor) + return hover_color.name() + + def __getPressedColor(self, base_color): + pressed_factor = 130 + pressed_color = self.__getColorByFactor(base_color, pressed_factor) + return pressed_color.name() + + def __getCheckedColor(self, base_color): + return self.__getPressedColor(self.__base_color) + + def __getButtonTextColor(self, base_color): + r, g, b = ( + base_color.red() ^ 255, + base_color.green() ^ 255, + base_color.blue() ^ 255, + ) + if r == g == b: + text_color = QColor(r, g, b) + elif qGray(r, g, b) > 255 // 2: + text_color = QColor(255, 255, 255) + else: + text_color = QColor(0, 0, 0) + return text_color.name() + + def styleInit(self): + self.__btn_style = f""" + QAbstractButton + {{ + border: 0; + width: {self.__size}px; + height: {self.__size}px; + background-color: {self.__background_color}; + border-radius: {self.__border_radius}px; + padding: {self.__padding}px; + }} + QAbstractButton:hover + {{ + background-color: {self.__hover_color}; + }} + QAbstractButton:pressed + {{ + background-color: {self.__pressed_color}; + }} + QAbstractButton:checked + {{ + background-color: {self.__checked_color}; + border: none; + }} + """ + return self.__btn_style + + def setPadding(self, padding: int): + self.__padding = padding + + def setBorderRadius(self, border_radius: int): + self.__border_radius = border_radius + + def setBackground(self, background=None): + if background: + self.__background_color = background + else: + self.__background_color = self.__base_color.name() + + def setAsCircle(self, height): + self.__border_radius = height // 2 + + def __scaleChanged(self, dpi): + self.__size = dpi // 4 diff --git a/pyqt_openai/util/common.py b/pyqt_openai/util/common.py index fd87d161..f5bbb2bf 100644 --- a/pyqt_openai/util/common.py +++ b/pyqt_openai/util/common.py @@ -1,1304 +1,1289 @@ -""" -This file includes utility functions that are used in various parts of the application. -Mostly, these functions are used to perform chat-related tasks such as sending and receiving messages -or common tasks such as opening a directory, generating random strings, etc. -Some of the functions are used to set PyQt settings, restart the application, show message boxes, etc. -""" - -import asyncio -import base64 -import csv -import json -import os -import random -import re -import string -import subprocess -import sys -import tempfile -import time -import filetype -import traceback -import wave -import zipfile -from datetime import datetime -from io import BytesIO -from pathlib import Path - -import PIL.Image -import numpy as np -import psutil -from g4f import ProviderType -from g4f.providers.base_provider import ProviderModelMixin -from litellm import completion - -from pyqt_openai.widgets.scrollableErrorDialog import ScrollableErrorDialog - -if sys.platform == "win32": - import winreg - -import pyaudio - -from PySide6.QtCore import Qt, QUrl, QThread, Signal -from PySide6.QtGui import QDesktopServices -from PySide6.QtWidgets import QMessageBox, QFrame -from g4f.Provider import ProviderUtils, __providers__, __map__ -from g4f.errors import ProviderNotFoundError -from g4f.models import ModelUtils -from g4f.providers.retry_provider import IterProvider -from jinja2 import Template - -import pyqt_openai.util -from pyqt_openai import ( - MAIN_INDEX, - PROMPT_MAIN_KEY_NAME, - PROMPT_BEGINNING_KEY_NAME, - PROMPT_END_KEY_NAME, - PROMPT_JSON_KEY_NAME, - CONTEXT_DELIMITER, - THREAD_ORDERBY, - DEFAULT_APP_NAME, - AUTOSTART_REGISTRY_KEY, - is_frozen, - G4F_PROVIDER_DEFAULT, - O1_MODELS, - STT_MODEL, - DEFAULT_DATETIME_FORMAT, - DEFAULT_TOKEN_CHUNK_SIZE, DEFAULT_API_CONFIGS, INDENT_SIZE, FAMOUS_LLM_LIST, -) -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.globals import ( - DB, - OPENAI_CLIENT, - G4F_CLIENT, - LLAMAINDEX_WRAPPER, - REPLICATE_CLIENT, -) -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.models import ImagePromptContainer, ChatMessageContainer - - -def get_generic_ext_out_of_qt_ext(text): - pattern = r"\((\*\.(.+))\)" - match = re.search(pattern, text) - extension = "." + match.group(2) if match.group(2) else "" - return extension - - -def open_directory(path): - QDesktopServices.openUrl(QUrl.fromLocalFile(path)) - - -def message_list_to_txt(db, thread_id, title, username="User", ai_name="AI"): - content = "" - certain_thread_filename_content = db.selectCertainThreadMessagesRaw(thread_id) - content += f"== {title} ==" + CONTEXT_DELIMITER - for unit in certain_thread_filename_content: - unit_prefix = username if unit[2] == 1 else ai_name - unit_content = unit[3] - content += f"{unit_prefix}: {unit_content}" + CONTEXT_DELIMITER - return content - - -def is_valid_regex(pattern): - try: - re.compile(pattern) - return True - except re.error: - return False - - -def conv_unit_to_html(db, id, title): - certain_conv_filename_content = db.selectCertainThreadMessagesRaw(id) - chat_history = [unit[3] for unit in certain_conv_filename_content] - template = Template( - """ - - - pyqt-openai html file - {{ title }} - - - -
-

{{ title }}

-
-
- {% for message in chat_history %} -
{{ message }}
- {% endfor %} -
- - - """ - ) - html = template.render(title=title, chat_history=chat_history) - return html - - -def add_file_to_zip(file_content, file_name, output_zip_file): - with zipfile.ZipFile(output_zip_file, "a") as zipf: - zipf.writestr(file_name, file_content) - - -def generate_random_string(length): - letters = string.ascii_letters + string.digits - return "".join(random.choice(letters) for _ in range(length)) - - -def get_image_filename_for_saving(arg: ImagePromptContainer): - ext = ".png" - filename_prompt_prefix = "_".join( - "".join(re.findall("[a-zA-Z0-9\\s]", arg.prompt[:20])).split(" ") - ) - size = f"{arg.width}x{arg.height}" - filename = ( - "_".join(map(str, [filename_prompt_prefix, size])) - + "_" - + generate_random_string(8) - + ext - ) - - return filename - - -def get_image_prompt_filename_for_saving(directory, filename): - txt_filename = os.path.join(directory, Path(filename).stem + ".txt") - return txt_filename - - -def restart_app(): - # Define the arguments to be passed to the executable - args = [sys.executable, MAIN_INDEX] - # Call os.execv() to execute the new process - os.execv(sys.executable, args) - - -def show_message_box_after_change_to_restart(change_list): - title = LangClass.TRANSLATIONS["Application Restart Required"] - text = LangClass.TRANSLATIONS[ - "The program needs to be restarted because of following changes" - ] - text += "\n\n" + "\n".join(change_list) + "\n\n" - text += LangClass.TRANSLATIONS["Would you like to restart it?"] - - msg_box = QMessageBox() - msg_box.setWindowTitle(title) - msg_box.setText(text) - msg_box.setStandardButtons( - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No - ) - msg_box.setDefaultButton(QMessageBox.StandardButton.Yes) - result = msg_box.exec() - return result - - -def get_chatgpt_data_for_preview(filename, most_recent_n: int = None): - data = json.load(open(filename, "r")) - conv_arr = [] - for i in range(len(data)): - conv = data[i] - conv_dict = {} - name = conv["title"] - insert_dt = ( - datetime.fromtimestamp(conv["create_time"]).strftime( - DEFAULT_DATETIME_FORMAT - ) - if conv["create_time"] - else None - ) - update_dt = ( - datetime.fromtimestamp(conv["update_time"]).strftime( - DEFAULT_DATETIME_FORMAT - ) - if conv["update_time"] - else None - ) - conv_dict["id"] = conv["id"] - conv_dict["name"] = name - conv_dict["insert_dt"] = insert_dt - conv_dict["update_dt"] = update_dt - conv_dict["mapping"] = conv["mapping"] - conv_arr.append(conv_dict) - - conv_arr = sorted(conv_arr, key=lambda x: x[THREAD_ORDERBY], reverse=True) - if most_recent_n is not None: - conv_arr = conv_arr[:most_recent_n] - - return {"columns": ["id", "name", "insert_dt", "update_dt"], "data": conv_arr} - - -def get_chatgpt_data_for_import(conv_arr): - for conv in conv_arr: - conv["messages"] = [] - for k, v in conv["mapping"].items(): - obj = {} - message = v["message"] - if message: - metadata = message["metadata"] - - role = message["author"]["role"] - create_time = ( - datetime.fromtimestamp(message["create_time"]).strftime( - DEFAULT_DATETIME_FORMAT - ) - if message["create_time"] - else None - ) - update_time = ( - datetime.fromtimestamp(message["update_time"]).strftime( - DEFAULT_DATETIME_FORMAT - ) - if message["update_time"] - else None - ) - content = message["content"] - - obj["role"] = role - obj["insert_dt"] = create_time - obj["update_dt"] = update_time - - if role == "user": - content_parts = "\n".join([str(c) for c in content["parts"]]) - obj["content"] = content_parts - conv["messages"].append(obj) - else: - if role == "tool": - pass - elif role == "assistant": - model_slug = metadata.get("model_slug", None) - obj["model"] = model_slug - content_type = content["content_type"] - # Text (General chat) - if content_type == "text": - content_parts = "\n".join(content["parts"]) - obj["content"] = content_parts - conv["messages"].append(obj) - elif content_type == "code": - # Currently there is no way to apply every aspect of the "code" content_type into the code. - # So let it be for now. - pass - elif role == "system": - # Won't use the system - pass - # Remove mapping keys - for conv in conv_arr: - del conv["mapping"] - - return conv_arr - - -def is_prompt_group_name_valid(text): - """ - Check if the prompt group name is valid or not and exists in the database - :param text: The text to check - """ - text = text.strip() - if not text: - return False - # Check if the prompt group with same name already exists - if DB.selectCertainPromptGroup(name=text): - return False - return True - - -def is_prompt_entry_name_valid(group_id, text): - """ - Check if the prompt entry name is valid or not and exists in the database - :param group_id: The group id to check - :param text: The text to check - """ - text = text.strip() - # Check if the prompt entry with same name already exists - exists_f = ( - True - if (True if text else False) - and DB.selectPromptEntry(group_id=group_id, act=text) - else False - ) - return exists_f - - -def validate_prompt_group_json(json_data): - # Check if json_data is a list - if not isinstance(json_data, list): - return False - - # Iterate through each item in the list - for item in json_data: - # Check if item is a dictionary - if not isinstance(item, dict): - return False - - # Check if 'name' and 'data' keys exist in the dictionary - if "name" not in item or "data" not in item: - return False - - # Check if 'name' is not empty - if not item["name"]: - return False - - # Check if 'data' is a list - if not isinstance(item["data"], list): - return False - - # Iterate through each data item in 'data' list - for data_item in item["data"]: - # Check if data_item is a dictionary - if not isinstance(data_item, dict): - return False - - # Check if 'act' and 'prompt' keys exist in data_item - if "act" not in data_item or "prompt" not in data_item: - return False - - # Check if 'act' in data_item is not empty - if not data_item["act"]: - return False - - return True - - -def get_prompt_data(prompt_type="form"): - data = [] - for group in DB.selectPromptGroup(prompt_type=prompt_type): - group_obj = {"name": group.name, "data": []} - for entry in DB.selectPromptEntry(group.id): - group_obj["data"].append({"act": entry.act, "prompt": entry.prompt}) - data.append(group_obj) - return data - - -def showJsonSample(json_sample_widget, json_sample): - json_sample_widget.setText(json_sample) - json_sample_widget.setReadOnly(True) - json_sample_widget.setMinimumSize(600, 350) - json_sample_widget.setWindowModality(Qt.WindowModality.ApplicationModal) - json_sample_widget.setWindowTitle(LangClass.TRANSLATIONS["JSON Sample"]) - json_sample_widget.setWindowModality(Qt.WindowModality.ApplicationModal) - json_sample_widget.setWindowFlags( - Qt.WindowType.Window - | Qt.WindowType.WindowCloseButtonHint - | Qt.WindowType.WindowStaysOnTopHint - ) - json_sample_widget.show() - - -def get_content_of_text_file_for_send(filenames: list[str]): - """ - Get the content of the text file for sending to the AI - :param filenames: The list of filenames to get the content from - :return: The content of the text file - """ - source_context = "" - for filename in filenames: - base_filename = os.path.basename(filename) - source_context += f"=== {base_filename} start ===" - source_context += CONTEXT_DELIMITER - with open(filename, "r", encoding="utf-8") as f: - source_context += f.read() - source_context += CONTEXT_DELIMITER - source_context += f"=== {base_filename} end ===" - source_context += CONTEXT_DELIMITER - prompt_context = f"== Source Start ==\n{source_context}== Source End ==" - return prompt_context - - -# FIXME This should be used but this has a couple of flaws that need to be fixed -def moveCursorToOtherPrompt(direction, textGroup): - """ - Move the cursor to another prompt based on the direction - :param direction: The direction to move the cursor to - :param textGroup: The prompt in the group to move the cursor to - """ - - def switch_focus(from_key, to_key): - """Switch focus from one text edit to another if both are visible.""" - if textGroup[from_key].isVisible() and textGroup[from_key].hasFocus(): - if textGroup[to_key].isVisible(): - textGroup[from_key].clearFocus() - textGroup[to_key].setFocus() - - if direction == "up": - switch_focus(PROMPT_MAIN_KEY_NAME, PROMPT_BEGINNING_KEY_NAME) - switch_focus(PROMPT_END_KEY_NAME, PROMPT_JSON_KEY_NAME) - switch_focus(PROMPT_END_KEY_NAME, PROMPT_MAIN_KEY_NAME) - switch_focus(PROMPT_JSON_KEY_NAME, PROMPT_MAIN_KEY_NAME) - elif direction == "down": - switch_focus(PROMPT_BEGINNING_KEY_NAME, PROMPT_MAIN_KEY_NAME) - switch_focus(PROMPT_MAIN_KEY_NAME, PROMPT_JSON_KEY_NAME) - switch_focus(PROMPT_MAIN_KEY_NAME, PROMPT_END_KEY_NAME) - switch_focus(PROMPT_JSON_KEY_NAME, PROMPT_END_KEY_NAME) - else: - print("Invalid direction:", direction) - - -def getSeparator(orientation="horizontal"): - sep = QFrame() - if orientation == "horizontal": - sep.setFrameShape(QFrame.Shape.HLine) - elif orientation == "vertical": - sep.setFrameShape(QFrame.Shape.VLine) - else: - raise ValueError("Invalid orientation") - sep.setFrameShadow(QFrame.Shadow.Sunken) - return sep - - -def handle_exception(exc_type, exc_value, exc_traceback): - """ - Global exception handler. - This should be only used in release mode. - """ - error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback)) - print(f"Unhandled exception: {error_msg}") - - msg_box = ScrollableErrorDialog(error_msg) - msg_box.exec() - - -def set_auto_start_windows(enable: bool): - # If OS is not Windows, return - if sys.platform != "win32": - return - - # If this is not a frozen application, return - if not is_frozen(): - return - - key = winreg.OpenKey( - winreg.HKEY_CURRENT_USER, AUTOSTART_REGISTRY_KEY, 0, winreg.KEY_WRITE - ) - - if enable: - exe_path = sys.executable # Current executable path - winreg.SetValueEx(key, DEFAULT_APP_NAME, 0, winreg.REG_SZ, exe_path) - else: - try: - winreg.DeleteValue(key, DEFAULT_APP_NAME) - except FileNotFoundError: - pass - - -def generate_random_prompt(arr): - if len(arr) > 0: - max_len = max(map(lambda x: len(x), arr)) - weights = [i for i in range(max_len, 0, -1)] - random_prompt = ", ".join( - list( - filter( - lambda x: x != "", - [random.choices(_, weights[: len(_)])[0] for _ in arr], - ) - ) - ) - else: - random_prompt = "" - return random_prompt - - -def get_g4f_models(): - models = list(ModelUtils.convert.keys()) - return models - - -def convert_to_provider(provider: str): - if " " in provider: - provider_list = [ - ProviderUtils.convert[p] - for p in provider.split() - if p in ProviderUtils.convert - ] - if not provider_list: - raise ProviderNotFoundError(f"Providers not found: {provider}") - provider = IterProvider(provider_list) - elif provider in ProviderUtils.convert: - provider = ProviderUtils.convert[provider] - elif provider: - raise ProviderNotFoundError(f"Provider not found: {provider}") - return provider - - -def get_g4f_providers(including_auto=False): - providers = list( - provider.__name__ for provider in __providers__ if provider.working - ) - if including_auto: - providers = [G4F_PROVIDER_DEFAULT] + providers - return providers - - -def get_g4f_models_by_provider(provider): - provider = ProviderUtils.convert[provider] - models = [] - if hasattr(provider, "models"): - models = provider.models if provider.models else [] - return models - - -def get_g4f_providers_by_model(model, including_auto=False): - providers = get_g4f_providers() - supported_providers = [] - - for provider in providers: - provider = ProviderUtils.convert[provider] - - if hasattr(provider, "models"): - models = provider.models if provider.models else models - if model in models: - supported_providers.append(provider) - - supported_providers = [ - provider.get_dict()["name"] for provider in supported_providers - ] - - if including_auto: - supported_providers = [G4F_PROVIDER_DEFAULT] + supported_providers - - return supported_providers - - -def get_chat_model(is_g4f=False): - if is_g4f: - return get_g4f_models() - else: - all_models = [] - for obj in DEFAULT_API_CONFIGS: - all_models.extend(obj.get("model_list", [])) - return all_models - -def set_api_key(env_var_name, api_key): - api_key = api_key.strip() if api_key else "" - if env_var_name == "OPENAI_API_KEY": - OPENAI_CLIENT.api_key = api_key - os.environ["OPENAI_API_KEY"] = api_key - if env_var_name == "GEMINI_API_KEY": - os.environ["GEMINI_API_KEY"] = api_key - if env_var_name == "CLAUDE_API_KEY": - os.environ["ANTHROPIC_API_KEY"] = api_key - if env_var_name == "REPLICATE_API_KEY": - REPLICATE_CLIENT.api_key = api_key - os.environ["REPLICATE_API_KEY"] = api_key - os.environ["REPLICATE_API_TOKEN"] = api_key - - # Set environment variables dynamically - os.environ[env_var_name] = api_key - -def get_mime_type_from_bytes(byte_data): - kind = filetype.guess(byte_data) - if kind is None: - raise ValueError("Could not determine MIME type from bytes") - print(kind.mime) - return kind.mime - -def get_image_url_from_local(image): - """ - Image is bytes, this function converts it to base64 and returns the image url - """ - - # Function to encode the image - def encode_image(image): - return base64.b64encode(image).decode("utf-8") - - base64_image = encode_image(image) - return f"data:{get_mime_type_from_bytes(image)};base64,{base64_image}" - - -def get_message_obj(role, content): - return {"role": role, "content": content} - - -# Check which provider a specific model belongs to -def get_provider_from_model(model): - for obj in DEFAULT_API_CONFIGS: - if model in obj.get("model_list", []): - return obj["display_name"] - return None - - -def get_g4f_image_models() -> list: - """ - Get all the models that support image generation - Some of the image providers are not included in this list - """ - image_models = [] - index = [] - for provider in __providers__: - if hasattr(provider, "image_models"): - if hasattr(provider, "get_models"): - provider.get_models() - parent = provider - if hasattr(provider, "parent"): - parent = __map__[provider.parent] - if parent.__name__ not in index: - if provider.image_models: - for model in provider.image_models: - image_models.append( - { - "provider": parent.__name__, - "url": parent.url, - "label": parent.label if hasattr(parent, "label") else None, - "image_model": model, - } - ) - index.append(parent.__name__) - - models = [model["image_model"] for model in image_models] - # Filter out the models in FAMOUS_LLM_LIST - models = [model for model in models if model not in FAMOUS_LLM_LIST] - return models - - -def get_g4f_image_providers(including_auto=False) -> list: - """ - Get all the providers that support image generation - (Even though this is not a perfect way to get the providers that support image generation) - (So i have to bring get_providers function directly from g4f library) - """ - - def get_providers(): - """ - The function get from g4f/gui/server/api.py - """ - return { - provider.__name__: ( - provider.label if hasattr(provider, "label") else provider.__name__ - ) - + (" (WebDriver)" if "webdriver" in provider.get_parameters() else "") - + (" (Auth)" if provider.needs_auth else "") - for provider in __providers__ - if provider.working - } - - providers = get_providers() - if including_auto: - providers = [G4F_PROVIDER_DEFAULT] + [provider for provider in providers] - return providers - - -def get_g4f_image_models_from_provider(provider) -> list: - """ - Get all the models that support image generation for a specific provider - (Again, this is not a perfect way to get the models that support image generation) - (So i have to bring get_provider_models function directly from g4f library) - """ - if provider == G4F_PROVIDER_DEFAULT: - return get_g4f_image_models() - - def get_provider_models(provider: str) -> list[dict]: - """ - From g4f/gui/server/api.py - """ - if provider in __map__: - provider: ProviderType = __map__[provider] - if issubclass(provider, ProviderModelMixin): - return [ - {"model": model, "default": model == provider.default_model} - for model in provider.get_models() - ] - elif provider.supports_gpt_35_turbo or provider.supports_gpt_4: - return [ - *( - [{"model": "gpt-4", "default": not provider.supports_gpt_4}] - if provider.supports_gpt_4 - else [] - ), - *( - [ - { - "model": "gpt-3.5-turbo", - "default": not provider.supports_gpt_4, - } - ] - if provider.supports_gpt_35_turbo - else [] - ), - ] - else: - return [] - - return [model["model"] for model in get_provider_models(provider)] - - -def get_g4f_argument(model, messages, cur_text, stream, images): - args = {"model": model, "messages": messages, "stream": stream, "images": images} - args["messages"].append({"role": "user", "content": cur_text}) - return args - - -def get_api_argument( - model, - system, - messages, - cur_text, - temperature, - top_p, - frequency_penalty, - presence_penalty, - stream, - use_max_tokens, - max_tokens, - images, - is_llama_available=False, - is_json_response_available=0, - json_content=None, -): - try: - if model in O1_MODELS: - stream = False - else: - system_obj = get_message_obj("system", system) - messages = [system_obj] + messages - - # Form argument - arg = { - "model": model, - "messages": messages, - "temperature": temperature, - "top_p": top_p, - "frequency_penalty": frequency_penalty, - "presence_penalty": presence_penalty, - "stream": stream, - } - if is_json_response_available: - arg["response_format"] = {"type": "json_object"} - cur_text += f" JSON {json_content}" - - # If there is at least one image, it should add - if len(images) > 0: - multiple_images_content = [] - for image in images: - multiple_images_content.append( - { - "type": "image_url", - "image_url": { - "url": get_image_url_from_local(image), - }, - } - ) - - multiple_images_content = [ - {"type": "text", "text": cur_text} - ] + multiple_images_content[:] - arg["messages"].append( - {"role": "user", "content": multiple_images_content} - ) - else: - arg["messages"].append({"role": "user", "content": cur_text}) - - if is_llama_available: - del arg["messages"] - if use_max_tokens: - arg["max_tokens"] = max_tokens - - return arg - except Exception as e: - print(e) - raise e - - -def get_argument( - model, - system, - messages, - cur_text, - temperature, - top_p, - frequency_penalty, - presence_penalty, - stream, - use_max_tokens, - max_tokens, - images, - is_llama_available=False, - is_json_response_available=0, - json_content=None, - is_g4f=False, -): - try: - if is_g4f: - args = get_g4f_argument(model, messages, cur_text, stream, images) - else: - args = get_api_argument( - model, - system, - messages, - cur_text, - temperature, - top_p, - frequency_penalty, - presence_penalty, - stream, - use_max_tokens, - max_tokens, - images, - is_llama_available=is_llama_available, - is_json_response_available=is_json_response_available, - json_content=json_content, - ) - return args - except Exception as e: - print(e) - raise e - - -def stream_response(response, is_g4f=False, get_content_only=True): - if is_g4f: - if get_content_only: - for chunk in response: - yield chunk.choices[0].delta.content - else: - for chunk in response: - yield chunk - else: - for part in response: - yield part.choices[0].delta.content or "" - - -def get_api_response(args, get_content_only=True): - try: - response = completion(drop_params=True, **args) - if args["stream"]: - return stream_response(response) - else: - return response.choices[0].message.content or "" - except Exception as e: - print(e) - raise e - - -def get_g4f_response(args, get_content_only=True): - try: - response = G4F_CLIENT.chat.completions.create(**args) - if args["stream"]: - return stream_response( - response=response, - is_g4f=True, - get_content_only=get_content_only, - ) - else: - if get_content_only: - return response.choices[0].message.content - else: - return response - except Exception as e: - print(e) - raise e - - -def get_response(args, is_g4f=False, get_content_only=True, provider=""): - """ - Get the response from the API - :param args: The arguments to pass to the API - :param is_g4f: Whether the model is G4F or not - :param get_content_only: Whether to get the content only or not - :param provider: The provider of the model (Auto if not provided) - """ - try: - if is_g4f: - if provider != G4F_PROVIDER_DEFAULT: - args["provider"] = convert_to_provider(provider) - return get_g4f_response(args, get_content_only=False) - else: - return get_api_response(args, get_content_only) - except Exception as e: - print(e) - raise e - - -# This has to be here because of the circular import problem -def init_llama(): - llama_index_directory = CONFIG_MANAGER.get_general_property("llama_index_directory") - if llama_index_directory and CONFIG_MANAGER.get_general_property("use_llama_index"): - LLAMAINDEX_WRAPPER.set_directory(llama_index_directory) - - -def kill(proc_pid): - process = psutil.Process(proc_pid) - for proc in process.children(recursive=True): - proc.kill() - process.kill() - - -# TTS -class TTSThread(QThread): - errorGenerated = Signal(str) - - def __init__(self, voice_provider, input_args): - super().__init__() - self.voice_provider = voice_provider - self.input_args = input_args - self.__stop = False - - def run(self): - try: - if self.voice_provider == "OpenAI": - player_stream = pyaudio.PyAudio().open( - format=pyaudio.paInt16, channels=1, rate=24000, output=True - ) - with OPENAI_CLIENT.audio.speech.with_streaming_response.create( - **self.input_args, - response_format="pcm", # similar to WAV, but without a header chunk at the start. - ) as response: - for chunk in response.iter_bytes( - chunk_size=DEFAULT_TOKEN_CHUNK_SIZE - ): - if self.__stop: - break - player_stream.write(chunk) - elif self.voice_provider == "edge-tts": - media = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) - media.close() - mp3_fname = media.name - - subtitle = tempfile.NamedTemporaryFile(suffix=".vtt", delete=False) - subtitle.close() - vtt_fname = subtitle.name - - print(f"Media file: {mp3_fname}") - print(f"Subtitle file: {vtt_fname}\n") - - if sys.platform == "win32": - with subprocess.Popen( - [ - "edge-tts", - f"--write-media={mp3_fname}", - f"--write-subtitles={vtt_fname}", - f"--voice={self.input_args['voice']}", - f"--text={self.input_args['input']}", - ], - creationflags=subprocess.CREATE_NO_WINDOW, - ) as process: - process.communicate() - else: - with subprocess.Popen( - [ - "edge-tts", - f"--write-media={mp3_fname}", - f"--write-subtitles={vtt_fname}", - f"--voice={self.input_args['voice']}", - f"--text={self.input_args['input']}", - ], - ) as process: - process.communicate() - - proc = subprocess.Popen( - [ - "mpv", - f"--sub-file={vtt_fname}", - mp3_fname, - ], - shell=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - while proc.poll() is None: - time.sleep(0.1) - if self.__stop: - kill(proc.pid) - break - if mp3_fname is not None and os.path.exists(mp3_fname): - os.unlink(mp3_fname) - if vtt_fname is not None and os.path.exists(vtt_fname): - os.unlink(vtt_fname) - except Exception as e: - error_text = f'

{e}

' - - # TODO LANGUAGE - if self.voice_provider == "OpenAI": - error_text += "
(Are you registered valid OpenAI API Key? This feature requires OpenAI API Key.)" - - self.errorGenerated.emit(error_text) - - def stop(self): - self.__stop = True - - -# STT -def check_microphone_access(): - try: - audio = pyaudio.PyAudio() - stream = audio.open( - format=pyaudio.paInt16, - channels=1, - rate=44100, - input=True, - frames_per_buffer=DEFAULT_TOKEN_CHUNK_SIZE, - ) - stream.close() - audio.terminate() - return True - except Exception as e: - return False - - -class RecorderThread(QThread): - recording_finished = Signal(str) - errorGenerated = Signal(str) - - # Silence detection parameters - def __init__( - self, is_silence_detection=False, silence_duration=3, silence_threshold=500 - ): - super().__init__() - self.__stop = False - self.__is_silence_detection = is_silence_detection - if self.__is_silence_detection: - self.__silence_duration = ( - silence_duration # Duration to detect silence (in seconds) - ) - self.__silence_threshold = ( - silence_threshold # Amplitude threshold for silence - ) - - def stop(self): - self.__stop = True - - def run(self): - try: - chunk = 1024 # Record in chunks of 1024 samples - sample_format = pyaudio.paInt16 # 16 bits per sample - channels = 2 - fs = 44100 # Record at 44100 samples per second - - p = pyaudio.PyAudio() # Create an interface to PortAudio - - stream = p.open( - format=sample_format, - channels=channels, - rate=fs, - frames_per_buffer=chunk, - input=True, - ) - - frames = [] # Initialize array to store frames - - silence_start_time = None # Track silence start time - - while True: - if self.__stop: - break - - data = stream.read(chunk) - frames.append(data) - - if self.__is_silence_detection: - # Convert the data to a numpy array for amplitude analysis - audio_data = np.frombuffer(data, dtype=np.int16) - amplitude = np.max(np.abs(audio_data)) - - if amplitude < self.__silence_threshold: - # If silent, check if the silence duration threshold is reached - if silence_start_time is None: - silence_start_time = time.time() - elif ( - time.time() - silence_start_time >= self.__silence_duration - ): - break - else: - # Reset silence start time if sound is detected - silence_start_time = None - - # Stop and close the stream - stream.stop_stream() - stream.close() - # Terminate the PortAudio interface - p.terminate() - - # Create a temporary file - with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmpfile: - filename = tmpfile.name - - # Save the recorded data as a WAV file in the temporary file - wf = wave.open(filename, "wb") - wf.setnchannels(channels) - wf.setsampwidth(p.get_sample_size(sample_format)) - wf.setframerate(fs) - wf.writeframes(b"".join(frames)) - wf.close() - - self.recording_finished.emit(filename) - except Exception as e: - if str(e).find("-9996") != -1: - self.errorGenerated.emit( - "No valid input device found. Please connect a microphone or check your audio device settings." - ) - else: - self.errorGenerated.emit(f'

{e}

') - - -class STTThread(QThread): - stt_finished = Signal(str) - errorGenerated = Signal(str) - - def __init__(self, filename): - super().__init__() - self.filename = filename - - def run(self): - try: - transcript = OPENAI_CLIENT.audio.transcriptions.create( - model=STT_MODEL, file=Path(self.filename) - ) - self.stt_finished.emit(transcript.text) - except Exception as e: - # TODO LANGUAGE - self.errorGenerated.emit( - f'

{e}\n\n' - f"(Are you registered valid OpenAI API Key? This feature requires OpenAI API Key.)

" - ) - finally: - os.remove(self.filename) - - -class ChatThread(QThread): - """ - == replyGenerated Signal == - First: response - Second: streaming or not streaming - Third: ChatMessageContainer - """ - - replyGenerated = Signal(str, bool, ChatMessageContainer) - streamFinished = Signal(ChatMessageContainer) - - def __init__( - self, input_args, info: ChatMessageContainer, is_g4f=False, provider="" - ): - super().__init__() - self.__input_args = input_args - self.__stop = False - self.__is_g4f = is_g4f - self.__provider = provider - - self.__info = info - self.__info.role = "assistant" - - def stop(self): - self.__stop = True - - def run(self): - try: - self.__info.is_g4f = self.__is_g4f - # For getting the provider if it is G4F - get_content_only = not self.__info.is_g4f - - if self.__input_args["stream"]: - response = get_response( - self.__input_args, self.__is_g4f, get_content_only, self.__provider - ) - for chunk in response: - # Get provider if it is G4F - # Get the content from choices[0].delta.content if it is G4F, otherwise get it from chunk - # The reason is that G4F has content in choices[0].delta.content, otherwise it has content in chunk. - if self.__is_g4f: - self.__info.provider = chunk.provider - self.__info.model = chunk.model - chunk = chunk.choices[0].delta.content - if self.__stop: - self.__info.finish_reason = "stopped by user" - self.streamFinished.emit(self.__info) - break - else: - self.replyGenerated.emit(chunk, True, self.__info) - else: - response = get_response( - self.__input_args, self.__is_g4f, get_content_only - ) - # Get provider if it is G4F - # Get the content from choices[0].message.content if it is G4F, otherwise get it from response - # The reason is that G4F has content in choices[0].message.content, otherwise it has content in response. - if self.__is_g4f: - self.__info.content = response.choices[0].message.content - self.__info.model = response.model - self.__info.provider = response.provider - else: - self.__info.content = response - self.__info.prompt_tokens = "" - self.__info.completion_tokens = "" - self.__info.total_tokens = "" - - self.__info.finish_reason = "stop" - - if self.__input_args["stream"]: - self.streamFinished.emit(self.__info) - else: - self.replyGenerated.emit(self.__info.content, False, self.__info) - except Exception as e: - self.__info.provider = self.__provider - self.__info.finish_reason = "Error" - self.__info.content = f'

{e}

' - if self.__is_g4f: - # TODO LANGUAGE - self.__info.content += """\n -You can try the following: - -- Change the provider -- Change the model -- Use API instead of G4F -""" - self.replyGenerated.emit(self.__info.content, False, self.__info) - - -# To manage only one TTS stream at a time -current_tts_thread = None - - -def stop_existing_tts_thread(): - if pyqt_openai.util.common.current_tts_thread: - pyqt_openai.util.common.current_tts_thread.stop() - pyqt_openai.util.common.current_tts_thread = None - - -def stream_to_speakers(voice_provider, input_args): - stop_existing_tts_thread() - - stream_thread = TTSThread(voice_provider, input_args) - pyqt_openai.util.common.current_tts_thread = stream_thread - return stream_thread - - -def get_litellm_prefixes(): - return [{'Provider': obj.get('display_name', ''), 'Prefix': obj.get('prefix', '')} for obj in DEFAULT_API_CONFIGS] - - -def export_prompt(data, filename, ext): - # Check if the extension is valid - if ext not in [".json", ".csv"]: - raise ValueError("Unsupported file extension. Only '.json' and '.csv' are allowed.") - - # Handle JSON export - if ext == ".json": - with open(filename, "w", encoding="utf-8") as f: - json.dump(data, f, indent=INDENT_SIZE) - elif ext == ".csv": - # Create a zip file - with zipfile.ZipFile(filename, mode='w', compression=zipfile.ZIP_DEFLATED) as zipf: - for d in data: - # Create individual CSV files for each item in data - csv_filename = d['name'] + ext - with open(csv_filename, mode='w', encoding='utf-8', newline='') as f: - writer = csv.DictWriter(f, fieldnames=['act', 'prompt']) - writer.writeheader() # Write the column headers - writer.writerows(d['data']) # Write the rows - - # Add the CSV file to the zip archive - zipf.write(csv_filename, arcname=csv_filename) - - # Remove the CSV file after adding it to the zip - os.remove(csv_filename) \ No newline at end of file +"""This file includes utility functions that are used in various parts of the application. +Mostly, these functions are used to perform chat-related tasks such as sending and receiving messages +or common tasks such as opening a directory, generating random strings, etc. +Some of the functions are used to set PyQt settings, restart the application, show message boxes, etc. +""" +from __future__ import annotations + +import base64 +import csv +import json +import os +import random +import re +import string +import subprocess +import sys +import tempfile +import time +import traceback +import wave +import zipfile + +from datetime import datetime +from pathlib import Path + +import filetype +import numpy as np +import psutil + +from g4f.providers.base_provider import ProviderModelMixin +from litellm import completion + +from pyqt_openai.widgets.scrollableErrorDialog import ScrollableErrorDialog + +if sys.platform == "win32": + import winreg + +import contextlib + +from typing import TYPE_CHECKING + +import pyaudio + +from g4f.Provider import ProviderUtils, __map__, __providers__ +from g4f.errors import ProviderNotFoundError +from g4f.models import ModelUtils +from g4f.providers.retry_provider import IterProvider +from jinja2 import Template +from qtpy.QtCore import QThread, QUrl, Qt, Signal +from qtpy.QtGui import QDesktopServices +from qtpy.QtWidgets import QFrame, QMessageBox + +import pyqt_openai.util + +from pyqt_openai import ( + AUTOSTART_REGISTRY_KEY, + CONTEXT_DELIMITER, + DEFAULT_API_CONFIGS, + DEFAULT_APP_NAME, + DEFAULT_DATETIME_FORMAT, + DEFAULT_TOKEN_CHUNK_SIZE, + FAMOUS_LLM_LIST, + G4F_PROVIDER_DEFAULT, + INDENT_SIZE, + MAIN_INDEX, + O1_MODELS, + PROMPT_BEGINNING_KEY_NAME, + PROMPT_END_KEY_NAME, + PROMPT_JSON_KEY_NAME, + PROMPT_MAIN_KEY_NAME, + STT_MODEL, + THREAD_ORDERBY, + is_frozen, +) +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.globals import ( + DB, + G4F_CLIENT, + LLAMAINDEX_WRAPPER, + OPENAI_CLIENT, + REPLICATE_CLIENT, +) +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.models import ChatMessageContainer + +if TYPE_CHECKING: + from g4f import ProviderType + + from pyqt_openai.models import ImagePromptContainer + + +def get_generic_ext_out_of_qt_ext(text): + pattern = r"\((\*\.(.+))\)" + match = re.search(pattern, text) + extension = "." + match.group(2) if match.group(2) else "" + return extension + + +def open_directory(path): + QDesktopServices.openUrl(QUrl.fromLocalFile(path)) + + +def message_list_to_txt(db, thread_id, title, username="User", ai_name="AI"): + content = "" + certain_thread_filename_content = db.selectCertainThreadMessagesRaw(thread_id) + content += f"== {title} ==" + CONTEXT_DELIMITER + for unit in certain_thread_filename_content: + unit_prefix = username if unit[2] == 1 else ai_name + unit_content = unit[3] + content += f"{unit_prefix}: {unit_content}" + CONTEXT_DELIMITER + return content + + +def is_valid_regex(pattern): + try: + re.compile(pattern) + return True + except re.error: + return False + + +def conv_unit_to_html(db, id, title): + certain_conv_filename_content = db.selectCertainThreadMessagesRaw(id) + chat_history = [unit[3] for unit in certain_conv_filename_content] + template = Template( + """ + + + pyqt-openai html file - {{ title }} + + + +
+

{{ title }}

+
+
+ {% for message in chat_history %} +
{{ message }}
+ {% endfor %} +
+ + + """, + ) + html = template.render(title=title, chat_history=chat_history) + return html + + +def add_file_to_zip(file_content, file_name, output_zip_file): + with zipfile.ZipFile(output_zip_file, "a") as zipf: + zipf.writestr(file_name, file_content) + + +def generate_random_string(length): + letters = string.ascii_letters + string.digits + return "".join(random.choice(letters) for _ in range(length)) + + +def get_image_filename_for_saving(arg: ImagePromptContainer): + ext = ".png" + filename_prompt_prefix = "_".join( + "".join(re.findall("[a-zA-Z0-9\\s]", arg.prompt[:20])).split(" "), + ) + size = f"{arg.width}x{arg.height}" + filename = ( + "_".join(map(str, [filename_prompt_prefix, size])) + + "_" + + generate_random_string(8) + + ext + ) + + return filename + + +def get_image_prompt_filename_for_saving(directory, filename): + txt_filename = os.path.join(directory, Path(filename).stem + ".txt") + return txt_filename + + +def restart_app(): + # Define the arguments to be passed to the executable + args = [sys.executable, MAIN_INDEX] + # Call os.execv() to execute the new process + os.execv(sys.executable, args) + + +def show_message_box_after_change_to_restart(change_list): + title = LangClass.TRANSLATIONS["Application Restart Required"] + text = LangClass.TRANSLATIONS[ + "The program needs to be restarted because of following changes" + ] + text += "\n\n" + "\n".join(change_list) + "\n\n" + text += LangClass.TRANSLATIONS["Would you like to restart it?"] + + msg_box = QMessageBox() + msg_box.setWindowTitle(title) + msg_box.setText(text) + msg_box.setStandardButtons( + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + msg_box.setDefaultButton(QMessageBox.StandardButton.Yes) + result = msg_box.exec() + return result + + +def get_chatgpt_data_for_preview(filename, most_recent_n: int = None): + data = json.load(open(filename)) + conv_arr = [] + for i in range(len(data)): + conv = data[i] + conv_dict = {} + name = conv["title"] + insert_dt = ( + datetime.fromtimestamp(conv["create_time"]).strftime( + DEFAULT_DATETIME_FORMAT, + ) + if conv["create_time"] + else None + ) + update_dt = ( + datetime.fromtimestamp(conv["update_time"]).strftime( + DEFAULT_DATETIME_FORMAT, + ) + if conv["update_time"] + else None + ) + conv_dict["id"] = conv["id"] + conv_dict["name"] = name + conv_dict["insert_dt"] = insert_dt + conv_dict["update_dt"] = update_dt + conv_dict["mapping"] = conv["mapping"] + conv_arr.append(conv_dict) + + conv_arr = sorted(conv_arr, key=lambda x: x[THREAD_ORDERBY], reverse=True) + if most_recent_n is not None: + conv_arr = conv_arr[:most_recent_n] + + return {"columns": ["id", "name", "insert_dt", "update_dt"], "data": conv_arr} + + +def get_chatgpt_data_for_import(conv_arr): + for conv in conv_arr: + conv["messages"] = [] + for k, v in conv["mapping"].items(): + obj = {} + message = v["message"] + if message: + metadata = message["metadata"] + + role = message["author"]["role"] + create_time = ( + datetime.fromtimestamp(message["create_time"]).strftime( + DEFAULT_DATETIME_FORMAT, + ) + if message["create_time"] + else None + ) + update_time = ( + datetime.fromtimestamp(message["update_time"]).strftime( + DEFAULT_DATETIME_FORMAT, + ) + if message["update_time"] + else None + ) + content = message["content"] + + obj["role"] = role + obj["insert_dt"] = create_time + obj["update_dt"] = update_time + + if role == "user": + content_parts = "\n".join([str(c) for c in content["parts"]]) + obj["content"] = content_parts + conv["messages"].append(obj) + elif role == "tool": + pass + elif role == "assistant": + model_slug = metadata.get("model_slug", None) + obj["model"] = model_slug + content_type = content["content_type"] + # Text (General chat) + if content_type == "text": + content_parts = "\n".join(content["parts"]) + obj["content"] = content_parts + conv["messages"].append(obj) + elif content_type == "code": + # Currently there is no way to apply every aspect of the "code" content_type into the code. + # So let it be for now. + pass + elif role == "system": + # Won't use the system + pass + # Remove mapping keys + for conv in conv_arr: + del conv["mapping"] + + return conv_arr + + +def is_prompt_group_name_valid(text): + """Check if the prompt group name is valid or not and exists in the database + :param text: The text to check. + """ + text = text.strip() + if not text: + return False + # Check if the prompt group with same name already exists + if DB.selectCertainPromptGroup(name=text): + return False + return True + + +def is_prompt_entry_name_valid(group_id, text): + """Check if the prompt entry name is valid or not and exists in the database + :param group_id: The group id to check + :param text: The text to check. + """ + text = text.strip() + # Check if the prompt entry with same name already exists + exists_f = ( + True + if (True if text else False) + and DB.selectPromptEntry(group_id=group_id, act=text) + else False + ) + return exists_f + + +def validate_prompt_group_json(json_data): + # Check if json_data is a list + if not isinstance(json_data, list): + return False + + # Iterate through each item in the list + for item in json_data: + # Check if item is a dictionary + if not isinstance(item, dict): + return False + + # Check if 'name' and 'data' keys exist in the dictionary + if "name" not in item or "data" not in item: + return False + + # Check if 'name' is not empty + if not item["name"]: + return False + + # Check if 'data' is a list + if not isinstance(item["data"], list): + return False + + # Iterate through each data item in 'data' list + for data_item in item["data"]: + # Check if data_item is a dictionary + if not isinstance(data_item, dict): + return False + + # Check if 'act' and 'prompt' keys exist in data_item + if "act" not in data_item or "prompt" not in data_item: + return False + + # Check if 'act' in data_item is not empty + if not data_item["act"]: + return False + + return True + + +def get_prompt_data(prompt_type="form"): + data = [] + for group in DB.selectPromptGroup(prompt_type=prompt_type): + group_obj = {"name": group.name, "data": []} + for entry in DB.selectPromptEntry(group.id): + group_obj["data"].append({"act": entry.act, "prompt": entry.prompt}) + data.append(group_obj) + return data + + +def showJsonSample(json_sample_widget, json_sample): + json_sample_widget.setText(json_sample) + json_sample_widget.setReadOnly(True) + json_sample_widget.setMinimumSize(600, 350) + json_sample_widget.setWindowModality(Qt.WindowModality.ApplicationModal) + json_sample_widget.setWindowTitle(LangClass.TRANSLATIONS["JSON Sample"]) + json_sample_widget.setWindowModality(Qt.WindowModality.ApplicationModal) + json_sample_widget.setWindowFlags( + Qt.WindowType.Window + | Qt.WindowType.WindowCloseButtonHint + | Qt.WindowType.WindowStaysOnTopHint, + ) + json_sample_widget.show() + + +def get_content_of_text_file_for_send(filenames: list[str]): + """Get the content of the text file for sending to the AI + :param filenames: The list of filenames to get the content from + :return: The content of the text file. + """ + source_context = "" + for filename in filenames: + base_filename = os.path.basename(filename) + source_context += f"=== {base_filename} start ===" + source_context += CONTEXT_DELIMITER + with open(filename, encoding="utf-8") as f: + source_context += f.read() + source_context += CONTEXT_DELIMITER + source_context += f"=== {base_filename} end ===" + source_context += CONTEXT_DELIMITER + prompt_context = f"== Source Start ==\n{source_context}== Source End ==" + return prompt_context + + +# FIXME This should be used but this has a couple of flaws that need to be fixed +def moveCursorToOtherPrompt(direction, textGroup): + """Move the cursor to another prompt based on the direction + :param direction: The direction to move the cursor to + :param textGroup: The prompt in the group to move the cursor to. + """ + + def switch_focus(from_key, to_key): + """Switch focus from one text edit to another if both are visible.""" + if textGroup[from_key].isVisible() and textGroup[from_key].hasFocus(): + if textGroup[to_key].isVisible(): + textGroup[from_key].clearFocus() + textGroup[to_key].setFocus() + + if direction == "up": + switch_focus(PROMPT_MAIN_KEY_NAME, PROMPT_BEGINNING_KEY_NAME) + switch_focus(PROMPT_END_KEY_NAME, PROMPT_JSON_KEY_NAME) + switch_focus(PROMPT_END_KEY_NAME, PROMPT_MAIN_KEY_NAME) + switch_focus(PROMPT_JSON_KEY_NAME, PROMPT_MAIN_KEY_NAME) + elif direction == "down": + switch_focus(PROMPT_BEGINNING_KEY_NAME, PROMPT_MAIN_KEY_NAME) + switch_focus(PROMPT_MAIN_KEY_NAME, PROMPT_JSON_KEY_NAME) + switch_focus(PROMPT_MAIN_KEY_NAME, PROMPT_END_KEY_NAME) + switch_focus(PROMPT_JSON_KEY_NAME, PROMPT_END_KEY_NAME) + else: + print("Invalid direction:", direction) + + +def getSeparator(orientation="horizontal"): + sep = QFrame() + if orientation == "horizontal": + sep.setFrameShape(QFrame.Shape.HLine) + elif orientation == "vertical": + sep.setFrameShape(QFrame.Shape.VLine) + else: + raise ValueError("Invalid orientation") + sep.setFrameShadow(QFrame.Shadow.Sunken) + return sep + + +def handle_exception(exc_type, exc_value, exc_traceback): + """Global exception handler. + This should be only used in release mode. + """ + error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback)) + print(f"Unhandled exception: {error_msg}") + + msg_box = ScrollableErrorDialog(error_msg) + msg_box.exec() + + +def set_auto_start_windows(enable: bool): + # If OS is not Windows, return + if sys.platform != "win32": + return + + # If this is not a frozen application, return + if not is_frozen(): + return + + key = winreg.OpenKey( + winreg.HKEY_CURRENT_USER, AUTOSTART_REGISTRY_KEY, 0, winreg.KEY_WRITE, + ) + + if enable: + exe_path = sys.executable # Current executable path + winreg.SetValueEx(key, DEFAULT_APP_NAME, 0, winreg.REG_SZ, exe_path) + else: + with contextlib.suppress(FileNotFoundError): + winreg.DeleteValue(key, DEFAULT_APP_NAME) + + +def generate_random_prompt(arr): + if len(arr) > 0: + max_len = max(map(lambda x: len(x), arr)) + weights = [i for i in range(max_len, 0, -1)] + random_prompt = ", ".join( + list( + filter( + lambda x: x != "", + [random.choices(_, weights[: len(_)])[0] for _ in arr], + ), + ), + ) + else: + random_prompt = "" + return random_prompt + + +def get_g4f_models(): + models = list(ModelUtils.convert.keys()) + return models + + +def convert_to_provider(provider: str): + if " " in provider: + provider_list = [ + ProviderUtils.convert[p] + for p in provider.split() + if p in ProviderUtils.convert + ] + if not provider_list: + raise ProviderNotFoundError(f"Providers not found: {provider}") + provider = IterProvider(provider_list) + elif provider in ProviderUtils.convert: + provider = ProviderUtils.convert[provider] + elif provider: + raise ProviderNotFoundError(f"Provider not found: {provider}") + return provider + + +def get_g4f_providers(including_auto=False): + providers = list( + provider.__name__ for provider in __providers__ if provider.working + ) + if including_auto: + providers = [G4F_PROVIDER_DEFAULT] + providers + return providers + + +def get_g4f_models_by_provider(provider): + provider = ProviderUtils.convert[provider] + models = [] + if hasattr(provider, "models"): + models = provider.models if provider.models else [] + return models + + +def get_g4f_providers_by_model(model, including_auto=False): + providers = get_g4f_providers() + supported_providers = [] + + for provider in providers: + provider = ProviderUtils.convert[provider] + + if hasattr(provider, "models"): + models = provider.models if provider.models else models + if model in models: + supported_providers.append(provider) + + supported_providers = [ + provider.get_dict()["name"] for provider in supported_providers + ] + + if including_auto: + supported_providers = [G4F_PROVIDER_DEFAULT] + supported_providers + + return supported_providers + + +def get_chat_model(is_g4f=False): + if is_g4f: + return get_g4f_models() + all_models = [] + for obj in DEFAULT_API_CONFIGS: + all_models.extend(obj.get("model_list", [])) + return all_models + +def set_api_key(env_var_name, api_key): + api_key = api_key.strip() if api_key else "" + if env_var_name == "OPENAI_API_KEY": + OPENAI_CLIENT.api_key = api_key + os.environ["OPENAI_API_KEY"] = api_key + if env_var_name == "GEMINI_API_KEY": + os.environ["GEMINI_API_KEY"] = api_key + if env_var_name == "CLAUDE_API_KEY": + os.environ["ANTHROPIC_API_KEY"] = api_key + if env_var_name == "REPLICATE_API_KEY": + REPLICATE_CLIENT.api_key = api_key + os.environ["REPLICATE_API_KEY"] = api_key + os.environ["REPLICATE_API_TOKEN"] = api_key + + # Set environment variables dynamically + os.environ[env_var_name] = api_key + +def get_mime_type_from_bytes(byte_data): + kind = filetype.guess(byte_data) + if kind is None: + raise ValueError("Could not determine MIME type from bytes") + print(kind.mime) + return kind.mime + +def get_image_url_from_local(image): + """Image is bytes, this function converts it to base64 and returns the image url.""" + + # Function to encode the image + def encode_image(image): + return base64.b64encode(image).decode("utf-8") + + base64_image = encode_image(image) + return f"data:{get_mime_type_from_bytes(image)};base64,{base64_image}" + + +def get_message_obj(role, content): + return {"role": role, "content": content} + + +# Check which provider a specific model belongs to +def get_provider_from_model(model): + for obj in DEFAULT_API_CONFIGS: + if model in obj.get("model_list", []): + return obj["display_name"] + return None + + +def get_g4f_image_models() -> list: + """Get all the models that support image generation + Some of the image providers are not included in this list. + """ + image_models = [] + index = [] + for provider in __providers__: + if hasattr(provider, "image_models"): + if hasattr(provider, "get_models"): + provider.get_models() + parent = provider + if hasattr(provider, "parent"): + parent = __map__[provider.parent] + if parent.__name__ not in index: + if provider.image_models: + for model in provider.image_models: + image_models.append( + { + "provider": parent.__name__, + "url": parent.url, + "label": parent.label if hasattr(parent, "label") else None, + "image_model": model, + }, + ) + index.append(parent.__name__) + + models = [model["image_model"] for model in image_models] + # Filter out the models in FAMOUS_LLM_LIST + models = [model for model in models if model not in FAMOUS_LLM_LIST] + return models + + +def get_g4f_image_providers(including_auto=False) -> list: + """Get all the providers that support image generation + (Even though this is not a perfect way to get the providers that support image generation) + (So i have to bring get_providers function directly from g4f library). + """ + + def get_providers(): + """The function get from g4f/gui/server/api.py.""" + return { + provider.__name__: ( + provider.label if hasattr(provider, "label") else provider.__name__ + ) + + (" (WebDriver)" if "webdriver" in provider.get_parameters() else "") + + (" (Auth)" if provider.needs_auth else "") + for provider in __providers__ + if provider.working + } + + providers = get_providers() + if including_auto: + providers = [G4F_PROVIDER_DEFAULT] + [provider for provider in providers] + return providers + + +def get_g4f_image_models_from_provider(provider) -> list: + """Get all the models that support image generation for a specific provider + (Again, this is not a perfect way to get the models that support image generation) + (So i have to bring get_provider_models function directly from g4f library). + """ + if provider == G4F_PROVIDER_DEFAULT: + return get_g4f_image_models() + + def get_provider_models(provider: str) -> list[dict]: + """From g4f/gui/server/api.py.""" + if provider in __map__: + provider: ProviderType = __map__[provider] + if issubclass(provider, ProviderModelMixin): + return [ + {"model": model, "default": model == provider.default_model} + for model in provider.get_models() + ] + if provider.supports_gpt_35_turbo or provider.supports_gpt_4: + return [ + *( + [{"model": "gpt-4", "default": not provider.supports_gpt_4}] + if provider.supports_gpt_4 + else [] + ), + *( + [ + { + "model": "gpt-3.5-turbo", + "default": not provider.supports_gpt_4, + }, + ] + if provider.supports_gpt_35_turbo + else [] + ), + ] + return [] + + return [model["model"] for model in get_provider_models(provider)] + + +def get_g4f_argument(model, messages, cur_text, stream, images): + args = {"model": model, "messages": messages, "stream": stream, "images": images} + args["messages"].append({"role": "user", "content": cur_text}) + return args + + +def get_api_argument( + model, + system, + messages, + cur_text, + temperature, + top_p, + frequency_penalty, + presence_penalty, + stream, + use_max_tokens, + max_tokens, + images, + is_llama_available=False, + is_json_response_available=0, + json_content=None, +): + try: + if model in O1_MODELS: + stream = False + else: + system_obj = get_message_obj("system", system) + messages = [system_obj] + messages + + # Form argument + arg = { + "model": model, + "messages": messages, + "temperature": temperature, + "top_p": top_p, + "frequency_penalty": frequency_penalty, + "presence_penalty": presence_penalty, + "stream": stream, + } + if is_json_response_available: + arg["response_format"] = {"type": "json_object"} + cur_text += f" JSON {json_content}" + + # If there is at least one image, it should add + if len(images) > 0: + multiple_images_content = [] + for image in images: + multiple_images_content.append( + { + "type": "image_url", + "image_url": { + "url": get_image_url_from_local(image), + }, + }, + ) + + multiple_images_content = [ + {"type": "text", "text": cur_text}, + ] + multiple_images_content[:] + arg["messages"].append( + {"role": "user", "content": multiple_images_content}, + ) + else: + arg["messages"].append({"role": "user", "content": cur_text}) + + if is_llama_available: + del arg["messages"] + if use_max_tokens: + arg["max_tokens"] = max_tokens + + return arg + except Exception as e: + print(e) + raise e + + +def get_argument( + model, + system, + messages, + cur_text, + temperature, + top_p, + frequency_penalty, + presence_penalty, + stream, + use_max_tokens, + max_tokens, + images, + is_llama_available=False, + is_json_response_available=0, + json_content=None, + is_g4f=False, +): + try: + if is_g4f: + args = get_g4f_argument(model, messages, cur_text, stream, images) + else: + args = get_api_argument( + model, + system, + messages, + cur_text, + temperature, + top_p, + frequency_penalty, + presence_penalty, + stream, + use_max_tokens, + max_tokens, + images, + is_llama_available=is_llama_available, + is_json_response_available=is_json_response_available, + json_content=json_content, + ) + return args + except Exception as e: + print(e) + raise e + + +def stream_response(response, is_g4f=False, get_content_only=True): + if is_g4f: + if get_content_only: + for chunk in response: + yield chunk.choices[0].delta.content + else: + for chunk in response: + yield chunk + else: + for part in response: + yield part.choices[0].delta.content or "" + + +def get_api_response(args, get_content_only=True): + try: + response = completion(drop_params=True, **args) + if args["stream"]: + return stream_response(response) + return response.choices[0].message.content or "" + except Exception as e: + print(e) + raise e + + +def get_g4f_response(args, get_content_only=True): + try: + response = G4F_CLIENT.chat.completions.create(**args) + if args["stream"]: + return stream_response( + response=response, + is_g4f=True, + get_content_only=get_content_only, + ) + if get_content_only: + return response.choices[0].message.content + return response + except Exception as e: + print(e) + raise e + + +def get_response(args, is_g4f=False, get_content_only=True, provider=""): + """Get the response from the API + :param args: The arguments to pass to the API + :param is_g4f: Whether the model is G4F or not + :param get_content_only: Whether to get the content only or not + :param provider: The provider of the model (Auto if not provided). + """ + try: + if is_g4f: + if provider != G4F_PROVIDER_DEFAULT: + args["provider"] = convert_to_provider(provider) + return get_g4f_response(args, get_content_only=False) + return get_api_response(args, get_content_only) + except Exception as e: + print(e) + raise e + + +# This has to be here because of the circular import problem +def init_llama(): + llama_index_directory = CONFIG_MANAGER.get_general_property("llama_index_directory") + if llama_index_directory and CONFIG_MANAGER.get_general_property("use_llama_index"): + LLAMAINDEX_WRAPPER.set_directory(llama_index_directory) + + +def kill(proc_pid): + process = psutil.Process(proc_pid) + for proc in process.children(recursive=True): + proc.kill() + process.kill() + + +# TTS +class TTSThread(QThread): + errorGenerated = Signal(str) + + def __init__(self, voice_provider, input_args): + super().__init__() + self.voice_provider = voice_provider + self.input_args = input_args + self.__stop = False + + def run(self): + try: + if self.voice_provider == "OpenAI": + player_stream = pyaudio.PyAudio().open( + format=pyaudio.paInt16, channels=1, rate=24000, output=True, + ) + with OPENAI_CLIENT.audio.speech.with_streaming_response.create( + **self.input_args, + response_format="pcm", # similar to WAV, but without a header chunk at the start. + ) as response: + for chunk in response.iter_bytes( + chunk_size=DEFAULT_TOKEN_CHUNK_SIZE, + ): + if self.__stop: + break + player_stream.write(chunk) + elif self.voice_provider == "edge-tts": + media = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) + media.close() + mp3_fname = media.name + + subtitle = tempfile.NamedTemporaryFile(suffix=".vtt", delete=False) + subtitle.close() + vtt_fname = subtitle.name + + print(f"Media file: {mp3_fname}") + print(f"Subtitle file: {vtt_fname}\n") + + if sys.platform == "win32": + with subprocess.Popen( + [ + "edge-tts", + f"--write-media={mp3_fname}", + f"--write-subtitles={vtt_fname}", + f"--voice={self.input_args['voice']}", + f"--text={self.input_args['input']}", + ], + creationflags=subprocess.CREATE_NO_WINDOW, + ) as process: + process.communicate() + else: + with subprocess.Popen( + [ + "edge-tts", + f"--write-media={mp3_fname}", + f"--write-subtitles={vtt_fname}", + f"--voice={self.input_args['voice']}", + f"--text={self.input_args['input']}", + ], + ) as process: + process.communicate() + + proc = subprocess.Popen( + [ + "mpv", + f"--sub-file={vtt_fname}", + mp3_fname, + ], + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + while proc.poll() is None: + time.sleep(0.1) + if self.__stop: + kill(proc.pid) + break + if mp3_fname is not None and os.path.exists(mp3_fname): + os.unlink(mp3_fname) + if vtt_fname is not None and os.path.exists(vtt_fname): + os.unlink(vtt_fname) + except Exception as e: + error_text = f'

{e}

' + + # TODO LANGUAGE + if self.voice_provider == "OpenAI": + error_text += "
(Are you registered valid OpenAI API Key? This feature requires OpenAI API Key.)" + + self.errorGenerated.emit(error_text) + + def stop(self): + self.__stop = True + + +# STT +def check_microphone_access(): + try: + audio = pyaudio.PyAudio() + stream = audio.open( + format=pyaudio.paInt16, + channels=1, + rate=44100, + input=True, + frames_per_buffer=DEFAULT_TOKEN_CHUNK_SIZE, + ) + stream.close() + audio.terminate() + return True + except Exception as e: + return False + + +class RecorderThread(QThread): + recording_finished = Signal(str) + errorGenerated = Signal(str) + + # Silence detection parameters + def __init__( + self, is_silence_detection=False, silence_duration=3, silence_threshold=500, + ): + super().__init__() + self.__stop = False + self.__is_silence_detection = is_silence_detection + if self.__is_silence_detection: + self.__silence_duration = ( + silence_duration # Duration to detect silence (in seconds) + ) + self.__silence_threshold = ( + silence_threshold # Amplitude threshold for silence + ) + + def stop(self): + self.__stop = True + + def run(self): + try: + chunk = 1024 # Record in chunks of 1024 samples + sample_format = pyaudio.paInt16 # 16 bits per sample + channels = 2 + fs = 44100 # Record at 44100 samples per second + + p = pyaudio.PyAudio() # Create an interface to PortAudio + + stream = p.open( + format=sample_format, + channels=channels, + rate=fs, + frames_per_buffer=chunk, + input=True, + ) + + frames = [] # Initialize array to store frames + + silence_start_time = None # Track silence start time + + while True: + if self.__stop: + break + + data = stream.read(chunk) + frames.append(data) + + if self.__is_silence_detection: + # Convert the data to a numpy array for amplitude analysis + audio_data = np.frombuffer(data, dtype=np.int16) + amplitude = np.max(np.abs(audio_data)) + + if amplitude < self.__silence_threshold: + # If silent, check if the silence duration threshold is reached + if silence_start_time is None: + silence_start_time = time.time() + elif ( + time.time() - silence_start_time >= self.__silence_duration + ): + break + else: + # Reset silence start time if sound is detected + silence_start_time = None + + # Stop and close the stream + stream.stop_stream() + stream.close() + # Terminate the PortAudio interface + p.terminate() + + # Create a temporary file + with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmpfile: + filename = tmpfile.name + + # Save the recorded data as a WAV file in the temporary file + wf = wave.open(filename, "wb") + wf.setnchannels(channels) + wf.setsampwidth(p.get_sample_size(sample_format)) + wf.setframerate(fs) + wf.writeframes(b"".join(frames)) + wf.close() + + self.recording_finished.emit(filename) + except Exception as e: + if str(e).find("-9996") != -1: + self.errorGenerated.emit( + "No valid input device found. Please connect a microphone or check your audio device settings.", + ) + else: + self.errorGenerated.emit(f'

{e}

') + + +class STTThread(QThread): + stt_finished = Signal(str) + errorGenerated = Signal(str) + + def __init__(self, filename): + super().__init__() + self.filename = filename + + def run(self): + try: + transcript = OPENAI_CLIENT.audio.transcriptions.create( + model=STT_MODEL, file=Path(self.filename), + ) + self.stt_finished.emit(transcript.text) + except Exception as e: + # TODO LANGUAGE + self.errorGenerated.emit( + f'

{e}\n\n' + f"(Are you registered valid OpenAI API Key? This feature requires OpenAI API Key.)

", + ) + finally: + os.remove(self.filename) + + +class ChatThread(QThread): + """== replyGenerated Signal == + First: response + Second: streaming or not streaming + Third: ChatMessageContainer. + """ + + replyGenerated = Signal(str, bool, ChatMessageContainer) + streamFinished = Signal(ChatMessageContainer) + + def __init__( + self, input_args, info: ChatMessageContainer, is_g4f=False, provider="", + ): + super().__init__() + self.__input_args = input_args + self.__stop = False + self.__is_g4f = is_g4f + self.__provider = provider + + self.__info = info + self.__info.role = "assistant" + + def stop(self): + self.__stop = True + + def run(self): + try: + self.__info.is_g4f = self.__is_g4f + # For getting the provider if it is G4F + get_content_only = not self.__info.is_g4f + + if self.__input_args["stream"]: + response = get_response( + self.__input_args, self.__is_g4f, get_content_only, self.__provider, + ) + for chunk in response: + # Get provider if it is G4F + # Get the content from choices[0].delta.content if it is G4F, otherwise get it from chunk + # The reason is that G4F has content in choices[0].delta.content, otherwise it has content in chunk. + if self.__is_g4f: + self.__info.provider = chunk.provider + self.__info.model = chunk.model + chunk = chunk.choices[0].delta.content + if self.__stop: + self.__info.finish_reason = "stopped by user" + self.streamFinished.emit(self.__info) + break + self.replyGenerated.emit(chunk, True, self.__info) + else: + response = get_response( + self.__input_args, self.__is_g4f, get_content_only, + ) + # Get provider if it is G4F + # Get the content from choices[0].message.content if it is G4F, otherwise get it from response + # The reason is that G4F has content in choices[0].message.content, otherwise it has content in response. + if self.__is_g4f: + self.__info.content = response.choices[0].message.content + self.__info.model = response.model + self.__info.provider = response.provider + else: + self.__info.content = response + self.__info.prompt_tokens = "" + self.__info.completion_tokens = "" + self.__info.total_tokens = "" + + self.__info.finish_reason = "stop" + + if self.__input_args["stream"]: + self.streamFinished.emit(self.__info) + else: + self.replyGenerated.emit(self.__info.content, False, self.__info) + except Exception as e: + self.__info.provider = self.__provider + self.__info.finish_reason = "Error" + self.__info.content = f'

{e}

' + if self.__is_g4f: + # TODO LANGUAGE + self.__info.content += """\n +You can try the following: + +- Change the provider +- Change the model +- Use API instead of G4F +""" + self.replyGenerated.emit(self.__info.content, False, self.__info) + + +# To manage only one TTS stream at a time +current_tts_thread = None + + +def stop_existing_tts_thread(): + if pyqt_openai.util.common.current_tts_thread: + pyqt_openai.util.common.current_tts_thread.stop() + pyqt_openai.util.common.current_tts_thread = None + + +def stream_to_speakers(voice_provider, input_args): + stop_existing_tts_thread() + + stream_thread = TTSThread(voice_provider, input_args) + pyqt_openai.util.common.current_tts_thread = stream_thread + return stream_thread + + +def get_litellm_prefixes(): + return [{"Provider": obj.get("display_name", ""), "Prefix": obj.get("prefix", "")} for obj in DEFAULT_API_CONFIGS] + + +def export_prompt(data, filename, ext): + # Check if the extension is valid + if ext not in [".json", ".csv"]: + raise ValueError("Unsupported file extension. Only '.json' and '.csv' are allowed.") + + # Handle JSON export + if ext == ".json": + with open(filename, "w", encoding="utf-8") as f: + json.dump(data, f, indent=INDENT_SIZE) + elif ext == ".csv": + # Create a zip file + with zipfile.ZipFile(filename, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for d in data: + # Create individual CSV files for each item in data + csv_filename = d["name"] + ext + with open(csv_filename, mode="w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=["act", "prompt"]) + writer.writeheader() # Write the column headers + writer.writerows(d["data"]) # Write the rows + + # Add the CSV file to the zip archive + zipf.write(csv_filename, arcname=csv_filename) + + # Remove the CSV file after adding it to the zip + os.remove(csv_filename) diff --git a/pyqt_openai/util/llamaindex.py b/pyqt_openai/util/llamaindex.py index 3b6789f8..71055edb 100644 --- a/pyqt_openai/util/llamaindex.py +++ b/pyqt_openai/util/llamaindex.py @@ -1,65 +1,83 @@ -import os.path - -from llama_index.core import VectorStoreIndex, SimpleDirectoryReader - -from pyqt_openai.config_loader import CONFIG_MANAGER - - -class LlamaIndexWrapper: - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._directory = "" - self._query_engine = None - self._index = None - - def set_directory(self, directory, ext=[]): - try: - if not ext: - ext = CONFIG_MANAGER.get_general_property("llama_index_supported_formats") - self._directory = directory - documents = SimpleDirectoryReader( - input_dir=self._directory, required_exts=ext - ).load_data() - self._index = VectorStoreIndex.from_documents(documents) - except Exception as e: - print(e) - - def is_query_engine_set(self): - return self._query_engine - - def set_query_engine(self, streaming=False, similarity_top_k=3): - if self._index is None: - raise Exception( - "Index must be initialized first. Call set_directory or set_files first." - ) - try: - self._query_engine = self._index.as_query_engine( - streaming=streaming, similarity_top_k=similarity_top_k - ) - except Exception as e: - raise Exception(f"Error in setting query engine: {e}") - - def get_directory(self): - """ - This function returns the directory path. - If directory does not exist, it will return the empty string. - """ - return ( - self._directory - if self._directory and os.path.exists(self._directory) - else "" - ) - - def get_response(self, text): - try: - if self._query_engine: - resp = self._query_engine.query( - text, - ) - return resp - else: - raise Exception( - "Query engine not initialized. Maybe you need to set the directory first. Check the directory path." - ) - except Exception as e: - return str(e) +from __future__ import annotations + +import os.path + +from typing import TYPE_CHECKING + +from llama_index.core import SimpleDirectoryReader, VectorStoreIndex +from llama_index.core.base.base_query_engine import BaseQueryEngine + +from pyqt_openai.config_loader import CONFIG_MANAGER + +if TYPE_CHECKING: + from llama_index.core.base.base_query_engine import BaseQueryEngine + + +class LlamaIndexWrapper: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._directory: str = "" + self._query_engine: BaseQueryEngine | None = None + self._index: VectorStoreIndex | None = None + + def set_directory( + self, + directory: str, + ext: list[str] | None = None, + ) -> None: + try: + if not ext: + default_ext = CONFIG_MANAGER.get_general_property("llama_index_supported_formats") + ext = [default_ext] if default_ext and default_ext.strip() else [] + assert ext, "llama_index_supported_formats is not set" + self._directory = directory + documents = SimpleDirectoryReader( + input_dir=self._directory, + required_exts=ext, + ).load_data() + self._index = VectorStoreIndex.from_documents(documents) + except Exception as e: + print(e) + + def is_query_engine_set(self) -> bool: + return self._query_engine is not None + + def set_query_engine( + self, + streaming: bool = False, + similarity_top_k: int = 3, + ): + if self._index is None: + raise Exception( + "Index must be initialized first. Call set_directory or set_files first.", + ) + try: + self._query_engine = self._index.as_query_engine( + streaming=streaming, + similarity_top_k=similarity_top_k, + ) + except Exception as e: + raise Exception("Error in setting query engine") from e + + def get_directory(self) -> str: + """This function returns the directory path. + If directory does not exist, it will return the empty string. + """ + return ( + self._directory + if self._directory and os.path.exists(self._directory) + else "" + ) + + def get_response(self, text: str) -> str: + try: + if self._query_engine: + resp = self._query_engine.query( + text, + ) + return resp + raise Exception( + "Query engine not initialized. Maybe you need to set the directory first. Check the directory path.", + ) + except Exception as e: + return str(e) diff --git a/pyqt_openai/util/replicate.py b/pyqt_openai/util/replicate.py index 082ea93a..fc2d9518 100644 --- a/pyqt_openai/util/replicate.py +++ b/pyqt_openai/util/replicate.py @@ -1,101 +1,103 @@ -import os -import requests -import base64 -import replicate - -from pyqt_openai.models import ImagePromptContainer - - -def download_image_as_base64(url: str): - response = requests.get(url) - response.raise_for_status() # Check if the URL is correct and raise an exception if there is a problem - image_data = response.content - base64_encoded = base64.b64decode(base64.b64encode(image_data).decode("utf-8")) - return base64_encoded - - -class ReplicateWrapper: - def __init__(self, api_key): - self.__api_key = api_key - - @property - def api_key(self): - return self.__api_key - - @api_key.setter - def api_key(self, value): - self.__api_key = value - os.environ["REPLICATE_API_KEY"] = self.__api_key - - def is_available(self): - return True if self.__api_key is not None else False - - def get_image_response(self, model, input_args): - try: - model = ( - "lucataco/playground-v2.5-1024px-aesthetic:419269784d9e00c56e5b09747cfc059a421e0c044d5472109e129b746508c365" - if model is None - else model - ) - - input_args = ( - { - "width": 768, - "height": 768, - "prompt": "Astronaut in a jungle, cold color palette, muted colors, detailed, 8k", - "negative_prompt": "ugly, deformed, noisy, blurry, distorted", - } - if input_args is None - else input_args - ) - - input_args["num_outputs"] = ( - 1 if "num_outputs" not in input_args else input_args["num_outputs"] - ) - input_args["guidance_scale"] = ( - 3 - if "guidance_scale" not in input_args - else input_args["guidance_scale"] - ) - input_args["apply_watermark"] = ( - True - if "apply_watermark" not in input_args - else input_args["apply_watermark"] - ) - input_args["prompt_strength"] = ( - 0.8 - if "prompt_strength" not in input_args - else input_args["prompt_strength"] - ) - input_args["num_inference_steps"] = ( - 50 - if "num_inference_steps" not in input_args - else input_args["num_inference_steps"] - ) - input_args["refine"] = ( - "expert_ensemble_refiner" - if "refine" not in input_args - else input_args["refine"] - ) - - output = replicate.run(model, input=input_args) - - if output is not None and len(output) > 0: - arg = { - "model": model, - "prompt": input_args["prompt"], - "size": f"{input_args['width']}x{input_args['height']}", - "quality": "high", - "data": download_image_as_base64(output[0]), - "style": "", - "revised_prompt": "", - "width": input_args["width"], - "height": input_args["height"], - "negative_prompt": input_args["negative_prompt"], - } - arg = ImagePromptContainer(**arg) - return arg - else: - raise Exception("No output") - except Exception as e: - raise e +from __future__ import annotations + +import base64 +import os + +import replicate +import requests + +from pyqt_openai.models import ImagePromptContainer + + +def download_image_as_base64(url: str): + response = requests.get(url) + response.raise_for_status() # Check if the URL is correct and raise an exception if there is a problem + image_data = response.content + base64_encoded = base64.b64decode(base64.b64encode(image_data).decode("utf-8")) + return base64_encoded + + +class ReplicateWrapper: + def __init__(self, api_key): + self.__api_key = api_key + + @property + def api_key(self): + return self.__api_key + + @api_key.setter + def api_key(self, value): + self.__api_key = value + os.environ["REPLICATE_API_KEY"] = self.__api_key + + def is_available(self): + return True if self.__api_key is not None else False + + def get_image_response(self, model, input_args): + try: + model = ( + "lucataco/playground-v2.5-1024px-aesthetic:419269784d9e00c56e5b09747cfc059a421e0c044d5472109e129b746508c365" + if model is None + else model + ) + + input_args = ( + { + "width": 768, + "height": 768, + "prompt": "Astronaut in a jungle, cold color palette, muted colors, detailed, 8k", + "negative_prompt": "ugly, deformed, noisy, blurry, distorted", + } + if input_args is None + else input_args + ) + + input_args["num_outputs"] = ( + 1 if "num_outputs" not in input_args else input_args["num_outputs"] + ) + input_args["guidance_scale"] = ( + 3 + if "guidance_scale" not in input_args + else input_args["guidance_scale"] + ) + input_args["apply_watermark"] = ( + True + if "apply_watermark" not in input_args + else input_args["apply_watermark"] + ) + input_args["prompt_strength"] = ( + 0.8 + if "prompt_strength" not in input_args + else input_args["prompt_strength"] + ) + input_args["num_inference_steps"] = ( + 50 + if "num_inference_steps" not in input_args + else input_args["num_inference_steps"] + ) + input_args["refine"] = ( + "expert_ensemble_refiner" + if "refine" not in input_args + else input_args["refine"] + ) + + output = replicate.run(model, input=input_args) + + if output is not None and len(output) > 0: + arg = { + "model": model, + "prompt": input_args["prompt"], + "size": f"{input_args['width']}x{input_args['height']}", + "quality": "high", + "data": download_image_as_base64(output[0]), + "style": "", + "revised_prompt": "", + "width": input_args["width"], + "height": input_args["height"], + "negative_prompt": input_args["negative_prompt"], + } + arg = ImagePromptContainer(**arg) + return arg + raise Exception("No output") + except Exception as e: + raise e diff --git a/pyqt_openai/widgets/APIInputButton.py b/pyqt_openai/widgets/APIInputButton.py index 90f6a478..aca62a73 100644 --- a/pyqt_openai/widgets/APIInputButton.py +++ b/pyqt_openai/widgets/APIInputButton.py @@ -1,67 +1,77 @@ -import colorsys - -from PySide6.QtWidgets import QPushButton - -from pyqt_openai.settings_dialog.settingsDialog import SettingsDialog - - -class APIInputButton(QPushButton): - """ - Stylish button for opening the API settings dialog. - """ - def __init__(self, base_color="#007BFF"): - super().__init__() - self.setObjectName("modernButton") - self.base_color = base_color # Default base color - self.__initUi() - - def __initUi(self): - self.clicked.connect( - lambda _: SettingsDialog(default_index=1, parent=self).exec() - ) - self.updateStylesheet(self.base_color) - - def updateStylesheet(self, base_color): - """Generate dynamic styles based on the base color""" - hover_color = self.adjust_brightness(base_color, 0.8) # Brighten - pressed_color = self.adjust_brightness(base_color, 0.6) # Darken - border_color = pressed_color # Use the same color for border - - # Dynamically generated stylesheet - self.setStyleSheet( - f""" - QPushButton#modernButton {{ - background-color: {base_color}; - color: white; - border-radius: 8px; - padding: 10px 20px; - font-size: 16px; - font-family: "Arial"; - font-weight: bold; - border: 2px solid {base_color}; - }} - QPushButton#modernButton:hover {{ - background-color: {hover_color}; - border-color: {hover_color}; - }} - QPushButton#modernButton:pressed {{ - background-color: {pressed_color}; - border-color: {border_color}; - }} - """ - ) - - def adjust_brightness(self, hex_color, factor): - """Adjust the brightness of a given hex color""" - hex_color = hex_color.lstrip("#") - r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) - - # Convert RGB to HLS - h, l, s = colorsys.rgb_to_hls(r / 255.0, g / 255.0, b / 255.0) - - # Adjust lightness - l = max(0, min(1, l * factor)) # Ensure lightness stays within 0-1 - r, g, b = colorsys.hls_to_rgb(h, l, s) - - # Convert RGB back to hex - return f"#{int(r * 255):02X}{int(g * 255):02X}{int(b * 255):02X}" +from __future__ import annotations + +import colorsys + +from qtpy.QtWidgets import QPushButton + +from pyqt_openai.settings_dialog.settingsDialog import SettingsDialog + + +class APIInputButton(QPushButton): + """Stylish button for opening the API settings dialog.""" + def __init__( + self, + base_color: str = "#007BFF", + ): + super().__init__() + self.setObjectName("modernButton") + self.base_color: str = base_color # Default base color + self.__initUi() + + def __initUi(self): + self.clicked.connect( + lambda _: SettingsDialog(default_index=1, parent=self).exec(), + ) + self.updateStylesheet(self.base_color) + + def updateStylesheet( + self, + base_color: str, + ): + """Generate dynamic styles based on the base color.""" + hover_color: str = self.adjust_brightness(base_color, 0.8) # Brighten + pressed_color: str = self.adjust_brightness(base_color, 0.6) # Darken + border_color: str = pressed_color # Use the same color for border + + # Dynamically generated stylesheet + self.setStyleSheet( + f""" + QPushButton#modernButton {{ + background-color: {base_color}; + color: white; + border-radius: 8px; + padding: 10px 20px; + font-size: 16px; + font-family: "Arial"; + font-weight: bold; + border: 2px solid {base_color}; + }} + QPushButton#modernButton:hover {{ + background-color: {hover_color}; + border-color: {hover_color}; + }} + QPushButton#modernButton:pressed {{ + background-color: {pressed_color}; + border-color: {border_color}; + }} + """, + ) + + def adjust_brightness( + self, + hex_color: str, + factor: float, + ) -> str: + """Adjust the brightness of a given hex color.""" + hex_color = hex_color.lstrip("#") + r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) + + # Convert RGB to HLS + h, l, s = colorsys.rgb_to_hls(r / 255.0, g / 255.0, b / 255.0) + + # Adjust lightness + l = max(0, min(1, l * factor)) # Ensure lightness stays within 0-1 + r, g, b = colorsys.hls_to_rgb(h, l, s) + + # Convert RGB back to hex + return f"#{int(r * 255):02X}{int(g * 255):02X}{int(b * 255):02X}" diff --git a/pyqt_openai/widgets/animationButton.py b/pyqt_openai/widgets/animationButton.py index 5ca36ae8..5b262275 100644 --- a/pyqt_openai/widgets/animationButton.py +++ b/pyqt_openai/widgets/animationButton.py @@ -1,43 +1,55 @@ -from PySide6.QtCore import QPropertyAnimation, QEasingCurve -from PySide6.QtWidgets import QGraphicsOpacityEffect, QPushButton - - -class AnimationButton(QPushButton): - def __init__( - self, text="Other API", duration=1000, start_value=1, end_value=0.5, parent=None - ): - super().__init__(text, parent) - - # Apply an opacity effect to the button - self.opacity_effect = QGraphicsOpacityEffect() - self.setGraphicsEffect(self.opacity_effect) - - # Create the animation for the opacity effect - self.opacity_animation = QPropertyAnimation(self.opacity_effect, b"opacity") - self.opacity_animation.setDuration( - duration - ) # Duration of one animation cycle (in milliseconds) - self.opacity_animation.setStartValue(start_value) # Start with full opacity - self.opacity_animation.setEndValue(end_value) # End with lower opacity - self.opacity_animation.setEasingCurve( - QEasingCurve.Type.InOutQuad - ) # Smooth transition - - # Set the animation to alternate between fading in and out - self.opacity_animation.setDirection( - QPropertyAnimation.Direction.Forward - ) # Start direction - - # Connect the animation's finished signal to reverse direction - self.opacity_animation.finished.connect(self.reverse_animation_direction) - - # Start the animation - self.opacity_animation.start() - - def reverse_animation_direction(self): - # Reverse the direction of the animation each time it finishes - if self.opacity_animation.direction() == QPropertyAnimation.Direction.Forward: - self.opacity_animation.setDirection(QPropertyAnimation.Direction.Backward) - else: - self.opacity_animation.setDirection(QPropertyAnimation.Direction.Forward) - self.opacity_animation.start() +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtCore import QEasingCurve, QPropertyAnimation +from qtpy.QtWidgets import QGraphicsOpacityEffect, QPushButton + +if TYPE_CHECKING: + from qtpy.QtWidgets import QWidget + + +class AnimationButton(QPushButton): + def __init__( + self, + text: str = "Other API", + duration: int = 1000, + start_value: float = 1, + end_value: float = 0.5, + parent: QWidget | None = None, + ): + super().__init__(text, parent) + + # Apply an opacity effect to the button + self.opacity_effect: QGraphicsOpacityEffect = QGraphicsOpacityEffect() + self.setGraphicsEffect(self.opacity_effect) + + # Create the animation for the opacity effect + self.opacity_animation: QPropertyAnimation = QPropertyAnimation(self.opacity_effect, b"opacity") + self.opacity_animation.setDuration( + duration, + ) # Duration of one animation cycle (in milliseconds) + self.opacity_animation.setStartValue(start_value) # Start with full opacity + self.opacity_animation.setEndValue(end_value) # End with lower opacity + self.opacity_animation.setEasingCurve( + QEasingCurve.Type.InOutQuad, + ) # Smooth transition + + # Set the animation to alternate between fading in and out + self.opacity_animation.setDirection( + QPropertyAnimation.Direction.Forward, + ) # Start direction + + # Connect the animation's finished signal to reverse direction + self.opacity_animation.finished.connect(self.reverse_animation_direction) + + # Start the animation + self.opacity_animation.start() + + def reverse_animation_direction(self): + # Reverse the direction of the animation each time it finishes + if self.opacity_animation.direction() == QPropertyAnimation.Direction.Forward: + self.opacity_animation.setDirection(QPropertyAnimation.Direction.Backward) + else: + self.opacity_animation.setDirection(QPropertyAnimation.Direction.Forward) + self.opacity_animation.start() diff --git a/pyqt_openai/widgets/baseNavWidget.py b/pyqt_openai/widgets/baseNavWidget.py index 4b9cd26d..18b32ed7 100644 --- a/pyqt_openai/widgets/baseNavWidget.py +++ b/pyqt_openai/widgets/baseNavWidget.py @@ -1,191 +1,235 @@ -from PySide6.QtCore import Signal, QSortFilterProxyModel, Qt -from PySide6.QtSql import QSqlTableModel, QSqlQuery -from PySide6.QtWidgets import ( - QWidget, - QMessageBox, - QStyledItemDelegate, - QTableView, - QAbstractItemView, - QLabel, -) - -from pyqt_openai import ICON_DELETE, ICON_CLOSE -from pyqt_openai.globals import DB -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.widgets.button import Button -from pyqt_openai.widgets.searchBar import SearchBar - - -class FilterProxyModel(QSortFilterProxyModel): - def __init__(self): - super().__init__() - self.__searchedText = "" - - @property - def searchedText(self): - return self.__searchedText - - @searchedText.setter - def searchedText(self, value): - self.__searchedText = value - self.invalidateFilter() - - -# for align text in every cell to center -class AlignDelegate(QStyledItemDelegate): - def initStyleOption(self, option, index): - super().initStyleOption(option, index) - option.displayAlignment = Qt.AlignmentFlag.AlignCenter - - -class SqlTableModel(QSqlTableModel): - added = Signal(int, str) - updated = Signal(int, str) - deleted = Signal(list) - addedCol = Signal() - deletedCol = Signal() - - def __init__(self, table_type="chat", parent=None): - super().__init__(parent) - self.__table_type = table_type - self.__parent = parent - - def flags(self, index): - if self.__table_type == "chat": - if index.column() == self.column_index_by_name("name"): - return ( - Qt.ItemFlag.ItemIsEnabled - | Qt.ItemFlag.ItemIsSelectable - | Qt.ItemFlag.ItemIsEditable - ) - return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable - elif self.__table_type == "image": - if index.column() == 0: - return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable - return super().flags(index) - - def column_index_by_name(self, name): - return self.fieldIndex(name) - - -class BaseNavWidget(QWidget): - def __init__(self, columns, table_nm, parent=None): - super().__init__(parent) - self.__initVal(columns, table_nm) - self.__initUi() - - def __initVal(self, columns, table_nm): - self._columns = columns - self._table_nm = table_nm - - def __initUi(self): - imageGenerationHistoryLbl = QLabel() - imageGenerationHistoryLbl.setText(LangClass.TRANSLATIONS["History"]) - - self._searchBar = SearchBar() - self._searchBar.setPlaceHolder(LangClass.TRANSLATIONS["Search..."]) - self._searchBar.searched.connect(self._search) - - self._delBtn = Button() - self._delBtn.setStyleAndIcon(ICON_DELETE) - self._delBtn.clicked.connect(self._delete) - self._delBtn.setToolTip(LangClass.TRANSLATIONS["Delete Certain Row"]) - - self._clearBtn = Button() - self._clearBtn.setStyleAndIcon(ICON_CLOSE) - self._clearBtn.clicked.connect(self._clear) - self._clearBtn.setToolTip(LangClass.TRANSLATIONS["Remove All"]) - - def setModel(self, table_type="chat"): - self._model = SqlTableModel(table_type, self) - self._model.setTable(self._table_nm) - self._model.beforeUpdate.connect(self._updated) - - # Set the query to fetch columns in the defined order - # Remove DATA for GUI performance - if table_type == "image": - if self._columns.__contains__("data"): - self._columns.remove("data") - self._model.setQuery( - QSqlQuery(f"SELECT {','.join(self._columns)} FROM {self._table_nm}") - ) - - for i in range(len(self._columns)): - self._model.setHeaderData(i, Qt.Orientation.Horizontal, self._columns[i]) - self._model.select() - # descending order by insert date - idx = self._columns.index("insert_dt") - self._model.sort(idx, Qt.SortOrder.DescendingOrder) - - # init the proxy model - self._proxyModel = FilterProxyModel() - - # set the table model as source model to make it enable to feature sort and filter function - self._proxyModel.setSourceModel(self._model) - - # set up the view - self._tableView = QTableView() - self._tableView.setModel(self._proxyModel) - self._tableView.setEditTriggers( - QTableView.EditTrigger.DoubleClicked - | QTableView.EditTrigger.SelectedClicked - ) - self._tableView.setSortingEnabled(True) - - # align to center - delegate = AlignDelegate() - for i in range(self._model.columnCount()): - self._tableView.setItemDelegateForColumn(i, delegate) - - # set selection/resize policy - self._tableView.setSelectionBehavior( - QAbstractItemView.SelectionBehavior.SelectRows - ) - self._tableView.resizeColumnsToContents() - self._tableView.setSelectionMode( - QAbstractItemView.SelectionMode.ExtendedSelection - ) - - # self.__tableView.activated.connect(self.__clicked) - # self.__tableView.clicked.connect(self.__clicked) - - def _updated(self, i, r): - # Send updated signal - self._model.updated.emit(r.value("id"), r.value("name")) - - def _delete(self): - pass - - def _search(self, text): - pass - - def _clear(self, table_type="chat"): - """ - Clear all data in the table - """ - # Before clearing, confirm the action - reply = QMessageBox.question( - self, - LangClass.TRANSLATIONS["Confirm"], - LangClass.TRANSLATIONS["Are you sure to clear all data?"], - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - ) - if reply == QMessageBox.StandardButton.Yes: - if table_type == "chat": - DB.deleteThread() - elif table_type == "image": - DB.removeImage() - self._model.select() - - def setColumns(self, columns, table_type="chat"): - self._columns = columns - self._model.clear() - self._model.setTable(self._table_nm) - if table_type == "image": - # Remove DATA for GUI performance - if self._columns.__contains__("data"): - self._columns.remove("data") - self._model.setQuery( - QSqlQuery(f"SELECT {','.join(self._columns)} FROM {self._table_nm}") - ) - self._model.select() +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtCore import QSortFilterProxyModel, Qt, Signal +from qtpy.QtSql import QSqlQuery, QSqlTableModel +from qtpy.QtWidgets import QAbstractItemView, QLabel, QMessageBox, QStyledItemDelegate, QTableView, QWidget + +from pyqt_openai import ICON_CLOSE, ICON_DELETE +from pyqt_openai.globals import DB +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.widgets.button import Button +from pyqt_openai.widgets.searchBar import SearchBar + +if TYPE_CHECKING: + from qtpy.QtCore import QModelIndex + from qtpy.QtSql import QSqlRecord + from qtpy.QtWidgets import QStyleOptionViewItem + + +class FilterProxyModel(QSortFilterProxyModel): + def __init__(self): + super().__init__() + self.__searchedText: str = "" + + @property + def searchedText(self) -> str: + return self.__searchedText + + @searchedText.setter + def searchedText( + self, + value: str, + ): + self.__searchedText = value + self.invalidateFilter() + + +# for align text in every cell to center +class AlignDelegate(QStyledItemDelegate): + def initStyleOption( + self, + option: QStyleOptionViewItem, + index: QModelIndex, + ): + super().initStyleOption(option, index) + option.displayAlignment = Qt.AlignmentFlag.AlignCenter + + +class SqlTableModel(QSqlTableModel): + added = Signal(int, str) + updated = Signal(int, str) + deleted = Signal(list) + addedCol = Signal() + deletedCol = Signal() + + def __init__( + self, + table_type: str = "chat", + parent: QWidget | None = None, + ): + super().__init__(parent) + self.__table_type: str = table_type + self.__parent: QWidget | None = parent + + def flags( + self, + index: QModelIndex, + ) -> Qt.ItemFlag: + if self.__table_type == "chat": + if index.column() == self.column_index_by_name("name"): + return ( + Qt.ItemFlag.ItemIsEnabled + | Qt.ItemFlag.ItemIsSelectable + | Qt.ItemFlag.ItemIsEditable + ) + return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable + if self.__table_type == "image": + if index.column() == 0: + return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable + return super().flags(index) + + def column_index_by_name( + self, + name: str, + ) -> int: + return self.fieldIndex(name) + + +class BaseNavWidget(QWidget): + def __init__( + self, + columns: list[str], + table_nm: str, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.__initVal(columns, table_nm) + self.__initUi() + + def __initVal( + self, + columns: list[str], + table_nm: str, + ): + self._columns: list[str] = columns + self._table_nm: str = table_nm + + def __initUi(self): + imageGenerationHistoryLbl = QLabel() + imageGenerationHistoryLbl.setText(LangClass.TRANSLATIONS["History"]) + + self._searchBar: SearchBar = SearchBar() + self._searchBar.setPlaceHolder(LangClass.TRANSLATIONS["Search..."]) + self._searchBar.searched.connect(self._search) + + self._delBtn: Button = Button() + self._delBtn.setStyleAndIcon(ICON_DELETE) + self._delBtn.clicked.connect(self._delete) + self._delBtn.setToolTip(LangClass.TRANSLATIONS["Delete Certain Row"]) + + self._clearBtn: Button = Button() + self._clearBtn.setStyleAndIcon(ICON_CLOSE) + self._clearBtn.clicked.connect(self._clear) + self._clearBtn.setToolTip(LangClass.TRANSLATIONS["Remove All"]) + + def setModel( + self, + table_type: str = "chat", + ): + self._model: SqlTableModel = SqlTableModel(table_type, self) + self._model.setTable(self._table_nm) + self._model.beforeUpdate.connect(self._updated) + + # Set the query to fetch columns in the defined order + # Remove DATA for GUI performance + if table_type == "image": + if self._columns.__contains__("data"): + self._columns.remove("data") + self._model.setQuery( + QSqlQuery(f"SELECT {','.join(self._columns)} FROM {self._table_nm}"), + ) + + for i in range(len(self._columns)): + self._model.setHeaderData(i, Qt.Orientation.Horizontal, self._columns[i]) + self._model.select() + # descending order by insert date + idx = self._columns.index("insert_dt") + self._model.sort(idx, Qt.SortOrder.DescendingOrder) + + # init the proxy model + self._proxyModel: FilterProxyModel = FilterProxyModel() + + # set the table model as source model to make it enable to feature sort and filter function + self._proxyModel.setSourceModel(self._model) + + # set up the view + self._tableView: QTableView = QTableView() + self._tableView.setModel(self._proxyModel) + self._tableView.setEditTriggers( + QTableView.EditTrigger.DoubleClicked + | QTableView.EditTrigger.SelectedClicked, + ) + self._tableView.setSortingEnabled(True) + + # align to center + delegate = AlignDelegate() + for i in range(self._model.columnCount()): + self._tableView.setItemDelegateForColumn(i, delegate) + + # set selection/resize policy + self._tableView.setSelectionBehavior( + QAbstractItemView.SelectionBehavior.SelectRows, + ) + self._tableView.resizeColumnsToContents() + self._tableView.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection, + ) + + # self.__tableView.activated.connect(self.__clicked) + # self.__tableView.clicked.connect(self.__clicked) + + def _updated( + self, + i: int, + r: QSqlRecord, + ): + # Send updated signal + self._model.updated.emit(r.value("id"), r.value("name")) + + def _delete(self): + pass + + def _search( + self, + text: str, + ): + pass + + def _clear( + self, + table_type: str = "chat", + ): + """Clear all data in the table.""" + # Before clearing, confirm the action + reply = QMessageBox.question( + None, # pyright: ignore[reportArgumentType] + LangClass.TRANSLATIONS["Confirm"], + LangClass.TRANSLATIONS["Are you sure to clear all data?"], + QMessageBox.StandardButton.Yes, + QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + if table_type == "chat": + DB.deleteThread() + elif table_type == "image": + DB.removeImage() + self._model.select() + + def setColumns( + self, + columns: list[str], + table_type: str = "chat", + ): + self._columns = columns + self._model.clear() + self._model.setTable(self._table_nm) + if table_type == "image": + # Remove DATA for GUI performance + if self._columns.__contains__("data"): + self._columns.remove("data") + self._model.setQuery( + QSqlQuery(f"SELECT {','.join(self._columns)} FROM {self._table_nm}"), + ) + self._model.select() diff --git a/pyqt_openai/widgets/button.py b/pyqt_openai/widgets/button.py index 8142d972..5ed8ce5a 100644 --- a/pyqt_openai/widgets/button.py +++ b/pyqt_openai/widgets/button.py @@ -1,30 +1,49 @@ -from PySide6.QtGui import QColor, QIcon -from PySide6.QtWidgets import QGraphicsColorizeEffect, QWidget, QPushButton - -from pyqt_openai.util.button_style_helper import ButtonStyleHelper - - -class Button(QPushButton): - def __init__(self, base_widget: QWidget = None, parent=None): - super().__init__(parent) - self.style_helper = ButtonStyleHelper(base_widget) - self.setStyleSheet(self.style_helper.styleInit()) - self.installEventFilter(self) - - def setStyleAndIcon(self, icon: str): - self.style_helper.__icon = icon - self.setStyleSheet(self.style_helper.styleInit()) - self.setIcon(QIcon(icon)) - - def eventFilter(self, obj, event): - if obj == self: - if event.type() == 98: # Event type for EnableChange - effect = QGraphicsColorizeEffect() - effect.setColor(QColor(255, 255, 255)) - if self.isEnabled(): - effect.setStrength(0) - else: - effect.setStrength(1) - effect.setColor(QColor(150, 150, 150)) - self.setGraphicsEffect(effect) - return super().eventFilter(obj, event) +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtGui import QColor, QIcon +from qtpy.QtWidgets import QGraphicsColorizeEffect, QPushButton + +from pyqt_openai.util.button_style_helper import ButtonStyleHelper + +if TYPE_CHECKING: + from qtpy.QtCore import QEvent, QObject + from qtpy.QtWidgets import QWidget + + +class Button(QPushButton): + def __init__( + self, + base_widget: QWidget | None = None, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.style_helper: ButtonStyleHelper = ButtonStyleHelper(base_widget) + self.setStyleSheet(self.style_helper.styleInit()) + self.installEventFilter(self) + + def setStyleAndIcon( + self, + icon: str, + ): + self.style_helper.__icon = icon + self.setStyleSheet(self.style_helper.styleInit()) + self.setIcon(QIcon(icon)) + + def eventFilter( + self, + obj: QObject, + event: QEvent, + ): + if obj == self: + if event.type() == 98: # Event type for EnableChange + effect: QGraphicsColorizeEffect = QGraphicsColorizeEffect() + effect.setColor(QColor(255, 255, 255)) + if self.isEnabled(): + effect.setStrength(0) + else: + effect.setStrength(1) + effect.setColor(QColor(150, 150, 150)) + self.setGraphicsEffect(effect) + return super().eventFilter(obj, event) diff --git a/pyqt_openai/widgets/checkBoxListWidget.py b/pyqt_openai/widgets/checkBoxListWidget.py index 475a5b4a..9a3d7181 100644 --- a/pyqt_openai/widgets/checkBoxListWidget.py +++ b/pyqt_openai/widgets/checkBoxListWidget.py @@ -1,76 +1,109 @@ -from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import QListWidgetItem, QListWidget - - -class CheckBoxListWidget(QListWidget): - checkedSignal = Signal(int, Qt.CheckState) - - def __init__(self, parent=None): - super().__init__(parent) - self.itemChanged.connect(self.__sendCheckedSignal) - - def __sendCheckedSignal(self, item): - r_idx = self.row(item) - state = item.checkState() - self.checkedSignal.emit(r_idx, state) - - def addItems(self, items, checked=False) -> None: - """ - Add items to the list widget. - If checked is True, the items will be checked. - """ - for item in items: - self.addItem(item, checked=checked) - - def addItem(self, item, checked=False) -> None: - if isinstance(item, str): - item = QListWidgetItem(item) - item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) - if checked: - item.setCheckState(Qt.CheckState.Checked) - else: - item.setCheckState(Qt.CheckState.Unchecked) - super().addItem(item) - - def toggleState(self, state): - for i in range(self.count()): - item = self.item(i) - state = Qt.CheckState(state) - if item.checkState() != state: - item.setCheckState(state) - - def getCheckedRows(self): - return self.__getFlagRows(Qt.CheckState.Checked) - - def getUncheckedRows(self): - return self.__getFlagRows(Qt.CheckState.Unchecked) - - def __getFlagRows(self, flag: Qt.CheckState): - flag_lst = [] - for i in range(self.count()): - item = self.item(i) - if item.checkState() == flag: - flag_lst.append(i) - - return flag_lst - - def removeCheckedRows(self): - self.__removeFlagRows(Qt.CheckState.Checked) - - def removeUncheckedRows(self): - self.__removeFlagRows(Qt.CheckState.Unchecked) - - def __removeFlagRows(self, flag): - flag_lst = self.__getFlagRows(flag) - flag_lst = reversed(flag_lst) - for i in flag_lst: - self.takeItem(i) - - def getCheckedItems(self): - return [self.item(i) for i in self.getCheckedRows()] - - def getCheckedItemsText(self, empty_str="", include_empty=True): - result = [item.text() if item else empty_str for item in self.getCheckedItems()] - if include_empty: - return result - return [text for text in result if text] +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import QListWidget, QListWidgetItem + +if TYPE_CHECKING: + from qtpy.QtWidgets import QWidget + + +class CheckBoxListWidget(QListWidget): + checkedSignal = Signal(int, Qt.CheckState) + + def __init__( + self, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.itemChanged.connect(self.__sendCheckedSignal) + + def __sendCheckedSignal( + self, + item: QListWidgetItem, + ): + r_idx: int = self.row(item) + state: Qt.CheckState = item.checkState() + self.checkedSignal.emit(r_idx, state) + + def addItems( + self, + items: list[str], + checked: bool = False, + ) -> None: + """Add items to the list widget. + If checked is True, the items will be checked. + """ + for item in items: + self.addItem(item, checked=checked) + + def addItem( + self, + item: str | QListWidgetItem, + checked: bool = False, + ) -> None: + if isinstance(item, str): + item = QListWidgetItem(item) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + if checked: + item.setCheckState(Qt.CheckState.Checked) + else: + item.setCheckState(Qt.CheckState.Unchecked) + super().addItem(item) + + def toggleState( + self, + state: Qt.CheckState, + ): + for i in range(self.count()): + item: QListWidgetItem = self.item(i) + state = Qt.CheckState(state) + if item.checkState() != state: + item.setCheckState(state) + + def getCheckedRows(self) -> list[int]: + return self.__getFlagRows(Qt.CheckState.Checked) + + def getUncheckedRows(self) -> list[int]: + return self.__getFlagRows(Qt.CheckState.Unchecked) + + def __getFlagRows( + self, + flag: Qt.CheckState, + ) -> list[int]: + flag_lst: list[int] = [] + for i in range(self.count()): + item: QListWidgetItem = self.item(i) + if item.checkState() == flag: + flag_lst.append(i) + + return flag_lst + + def removeCheckedRows(self): + self.__removeFlagRows(Qt.CheckState.Checked) + + def removeUncheckedRows(self): + self.__removeFlagRows(Qt.CheckState.Unchecked) + + def __removeFlagRows( + self, + flag: Qt.CheckState, + ): + flag_lst = self.__getFlagRows(flag) + flag_lst = reversed(flag_lst) + for i in flag_lst: + self.takeItem(i) + + def getCheckedItems(self) -> list[QListWidgetItem]: + return [self.item(i) for i in self.getCheckedRows()] + + def getCheckedItemsText( + self, + empty_str: str = "", + include_empty: bool = True, + ) -> list[str]: + result: list[str] = [item.text() if item else empty_str for item in self.getCheckedItems()] + if include_empty: + return result + return [text for text in result if text] diff --git a/pyqt_openai/widgets/checkBoxTableWidget.py b/pyqt_openai/widgets/checkBoxTableWidget.py index 7b5b1542..8bd5cd93 100644 --- a/pyqt_openai/widgets/checkBoxTableWidget.py +++ b/pyqt_openai/widgets/checkBoxTableWidget.py @@ -1,137 +1,184 @@ -import typing - -from PySide6.QtCore import Signal, Qt -from PySide6.QtWidgets import QHeaderView, QTableWidget, QCheckBox -from PySide6.QtWidgets import QWidget, QGridLayout - - -class CheckBox(QWidget): - checkedSignal = Signal(int, Qt.CheckState) - - def __init__(self, r_idx, flag, parent=None): - super().__init__(parent) - self.__r_idx = r_idx - self.__initUi(flag) - - def __initUi(self, flag): - chkBox = QCheckBox() - chkBox.setChecked(flag) - chkBox.stateChanged.connect(self.__sendCheckedSignal) - - lay = QGridLayout() - lay.addWidget(chkBox) - lay.setContentsMargins(2, 2, 2, 2) - lay.setAlignment(chkBox, Qt.AlignmentFlag.AlignCenter) - - self.setLayout(lay) - - def __sendCheckedSignal(self, flag): - flag = Qt.CheckState(flag) - self.checkedSignal.emit(self.__r_idx, flag) - - def isChecked(self): - f = self.layout().itemAt(0).widget().isChecked() - return Qt.CheckState.Checked if f else Qt.CheckState.Unchecked - - def setChecked(self, f): - if isinstance(f, Qt.CheckState): - self.getCheckBox().setCheckState(f) - elif isinstance(f, bool): - self.getCheckBox().setChecked(f) - - def getCheckBox(self): - return self.layout().itemAt(0).widget() - - -class CheckBoxTableWidget(QTableWidget): - checkedSignal = Signal(int, Qt.CheckState) - - def __init__(self, parent=None): - self._default_check_flag = False - super().__init__(parent) - self.__initUi() - - def __initUi(self): - # Least column count (one for checkbox, one for another) - self.setColumnCount(2) - - def setHorizontalHeaderLabels(self, labels: typing.Iterable[str]) -> None: - lst = [_ for _ in labels if _] - lst.insert(0, "") # 0 index vacant for checkbox - self.setColumnCount(len(lst)) - super().setHorizontalHeaderLabels(lst) - self.horizontalHeader().setSectionResizeMode( - 0, QHeaderView.ResizeMode.ResizeToContents - ) - - def clearContents(self, start_r_idx=0): - for i in range(start_r_idx, self.rowCount()): - for j in range(1, self.columnCount()): - self.takeItem(i, j) - - def setDefaultValueOfCheckBox(self, flag: bool): - self._default_check_flag = flag - - def stretchEveryColumnExceptForCheckBox(self): - if self.horizontalHeader(): - self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) - self.horizontalHeader().setSectionResizeMode( - 0, QHeaderView.ResizeMode.Fixed - ) - - def insertRow(self, row: int) -> None: - super().insertRow(row) - self.__setCheckBox(row) - - def setRowCount(self, rows: int) -> None: - super().setRowCount(rows) - for row in range(0, rows): - self.__setCheckBox(row) - - def __setCheckBox(self, r_idx): - chkBox = CheckBox(r_idx, self._default_check_flag) - chkBox.checkedSignal.connect(self.__sendCheckedSignal) - - self.setCellWidget(r_idx, 0, chkBox) - - if self._default_check_flag: - self.checkedSignal.emit(r_idx, Qt.CheckState.Checked) - self.resizeColumnToContents(0) - - def __sendCheckedSignal(self, r_idx, flag: Qt.CheckState): - self.checkedSignal.emit(r_idx, flag) - - def toggleState(self, state): - for i in range(self.rowCount()): - item = super().cellWidget(i, 0).getCheckBox() - item.setChecked(state) - - def getCheckedRows(self): - return self.__getCheckedStateOfRows(Qt.CheckState.Checked) - - def getUncheckedRows(self): - return self.__getCheckedStateOfRows(Qt.CheckState.Unchecked) - - def __getCheckedStateOfRows(self, flag: Qt.CheckState.Checked): - flag_lst = [] - for i in range(self.rowCount()): - item = super().cellWidget(i, 0) - if item.isChecked() == flag: - flag_lst.append(i) - - return flag_lst - - def setCheckedAt(self, idx: int, f: bool): - self.cellWidget(idx, 0).setChecked(f) - - def removeCheckedRows(self): - self.__removeCertainCheckedStateRows(Qt.CheckState.Checked) - - def removeUncheckedRows(self): - self.__removeCertainCheckedStateRows(Qt.CheckState.Unchecked) - - def __removeCertainCheckedStateRows(self, flag): - flag_lst = self.__getCheckedStateOfRows(flag) - flag_lst = reversed(flag_lst) - for i in flag_lst: - self.removeRow(i) +from __future__ import annotations + +import typing + +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import QCheckBox, QGridLayout, QHeaderView, QTableWidget, QWidget + + +class CheckBox(QWidget): + checkedSignal = Signal(int, Qt.CheckState) + + def __init__( + self, + r_idx: int, + flag: bool, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.__r_idx: int = r_idx + self.__initUi(flag) + + def __initUi( + self, + flag: bool, + ): + chkBox: QCheckBox = QCheckBox() + chkBox.setChecked(flag) + chkBox.stateChanged.connect(self.__sendCheckedSignal) + + lay = QGridLayout() + lay.addWidget(chkBox) + lay.setContentsMargins(2, 2, 2, 2) + lay.setAlignment(chkBox, Qt.AlignmentFlag.AlignCenter) + + self.setLayout(lay) + + def __sendCheckedSignal( + self, + flag: int, + ): + flag: Qt.CheckState = Qt.CheckState(flag) + self.checkedSignal.emit(self.__r_idx, flag) + + def isChecked(self) -> Qt.CheckState: + f: bool = self.layout().itemAt(0).widget().isChecked() + return Qt.CheckState.Checked if f else Qt.CheckState.Unchecked + + def setChecked( + self, + f: Qt.CheckState | bool, + ): + if isinstance(f, Qt.CheckState): + self.getCheckBox().setCheckState(f) + elif isinstance(f, bool): + self.getCheckBox().setChecked(f) + + def getCheckBox(self) -> QWidget: + return self.layout().itemAt(0).widget() + + +class CheckBoxTableWidget(QTableWidget): + checkedSignal = Signal(int, Qt.CheckState) + + def __init__( + self, + parent: QWidget | None = None, + ): + self._default_check_flag: bool = False + super().__init__(parent) + self.__initUi() + + def __initUi(self): + # Least column count (one for checkbox, one for another) + self.setColumnCount(2) + + def setHorizontalHeaderLabels( + self, + labels: typing.Iterable[str], + ) -> None: + lst: list[str] = [_ for _ in labels if _] + lst.insert(0, "") # 0 index vacant for checkbox + self.setColumnCount(len(lst)) + super().setHorizontalHeaderLabels(lst) + self.horizontalHeader().setSectionResizeMode( + 0, QHeaderView.ResizeMode.ResizeToContents, + ) + + def clearContents( + self, + start_r_idx: int = 0, + ): + for i in range(start_r_idx, self.rowCount()): + for j in range(1, self.columnCount()): + self.takeItem(i, j) + + def setDefaultValueOfCheckBox( + self, + flag: bool, + ): + self._default_check_flag = flag + + def stretchEveryColumnExceptForCheckBox(self): + if self.horizontalHeader(): + self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) + + def insertRow( + self, + row: int, + ) -> None: + super().insertRow(row) + self.__setCheckBox(row) + + def setRowCount(self, rows: int) -> None: + super().setRowCount(rows) + for row in range(rows): + self.__setCheckBox(row) + + def __setCheckBox(self, r_idx: int): + chkBox: CheckBox = CheckBox(r_idx, self._default_check_flag) + chkBox.checkedSignal.connect(self.__sendCheckedSignal) + + self.setCellWidget(r_idx, 0, chkBox) + + if self._default_check_flag: + self.checkedSignal.emit(r_idx, Qt.CheckState.Checked) + self.resizeColumnToContents(0) + + def __sendCheckedSignal( + self, + r_idx: int, + flag: Qt.CheckState, + ): + self.checkedSignal.emit(r_idx, flag) + + def toggleState( + self, + state: Qt.CheckState | bool, + ): + for i in range(self.rowCount()): + item: QWidget = super().cellWidget(i, 0).getCheckBox() # pyright: ignore[reportAttributeAccessIssue] + if not isinstance(item, QCheckBox): + continue + item.setChecked(state) # pyright: ignore[reportArgumentType] + + def getCheckedRows(self): + return self.__getCheckedStateOfRows(Qt.CheckState.Checked) + + def getUncheckedRows(self): + return self.__getCheckedStateOfRows(Qt.CheckState.Unchecked) + + def __getCheckedStateOfRows( + self, + flag: Qt.CheckState, + ) -> list[int]: + flag_lst: list[int] = [] + for i in range(self.rowCount()): + item: QWidget = super().cellWidget(i, 0) # pyright: ignore[reportAttributeAccessIssue] + if isinstance(item, QCheckBox) and item.isChecked() == flag: + flag_lst.append(i) + + return flag_lst + + def setCheckedAt( + self, + idx: int, + f: bool, + ): + self.cellWidget(idx, 0).setChecked(f) # pyright: ignore[reportAttributeAccessIssue] + + def removeCheckedRows(self): + self.__removeCertainCheckedStateRows(Qt.CheckState.Checked) + + def removeUncheckedRows(self): + self.__removeCertainCheckedStateRows(Qt.CheckState.Unchecked) + + def __removeCertainCheckedStateRows( + self, + flag: Qt.CheckState, + ): + flag_lst: list[int] = self.__getCheckedStateOfRows(flag) + flag_lst.reverse() + for i in flag_lst: + self.removeRow(i) diff --git a/pyqt_openai/widgets/circleProfileImage.py b/pyqt_openai/widgets/circleProfileImage.py index 6d7ce0ed..3fadd444 100644 --- a/pyqt_openai/widgets/circleProfileImage.py +++ b/pyqt_openai/widgets/circleProfileImage.py @@ -1,53 +1,66 @@ -from PySide6.QtCore import Qt -from PySide6.QtGui import QPixmap, QPainter, QBitmap -from PySide6.QtWidgets import QLabel - - -class RoundedImage(QLabel): - def __init__(self, parent=None): - super().__init__(parent) - self.__initVal() - self.__initUi() - - def __initVal(self): - self.__pixmap = "" - self.__mask = "" - - def __initUi(self): - pass - - def setImage(self, filename: str): - # Load the image and set it as the pixmap for the label - self.__pixmap = QPixmap(filename) - self.__pixmap = self.__pixmap.scaled( - self.__pixmap.width(), - self.__pixmap.height(), - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation, - ) - # Create a mask the same shape as the image - self.__mask = QBitmap(self.__pixmap.size()) - - # Create a QPainter to draw the mask - self.__painter = QPainter(self.__mask) - self.__painter.setRenderHint(QPainter.RenderHint.Antialiasing) - self.__painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) - self.__painter.fillRect(self.__mask.rect(), Qt.GlobalColor.white) - - # Draw a black, rounded rectangle on the mask - self.__painter.setPen(Qt.GlobalColor.black) - self.__painter.setBrush(Qt.GlobalColor.black) - self.__painter.drawRoundedRect( - self.__pixmap.rect(), - self.__pixmap.size().width(), - self.__pixmap.size().height(), - ) - self.__painter.end() - - # Apply the mask to the image - self.__pixmap.setMask(self.__mask) - self.setPixmap(self.__pixmap) - self.setScaledContents(True) - - def getImage(self): - return self.__pixmap +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtCore import Qt +from qtpy.QtGui import QBitmap, QPainter, QPixmap +from qtpy.QtWidgets import QLabel + +if TYPE_CHECKING: + from qtpy.QtWidgets import QWidget + + +class RoundedImage(QLabel): + def __init__( + self, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.__initVal() + self.__initUi() + + def __initVal(self): + self.__pixmap: str = "" + self.__mask: str = "" + + def __initUi(self): + pass + + def setImage( + self, + filename: str, + ): + # Load the image and set it as the pixmap for the label + self.__pixmap: QPixmap = QPixmap(filename) + self.__pixmap = self.__pixmap.scaled( + self.__pixmap.width(), + self.__pixmap.height(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + # Create a mask the same shape as the image + self.__mask: QBitmap = QBitmap(self.__pixmap.size()) + + # Create a QPainter to draw the mask + self.__painter: QPainter = QPainter(self.__mask) + self.__painter.setRenderHint(QPainter.RenderHint.Antialiasing) + self.__painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + self.__painter.fillRect(self.__mask.rect(), Qt.GlobalColor.white) + + # Draw a black, rounded rectangle on the mask + self.__painter.setPen(Qt.GlobalColor.black) + self.__painter.setBrush(Qt.GlobalColor.black) + self.__painter.drawRoundedRect( + self.__pixmap.rect(), + self.__pixmap.size().width(), + self.__pixmap.size().height(), + ) + self.__painter.end() + + # Apply the mask to the image + self.__pixmap.setMask(self.__mask) + self.setPixmap(self.__pixmap) + self.setScaledContents(True) + + def getImage(self) -> QPixmap: + return self.__pixmap diff --git a/pyqt_openai/widgets/fileTableDialog.py b/pyqt_openai/widgets/fileTableDialog.py index 892fbf94..c7993600 100644 --- a/pyqt_openai/widgets/fileTableDialog.py +++ b/pyqt_openai/widgets/fileTableDialog.py @@ -1,35 +1,43 @@ -from PySide6.QtWidgets import QDialog, QTableWidget, QHeaderView, QVBoxLayout - - -# TODO v1.8.0 -class FileTableDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - # TODO LANGUAGE - self.setWindowTitle("File List (Working)") - # File tables - self.__fileTable = QTableWidget() - self.__fileTable.setColumnCount(2) - self.__fileTable.setHorizontalHeaderLabels(["File Name", "Type"]) - self.__fileTable.setColumnWidth(0, 300) - self.__fileTable.setColumnWidth(1, 100) - self.__fileTable.setEditTriggers(QTableWidget.NoEditTriggers) - self.__fileTable.setSelectionBehavior(QTableWidget.SelectRows) - self.__fileTable.setSelectionMode(QTableWidget.SingleSelection) - self.__fileTable.setSortingEnabled(True) - self.__fileTable.setAlternatingRowColors(True) - self.__fileTable.setShowGrid(False) - self.__fileTable.verticalHeader().hide() - self.__fileTable.horizontalHeader().setStretchLastSection(True) - self.__fileTable.horizontalHeader().setHighlightSections(False) - self.__fileTable.horizontalHeader().setSectionsClickable(True) - self.__fileTable.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) - self.__fileTable.horizontalHeader().setSectionResizeMode( - 1, QHeaderView.ResizeToContents - ) - lay = QVBoxLayout() - lay.addWidget(self.__fileTable) - self.setLayout(lay) +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtWidgets import QDialog, QHeaderView, QTableWidget, QVBoxLayout + +if TYPE_CHECKING: + from qtpy.QtWidgets import QWidget + + +# TODO v1.8.0 +class FileTableDialog(QDialog): + def __init__( + self, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.__initUi() + + def __initUi(self): + # TODO LANGUAGE + self.setWindowTitle("File List (Working)") + # File tables + self.__fileTable = QTableWidget() + self.__fileTable.setColumnCount(2) + self.__fileTable.setHorizontalHeaderLabels(["File Name", "Type"]) + self.__fileTable.setColumnWidth(0, 300) + self.__fileTable.setColumnWidth(1, 100) + self.__fileTable.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.__fileTable.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.__fileTable.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) + self.__fileTable.setSortingEnabled(True) + self.__fileTable.setAlternatingRowColors(True) + self.__fileTable.setShowGrid(False) + self.__fileTable.verticalHeader().hide() + self.__fileTable.horizontalHeader().setStretchLastSection(True) + self.__fileTable.horizontalHeader().setHighlightSections(False) + self.__fileTable.horizontalHeader().setSectionsClickable(True) + self.__fileTable.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + self.__fileTable.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) + lay = QVBoxLayout() + lay.addWidget(self.__fileTable) + self.setLayout(lay) diff --git a/pyqt_openai/widgets/findPathWidget.py b/pyqt_openai/widgets/findPathWidget.py index c3ef5fb8..006af7b6 100644 --- a/pyqt_openai/widgets/findPathWidget.py +++ b/pyqt_openai/widgets/findPathWidget.py @@ -1,145 +1,169 @@ -import os - -import subprocess - -from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import ( - QPushButton, - QHBoxLayout, - QWidget, - QLabel, - QFileDialog, - QLineEdit, - QMenu, -) -from PySide6.QtGui import QAction - -from pyqt_openai.lang.translations import LangClass - - -class FindPathLineEdit(QLineEdit): - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - self.setMouseTracking(True) - self.setReadOnly(True) - self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.customContextMenuRequested.connect(self.__prepareMenu) - - def mouseMoveEvent(self, event): - self.__showToolTip() - return super().mouseMoveEvent(event) - - def __showToolTip(self): - text = self.text() - text_width = self.fontMetrics().boundingRect(text).width() - - if text_width > self.width(): - self.setToolTip(text) - else: - self.setToolTip("") - - def __prepareMenu(self, pos): - menu = QMenu(self) - openDirAction = QAction(LangClass.TRANSLATIONS["Open Path"]) - openDirAction.setEnabled(self.text().strip() != "") - openDirAction.triggered.connect(self.__openPath) - menu.addAction(openDirAction) - menu.exec(self.mapToGlobal(pos)) - - def __openPath(self): - filename = self.text() - path = filename.replace("/", "\\") - subprocess.Popen(r'explorer /select,"' + path + '"') - - -class FindPathWidget(QWidget): - findClicked = Signal() - added = Signal(str) - - def __init__(self, default_filename: str = "", parent=None): - super().__init__(parent) - self.__initVal() - self.__initUi(default_filename) - - def __initVal(self): - self.__ext_of_files = "" - self.__directory = False - - def __initUi(self, default_filename: str = ""): - self.__pathLineEdit = FindPathLineEdit() - if default_filename: - self.__pathLineEdit.setText(default_filename) - - self.__pathFindBtn = QPushButton(LangClass.TRANSLATIONS["Find..."]) - - self.__pathFindBtn.clicked.connect(self.__find) - - self.__pathLineEdit.setMaximumHeight(self.__pathFindBtn.sizeHint().height()) - - lay = QHBoxLayout() - lay.addWidget(self.__pathLineEdit) - lay.addWidget(self.__pathFindBtn) - lay.setContentsMargins(0, 0, 0, 0) - - self.setLayout(lay) - - def setLabel(self, text): - self.layout().insertWidget(0, QLabel(text)) - - def setExtOfFiles(self, ext_of_files): - self.__ext_of_files = ext_of_files - - def getLineEdit(self): - return self.__pathLineEdit - - def getButton(self): - return self.__pathFindBtn - - def getFileName(self): - return self.__pathLineEdit.text() - - def setCustomFind(self, f: bool): - if f: - self.__pathFindBtn.clicked.disconnect(self.__find) - self.__pathFindBtn.clicked.connect(self.__customFind) - - def __customFind(self): - self.findClicked.emit() - - def __find(self): - if self.isForDirectory(): - filename = QFileDialog.getExistingDirectory( - self, - LangClass.TRANSLATIONS["Open Directory"], - os.path.expanduser("~"), - QFileDialog.Option.ShowDirsOnly, - ) - if filename: - pass - else: - return - else: - str_exp_files_to_open = ( - self.__ext_of_files if self.__ext_of_files else "All Files (*.*)" - ) - filename = QFileDialog.getOpenFileName( - self, - LangClass.TRANSLATIONS["Find"], - os.path.expanduser("~"), - str_exp_files_to_open, - ) - if filename[0]: - filename = filename[0] - else: - return - self.__pathLineEdit.setText(filename) - self.added.emit(filename) - - def setAsDirectory(self, f: bool): - self.__directory = f - - def isForDirectory(self) -> bool: - return self.__directory +from __future__ import annotations + +import os +import subprocess + +from typing import TYPE_CHECKING, cast + +from qtpy.QtCore import Qt, Signal +from qtpy.QtGui import QAction +from qtpy.QtWidgets import QFileDialog, QHBoxLayout, QLabel, QLineEdit, QMenu, QPushButton, QWidget + +from pyqt_openai.lang.translations import LangClass + +if TYPE_CHECKING: + from qtpy.QtCore import QPoint + from qtpy.QtGui import QMouseEvent + + + +class FindPathLineEdit(QLineEdit): + def __init__( + self, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.__initUi() + + def __initUi(self): + self.setMouseTracking(True) + self.setReadOnly(True) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self.__prepareMenu) + + def mouseMoveEvent( + self, + event: QMouseEvent, + ): + self.__showToolTip() + return super().mouseMoveEvent(event) + + def __showToolTip(self): + text = self.text() + text_width = self.fontMetrics().boundingRect(text).width() + + if text_width > self.width(): + self.setToolTip(text) + else: + self.setToolTip("") + + def __prepareMenu( + self, + pos: QPoint, + ): + menu = QMenu(self) + openDirAction = QAction(LangClass.TRANSLATIONS["Open Path"]) + openDirAction.setEnabled(self.text().strip() != "") + openDirAction.triggered.connect(self.__openPath) + menu.addAction(openDirAction) + menu.exec(self.mapToGlobal(pos)) + + def __openPath(self): + filename = self.text() + path = filename.replace("/", "\\") + subprocess.Popen(r'explorer /select,"' + path + '"') + + +class FindPathWidget(QWidget): + findClicked = Signal() + added = Signal(str) + + def __init__( + self, + default_filename: str = "", + parent: QWidget | None = None, + ): + super().__init__(parent) + self.__initVal() + self.__initUi(default_filename) + + def __initVal(self): + self.__ext_of_files: str = "" + self.__directory: bool = False + + def __initUi( + self, + default_filename: str = "", + ): + self.__pathLineEdit: FindPathLineEdit = FindPathLineEdit() + if default_filename: + self.__pathLineEdit.setText(default_filename) + + self.__pathFindBtn: QPushButton = QPushButton(LangClass.TRANSLATIONS["Find..."]) + + self.__pathFindBtn.clicked.connect(self.__find) + + self.__pathLineEdit.setMaximumHeight(self.__pathFindBtn.sizeHint().height()) + + lay = QHBoxLayout() + lay.addWidget(self.__pathLineEdit) + lay.addWidget(self.__pathFindBtn) + lay.setContentsMargins(0, 0, 0, 0) + + self.setLayout(lay) + + def setLabel( + self, + text: str, + ): + cast(QHBoxLayout, self.layout()).insertWidget(0, QLabel(text)) + + def setExtOfFiles( + self, + ext_of_files: str, + ): + self.__ext_of_files = ext_of_files + + def getLineEdit(self) -> FindPathLineEdit: + return self.__pathLineEdit + + def getButton(self) -> QPushButton: + return self.__pathFindBtn + + def getFileName(self) -> str: + return self.__pathLineEdit.text() + + def setCustomFind(self, f: bool): + if f: + self.__pathFindBtn.clicked.disconnect(self.__find) + self.__pathFindBtn.clicked.connect(self.__customFind) + + def __customFind(self): + self.findClicked.emit() + + def __find(self): + if self.isForDirectory(): + filename = QFileDialog.getExistingDirectory( + self, + LangClass.TRANSLATIONS["Open Directory"], + os.path.expanduser("~"), + QFileDialog.Option.ShowDirsOnly, + ) + if filename: + pass + else: + return + else: + str_exp_files_to_open = ( + self.__ext_of_files if self.__ext_of_files else "All Files (*.*)" + ) + filename = QFileDialog.getOpenFileName( + self, + LangClass.TRANSLATIONS["Find"], + os.path.expanduser("~"), + str_exp_files_to_open, + ) + if not filename[0] or not filename[0].strip(): + return + filename = filename[0] + self.__pathLineEdit.setText(filename) + self.added.emit(filename) + + def setAsDirectory( + self, + f: bool, + ): + self.__directory = f + + def isForDirectory(self) -> bool: + return self.__directory diff --git a/pyqt_openai/widgets/imageControlWidget.py b/pyqt_openai/widgets/imageControlWidget.py index 67af79da..86e995d9 100644 --- a/pyqt_openai/widgets/imageControlWidget.py +++ b/pyqt_openai/widgets/imageControlWidget.py @@ -1,191 +1,232 @@ -from PySide6.QtCore import Signal, QThread -from PySide6.QtWidgets import ( - QMessageBox, - QScrollArea, - QWidget, - QCheckBox, - QSpinBox, - QGroupBox, - QVBoxLayout, - QPushButton, - QPlainTextEdit, -) - -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.models import ImagePromptContainer -from pyqt_openai.util.common import getSeparator -from pyqt_openai.widgets.findPathWidget import FindPathWidget -from pyqt_openai.widgets.notifier import NotifierWidget -from pyqt_openai.widgets.randomImagePromptGeneratorWidget import ( - RandomImagePromptGeneratorWidget, -) - - -class ImageControlWidget(QScrollArea): - submit = Signal(ImagePromptContainer) - submitAllComplete = Signal() - - def __init__(self, parent=None): - super().__init__(parent) - self._initVal() - self._initUi() - - def _initVal(self): - self._prompt = "" - self._continue_generation = False - self._save_prompt_as_text = False - self._is_save = False - self._directory = False - self._number_of_images_to_create = 1 - - def _initUi(self): - self._findPathWidget = FindPathWidget() - self._findPathWidget.setAsDirectory(True) - self._findPathWidget.getLineEdit().setPlaceholderText( - LangClass.TRANSLATIONS["Choose Directory to Save..."] - ) - self._findPathWidget.getLineEdit().setText(self._directory) - self._findPathWidget.added.connect(self._setSaveDirectory) - - self._saveChkBox = QCheckBox(LangClass.TRANSLATIONS["Save After Submit"]) - self._saveChkBox.setChecked(True) - self._saveChkBox.toggled.connect(self._saveChkBoxToggled) - self._saveChkBox.setChecked(self._is_save) - - self._numberOfImagesToCreateSpinBox = QSpinBox() - self._numberOfImagesToCreateSpinBox.setRange(2, 1000) - self._numberOfImagesToCreateSpinBox.setValue(self._number_of_images_to_create) - self._numberOfImagesToCreateSpinBox.valueChanged.connect( - self._numberOfImagesToCreateSpinBoxValueChanged - ) - - self._continueGenerationChkBox = QCheckBox( - LangClass.TRANSLATIONS["Continue Image Generation"] - ) - self._continueGenerationChkBox.setChecked(True) - self._continueGenerationChkBox.toggled.connect( - self._continueGenerationChkBoxToggled - ) - self._continueGenerationChkBox.setChecked(self._continue_generation) - - self._savePromptAsTextChkBox = QCheckBox( - LangClass.TRANSLATIONS["Save Prompt as Text"] - ) - self._savePromptAsTextChkBox.setChecked(True) - self._savePromptAsTextChkBox.toggled.connect( - self._savePromptAsTextChkBoxToggled - ) - self._savePromptAsTextChkBox.setChecked(self._save_prompt_as_text) - - self._generalGrpBox = QGroupBox() - self._generalGrpBox.setTitle(LangClass.TRANSLATIONS["General"]) - - self._promptTextEdit = QPlainTextEdit() - self._promptTextEdit.setPlaceholderText( - LangClass.TRANSLATIONS["Enter prompt here..."] - ) - self._promptTextEdit.setPlainText(self._prompt) - - self._randomImagePromptGeneratorWidget = RandomImagePromptGeneratorWidget() - - self._paramGrpBox = QGroupBox() - self._paramGrpBox.setTitle(LangClass.TRANSLATIONS["Parameters"]) - - self._submitBtn = QPushButton(LangClass.TRANSLATIONS["Submit"]) - self._submitBtn.clicked.connect(self._submit) - - self._stopGeneratingImageBtn = QPushButton( - LangClass.TRANSLATIONS["Stop Generating Image"] - ) - self._stopGeneratingImageBtn.clicked.connect(self._stopGeneratingImage) - self._stopGeneratingImageBtn.setEnabled(False) - - def _completeUi(self): - sep = getSeparator("horizontal") - - lay = QVBoxLayout() - lay.addWidget(self._generalGrpBox) - lay.addWidget(self._paramGrpBox) - lay.addWidget(sep) - lay.addWidget(self._submitBtn) - lay.addWidget(self._stopGeneratingImageBtn) - - mainWidget = QWidget() - mainWidget.setLayout(lay) - - self.setWidget(mainWidget) - self.setWidgetResizable(True) - - def _setThread(self, thread: QThread): - self._t = thread - - def _submit(self): - self._t.start() - self._t.started.connect(self._toggleWidget) - self._t.replyGenerated.connect(self._afterGenerated) - self._t.errorGenerated.connect(self._failToGenerate) - self._t.finished.connect(self._toggleWidget) - self._t.allReplyGenerated.connect(self.submitAllComplete) - - def _toggleWidget(self): - f = not self._t.isRunning() - continue_generation = self._continue_generation - self._generalGrpBox.setEnabled(f) - self._submitBtn.setEnabled(f) - if continue_generation: - self._stopGeneratingImageBtn.setEnabled(not f) - - def _stopGeneratingImage(self): - if self._t.isRunning(): - self._t.stop() - - def _failToGenerate(self, event): - informative_text = "Error 😥" - detailed_text = event - if not self.isVisible() or not self.window().isActiveWindow(): - self._notifierWidget = NotifierWidget( - informative_text=informative_text, detailed_text=detailed_text - ) - self._notifierWidget.show() - self._notifierWidget.doubleClicked.connect(self._bringWindowToFront) - else: - QMessageBox.critical(self, informative_text, detailed_text) - - def _bringWindowToFront(self): - window = self.window() - window.showNormal() - window.raise_() - window.activateWindow() - - def _afterGenerated(self, result): - self.submit.emit(result) - - def getArgument(self): - return { - "prompt": self._promptTextEdit.toPlainText(), - } - - def _setSaveDirectory(self, directory): - self._directory = directory - - def _saveChkBoxToggled(self, f): - self._is_save = f - - def _continueGenerationChkBoxToggled(self, f): - self._continue_generation = f - self._numberOfImagesToCreateSpinBox.setEnabled(f) - - def _savePromptAsTextChkBoxToggled(self, f): - self._save_prompt_as_text = f - - def _numberOfImagesToCreateSpinBoxValueChanged(self, value): - self._number_of_images_to_create = value - - def getSavePromptAsText(self): - return self._save_prompt_as_text - - def isSavedEnabled(self): - return self._is_save - - def getDirectory(self): - return self._directory +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from qtpy.QtCore import Signal +from qtpy.QtWidgets import QCheckBox, QGroupBox, QMessageBox, QPlainTextEdit, QPushButton, QScrollArea, QSpinBox, QVBoxLayout, QWidget + +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.models import ImagePromptContainer +from pyqt_openai.util.common import getSeparator +from pyqt_openai.widgets.findPathWidget import FindPathWidget +from pyqt_openai.widgets.notifier import NotifierWidget +from pyqt_openai.widgets.randomImagePromptGeneratorWidget import RandomImagePromptGeneratorWidget + +if TYPE_CHECKING: + from pyqt_openai.dalle_widget.dalleThread import DallEThread + from pyqt_openai.g4f_image_widget.g4fImageThread import G4FImageThread + from pyqt_openai.replicate_widget.replicateThread import ReplicateThread + + +class ImageControlWidget(QScrollArea): + submit = Signal(ImagePromptContainer) + submitAllComplete = Signal() + + def __init__( + self, + parent: QWidget | None = None, + ): + super().__init__(parent) + self._initVal() + self._initUi() + + def _initVal(self): + self._prompt: str = "" + self._continue_generation: bool = False + self._save_prompt_as_text: bool = False + self._is_save: bool = False + self._directory: bool | str = False + self._number_of_images_to_create: int = 1 + self._t: DallEThread | G4FImageThread | ReplicateThread | None = None + + def _initUi(self): + self._findPathWidget: FindPathWidget = FindPathWidget() + self._findPathWidget.setAsDirectory(True) + self._findPathWidget.getLineEdit().setPlaceholderText(LangClass.TRANSLATIONS["Choose Directory to Save..."]) + self._findPathWidget.getLineEdit().setText(cast(str, self._directory)) + self._findPathWidget.added.connect(self._setSaveDirectory) + + self._saveChkBox: QCheckBox = QCheckBox(LangClass.TRANSLATIONS["Save After Submit"]) + self._saveChkBox.setChecked(True) + self._saveChkBox.toggled.connect(self._saveChkBoxToggled) + self._saveChkBox.setChecked(self._is_save) + + self._numberOfImagesToCreateSpinBox: QSpinBox = QSpinBox() + self._numberOfImagesToCreateSpinBox.setRange(2, 1000) + self._numberOfImagesToCreateSpinBox.setValue(self._number_of_images_to_create) + self._numberOfImagesToCreateSpinBox.valueChanged.connect(self._numberOfImagesToCreateSpinBoxValueChanged) + + self._continueGenerationChkBox: QCheckBox = QCheckBox(LangClass.TRANSLATIONS["Continue Image Generation"]) + self._continueGenerationChkBox.setChecked(True) + self._continueGenerationChkBox.toggled.connect(self._continueGenerationChkBoxToggled) + self._continueGenerationChkBox.setChecked(self._continue_generation) + + self._savePromptAsTextChkBox: QCheckBox = QCheckBox(LangClass.TRANSLATIONS["Save Prompt as Text"]) + self._savePromptAsTextChkBox.setChecked(True) + self._savePromptAsTextChkBox.toggled.connect(self._savePromptAsTextChkBoxToggled) + self._savePromptAsTextChkBox.setChecked(self._save_prompt_as_text) + self._savePromptAsTextChkBox.setEnabled(self._save_prompt_as_text) + + self._generalGrpBox: QGroupBox = QGroupBox() + self._generalGrpBox.setTitle(LangClass.TRANSLATIONS["General"]) + + self._promptTextEdit: QPlainTextEdit = QPlainTextEdit() + self._promptTextEdit.setPlaceholderText(LangClass.TRANSLATIONS["Enter prompt here..."]) + self._promptTextEdit.setPlainText(self._prompt) + + self._randomImagePromptGeneratorWidget: RandomImagePromptGeneratorWidget = RandomImagePromptGeneratorWidget() + + self._paramGrpBox: QGroupBox = QGroupBox() + self._paramGrpBox.setTitle(LangClass.TRANSLATIONS["Parameters"]) + + self._submitBtn: QPushButton = QPushButton(LangClass.TRANSLATIONS["Submit"]) + self._submitBtn.clicked.connect(self._submit) + + self._stopGeneratingImageBtn: QPushButton = QPushButton(LangClass.TRANSLATIONS["Stop Generating Image"]) + self._stopGeneratingImageBtn.clicked.connect(self._stopGeneratingImage) + self._stopGeneratingImageBtn.setEnabled(False) + + self._stopGeneratingImageBtn: QPushButton = QPushButton(LangClass.TRANSLATIONS["Stop Generating Image"]) + self._stopGeneratingImageBtn.clicked.connect(self._stopGeneratingImage) + self._stopGeneratingImageBtn.setEnabled(False) + + sep = getSeparator("horizontal") + + lay = QVBoxLayout() + lay.addWidget(self._generalGrpBox) + lay.addWidget(self._paramGrpBox) + lay.addWidget(sep) + lay.addWidget(self._submitBtn) + lay.addWidget(self._stopGeneratingImageBtn) + + mainWidget = QWidget() + mainWidget.setLayout(lay) + + self.setWidget(mainWidget) + self.setWidgetResizable(True) + + def _setThread( + self, + thread: DallEThread | G4FImageThread | ReplicateThread, + ): + self._t = thread + + def _submit(self): + assert self._t is not None + self._t.start() + self._t.started.connect(self._toggleWidget) + self._t.replyGenerated.connect(self._afterGenerated) + self._t.errorGenerated.connect(self._failToGenerate) + self._t.finished.connect(self._toggleWidget) + self._t.allReplyGenerated.connect(self.submitAllComplete) + + def _toggleWidget(self): + assert self._t is not None + f = not self._t.isRunning() + continue_generation = self._continue_generation + self._generalGrpBox.setEnabled(f) + self._submitBtn.setEnabled(f) + if continue_generation: + self._stopGeneratingImageBtn.setEnabled(not f) + + def _stopGeneratingImage(self): + assert self._t is not None + if self._t.isRunning(): + self._t.stop() + + def _failToGenerate( + self, + event: str, + ): + informative_text: str = "Error 😥" + detailed_text: str = event + + window_of_self: QWidget | None = self.window() + assert window_of_self is not None + if window_of_self is None or not self.isVisible() or not window_of_self.isActiveWindow(): + self._notifierWidget: NotifierWidget = NotifierWidget( + informative_text=informative_text, + detailed_text=detailed_text, + ) + self._notifierWidget.show() + self._notifierWidget.doubleClicked.connect(self._bringWindowToFront) + else: + QMessageBox.critical( + None, # pyright: ignore[reportArgumentType] + informative_text, + detailed_text, + QMessageBox.StandardButton.Ok, + QMessageBox.StandardButton.Cancel, + ) + + def _bringWindowToFront(self): + window: QWidget | None = self.window() + if window is None: + return + window.showNormal() + window.raise_() + window.activateWindow() + + def _afterGenerated( + self, + result: object, + ): + self.submit.emit(result) + + def getArgument(self) -> dict[str, str]: + return { + "prompt": self._promptTextEdit.toPlainText(), + } + + def _setSaveDirectory( + self, + directory: str, + ): + self._directory = directory + + def _saveChkBoxToggled( + self, + f: bool, + ): + self._is_save = f + + def _continueGenerationChkBoxToggled( + self, + f: bool, + ): + self._continue_generation = f + self._numberOfImagesToCreateSpinBox.setEnabled(f) + + def _savePromptAsTextChkBoxToggled( + self, + f: bool, + ): + self._save_prompt_as_text = f + + def _numberOfImagesToCreateSpinBoxValueChanged( + self, + value: int, + ): + self._number_of_images_to_create = value + + def getSavePromptAsText(self) -> bool: + return self._save_prompt_as_text + + def isSavedEnabled(self) -> bool: + return self._is_save + + def getDirectory(self) -> bool | str: + return self._directory + + def _completeUi(self): + """Complete the UI setup after all widgets have been initialized.""" + mainWidget = QWidget() + lay = QVBoxLayout() + lay.addWidget(self._generalGrpBox) + lay.addWidget(self._paramGrpBox) + lay.addWidget(getSeparator("horizontal")) + lay.addWidget(self._submitBtn) + lay.addWidget(self._stopGeneratingImageBtn) + mainWidget.setLayout(lay) + self.setWidget(mainWidget) + self.setWidgetResizable(True) diff --git a/pyqt_openai/widgets/imageMainWidget.py b/pyqt_openai/widgets/imageMainWidget.py index b4a9d599..965625bb 100644 --- a/pyqt_openai/widgets/imageMainWidget.py +++ b/pyqt_openai/widgets/imageMainWidget.py @@ -1,216 +1,221 @@ -import os - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QStackedWidget, - QHBoxLayout, - QVBoxLayout, - QWidget, - QSplitter, -) - -from pyqt_openai import ( - ICON_HISTORY, - ICON_SETTING, - DEFAULT_SHORTCUT_LEFT_SIDEBAR_WINDOW, - DEFAULT_SHORTCUT_RIGHT_SIDEBAR_WINDOW, -) -from pyqt_openai.config_loader import CONFIG_MANAGER -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.models import ImagePromptContainer -from pyqt_openai.globals import DB -from pyqt_openai.util.common import ( - get_image_filename_for_saving, - open_directory, - get_image_prompt_filename_for_saving, - getSeparator, -) -from pyqt_openai.widgets.button import Button -from pyqt_openai.widgets.imageNavWidget import ImageNavWidget -from pyqt_openai.widgets.notifier import NotifierWidget -from pyqt_openai.widgets.thumbnailView import ThumbnailView - - -class ImageMainWidget(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.__initVal() - self.__initUi() - - def __initVal(self): - # ini - self._show_history = CONFIG_MANAGER.get_dalle_property("show_history") - self._show_setting = CONFIG_MANAGER.get_dalle_property("show_setting") - - def __initUi(self): - self._imageNavWidget = ImageNavWidget( - ImagePromptContainer.get_keys(), "image_tb" - ) - - # Main widget - # This contains home page (at the beginning of the stack) and - # widget for main view - self._centralWidget = QStackedWidget() - - self._viewWidget = ThumbnailView() - - self._imageNavWidget.getContent.connect( - lambda x: self._updateCenterWidget(1, x) - ) - - self._historyBtn = Button() - self._historyBtn.setStyleAndIcon(ICON_HISTORY) - self._historyBtn.setCheckable(True) - self._historyBtn.setToolTip( - LangClass.TRANSLATIONS["History"] - + f" ({DEFAULT_SHORTCUT_LEFT_SIDEBAR_WINDOW})" - ) - self._historyBtn.setChecked(self._show_history) - self._historyBtn.toggled.connect(self.toggleHistory) - self._historyBtn.setShortcut(DEFAULT_SHORTCUT_LEFT_SIDEBAR_WINDOW) - - self._settingBtn = Button() - self._settingBtn.setStyleAndIcon(ICON_SETTING) - self._settingBtn.setCheckable(True) - self._settingBtn.setToolTip( - LangClass.TRANSLATIONS["Settings"] - + f" ({DEFAULT_SHORTCUT_RIGHT_SIDEBAR_WINDOW})" - ) - self._settingBtn.setChecked(self._show_setting) - self._settingBtn.toggled.connect(self.toggleSetting) - self._settingBtn.setShortcut(DEFAULT_SHORTCUT_RIGHT_SIDEBAR_WINDOW) - - lay = QHBoxLayout() - lay.addWidget(self._historyBtn) - lay.addWidget(self._settingBtn) - lay.setContentsMargins(2, 2, 2, 2) - lay.setAlignment(Qt.AlignmentFlag.AlignLeft) - - self._menuWidget = QWidget() - self._menuWidget.setLayout(lay) - self._menuWidget.setMaximumHeight(self._menuWidget.sizeHint().height()) - - self._rightSideBarWidget = QWidget() - - self._mainWidget = QSplitter() - self._mainWidget.addWidget(self._imageNavWidget) - self._mainWidget.addWidget(self._centralWidget) - - def _setHomeWidget(self, home_page): - self._homePage = home_page - self._centralWidget.addWidget(self._homePage) - self._centralWidget.addWidget(self._viewWidget) - - def _setRightSideBarWidget(self, right_side_bar_widget): - self._rightSideBarWidget = right_side_bar_widget - self._rightSideBarWidget.submit.connect(self._setResult) - self._rightSideBarWidget.submitAllComplete.connect( - self._imageGenerationAllComplete - ) - - def _completeUi(self): - self._mainWidget.addWidget(self._rightSideBarWidget) - self._mainWidget.setSizes([200, 500, 300]) - self._mainWidget.setChildrenCollapsible(False) - self._mainWidget.setHandleWidth(2) - self._mainWidget.setStyleSheet( - """ - QSplitter::handle:horizontal - { - background: #CCC; - height: 1px; - } - """ - ) - - sep = getSeparator("horizontal") - - lay = QVBoxLayout() - lay.addWidget(self._menuWidget) - lay.addWidget(sep) - lay.addWidget(self._mainWidget) - lay.setContentsMargins(0, 0, 0, 0) - lay.setSpacing(0) - self.setLayout(lay) - - # Put this below to prevent the widgets pop up when app is opened - self._imageNavWidget.setVisible(self._show_history) - self._rightSideBarWidget.setVisible(self._show_setting) - - def _updateCenterWidget(self, idx, data=None): - """ - 0 is home page, 1 is the main view - :param idx: index - :param data: data (bytes) - """ - - # Set the current index - self._centralWidget.setCurrentIndex(idx) - - # If the index is 1, set the content - if idx == 1 and data is not None: - self._viewWidget.setContent(data) - - def showSecondaryToolBar(self, f): - self._menuWidget.setVisible(f) - CONFIG_MANAGER.set_general_property("show_secondary_toolbar", f) - - def toggleButtons(self, x): - self._historyBtn.setChecked(x) - self._settingBtn.setChecked(x) - - def setAIEnabled(self, f): - self._rightSideBarWidget.setEnabled(f) - - def _setResult(self, result): - self._updateCenterWidget(1, result.data) - # save - if self._rightSideBarWidget.isSavedEnabled(): - self._saveResultImage(result) - DB.insertImage(result) - self._imageNavWidget.refresh() - - def _saveResultImage(self, result): - directory = self._rightSideBarWidget.getDirectory() - os.makedirs(directory, exist_ok=True) - filename = os.path.join(directory, get_image_filename_for_saving(result)) - with open(filename, "wb") as f: - f.write(result.data) - - if self._rightSideBarWidget.getSavePromptAsText(): - txt_filename = get_image_prompt_filename_for_saving(directory, filename) - with open(txt_filename, "w") as f: - f.write(result.prompt) - - def _imageGenerationAllComplete(self): - if not self.isVisible() or not self.window().isActiveWindow(): - if CONFIG_MANAGER.get_general_property("notify_finish"): - self.__notifierWidget = NotifierWidget( - informative_text=LangClass.TRANSLATIONS["Response 👌"], - detailed_text=LangClass.TRANSLATIONS["Image Generation complete."], - ) - self.__notifierWidget.show() - self.__notifierWidget.doubleClicked.connect(self._bringWindowToFront) - - open_directory(self._rightSideBarWidget.getDirectory()) - - def _bringWindowToFront(self): - window = self.window() - window.showNormal() - window.raise_() - window.activateWindow() - - def showEvent(self, event): - self._imageNavWidget.refresh() - super().showEvent(event) - - def setColumns(self, columns): - self._imageNavWidget.setColumns(columns) - - def toggleHistory(self, f): - self._imageNavWidget.setVisible(f) - self._show_history = f - - def toggleSetting(self, f): - self._rightSideBarWidget.setVisible(f) - self._show_setting = f +from __future__ import annotations + +import os + +from typing import TYPE_CHECKING + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QHBoxLayout, QSplitter, QStackedWidget, QVBoxLayout, QWidget + +from pyqt_openai import DEFAULT_SHORTCUT_LEFT_SIDEBAR_WINDOW, DEFAULT_SHORTCUT_RIGHT_SIDEBAR_WINDOW, ICON_HISTORY, ICON_SETTING +from pyqt_openai.config_loader import CONFIG_MANAGER +from pyqt_openai.globals import DB +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.models import ImagePromptContainer +from pyqt_openai.util.common import getSeparator, get_image_filename_for_saving, get_image_prompt_filename_for_saving, open_directory +from pyqt_openai.widgets.button import Button +from pyqt_openai.widgets.imageNavWidget import ImageNavWidget +from pyqt_openai.widgets.notifier import NotifierWidget +from pyqt_openai.widgets.thumbnailView import ThumbnailView + +if TYPE_CHECKING: + from qtpy.QtGui import QShowEvent + + +class ImageMainWidget(QWidget): + def __init__( + self, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.__initVal() + self.__initUi() + + def __initVal(self): + # ini + self._show_history: str | None = CONFIG_MANAGER.get_dalle_property("show_history") + self._show_setting: str | None = CONFIG_MANAGER.get_dalle_property("show_setting") + + def __initUi(self): + self._imageNavWidget: ImageNavWidget = ImageNavWidget(ImagePromptContainer.get_keys(), "image_tb") + + # Main widget + # This contains home page (at the beginning of the stack) and + # widget for main view + self._centralWidget: QStackedWidget = QStackedWidget() + + self._viewWidget: ThumbnailView = ThumbnailView() + + self._imageNavWidget.getContent.connect(lambda x: self._updateCenterWidget(1, x)) + + self._historyBtn: Button = Button() + self._historyBtn.setStyleAndIcon(ICON_HISTORY) + self._historyBtn.setCheckable(True) + self._historyBtn.setToolTip(LangClass.TRANSLATIONS["History"] + f" ({DEFAULT_SHORTCUT_LEFT_SIDEBAR_WINDOW})") + self._historyBtn.setChecked(self._show_history) + self._historyBtn.toggled.connect(self.toggleHistory) + self._historyBtn.setShortcut(DEFAULT_SHORTCUT_LEFT_SIDEBAR_WINDOW) + + self._settingBtn: Button = Button() + self._settingBtn.setStyleAndIcon(ICON_SETTING) + self._settingBtn.setCheckable(True) + self._settingBtn.setToolTip(LangClass.TRANSLATIONS["Settings"] + f" ({DEFAULT_SHORTCUT_RIGHT_SIDEBAR_WINDOW})") + self._settingBtn.setChecked(self._show_setting) + self._settingBtn.toggled.connect(self.toggleSetting) + self._settingBtn.setShortcut(DEFAULT_SHORTCUT_RIGHT_SIDEBAR_WINDOW) + + lay = QHBoxLayout() + lay.addWidget(self._historyBtn) + lay.addWidget(self._settingBtn) + lay.setContentsMargins(2, 2, 2, 2) + lay.setAlignment(Qt.AlignmentFlag.AlignLeft) + + self._menuWidget: QWidget = QWidget() + self._menuWidget.setLayout(lay) + self._menuWidget.setMaximumHeight(self._menuWidget.sizeHint().height()) + + self._rightSideBarWidget: QWidget = QWidget() + + self._mainWidget: QSplitter = QSplitter() + self._mainWidget.addWidget(self._imageNavWidget) + self._mainWidget.addWidget(self._centralWidget) + + def _setHomeWidget(self, home_page: QWidget): + self._homePage: QWidget = home_page + self._centralWidget.addWidget(self._homePage) + self._centralWidget.addWidget(self._viewWidget) + + def _setRightSideBarWidget(self, right_side_bar_widget: QWidget): + self._rightSideBarWidget: QWidget = right_side_bar_widget + self._rightSideBarWidget.submit.connect(self._setResult) + self._rightSideBarWidget.submitAllComplete.connect(self._imageGenerationAllComplete) + + def _completeUi(self): + self._mainWidget.addWidget(self._rightSideBarWidget) + self._mainWidget.setSizes([200, 500, 300]) + self._mainWidget.setChildrenCollapsible(False) + self._mainWidget.setHandleWidth(2) + self._mainWidget.setStyleSheet( + """ + QSplitter::handle:horizontal + { + background: #CCC; + height: 1px; + } + """, + ) + + sep = getSeparator("horizontal") + + lay = QVBoxLayout() + lay.addWidget(self._menuWidget) + lay.addWidget(sep) + lay.addWidget(self._mainWidget) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(0) + self.setLayout(lay) + + # Put this below to prevent the widgets pop up when app is opened + self._imageNavWidget.setVisible(self._show_history) + self._rightSideBarWidget.setVisible(self._show_setting) + + def _updateCenterWidget( + self, + idx: int, + data: bytes | None = None, + ): + """0 is home page, 1 is the main view + :param idx: index + :param data: data (bytes). + """ + # Set the current index + self._centralWidget.setCurrentIndex(idx) + + # If the index is 1, set the content + if idx == 1 and data is not None: + self._viewWidget.setContent(data) + + def showSecondaryToolBar( + self, + f: bool, + ): + self._menuWidget.setVisible(f) + CONFIG_MANAGER.set_general_property("show_secondary_toolbar", f) + + def toggleButtons( + self, + x: bool, + ): + self._historyBtn.setChecked(x) + self._settingBtn.setChecked(x) + + def setAIEnabled( + self, + f: bool, + ): + self._rightSideBarWidget.setEnabled(f) + + def _setResult( + self, + result: ImagePromptContainer, + ): + self._updateCenterWidget(1, result.data) + # save + if self._rightSideBarWidget.isSavedEnabled(): + self._saveResultImage(result) + DB.insertImage(result) + self._imageNavWidget.refresh() + + def _saveResultImage( + self, + result: ImagePromptContainer, + ): + directory: str = self._rightSideBarWidget.getDirectory() + os.makedirs(directory, exist_ok=True) + filename: str = os.path.join(directory, get_image_filename_for_saving(result)) + with open(filename, "wb") as f: + f.write(result.data) + + if self._rightSideBarWidget.getSavePromptAsText(): + txt_filename = get_image_prompt_filename_for_saving(directory, filename) + with open(txt_filename, "w") as f: + f.write(result.prompt) + + def _imageGenerationAllComplete(self): + window: QWidget | None = self.window() + assert window is not None + if not self.isVisible() and not window.isActiveWindow(): + if CONFIG_MANAGER.get_general_property("notify_finish"): + self.__notifierWidget: NotifierWidget = NotifierWidget( + informative_text=LangClass.TRANSLATIONS["Response 👌"], + detailed_text=LangClass.TRANSLATIONS["Image Generation complete."], + ) + self.__notifierWidget.show() + self.__notifierWidget.doubleClicked.connect(self._bringWindowToFront) + + open_directory(self._rightSideBarWidget.getDirectory()) + + def _bringWindowToFront(self): + window: QWidget | None = self.window() + assert window is not None + window.showNormal() + window.raise_() + window.activateWindow() + + def showEvent(self, event: QShowEvent ): + self._imageNavWidget.refresh() + super().showEvent(event) + + def setColumns( + self, + columns: list[str], + ): + self._imageNavWidget.setColumns(columns) + + def toggleHistory(self, f): + self._imageNavWidget.setVisible(f) + self._show_history = f + + def toggleSetting(self, f): + self._rightSideBarWidget.setVisible(f) + self._show_setting = f diff --git a/pyqt_openai/widgets/imageNavWidget.py b/pyqt_openai/widgets/imageNavWidget.py index d9e34577..f2aa8531 100644 --- a/pyqt_openai/widgets/imageNavWidget.py +++ b/pyqt_openai/widgets/imageNavWidget.py @@ -1,145 +1,164 @@ -from PySide6.QtCore import Signal, QSortFilterProxyModel, Qt, QByteArray -from PySide6.QtSql import QSqlTableModel, QSqlQuery -from PySide6.QtWidgets import ( - QWidget, - QVBoxLayout, - QStyledItemDelegate, - QTableView, - QAbstractItemView, - QHBoxLayout, - QMessageBox, - QLabel, -) - -import pyqt_openai.globals -from pyqt_openai import ICON_DELETE, ICON_CLOSE -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.globals import DB -from pyqt_openai.widgets.baseNavWidget import BaseNavWidget -from pyqt_openai.widgets.button import Button -from pyqt_openai.widgets.searchBar import SearchBar - - -class FilterProxyModel(QSortFilterProxyModel): - def __init__(self): - super().__init__() - self.__searchedText = "" - - @property - def searchedText(self): - return self.__searchedText - - @searchedText.setter - def searchedText(self, value): - self.__searchedText = value - self.invalidateFilter() - - -# for align text in every cell to center -class AlignDelegate(QStyledItemDelegate): - def initStyleOption(self, option, index): - super().initStyleOption(option, index) - option.displayAlignment = Qt.AlignmentFlag.AlignCenter - - -class SqlTableModel(QSqlTableModel): - added = Signal(int, str) - updated = Signal(int, str) - deleted = Signal(list) - addedCol = Signal() - deletedCol = Signal() - - def flags(self, index): - if index.column() == 0: - return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable - return super().flags(index) - - -class ImageNavWidget(BaseNavWidget): - getContent = Signal(bytes) - - def __init__(self, columns, table_nm, parent=None): - super().__init__(columns, table_nm, parent) - self.__initUi() - - def __initUi(self): - self.setModel(table_type="image") - - imageGenerationHistoryLbl = QLabel() - imageGenerationHistoryLbl.setText(LangClass.TRANSLATIONS["History"]) - - lay = QHBoxLayout() - lay.addWidget(self._searchBar) - lay.addWidget(self._delBtn) - lay.addWidget(self._clearBtn) - lay.setContentsMargins(0, 0, 0, 0) - - menuWidget = QWidget() - menuWidget.setLayout(lay) - - self._tableView.activated.connect(self.__clicked) - self._tableView.clicked.connect(self.__clicked) - - lay = QVBoxLayout() - lay.addWidget(imageGenerationHistoryLbl) - lay.addWidget(menuWidget) - lay.addWidget(self._tableView) - self.setLayout(lay) - - # Show default result (which means "show all") - self._search("") - - def _clear(self, table_type="image"): - table_type = table_type or "image" - super()._clear(table_type=table_type) - - def refresh(self): - self._model.select() - - def __clicked(self, idx): - # get the source index - source_idx = self._proxyModel.mapToSource(idx) - - # get the primary key value of the row - cur_id = self._model.record(source_idx.row()).value("id") - - # Get data from DB id - data = DB.selectCertainImage(cur_id)["data"] - if data: - if isinstance(data, str): - QMessageBox.critical( - self, - LangClass.TRANSLATIONS["Error"], - LangClass.TRANSLATIONS[ - "Image URL can't be seen after v0.2.51, Now it is replaced with b64_json." - ], - ) - else: - data = QByteArray(data).data() - self.getContent.emit(data) - else: - QMessageBox.critical( - self, - LangClass.TRANSLATIONS["Error"], - LangClass.TRANSLATIONS[ - "No image data is found. Maybe you are using really old version." - ], - ) - - def _search(self, text): - # index -1 will be read from all columns - # otherwise it will be read the current column number indicated by combobox - self._proxyModel.setFilterKeyColumn(-1) - # regular expression can be used - self._proxyModel.setFilterRegularExpression(text) - - def _delete(self): - idx_s = self._tableView.selectedIndexes() - for idx in idx_s: - idx = idx.siblingAtColumn(0) - id = self._model.data(idx, role=Qt.ItemDataRole.DisplayRole) - DB.removeImage(id) - self._model.select() - - def setColumns(self, columns, table_type="image"): - super().setColumns(columns, table_type="image") +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtCore import QByteArray, QSortFilterProxyModel, Qt, Signal +from qtpy.QtSql import QSqlTableModel +from qtpy.QtWidgets import QHBoxLayout, QLabel, QMessageBox, QStyledItemDelegate, QVBoxLayout, QWidget + +from pyqt_openai.globals import DB +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.widgets.baseNavWidget import BaseNavWidget + +if TYPE_CHECKING: + from qtpy.QtCore import QModelIndex, QPersistentModelIndex + from qtpy.QtWidgets import QStyleOptionViewItem + + +class FilterProxyModel(QSortFilterProxyModel): + def __init__(self): + super().__init__() + self.__searchedText: str = "" + + @property + def searchedText(self): + return self.__searchedText + + @searchedText.setter + def searchedText(self, value): + self.__searchedText = value + self.invalidateFilter() + + +# for align text in every cell to center +class AlignDelegate(QStyledItemDelegate): + def initStyleOption( + self, + option: QStyleOptionViewItem, + index: QModelIndex | QPersistentModelIndex, + ): + super().initStyleOption(option, index) + option.displayAlignment = Qt.AlignmentFlag.AlignCenter + + +class SqlTableModel(QSqlTableModel): + added = Signal(int, str) + updated = Signal(int, str) + deleted = Signal(list) + addedCol = Signal() + deletedCol = Signal() + + def flags(self, index): + if index.column() == 0: + return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable + return super().flags(index) + + +class ImageNavWidget(BaseNavWidget): + getContent = Signal(bytes) + + def __init__( + self, + columns: list[str], + table_nm: str, + parent: QWidget | None = None, + ): + super().__init__(columns, table_nm, parent) + self.__initUi() + + def __initUi(self): + self.setModel(table_type="image") + + imageGenerationHistoryLbl: QLabel = QLabel() + imageGenerationHistoryLbl.setText(LangClass.TRANSLATIONS["History"]) + + lay = QHBoxLayout() + lay.addWidget(self._searchBar) + lay.addWidget(self._delBtn) + lay.addWidget(self._clearBtn) + lay.setContentsMargins(0, 0, 0, 0) + + menuWidget = QWidget() + menuWidget.setLayout(lay) + + self._tableView.activated.connect(self.__clicked) + self._tableView.clicked.connect(self.__clicked) + + lay = QVBoxLayout() + lay.addWidget(imageGenerationHistoryLbl) + lay.addWidget(menuWidget) + lay.addWidget(self._tableView) + self.setLayout(lay) + + # Show default result (which means "show all") + self._search("") + + def _clear( + self, + table_type: str = "image", + ): + table_type = table_type or "image" + super()._clear(table_type=table_type) + + def refresh(self): + self._model.select() + + def __clicked( + self, + idx: QModelIndex, + ): + # get the source index + source_idx: QModelIndex = self._proxyModel.mapToSource(idx) + + # get the primary key value of the row + cur_id: int = self._model.record(source_idx.row()).value("id") + + # Get data from DB id + data: bytes | str = DB.selectCertainImage(cur_id)["data"] + if data: + if isinstance(data, str): + QMessageBox.critical( + None, # pyright: ignore[reportArgumentType] + LangClass.TRANSLATIONS["Error"], + LangClass.TRANSLATIONS[ + "Image URL can't be seen after v0.2.51, Now it is replaced with b64_json." + ], + QMessageBox.StandardButton.Ok, + QMessageBox.StandardButton.No, + ) + else: + data = QByteArray(data).data() + self.getContent.emit(data) + else: + QMessageBox.critical( + None, # pyright: ignore[reportArgumentType] + LangClass.TRANSLATIONS["Error"], + LangClass.TRANSLATIONS["No image data is found. Maybe you are using really old version."], + QMessageBox.StandardButton.Ok, + QMessageBox.StandardButton.No, + ) + + def _search( + self, + text: str, + ): + # index -1 will be read from all columns + # otherwise it will be read the current column number indicated by combobox + self._proxyModel.setFilterKeyColumn(-1) + # regular expression can be used + self._proxyModel.setFilterRegularExpression(text) + + def _delete(self): + idx_s: list[QModelIndex] = self._tableView.selectedIndexes() + for idx in idx_s: + idx = idx.siblingAtColumn(0) + id = self._model.data(idx, role=Qt.ItemDataRole.DisplayRole) + DB.removeImage(id) + self._model.select() + + def setColumns( + self, + columns: list[str], + table_type: str = "image", + ): + super().setColumns(columns, table_type=table_type) diff --git a/pyqt_openai/widgets/inputDialog.py b/pyqt_openai/widgets/inputDialog.py index d9baca71..7f506848 100644 --- a/pyqt_openai/widgets/inputDialog.py +++ b/pyqt_openai/widgets/inputDialog.py @@ -1,54 +1,68 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QLineEdit, - QPushButton, - QHBoxLayout, - QWidget, -) - -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.util.common import getSeparator - - -class InputDialog(QDialog): - def __init__(self, title, text, parent=None): - super().__init__(parent) - self.__initUi(title, text) - - def __initUi(self, title, text): - self.setWindowTitle(title) - self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) - - self.__newName = QLineEdit(text) - self.__newName.textChanged.connect(self.__setAccept) - sep = getSeparator("horizontal") - - self.__okBtn = QPushButton(LangClass.TRANSLATIONS["OK"]) - self.__okBtn.clicked.connect(self.accept) - - cancelBtn = QPushButton(LangClass.TRANSLATIONS["Cancel"]) - cancelBtn.clicked.connect(self.close) - - lay = QHBoxLayout() - lay.addWidget(self.__okBtn) - lay.addWidget(cancelBtn) - lay.setAlignment(Qt.AlignmentFlag.AlignRight) - lay.setContentsMargins(0, 0, 0, 0) - - okCancelWidget = QWidget() - okCancelWidget.setLayout(lay) - - lay = QVBoxLayout() - lay.addWidget(self.__newName) - lay.addWidget(sep) - lay.addWidget(okCancelWidget) - - self.setLayout(lay) - - def getText(self): - return self.__newName.text() - - def __setAccept(self, text): - self.__okBtn.setEnabled(text.strip() != "") +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QDialog, + QHBoxLayout, + QLineEdit, + QPushButton, + QVBoxLayout, + QWidget, +) + +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.util.common import getSeparator + + +class InputDialog(QDialog): + def __init__( + self, + title: str, + text: str, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.__initUi(title, text) + + def __initUi( + self, + title: str, + text: str, + ): + self.setWindowTitle(title) + self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint) + + self.__newName: QLineEdit = QLineEdit(text) + self.__newName.textChanged.connect(self.__setAccept) + sep: QWidget = getSeparator("horizontal") + + self.__okBtn: QPushButton = QPushButton(LangClass.TRANSLATIONS["OK"]) + self.__okBtn.clicked.connect(self.accept) + + cancelBtn: QPushButton = QPushButton(LangClass.TRANSLATIONS["Cancel"]) + cancelBtn.clicked.connect(self.close) + + lay: QHBoxLayout = QHBoxLayout() + lay.addWidget(self.__okBtn) + lay.addWidget(cancelBtn) + lay.setAlignment(Qt.AlignmentFlag.AlignRight) + lay.setContentsMargins(0, 0, 0, 0) + + okCancelWidget: QWidget = QWidget() + okCancelWidget.setLayout(lay) + + lay: QVBoxLayout = QVBoxLayout() + lay.addWidget(self.__newName) + lay.addWidget(sep) + lay.addWidget(okCancelWidget) + + self.setLayout(lay) + + def getText(self): + return self.__newName.text() + + def __setAccept( + self, + text: str, + ): + self.__okBtn.setEnabled(text.strip() != "") diff --git a/pyqt_openai/widgets/jsonEditor.py b/pyqt_openai/widgets/jsonEditor.py index 10a14fe2..d406957e 100644 --- a/pyqt_openai/widgets/jsonEditor.py +++ b/pyqt_openai/widgets/jsonEditor.py @@ -1,211 +1,238 @@ -import json -import re - -from PySide6.QtCore import Qt, QTimer, Signal -from PySide6.QtGui import QTextCursor, QTextCharFormat, QColor -from PySide6.QtWidgets import QTextEdit, QMessageBox - -from pyqt_openai import ( - INDENT_SIZE, - DEFAULT_SOURCE_HIGHLIGHT_COLOR, - DEFAULT_SOURCE_ERROR_COLOR, -) -from pyqt_openai.lang.translations import LangClass - - -class JSONEditor(QTextEdit): - moveCursorToOtherPrompt = Signal(str) - - def __init__(self, parent=None): - super().__init__(parent) - font = self.font() - - self.setFont(font) - self.setPlaceholderText(LangClass.TRANSLATIONS["Enter JSON data here..."]) - self.textChanged.connect(self.on_text_changed) - self.error_format = QTextCharFormat() - self.error_format.setUnderlineColor(QColor(DEFAULT_SOURCE_ERROR_COLOR)) - self.error_format.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) - self.key_format = QTextCharFormat() - self.key_format.setForeground(QColor(DEFAULT_SOURCE_HIGHLIGHT_COLOR)) - self.check_json_timer = QTimer() - self.check_json_timer.setSingleShot(True) - self.check_json_timer.timeout.connect(self.validate_json) - - def on_text_changed(self): - self.check_json_timer.start(500) # Validate after 500ms - - def validate_json(self): - cursor = self.textCursor() - cursor.select(QTextCursor.Document) - cursor.setCharFormat(QTextCharFormat()) # Initialize format - - try: - json_data = self.toPlainText() - parsed_data = json.loads(json_data) - self.highlight_keys(parsed_data) - except json.JSONDecodeError as e: - error_pos = e.pos - cursor.setPosition(error_pos) - cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) - cursor.setCharFormat(self.error_format) - - def highlight_keys(self, parsed_data): - cursor = self.textCursor() - self.setUpdatesEnabled(False) - cursor.beginEditBlock() - for match in self.find_iter(r"\"(.*?)\":"): - cursor.setPosition(match[0]) - cursor.setPosition(match[1], QTextCursor.KeepAnchor) - cursor.setCharFormat(self.key_format) - cursor.endEditBlock() - self.setUpdatesEnabled(True) - - def find_iter(self, pattern): - regex = re.compile(pattern) - for match in regex.finditer(self.toPlainText()): - yield match.span() - - def keyPressEvent(self, event): - cursor = self.textCursor() - # If up and down keys are pressed and cursor is at the beginning or end of the text - if event.modifiers() == Qt.KeyboardModifier.ControlModifier: - if event.key() == Qt.Key.Key_Up: - self.moveCursorToOtherPrompt.emit("up") - return - elif event.key() == Qt.Key.Key_Down: - self.moveCursorToOtherPrompt.emit("down") - return - else: - return super().keyPressEvent(event) - - if event.key() == Qt.Key.Key_BraceLeft: - super().keyPressEvent(event) - self.insertPlainText("\n\n") - self.insertPlainText("}") - cursor.movePosition(QTextCursor.Up) - cursor.movePosition(QTextCursor.StartOfLine) - cursor.insertText(" " * INDENT_SIZE) - self.setTextCursor(cursor) - elif event.key() == Qt.Key.Key_QuoteDbl: - super().keyPressEvent(event) - self.insertPlainText('"') - cursor.movePosition(QTextCursor.PreviousCharacter) - self.setTextCursor(cursor) - elif event.key() == Qt.Key.Key_BracketLeft: - super().keyPressEvent(event) - self.insertPlainText("]") - cursor.movePosition(QTextCursor.PreviousCharacter) - self.setTextCursor(cursor) - elif ( - event.key() == Qt.Key.Key_Tab - and not event.modifiers() & Qt.KeyboardModifier.ShiftModifier - ): - cursor = self.textCursor() - if cursor.hasSelection(): - self.indent_selected_text() - else: - self.insertPlainText(" " * INDENT_SIZE) - elif ( - event.key() == Qt.Key.Key_Tab - and event.modifiers() & Qt.KeyboardModifier.ShiftModifier - ): - cursor = self.textCursor() - if cursor.hasSelection(): - self.dedent_selected_text() - else: - self.dedent_text() - else: - super().keyPressEvent(event) - - def indent_selected_text(self): - cursor = self.textCursor() - start = cursor.selectionStart() - end = cursor.selectionEnd() - - cursor.setPosition(start) - cursor.beginEditBlock() - - while cursor.position() < end: - cursor.movePosition(QTextCursor.StartOfLine) - cursor.insertText(" " * INDENT_SIZE) - cursor.movePosition(QTextCursor.Down) - - cursor.endEditBlock() - - def dedent_selected_text(self): - cursor = self.textCursor() - start = cursor.selectionStart() - end = cursor.selectionEnd() - - cursor.setPosition(start) - cursor.beginEditBlock() - - while cursor.position() < end: - cursor.movePosition(QTextCursor.StartOfLine) - line_text = cursor.block().text() - if line_text.startswith(" " * INDENT_SIZE): - cursor.movePosition( - QTextCursor.Right, QTextCursor.KeepAnchor, INDENT_SIZE - ) - for _ in range(INDENT_SIZE): - cursor.deleteChar() - cursor.movePosition(QTextCursor.Down) - - cursor.endEditBlock() - - def dedent_text(self): - cursor = self.textCursor() - cursor.movePosition(QTextCursor.StartOfLine) - line_text = cursor.block().text() - if line_text.startswith(" " * INDENT_SIZE): - cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, INDENT_SIZE) - for _ in range(INDENT_SIZE): - cursor.deleteChar() - - def format_json(self): - try: - json_data = self.toPlainText() - parsed = json.loads(json_data) - formatted = json.dumps(parsed, indent=INDENT_SIZE, sort_keys=True) - self.setPlainText(formatted) - except json.JSONDecodeError as e: - QMessageBox.critical(self, "Invalid JSON", f"Error: {str(e)}") - - def focusInEvent(self, event): - self.setCursorWidth(1) - return super().focusInEvent(event) - - def focusOutEvent(self, event): - self.setCursorWidth(0) - return super().focusInEvent(event) - - -# # Usage -# class AIWidget(QWidget): -# def __init__(self): -# super().__init__() -# self.init_ui() -# -# def init_ui(self): -# self.editor = JSONEditor() -# -# self.format_button = QPushButton("Format JSON") -# self.format_button.clicked.connect(self.format_json) -# -# layout = QVBoxLayout() -# layout.addWidget(self.editor) -# layout.addWidget(self.format_button) -# -# self.setLayout(layout) -# self.setWindowTitle("JSON Editor") -# -# def format_json(self): -# return self.editor.format_json() -# -# -# if __name__ == '__main__': -# app = QApplication(sys.argv) -# window = AIWidget() -# window.show() -# sys.exit(app.exec_()) +from __future__ import annotations + +import json +import re + +from typing import TYPE_CHECKING + +from qtpy.QtCore import QTimer, Qt, Signal +from qtpy.QtGui import QColor, QTextCharFormat, QTextCursor +from qtpy.QtWidgets import QMessageBox, QTextEdit + +from pyqt_openai import DEFAULT_SOURCE_ERROR_COLOR, DEFAULT_SOURCE_HIGHLIGHT_COLOR, INDENT_SIZE +from pyqt_openai.lang.translations import LangClass + +if TYPE_CHECKING: + from qtpy.QtGui import QKeyEvent + from qtpy.QtWidgets import QFocusEvent, QWidget + + +class JSONEditor(QTextEdit): + moveCursorToOtherPrompt = Signal(str) + + def __init__( + self, + parent: QWidget | None = None, + ): + super().__init__(parent) + font = self.font() + + self.setFont(font) + self.setPlaceholderText(LangClass.TRANSLATIONS["Enter JSON data here..."]) + self.textChanged.connect(self.on_text_changed) + self.error_format: QTextCharFormat = QTextCharFormat() + self.error_format.setUnderlineColor(QColor(DEFAULT_SOURCE_ERROR_COLOR)) + self.error_format.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SpellCheckUnderline) + self.key_format: QTextCharFormat = QTextCharFormat() + self.key_format.setForeground(QColor(DEFAULT_SOURCE_HIGHLIGHT_COLOR)) + self.check_json_timer: QTimer = QTimer() + self.check_json_timer.setSingleShot(True) + self.check_json_timer.timeout.connect(self.validate_json) + + def on_text_changed(self): + self.check_json_timer.start(500) # Validate after 500ms + + def validate_json(self): + cursor: QTextCursor = self.textCursor() + cursor.select(QTextCursor.SelectionType.Document) + cursor.setCharFormat(QTextCharFormat()) # Initialize format + + try: + json_data = self.toPlainText() + parsed_data = json.loads(json_data) + self.highlight_keys(parsed_data) + except json.JSONDecodeError as e: + error_pos = e.pos + cursor.setPosition(error_pos) + cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor, 1) + cursor.setCharFormat(self.error_format) + + def highlight_keys( + self, + parsed_data: dict, + ): + cursor: QTextCursor = self.textCursor() + self.setUpdatesEnabled(False) + cursor.beginEditBlock() + for match in self.find_iter(r"\"(.*?)\":"): + cursor.setPosition(match[0]) + cursor.setPosition(match[1], QTextCursor.MoveMode.KeepAnchor) + cursor.setCharFormat(self.key_format) + cursor.endEditBlock() + self.setUpdatesEnabled(True) + + def find_iter( + self, + pattern: str, + ): + regex: re.Pattern = re.compile(pattern) + for match in regex.finditer(self.toPlainText()): + yield match.span() + + def keyPressEvent( + self, + event: QKeyEvent, + ): + cursor: QTextCursor = self.textCursor() + # If up and down keys are pressed and cursor is at the beginning or end of the text + if event.modifiers() == Qt.KeyboardModifier.ControlModifier: + if event.key() == Qt.Key.Key_Up: + self.moveCursorToOtherPrompt.emit("up") + return None + if event.key() == Qt.Key.Key_Down: + self.moveCursorToOtherPrompt.emit("down") + return None + return super().keyPressEvent(event) + + if event.key() == Qt.Key.Key_BraceLeft: + super().keyPressEvent(event) + self.insertPlainText("\n\n") + self.insertPlainText("}") + cursor.movePosition(QTextCursor.MoveOperation.Up) + cursor.movePosition(QTextCursor.MoveOperation.StartOfLine) + cursor.insertText(" " * INDENT_SIZE) + self.setTextCursor(cursor) + elif event.key() == Qt.Key.Key_QuoteDbl: + super().keyPressEvent(event) + self.insertPlainText('"') + cursor.movePosition(QTextCursor.MoveOperation.PreviousCharacter) + self.setTextCursor(cursor) + elif event.key() == Qt.Key.Key_BracketLeft: + super().keyPressEvent(event) + self.insertPlainText("]") + cursor.movePosition(QTextCursor.MoveOperation.PreviousCharacter) + self.setTextCursor(cursor) + elif ( + event.key() == Qt.Key.Key_Tab + and not event.modifiers() & Qt.KeyboardModifier.ShiftModifier + ): + cursor = self.textCursor() + if cursor.hasSelection(): + self.indent_selected_text() + else: + self.insertPlainText(" " * INDENT_SIZE) + elif ( + event.key() == Qt.Key.Key_Tab + and event.modifiers() & Qt.KeyboardModifier.ShiftModifier + ): + cursor = self.textCursor() + if cursor.hasSelection(): + self.dedent_selected_text() + else: + self.dedent_text() + else: + super().keyPressEvent(event) + + def indent_selected_text(self): + cursor: QTextCursor = self.textCursor() + start: int = cursor.selectionStart() + end: int = cursor.selectionEnd() + + cursor.setPosition(start) + cursor.beginEditBlock() + + while cursor.position() < end: + cursor.movePosition(QTextCursor.MoveOperation.StartOfLine) + cursor.insertText(" " * INDENT_SIZE) + cursor.movePosition(QTextCursor.MoveOperation.Down) + + cursor.endEditBlock() + + def dedent_selected_text(self): + cursor: QTextCursor = self.textCursor() + start: int = cursor.selectionStart() + end: int = cursor.selectionEnd() + + cursor.setPosition(start) + cursor.beginEditBlock() + + while cursor.position() < end: + cursor.movePosition(QTextCursor.MoveOperation.StartOfLine) + line_text = cursor.block().text() + if line_text.startswith(" " * INDENT_SIZE): + cursor.movePosition( + QTextCursor.MoveOperation.Right, + QTextCursor.MoveMode.KeepAnchor, + INDENT_SIZE, + ) + for _ in range(INDENT_SIZE): + cursor.deleteChar() + cursor.movePosition(QTextCursor.MoveOperation.Down) + + cursor.endEditBlock() + + def dedent_text(self): + cursor: QTextCursor = self.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.StartOfLine) + line_text: str = cursor.block().text() + if line_text.startswith(" " * INDENT_SIZE): + cursor.movePosition( + QTextCursor.MoveOperation.Right, + QTextCursor.MoveMode.KeepAnchor, + INDENT_SIZE, + ) + for _ in range(INDENT_SIZE): + cursor.deleteChar() + + def format_json(self): + try: + json_data: str = self.toPlainText() + parsed: dict = json.loads(json_data) + formatted: str = json.dumps(parsed, indent=INDENT_SIZE, sort_keys=True) + self.setPlainText(formatted) + except json.JSONDecodeError as e: + QMessageBox.critical( + None, # pyright: ignore[reportArgumentType] + "Invalid JSON", + f"Error: {e!s}", + QMessageBox.StandardButton.Ok, + QMessageBox.StandardButton.No, + ) + + def focusInEvent(self, event: QFocusEvent): + self.setCursorWidth(1) + return super().focusInEvent(event) + + def focusOutEvent(self, event: QFocusEvent): + self.setCursorWidth(0) + return super().focusInEvent(event) + + +# # Usage +# class AIWidget(QWidget): +# def __init__(self): +# super().__init__() +# self.init_ui() +# +# def init_ui(self): +# self.editor = JSONEditor() +# +# self.format_button = QPushButton("Format JSON") +# self.format_button.clicked.connect(self.format_json) +# +# layout = QVBoxLayout() +# layout.addWidget(self.editor) +# layout.addWidget(self.format_button) +# +# self.setLayout(layout) +# self.setWindowTitle("JSON Editor") +# +# def format_json(self): +# return self.editor.format_json() +# +# +# if __name__ == '__main__': +# app = QApplication(sys.argv) +# window = AIWidget() +# window.show() +# sys.exit(app.exec_()) diff --git a/pyqt_openai/widgets/linkLabel.py b/pyqt_openai/widgets/linkLabel.py index 5a31506f..b84633bb 100644 --- a/pyqt_openai/widgets/linkLabel.py +++ b/pyqt_openai/widgets/linkLabel.py @@ -1,22 +1,39 @@ -from PySide6.QtCore import Qt, QUrl -from PySide6.QtGui import QDesktopServices - -from pyqt_openai import DEFAULT_LINK_COLOR, DEFAULT_LINK_HOVER_COLOR -from pyqt_openai.widgets.svgLabel import SvgLabel - - -class LinkLabel(SvgLabel): - def __init__(self, parent=None): - super().__init__(parent) - self.__url = "127.0.0.1" - self.setStyleSheet( - f"QLabel {{ color: {DEFAULT_LINK_COLOR}; }} QLabel:hover {{ color: {DEFAULT_LINK_HOVER_COLOR}; }}" - ) - - def setUrl(self, url): - self.__url = url - - def mouseReleaseEvent(self, QMouseEvent): - v = Qt.MouseButton.LeftButton - if QMouseEvent.button() == v: - QDesktopServices.openUrl(QUrl(self.__url)) +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtCore import QUrl, Qt +from qtpy.QtGui import QDesktopServices + +from pyqt_openai import DEFAULT_LINK_COLOR, DEFAULT_LINK_HOVER_COLOR +from pyqt_openai.widgets.svgLabel import SvgLabel + +if TYPE_CHECKING: + from qtpy.QtGui import QMouseEvent + from qtpy.QtWidgets import QWidget + + +class LinkLabel(SvgLabel): + def __init__( + self, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.__url: str = "127.0.0.1" + self.setStyleSheet( + f"QLabel {{ color: {DEFAULT_LINK_COLOR}; }} QLabel:hover {{ color: {DEFAULT_LINK_HOVER_COLOR}; }}", + ) + + def setUrl( + self, + url: str, + ): + self.__url = url + + def mouseReleaseEvent( + self, + event: QMouseEvent, + ): + v: Qt.MouseButton = Qt.MouseButton.LeftButton + if event.button() == v: + QDesktopServices.openUrl(QUrl(self.__url)) diff --git a/pyqt_openai/widgets/modelInputManualDialog.py b/pyqt_openai/widgets/modelInputManualDialog.py index 02ae58f7..24e93186 100644 --- a/pyqt_openai/widgets/modelInputManualDialog.py +++ b/pyqt_openai/widgets/modelInputManualDialog.py @@ -1,39 +1,47 @@ -from PySide6.QtCore import Qt -from PySide6.QtGui import QFont -from PySide6.QtWidgets import QDialog, QLabel, QHBoxLayout - -from pyqt_openai import SMALL_LABEL_PARAM, DEFAULT_WARNING_COLOR - - -class ModelInputManualDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.__initVal() - self.__initUi() - - def __initVal(self): - self.__warningMessage = ( - "💡 Tip: For models other than OpenAI and Anthropic, enter the model name as " - "[ProviderName]/[ModelName].

" - "🔗 For details on ProviderName and ModelName, check out the " - "LiteLLM documentation! 😊

" - "⚠️ Note: Some models may not support JSON Mode or LlamaIndex features." - ) - - def __initUi(self): - self.setWindowTitle('Model Input Manual') - self.__warningLbl = QLabel() - self.__warningLbl.setStyleSheet( - f"color: {DEFAULT_WARNING_COLOR};" - ) # Text color remains orange for visibility. - self.__warningLbl.setWordWrap(True) - self.__warningLbl.setFont(QFont(SMALL_LABEL_PARAM)) - self.__warningLbl.setTextInteractionFlags( - Qt.TextInteractionFlag.TextBrowserInteraction - ) - self.__warningLbl.setOpenExternalLinks(True) # Enable hyperlink functionality. - self.__warningLbl.setText(self.__warningMessage) # Ensure HTML is passed as text. - - lay = QHBoxLayout() - lay.addWidget(self.__warningLbl) - self.setLayout(lay) \ No newline at end of file +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtCore import Qt +from qtpy.QtGui import QFont +from qtpy.QtWidgets import QDialog, QHBoxLayout, QLabel + +from pyqt_openai import DEFAULT_WARNING_COLOR, SMALL_LABEL_PARAM + +if TYPE_CHECKING: + from qtpy.QtWidgets import QWidget + + +class ModelInputManualDialog(QDialog): + def __init__( + self, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.__initVal() + self.__initUi() + + def __initVal(self): + self.__warningMessage = ( + "💡 Tip: For models other than OpenAI and Anthropic, enter the model name as " + "[ProviderName]/[ModelName].

" + "🔗 For details on ProviderName and ModelName, check out the " + "LiteLLM documentation! 😊

" + "⚠️ Note: Some models may not support JSON Mode or LlamaIndex features." + ) + + def __initUi(self): + self.setWindowTitle("Model Input Manual") + self.__warningLbl: QLabel = QLabel() + self.__warningLbl.setStyleSheet( + f"color: {DEFAULT_WARNING_COLOR};", + ) # Text color remains orange for visibility. + self.__warningLbl.setWordWrap(True) + self.__warningLbl.setFont(QFont(*SMALL_LABEL_PARAM)) + self.__warningLbl.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) + self.__warningLbl.setOpenExternalLinks(True) # Enable hyperlink functionality. + self.__warningLbl.setText(self.__warningMessage) # Ensure HTML is passed as text. + + lay = QHBoxLayout() + lay.addWidget(self.__warningLbl) + self.setLayout(lay) diff --git a/pyqt_openai/widgets/navWidget.py b/pyqt_openai/widgets/navWidget.py index 6bb4abe8..f02093e3 100644 --- a/pyqt_openai/widgets/navWidget.py +++ b/pyqt_openai/widgets/navWidget.py @@ -1,52 +1,69 @@ -from PySide6.QtCore import Signal, Qt -from PySide6.QtWidgets import QWidget, QHBoxLayout, QPushButton, QVBoxLayout - - -class NavBar(QWidget): - itemClicked = Signal(int) # Signal to emit the index when an item is clicked - - def __init__(self, parent=None, orientation=Qt.Orientation.Horizontal): - super().__init__(parent) - self.__initVal() - self.__initUi(orientation) - - def __initVal(self): - self.__buttons = [] # List to store button references - - def __initUi(self, orientation): - if orientation == Qt.Orientation.Horizontal: - lay = QHBoxLayout() - lay.setAlignment(Qt.AlignmentFlag.AlignLeft) - else: - lay = QVBoxLayout() - lay.setAlignment(Qt.AlignmentFlag.AlignTop) - lay.setContentsMargins(0, 0, 0, 0) - self.setLayout(lay) - - def add(self, name): - """Add a new navigation item.""" - button = QPushButton(name) - button_style = """ - QPushButton { - border: none; - background-color: transparent; - font-family: "Arial"; - font-size: 16px; - padding: 10px 15px; - } - QPushButton:hover { - color: #007BFF; /* Highlight color */ - } - """ - button.setStyleSheet(button_style) - index = len(self.__buttons) - button.clicked.connect(lambda: self.itemClicked.emit(index)) - self.layout().addWidget(button) - self.__buttons.append(button) - - def setActiveButton(self, active_index): - """Set the active button as bold.""" - for index, button in enumerate(self.__buttons): - font = button.font() - font.setBold(index == active_index) - button.setFont(font) +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget + +if TYPE_CHECKING: + from qtpy.QtGui import QFont + + +class NavBar(QWidget): + itemClicked = Signal(int) # Signal to emit the index when an item is clicked + + def __init__( + self, + parent: QWidget | None = None, + orientation: Qt.Orientation = Qt.Orientation.Horizontal, + ): + super().__init__(parent) + self.__initVal() + self.__initUi(orientation) + + def __initVal(self): + self.__buttons: list[QPushButton] = [] # List to store button references + + def __initUi(self, orientation: Qt.Orientation): + if orientation == Qt.Orientation.Horizontal: + lay = QHBoxLayout() + lay.setAlignment(Qt.AlignmentFlag.AlignLeft) + else: + lay = QVBoxLayout() + lay.setAlignment(Qt.AlignmentFlag.AlignTop) + lay.setContentsMargins(0, 0, 0, 0) + self.setLayout(lay) + + def add( + self, + name: str, + ): + """Add a new navigation item.""" + button: QPushButton = QPushButton(name) + button_style = """ + QPushButton { + border: none; + background-color: transparent; + font-family: "Arial"; + font-size: 16px; + padding: 10px 15px; + } + QPushButton:hover { + color: #007BFF; /* Highlight color */ + } + """ + button.setStyleSheet(button_style) + index = len(self.__buttons) + button.clicked.connect(lambda: self.itemClicked.emit(index)) + self.layout().addWidget(button) + self.__buttons.append(button) + + def setActiveButton( + self, + active_index: int, + ): + """Set the active button as bold.""" + for index, button in enumerate(self.__buttons): + font: QFont = button.font() + font.setBold(index == active_index) + button.setFont(font) diff --git a/pyqt_openai/widgets/normalImageView.py b/pyqt_openai/widgets/normalImageView.py index 26c23d82..0a04f62b 100644 --- a/pyqt_openai/widgets/normalImageView.py +++ b/pyqt_openai/widgets/normalImageView.py @@ -1,40 +1,63 @@ -from PySide6.QtCore import Qt -from PySide6.QtGui import QPixmap -from PySide6.QtWidgets import QGraphicsScene, QGraphicsView - - -class NormalImageView(QGraphicsView): - def __init__(self): - super().__init__() - self.__aspectRatioMode = Qt.AspectRatioMode.KeepAspectRatio - self.__initVal() - - def __initVal(self): - self._scene = QGraphicsScene() - self._p = QPixmap() - self._item = "" - - def setFilename(self, filename: str): - if filename == "": - pass - else: - self._p = QPixmap(filename) - self._setPixmap(self._p) - - def setPixmap(self, p): - self._setPixmap(p) - - def _setPixmap(self, p): - self._p = p - self._scene = QGraphicsScene() - self._item = self._scene.addPixmap(self._p) - self.setScene(self._scene) - self.fitInView(self._item, self.__aspectRatioMode) - - def setAspectRatioMode(self, mode): - self.__aspectRatioMode = mode - - def resizeEvent(self, event): - if self._item: - self.fitInView(self._item, self.__aspectRatioMode) - return super().resizeEvent(event) +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtCore import Qt +from qtpy.QtGui import QPixmap +from qtpy.QtWidgets import QGraphicsScene, QGraphicsView + +if TYPE_CHECKING: + from qtpy.QtGui import QResizeEvent + from qtpy.QtWidgets import QGraphicsPixmapItem + + +class NormalImageView(QGraphicsView): + def __init__(self): + super().__init__() + self.__aspectRatioMode: Qt.AspectRatioMode = Qt.AspectRatioMode.KeepAspectRatio + self.__initVal() + + def __initVal(self): + self._scene = QGraphicsScene() + self._p = QPixmap() + self._item: str = "" + + def setFilename( + self, + filename: str, + ): + if filename == "": + pass + else: + self._p = QPixmap(filename) + self._setPixmap(self._p) + + def setPixmap( + self, + p: QPixmap, + ): + self._setPixmap(p) + + def _setPixmap( + self, + p: QPixmap, + ): + self._p: QPixmap = p + self._scene: QGraphicsScene = QGraphicsScene() + self._item: QGraphicsPixmapItem = self._scene.addPixmap(self._p) + self.setScene(self._scene) + self.fitInView(self._item, self.__aspectRatioMode) + + def setAspectRatioMode( + self, + mode: Qt.AspectRatioMode, + ): + self.__aspectRatioMode = mode + + def resizeEvent( + self, + event: QResizeEvent, + ): + if self._item: + self.fitInView(self._item, self.__aspectRatioMode) + return super().resizeEvent(event) diff --git a/pyqt_openai/widgets/notifier.py b/pyqt_openai/widgets/notifier.py index 90ed4bfe..7d31d554 100644 --- a/pyqt_openai/widgets/notifier.py +++ b/pyqt_openai/widgets/notifier.py @@ -1,119 +1,121 @@ -from PySide6 import QtGui -from PySide6.QtCore import Qt, Signal, QTimer, QPropertyAnimation -from PySide6.QtGui import QIcon -from PySide6.QtWidgets import ( - QWidget, - QLabel, - QHBoxLayout, - QVBoxLayout, - QPushButton, - QApplication, -) - -from pyqt_openai import ICON_CLOSE, NOTIFIER_MAX_CHAR - - -class NotifierWidget(QWidget): - doubleClicked = Signal() - - def __init__(self, informative_text="", detailed_text="", parent=None): - super().__init__(parent) - self.__timerVal = 10000 - self.__initUi(informative_text, detailed_text) - self.__repositionWidget() - - def __initUi(self, informative_text="", detailed_text=""): - self.setWindowFlags( - Qt.WindowType.FramelessWindowHint - | Qt.WindowType.WindowStaysOnTopHint - | Qt.WindowType.SubWindow - ) - - self.__informativeTextLabel = ( - QLabel(informative_text) if informative_text else QLabel("Informative") - ) - self.__detailedTextLabel = ( - QLabel(detailed_text) if detailed_text else QLabel("Detailed") - ) - self.__detailedTextLabel.setText( - self.__detailedTextLabel.text()[:NOTIFIER_MAX_CHAR] + "..." - ) - self.__detailedTextLabel.setWordWrap(True) - - closeBtn = QPushButton() - closeBtn.clicked.connect(self.close) - closeBtn.setIcon(QIcon(ICON_CLOSE)) - - lay = QHBoxLayout() - lay.setContentsMargins(0, 0, 0, 0) - - self.__btnWidget = QWidget() - self.__btnWidget.setLayout(lay) - - lay = QHBoxLayout() - lay.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight) - lay.addWidget(closeBtn) - lay.setContentsMargins(0, 0, 0, 0) - - customMenuBar = QWidget() - customMenuBar.setLayout(lay) - - lay = QVBoxLayout() - lay.addWidget(customMenuBar) - lay.addWidget(self.__informativeTextLabel) - lay.addWidget(self.__detailedTextLabel) - lay.addWidget(self.__btnWidget) - lay.setContentsMargins(0, 0, 0, 0) - - lay.setContentsMargins(8, 8, 8, 8) - self.setLayout(lay) - self.adjustSize() # Adjust size after setting the layout - - def __repositionWidget(self): - ag = QtGui.QGuiApplication.primaryScreen().availableGeometry() - - # Move to bottom right corner - bottom_right_x = ag.width() - self.width() - bottom_right_y = ag.height() - self.height() - self.move(bottom_right_x, bottom_right_y) - - def keyPressEvent(self, event): - if event.key() == Qt.Key.Key_Escape: - self.close() - - return super().keyPressEvent(event) - - def addWidgets(self, widgets: list): - for widget in widgets: - self.__btnWidget.layout().addWidget(widget) - self.adjustSize() # Adjust size after adding widgets - self.__repositionWidget() # Reposition widget after size adjustment - - def show(self) -> None: - super().show() - self.adjustSize() # Adjust size when showing the widget - self.__repositionWidget() # Reposition widget when showing - QApplication.beep() - self.__timer = QTimer(self) - self.__timer.timeout.connect(self.__checkTimer) - self.__timer.start(1000) - - def __checkTimer(self): - self.__timerVal -= 1000 - if self.__timerVal == 1000: - self.__showAnimation() - elif self.__timerVal <= 0: - self.close() - - def __showAnimation(self): - self.__animation = QPropertyAnimation(self, b"windowOpacity") - self.__animation.finished.connect(self.close) - self.__animation.setDuration(1000) - self.__animation.setStartValue(1.0) - self.__animation.setEndValue(0.0) - self.__animation.start() - - def mouseDoubleClickEvent(self, event): - self.doubleClicked.emit() - self.close() - return super().mouseDoubleClickEvent(event) +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy import QtGui +from qtpy.QtCore import QPropertyAnimation, QTimer, Qt, Signal +from qtpy.QtGui import QIcon +from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget + +from pyqt_openai import ICON_CLOSE, NOTIFIER_MAX_CHAR + +if TYPE_CHECKING: + from qtpy.QtGui import QMouseEvent + + +class NotifierWidget(QWidget): + doubleClicked = Signal() + + def __init__( + self, + informative_text: str = "", + detailed_text: str = "", + parent: QWidget | None = None, + ): + super().__init__(parent) + self.__timerVal: int = 10000 + self.__initUi(informative_text, detailed_text) + self.__repositionWidget() + + def __initUi( + self, + informative_text: str = "", + detailed_text: str = "", + ): + self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.SubWindow) + + self.__informativeTextLabel = QLabel(informative_text) if informative_text else QLabel("Informative") + self.__detailedTextLabel = QLabel(detailed_text) if detailed_text else QLabel("Detailed") + self.__detailedTextLabel.setText(self.__detailedTextLabel.text()[:NOTIFIER_MAX_CHAR] + "...") + self.__detailedTextLabel.setWordWrap(True) + + closeBtn = QPushButton() + closeBtn.clicked.connect(self.close) + closeBtn.setIcon(QIcon(ICON_CLOSE)) + + lay = QHBoxLayout() + lay.setContentsMargins(0, 0, 0, 0) + + self.__btnWidget = QWidget() + self.__btnWidget.setLayout(lay) + + lay = QHBoxLayout() + lay.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight) + lay.addWidget(closeBtn) + lay.setContentsMargins(0, 0, 0, 0) + + customMenuBar = QWidget() + customMenuBar.setLayout(lay) + + lay = QVBoxLayout() + lay.addWidget(customMenuBar) + lay.addWidget(self.__informativeTextLabel) + lay.addWidget(self.__detailedTextLabel) + lay.addWidget(self.__btnWidget) + lay.setContentsMargins(0, 0, 0, 0) + + lay.setContentsMargins(8, 8, 8, 8) + self.setLayout(lay) + self.adjustSize() # Adjust size after setting the layout + + def __repositionWidget(self): + ag = QtGui.QGuiApplication.primaryScreen().availableGeometry() + + # Move to bottom right corner + bottom_right_x = ag.width() - self.width() + bottom_right_y = ag.height() - self.height() + self.move(bottom_right_x, bottom_right_y) + + def keyPressEvent(self, event): + if event.key() == Qt.Key.Key_Escape: + self.close() + + return super().keyPressEvent(event) + + def addWidgets( + self, + widgets: list[QWidget], + ): + for widget in widgets: + self.__btnWidget.layout().addWidget(widget) + self.adjustSize() # Adjust size after adding widgets + self.__repositionWidget() # Reposition widget after size adjustment + + def show(self) -> None: + super().show() + self.adjustSize() # Adjust size when showing the widget + self.__repositionWidget() # Reposition widget when showing + QApplication.beep() + self.__timer = QTimer(self) + self.__timer.timeout.connect(self.__checkTimer) + self.__timer.start(1000) + + def __checkTimer(self): + self.__timerVal -= 1000 + if self.__timerVal == 1000: + self.__showAnimation() + elif self.__timerVal <= 0: + self.close() + + def __showAnimation(self): + self.__animation: QPropertyAnimation = QPropertyAnimation(self, b"windowOpacity") + self.__animation.finished.connect(self.close) + self.__animation.setDuration(1000) + self.__animation.setStartValue(1.0) + self.__animation.setEndValue(0.0) + self.__animation.start() + + def mouseDoubleClickEvent(self, event: QMouseEvent): + self.doubleClicked.emit() + self.close() + return super().mouseDoubleClickEvent(event) diff --git a/pyqt_openai/widgets/questionTooltipLabel.py b/pyqt_openai/widgets/questionTooltipLabel.py index 887053b0..264111b0 100644 --- a/pyqt_openai/widgets/questionTooltipLabel.py +++ b/pyqt_openai/widgets/questionTooltipLabel.py @@ -1,11 +1,22 @@ -from PySide6.QtGui import QPixmap - -from pyqt_openai import ICON_QUESTION -from pyqt_openai.widgets.svgLabel import SvgLabel - - -class QuestionTooltipLabel(SvgLabel): - def __init__(self, tooltip="Click for more information", parent=None): - super().__init__(parent) - self.setPixmap(QPixmap(ICON_QUESTION)) - self.setToolTip(tooltip) +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtGui import QPixmap + +from pyqt_openai import ICON_QUESTION +from pyqt_openai.widgets.svgLabel import SvgLabel + +if TYPE_CHECKING: + from qtpy.QtWidgets import QWidget + + +class QuestionTooltipLabel(SvgLabel): + def __init__( + self, + tooltip: str = "Click for more information", + parent: QWidget | None = None, + ): + super().__init__(parent) + self.setPixmap(QPixmap(ICON_QUESTION)) + self.setToolTip(tooltip) diff --git a/pyqt_openai/widgets/randomImagePromptGeneratorWidget.py b/pyqt_openai/widgets/randomImagePromptGeneratorWidget.py index 83d02021..1b12aacf 100644 --- a/pyqt_openai/widgets/randomImagePromptGeneratorWidget.py +++ b/pyqt_openai/widgets/randomImagePromptGeneratorWidget.py @@ -1,77 +1,75 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QVBoxLayout, - QWidget, - QListWidgetItem, - QLabel, - QCheckBox, - QGroupBox, -) - -from pyqt_openai import RANDOMIZING_PROMPT_SOURCE_ARR -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.widgets.checkBoxListWidget import CheckBoxListWidget - - -class RandomImagePromptGeneratorWidget(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.__initUi() - - def __initUi(self): - # TODO LANGUAGE - self.setWindowTitle("Random Sentence Generator") - - lbl = QLabel("Select the elements you want to include in the prompt") - - self.__allCheckBox = QCheckBox(LangClass.TRANSLATIONS["Select All"]) - - self.__listWidget = CheckBoxListWidget() - - for e in RANDOMIZING_PROMPT_SOURCE_ARR: - item = QListWidgetItem(", ".join(e)) - item.setFlags( - item.flags() - | Qt.ItemFlag.ItemIsUserCheckable - | Qt.ItemFlag.ItemIsEditable - ) - item.setCheckState(Qt.CheckState.Unchecked) - self.__listWidget.addItem(item) - - lay = QVBoxLayout() - lay.addWidget(lbl) - lay.addWidget(self.__allCheckBox) - lay.addWidget(self.__listWidget) - - self.__randomPromptGroup = QGroupBox() - self.__randomPromptGroup.setTitle("List of random words to generate a prompt") - self.__randomPromptGroup.setLayout(lay) - - useBtn = QCheckBox("Use random-generated prompt") - useBtn.toggled.connect(self.__toggleRandomPrompt) - - lay = QVBoxLayout() - lay.addWidget(useBtn) - lay.addWidget(self.__randomPromptGroup) - lay.setContentsMargins(0, 0, 0, 0) - - self.setLayout(lay) - - self.__allCheckBox.stateChanged.connect( - self.__listWidget.toggleState - ) # if allChkBox is checked, tablewidget checkboxes will also be checked - self.__randomPromptGroup.setVisible(False) - - def isRandomPromptEnabled(self): - return self.__randomPromptGroup.isVisible() - - def __toggleRandomPrompt(self, f): - self.__randomPromptGroup.setVisible(f) - self.__allCheckBox.setChecked(f) - - def getRandomPromptSourceArr(self): - return ( - [t.split(", ") for t in self.__listWidget.getCheckedItemsText()] - if self.isRandomPromptEnabled() - else None - ) +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QCheckBox, QGroupBox, QLabel, QListWidgetItem, QVBoxLayout, QWidget + +from pyqt_openai import RANDOMIZING_PROMPT_SOURCE_ARR +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.widgets.checkBoxListWidget import CheckBoxListWidget + + +class RandomImagePromptGeneratorWidget(QWidget): + def __init__( + self, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.__initUi() + + def __initUi(self): + # TODO LANGUAGE + self.setWindowTitle("Random Sentence Generator") + + lbl = QLabel("Select the elements you want to include in the prompt") + + self.__allCheckBox: QCheckBox = QCheckBox(LangClass.TRANSLATIONS["Select All"]) + + self.__listWidget: CheckBoxListWidget = CheckBoxListWidget() + + for e in RANDOMIZING_PROMPT_SOURCE_ARR: + item = QListWidgetItem(", ".join(e)) + item.setFlags( + item.flags() + | Qt.ItemFlag.ItemIsUserCheckable + | Qt.ItemFlag.ItemIsEditable, + ) + item.setCheckState(Qt.CheckState.Unchecked) + self.__listWidget.addItem(item) + + lay = QVBoxLayout() + lay.addWidget(lbl) + lay.addWidget(self.__allCheckBox) + lay.addWidget(self.__listWidget) + + self.__randomPromptGroup: QGroupBox = QGroupBox() + self.__randomPromptGroup.setTitle("List of random words to generate a prompt") + self.__randomPromptGroup.setLayout(lay) + + useBtn = QCheckBox("Use random-generated prompt") + useBtn.toggled.connect(self.__toggleRandomPrompt) + + lay = QVBoxLayout() + lay.addWidget(useBtn) + lay.addWidget(self.__randomPromptGroup) + lay.setContentsMargins(0, 0, 0, 0) + + self.setLayout(lay) + + self.__allCheckBox.stateChanged.connect( + self.__listWidget.toggleState, + ) # if allChkBox is checked, tablewidget checkboxes will also be checked + self.__randomPromptGroup.setVisible(False) + + def isRandomPromptEnabled(self): + return self.__randomPromptGroup.isVisible() + + def __toggleRandomPrompt(self, f: bool): + self.__randomPromptGroup.setVisible(f) + self.__allCheckBox.setChecked(f) + + def getRandomPromptSourceArr(self) -> list[list[str]] | None: + return ( + [t.split(", ") for t in self.__listWidget.getCheckedItemsText()] + if self.isRandomPromptEnabled() + else None + ) diff --git a/pyqt_openai/widgets/scrollableErrorDialog.py b/pyqt_openai/widgets/scrollableErrorDialog.py index 5d7e2c4b..08060118 100644 --- a/pyqt_openai/widgets/scrollableErrorDialog.py +++ b/pyqt_openai/widgets/scrollableErrorDialog.py @@ -1,59 +1,66 @@ -from PySide6.QtWidgets import ( - QDialog, - QLabel, - QVBoxLayout, - QScrollArea, - QPushButton, - QHBoxLayout, -) - -from pyqt_openai import REPORT_ERROR_URL -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.widgets.linkLabel import LinkLabel - - -class ScrollableErrorDialog(QDialog): - def __init__(self, error_msg, parent=None): - super().__init__(parent) - self.__initUi(error_msg) - - def __initUi(self, error_msg): - # TODO LANGUAGE - self.setWindowTitle(LangClass.TRANSLATIONS["Error"]) - - # Main layout - main_layout = QVBoxLayout(self) - - # Add a label to display the critical error title - label = QLabel("An unexpected error occurred.") - main_layout.addWidget(label) - - # Scroll Area to hold the error message - scroll_area = QScrollArea(self) - scroll_area.setWidgetResizable(True) - - # Error message label inside the scroll area - error_label = QLabel(error_msg) - error_label.setWordWrap(True) - - # Set the error message into the scroll area - scroll_area.setWidget(error_label) - - # Add the scroll area to the main layout - main_layout.addWidget(scroll_area) - - # Button layout for the "OK" button - button_layout = QHBoxLayout() - report_error_lbl = LinkLabel() - report_error_lbl.setUrl(REPORT_ERROR_URL) - # TODO LANGUAGE - report_error_lbl.setText("Report Error") - - ok_button = QPushButton(LangClass.TRANSLATIONS["OK"]) - ok_button.clicked.connect(self.accept) # Close the dialog when OK is clicked - button_layout.addWidget(report_error_lbl) - button_layout.addStretch(1) # To push the button to the right - button_layout.addWidget(ok_button) - - # Add the button layout to the main layout - main_layout.addLayout(button_layout) +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QScrollArea, QVBoxLayout + +from pyqt_openai import REPORT_ERROR_URL +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.widgets.linkLabel import LinkLabel + +if TYPE_CHECKING: + from qtpy.QtWidgets import QWidget + + +class ScrollableErrorDialog(QDialog): + def __init__( + self, + error_msg: str, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.__initUi(error_msg) + + def __initUi( + self, + error_msg: str, + ): + # TODO LANGUAGE + self.setWindowTitle(LangClass.TRANSLATIONS["Error"]) + + # Main layout + main_layout = QVBoxLayout(self) + + # Add a label to display the critical error title + label = QLabel("An unexpected error occurred.") + main_layout.addWidget(label) + + # Scroll Area to hold the error message + scroll_area = QScrollArea(self) + scroll_area.setWidgetResizable(True) + + # Error message label inside the scroll area + error_label = QLabel(error_msg) + error_label.setWordWrap(True) + + # Set the error message into the scroll area + scroll_area.setWidget(error_label) + + # Add the scroll area to the main layout + main_layout.addWidget(scroll_area) + + # Button layout for the "OK" button + button_layout = QHBoxLayout() + report_error_lbl = LinkLabel() + report_error_lbl.setUrl(REPORT_ERROR_URL) + # TODO LANGUAGE + report_error_lbl.setText("Report Error") + + ok_button = QPushButton(LangClass.TRANSLATIONS["OK"]) + ok_button.clicked.connect(self.accept) # Close the dialog when OK is clicked + button_layout.addWidget(report_error_lbl) + button_layout.addStretch(1) # To push the button to the right + button_layout.addWidget(ok_button) + + # Add the button layout to the main layout + main_layout.addLayout(button_layout) diff --git a/pyqt_openai/widgets/searchBar.py b/pyqt_openai/widgets/searchBar.py index 30f3e5d1..7237abe1 100644 --- a/pyqt_openai/widgets/searchBar.py +++ b/pyqt_openai/widgets/searchBar.py @@ -1,110 +1,115 @@ -from PySide6.QtWidgets import ( - QWidget, - QLineEdit, - QGridLayout, - QLabel, - QHBoxLayout, - QApplication, - QSizePolicy, -) -from PySide6.QtCore import Signal - -from pyqt_openai import ICON_SEARCH -from pyqt_openai.widgets.svgLabel import SvgLabel - - -class SearchBar(QWidget): - searched = Signal(str) - - def __init__(self, parent=None): - super().__init__(parent) - # search bar label - self.__label = QLabel() - - self._initUi() - - def _initUi(self): - self.__searchLineEdit = QLineEdit() - self.__searchIconLbl = SvgLabel() - ps = QApplication.font().pointSize() - self.__searchIconLbl.setFixedSize(ps, ps) - - self.__searchBar = QWidget() - self.__searchBar.setObjectName("searchBar") - - lay = QHBoxLayout() - lay.addWidget(self.__searchIconLbl) - lay.addWidget(self.__searchLineEdit) - self.__searchBar.setLayout(lay) - lay.setContentsMargins(ps // 2, 0, 0, 0) - lay.setSpacing(0) - - self.__searchLineEdit.setFocus() - self.__searchLineEdit.textChanged.connect(self.__searched) - - self.setAutoFillBackground(True) - - lay = QHBoxLayout() - lay.addWidget(self.__searchBar) - lay.setContentsMargins(0, 0, 0, 0) - lay.setSpacing(2) - - self._topWidget = QWidget() - self._topWidget.setLayout(lay) - - lay = QGridLayout() - lay.addWidget(self._topWidget) - - searchWidget = QWidget() - searchWidget.setLayout(lay) - lay.setContentsMargins(0, 0, 0, 0) - - lay = QGridLayout() - lay.addWidget(searchWidget) - lay.setContentsMargins(0, 0, 0, 0) - - self.setSizePolicy( - QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Preferred - ) - - self.__setStyle() - - self.setLayout(lay) - - # ex) searchBar.setLabel(True, 'Search Text') - def setLabel(self, visibility: bool = True, text=None): - if text: - self.__label.setText(text) - self.__label.setVisible(visibility) - - def __setStyle(self): - self.__searchIconLbl.setSvgFile(ICON_SEARCH) - self.setStyleSheet( - f""" - QLineEdit - {{ - background: transparent; - border: none; - }} - QWidget#searchBar - {{ - border: 1px solid gray; - }} - QWidget {{ padding: 5px; }} - """ - ) - - def __searched(self, text): - self.searched.emit(text) - - def setSearchIcon(self, icon_filename: str): - self.__searchIconLbl.setIcon(icon_filename) - - def setPlaceHolder(self, text: str): - self.__searchLineEdit.setPlaceholderText(text) - - def getSearchBar(self): - return self.__searchLineEdit - - def getSearchLabel(self): - return self.__searchIconLbl +from __future__ import annotations + +from qtpy.QtCore import Signal +from qtpy.QtWidgets import QApplication, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QSizePolicy, QWidget + +from pyqt_openai import ICON_SEARCH +from pyqt_openai.widgets.svgLabel import SvgLabel + + +class SearchBar(QWidget): + searched = Signal(str) + + def __init__( + self, + parent: QWidget | None = None, + ): + super().__init__(parent) + # search bar label + self.__label: QLabel = QLabel() + + self._initUi() + + def _initUi(self): + self.__searchLineEdit: QLineEdit = QLineEdit() + self.__searchIconLbl: SvgLabel = SvgLabel() + ps = QApplication.font().pointSize() + self.__searchIconLbl.setFixedSize(ps, ps) + + self.__searchBar: QWidget = QWidget() + self.__searchBar.setObjectName("searchBar") + + lay = QHBoxLayout() + lay.addWidget(self.__searchIconLbl) + lay.addWidget(self.__searchLineEdit) + self.__searchBar.setLayout(lay) + lay.setContentsMargins(ps // 2, 0, 0, 0) + lay.setSpacing(0) + + self.__searchLineEdit.setFocus() + self.__searchLineEdit.textChanged.connect(self.__searched) + + self.setAutoFillBackground(True) + + lay = QHBoxLayout() + lay.addWidget(self.__searchBar) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(2) + + self._topWidget: QWidget = QWidget() + self._topWidget.setLayout(lay) + + lay = QGridLayout() + lay.addWidget(self._topWidget) + + searchWidget: QWidget = QWidget() + searchWidget.setLayout(lay) + lay.setContentsMargins(0, 0, 0, 0) + + lay = QGridLayout() + lay.addWidget(searchWidget) + lay.setContentsMargins(0, 0, 0, 0) + + self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Preferred) + + self.__setStyle() + + self.setLayout(lay) + + # ex) searchBar.setLabel(True, 'Search Text') + def setLabel( + self, + visibility: bool = True, + text: str | None = None, + ): + if text: + self.__label.setText(text) + self.__label.setVisible(visibility) + + def __setStyle(self): + self.__searchIconLbl.setSvgFile(ICON_SEARCH) + self.setStyleSheet( + """ + QLineEdit + { + background: transparent; + border: none; + } + QWidget#searchBar + { + border: 1px solid gray; + } + QWidget { padding: 5px; } + """, + ) + + def __searched(self, text: str): + self.searched.emit(text) + + def setSearchIcon( + self, + icon_filename: str, + ): + self.__searchIconLbl.setSvgFile(icon_filename) + + def setPlaceHolder( + self, + text: str, + ): + self.__searchLineEdit.setPlaceholderText(text) + + def getSearchBar(self) -> QLineEdit: + return self.__searchLineEdit + + def getSearchLabel(self) -> SvgLabel: + return self.__searchIconLbl diff --git a/pyqt_openai/widgets/showingKeyUserInputLineEdit.py b/pyqt_openai/widgets/showingKeyUserInputLineEdit.py index 79245ba3..0a7bb2c6 100644 --- a/pyqt_openai/widgets/showingKeyUserInputLineEdit.py +++ b/pyqt_openai/widgets/showingKeyUserInputLineEdit.py @@ -1,75 +1,90 @@ -# TODO WILL_BE_USED AFTER v2.x.0 - -# This works perfectly for showing the key combination in a QLineEdit widget. - -from PySide6.QtCore import Qt -from PySide6.QtGui import QKeyEvent -from PySide6.QtWidgets import QLineEdit - - -class ShowingKeyUserInputLineEdit(QLineEdit): - def __init__(self, parent=None): - super().__init__(parent) - self.shortcut = "" - - def keyPressEvent(self, event: QKeyEvent): - modifiers = event.modifiers() - key = event.key() - - key_text = self._get_key_text(key) - if key_text: - parts = [] - - if modifiers & Qt.KeyboardModifier.ControlModifier: - parts.append("Ctrl") - if modifiers & Qt.KeyboardModifier.AltModifier: - parts.append("Alt") - if modifiers & Qt.KeyboardModifier.ShiftModifier: - parts.append("Shift") - if modifiers & Qt.KeyboardModifier.MetaModifier: - parts.append("Meta") - - parts.append(key_text) - self.shortcut = "+".join(parts) - self.setText(self.shortcut) - - def _get_key_text(self, key): - special_keys = { - Qt.Key.Key_Escape: "Esc", - Qt.Key.Key_Tab: "Tab", - Qt.Key.Key_Backtab: "Backtab", - Qt.Key.Key_Backspace: "Backspace", - Qt.Key.Key_Return: "Return", - Qt.Key.Key_Enter: "Enter", - Qt.Key.Key_Insert: "Ins", - Qt.Key.Key_Delete: "Del", - Qt.Key.Key_Pause: "Pause", - Qt.Key.Key_Print: "Print", - Qt.Key.Key_SysReq: "SysReq", - Qt.Key.Key_Clear: "Clear", - Qt.Key.Key_Home: "Home", - Qt.Key.Key_End: "End", - Qt.Key.Key_Left: "Left", - Qt.Key.Key_Up: "Up", - Qt.Key.Key_Right: "Right", - Qt.Key.Key_Down: "Down", - Qt.Key.Key_PageUp: "PageUp", - Qt.Key.Key_PageDown: "PageDown", - Qt.Key.Key_Space: "Space", - Qt.Key.Key_CapsLock: "CapsLock", - Qt.Key.Key_NumLock: "NumLock", - Qt.Key.Key_ScrollLock: "ScrollLock", - Qt.Key.Key_Menu: "Menu", - Qt.Key.Key_Help: "Help", - } - - if key in special_keys: - return special_keys[key] - elif 0x20 <= key <= 0x7E: # Regular printable ASCII characters - return chr(key) - elif 0x100 <= key <= 0x10FFFF: # Other valid Unicode characters - try: - return chr(key).upper() - except ValueError: - pass - return None +# TODO WILL_BE_USED AFTER v2.x.0 + +# This works perfectly for showing the key combination in a QLineEdit widget. +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QLineEdit + +if TYPE_CHECKING: + from qtpy.QtGui import QKeyEvent + from qtpy.QtWidgets import QWidget + + +class ShowingKeyUserInputLineEdit(QLineEdit): + def __init__( + self, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.shortcut: str = "" + + def keyPressEvent( + self, + event: QKeyEvent, + ): + modifiers: Qt.KeyboardModifier = event.modifiers() + key: Qt.Key | int = event.key() + + key_text: str | None = self._get_key_text(key) + if key_text: + parts: list[str] = [] + + if modifiers & Qt.KeyboardModifier.ControlModifier: + parts.append("Ctrl") + if modifiers & Qt.KeyboardModifier.AltModifier: + parts.append("Alt") + if modifiers & Qt.KeyboardModifier.ShiftModifier: + parts.append("Shift") + if modifiers & Qt.KeyboardModifier.MetaModifier: + parts.append("Meta") + + parts.append(key_text) + self.shortcut = "+".join(parts) + self.setText(self.shortcut) + + def _get_key_text( + self, + key: Qt.Key | int, + ) -> str | None: + special_keys: dict[Qt.Key, str] = { + Qt.Key.Key_Escape: "Esc", + Qt.Key.Key_Tab: "Tab", + Qt.Key.Key_Backtab: "Backtab", + Qt.Key.Key_Backspace: "Backspace", + Qt.Key.Key_Return: "Return", + Qt.Key.Key_Enter: "Enter", + Qt.Key.Key_Insert: "Ins", + Qt.Key.Key_Delete: "Del", + Qt.Key.Key_Pause: "Pause", + Qt.Key.Key_Print: "Print", + Qt.Key.Key_SysReq: "SysReq", + Qt.Key.Key_Clear: "Clear", + Qt.Key.Key_Home: "Home", + Qt.Key.Key_End: "End", + Qt.Key.Key_Left: "Left", + Qt.Key.Key_Up: "Up", + Qt.Key.Key_Right: "Right", + Qt.Key.Key_Down: "Down", + Qt.Key.Key_PageUp: "PageUp", + Qt.Key.Key_PageDown: "PageDown", + Qt.Key.Key_Space: "Space", + Qt.Key.Key_CapsLock: "CapsLock", + Qt.Key.Key_NumLock: "NumLock", + Qt.Key.Key_ScrollLock: "ScrollLock", + Qt.Key.Key_Menu: "Menu", + Qt.Key.Key_Help: "Help", + } + + if key in special_keys: + return special_keys[key] + if 0x20 <= key <= 0x7E: # Regular printable ASCII characters + return chr(key) + if 0x100 <= key <= 0x10FFFF: # Other valid Unicode characters + try: + return chr(key).upper() + except ValueError: + pass + return None diff --git a/pyqt_openai/widgets/svgLabel.py b/pyqt_openai/widgets/svgLabel.py index 5f0b9d44..f6e49f18 100644 --- a/pyqt_openai/widgets/svgLabel.py +++ b/pyqt_openai/widgets/svgLabel.py @@ -1,23 +1,38 @@ -import os - -from PySide6.QtGui import QPainter -from PySide6.QtSvg import QSvgRenderer -from PySide6.QtWidgets import QLabel - - -class SvgLabel(QLabel): - def __init__(self, parent=None): - super().__init__(parent) - self.__renderer = "" - - def paintEvent(self, event): - painter = QPainter(self) - if self.__renderer: - self.__renderer.render(painter) - return super().paintEvent(event) - - def setSvgFile(self, filename: str): - self.__renderer = QSvgRenderer(filename) - self.resize(self.__renderer.defaultSize()) - length = max(self.sizeHint().width(), self.sizeHint().height()) - self.setFixedSize(length, length) +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtGui import QPainter +from qtpy.QtSvg import QSvgRenderer +from qtpy.QtWidgets import QLabel + +if TYPE_CHECKING: + from qtpy.QtGui import QPaintEvent + from qtpy.QtWidgets import QWidget + + +class SvgLabel(QLabel): + def __init__( + self, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.__renderer: QSvgRenderer | None = None + + def paintEvent( + self, + event: QPaintEvent, + ): + painter = QPainter(self) + if self.__renderer: + self.__renderer.render(painter) + return super().paintEvent(event) + + def setSvgFile( + self, + filename: str, + ): + self.__renderer = QSvgRenderer(filename) + self.resize(self.__renderer.defaultSize()) + length = max(self.sizeHint().width(), self.sizeHint().height()) + self.setFixedSize(length, length) diff --git a/pyqt_openai/widgets/thumbnailView.py b/pyqt_openai/widgets/thumbnailView.py index 89e9ad07..2c8b86ed 100644 --- a/pyqt_openai/widgets/thumbnailView.py +++ b/pyqt_openai/widgets/thumbnailView.py @@ -1,168 +1,194 @@ -import os - -from PySide6.QtCore import Qt, QPointF, Signal -from PySide6.QtGui import QPixmap, QColor, QBrush, QLinearGradient -from PySide6.QtWidgets import ( - QGraphicsScene, - QGraphicsPixmapItem, - QGraphicsView, - QApplication, - QWidget, - QHBoxLayout, - QFileDialog, -) - -from pyqt_openai import ICON_SAVE, ICON_ADD, ICON_DELETE, ICON_COPY -from pyqt_openai.lang.translations import LangClass -from pyqt_openai.widgets.button import Button - - -class ThumbnailView(QGraphicsView): - clicked = Signal(QPixmap) - - def __init__(self): - super().__init__() - self.__initVal() - self.__initUi() - - def __initVal(self): - self._scene = QGraphicsScene() - self._p = QPixmap() - self._item = QGraphicsPixmapItem() - self.__aspectRatioMode = Qt.AspectRatioMode.KeepAspectRatio - - self.__factor = 1.1 # Zoom factor - - def __initUi(self): - self.__setControlWidget() - - # set mouse event - # to make buttons appear and apply gradient - # above the top of an image when you hover the mouse cursor over it - self.setMouseTracking(True) - self.__defaultBrush = self.foregroundBrush() - gradient = QLinearGradient(QPointF(0, 0), QPointF(0, self.viewport().height())) - gradient.setColorAt(0, QColor(0, 0, 0, 200)) - gradient.setColorAt(1, QColor(0, 0, 0, 0)) - self.__brush = QBrush(gradient) - - self.setMinimumSize(150, 150) - - def __setControlWidget(self): - # copy the image - copyBtn = Button() - copyBtn.setStyleAndIcon(ICON_COPY) - copyBtn.clicked.connect(self.__copy) - - # download the image - saveBtn = Button() - saveBtn.setStyleAndIcon(ICON_SAVE) - saveBtn.clicked.connect(self.__save) - - # zoom in - zoomInBtn = Button() - zoomInBtn.setStyleAndIcon(ICON_ADD) - zoomInBtn.clicked.connect(self.__zoomIn) - - # zoom out - zoomOutBtn = Button() - zoomOutBtn.setStyleAndIcon(ICON_DELETE) - zoomOutBtn.clicked.connect(self.__zoomOut) - - lay = QHBoxLayout() - lay.addWidget(copyBtn) - lay.addWidget(saveBtn) - lay.addWidget(zoomInBtn) - lay.addWidget(zoomOutBtn) - - self.__controlWidget = QWidget(self) - self.__controlWidget.setLayout(lay) - - self.__controlWidget.hide() - - def __refreshSceneAndView(self): - self._item = self._scene.addPixmap(self._p) - self._item.setTransformationMode(Qt.TransformationMode.SmoothTransformation) - rect = ( - self.sceneRect() - if (self._item.boundingRect().width() > self.sceneRect().width()) - or (self._item.boundingRect().height() > self.sceneRect().height()) - else self._item.boundingRect() - ) - self.fitInView(rect, self.__aspectRatioMode) - self.setScene(self._scene) - - def setFilename(self, filename: str): - self._scene = QGraphicsScene() - self._p = QPixmap(filename) - self.__refreshSceneAndView() - - def setContent(self, content): - self._scene = QGraphicsScene() - self._p.loadFromData(content) - self.__refreshSceneAndView() - - def setPixmap(self, pixmap): - self._scene = QGraphicsScene() - self._p = pixmap - self.__refreshSceneAndView() - - def setAspectRatioMode(self, mode): - self.__aspectRatioMode = mode - - def __copy(self): - QApplication.clipboard().setPixmap(self._p) - - def __save(self): - filename = QFileDialog.getSaveFileName( - self, - LangClass.TRANSLATIONS["Save"], - os.path.expanduser("~"), - "Image file (*.png)", - ) - if filename[0]: - filename = filename[0] - if filename: - self._p.save(filename) - - def __zoomIn(self): - self.scale(self.__factor, self.__factor) - - def __zoomOut(self): - self.scale(1 / self.__factor, 1 / self.__factor) - - def enterEvent(self, event): - # Show the button when the mouse enters the view - if self._item.pixmap().width(): - self.__controlWidget.move(self.rect().x(), self.rect().y()) - self.setForegroundBrush(self.__brush) - self.__controlWidget.show() - return super().enterEvent(event) - - def leaveEvent(self, event): - # Hide the button when the mouse leaves the view - self.__controlWidget.hide() - self.setForegroundBrush(self.__defaultBrush) - return super().leaveEvent(event) - - def resizeEvent(self, event): - if self._item.pixmap().width(): - self.setScene(self._scene) - return super().resizeEvent(event) - - def mousePressEvent(self, event): - self.clicked.emit(self._p) - return super().mousePressEvent(event) - - def wheelEvent(self, event): - if event.modifiers() == Qt.KeyboardModifier.ControlModifier: - # Check if Ctrl key is pressed - if event.angleDelta().y() > 0: - # Ctrl + wheel up, zoom in - self.__zoomIn() - else: - # Ctrl + wheel down, zoom out - self.__zoomOut() - event.accept() # Accept the event if Ctrl is pressed - else: - super().wheelEvent(event) # Default behavior for other cases +from __future__ import annotations + +import os + +from typing import TYPE_CHECKING + +from qtpy.QtCore import QPointF, Qt, Signal +from qtpy.QtGui import QBrush, QColor, QLinearGradient, QPixmap +from qtpy.QtWidgets import QApplication, QFileDialog, QGraphicsPixmapItem, QGraphicsScene, QGraphicsView, QHBoxLayout, QWidget + +from pyqt_openai import ICON_ADD, ICON_COPY, ICON_DELETE, ICON_SAVE +from pyqt_openai.lang.translations import LangClass +from pyqt_openai.widgets.button import Button + +if TYPE_CHECKING: + from qtpy.QtCore import QEvent + from qtpy.QtGui import QEnterEvent, QMouseEvent, QResizeEvent, QWheelEvent + + +class ThumbnailView(QGraphicsView): + clicked = Signal(QPixmap) + + def __init__(self): + super().__init__() + self.__initVal() + self.__initUi() + + def __initVal(self): + self._scene: QGraphicsScene = QGraphicsScene() + self._p: QPixmap = QPixmap() + self._item: QGraphicsPixmapItem = QGraphicsPixmapItem() + self.__aspectRatioMode: Qt.AspectRatioMode = Qt.AspectRatioMode.KeepAspectRatio + + self.__factor: float = 1.1 # Zoom factor + + def __initUi(self): + self.__setControlWidget() + + # set mouse event + # to make buttons appear and apply gradient + # above the top of an image when you hover the mouse cursor over it + self.setMouseTracking(True) + self.__defaultBrush: QBrush = self.foregroundBrush() + gradient = QLinearGradient(QPointF(0, 0), QPointF(0, self.viewport().height())) + gradient.setColorAt(0, QColor(0, 0, 0, 200)) + gradient.setColorAt(1, QColor(0, 0, 0, 0)) + self.__brush: QBrush = QBrush(gradient) + + self.setMinimumSize(150, 150) + + def __setControlWidget(self): + # copy the image + copyBtn = Button() + copyBtn.setStyleAndIcon(ICON_COPY) + copyBtn.clicked.connect(self.__copy) + + # download the image + saveBtn = Button() + saveBtn.setStyleAndIcon(ICON_SAVE) + saveBtn.clicked.connect(self.__save) + + # zoom in + zoomInBtn = Button() + zoomInBtn.setStyleAndIcon(ICON_ADD) + zoomInBtn.clicked.connect(self.__zoomIn) + + # zoom out + zoomOutBtn = Button() + zoomOutBtn.setStyleAndIcon(ICON_DELETE) + zoomOutBtn.clicked.connect(self.__zoomOut) + + lay = QHBoxLayout() + lay.addWidget(copyBtn) + lay.addWidget(saveBtn) + lay.addWidget(zoomInBtn) + lay.addWidget(zoomOutBtn) + + self.__controlWidget: QWidget = QWidget(self) + self.__controlWidget.setLayout(lay) + + self.__controlWidget.hide() + + def __refreshSceneAndView(self): + self._item: QGraphicsPixmapItem = self._scene.addPixmap(self._p) + self._item.setTransformationMode(Qt.TransformationMode.SmoothTransformation) + rect = ( + self.sceneRect() + if (self._item.boundingRect().width() > self.sceneRect().width()) or (self._item.boundingRect().height() > self.sceneRect().height()) + else self._item.boundingRect() + ) + self.fitInView(rect, self.__aspectRatioMode) + self.setScene(self._scene) + + def setFilename( + self, + filename: str, + ): + self._scene = QGraphicsScene() + self._p = QPixmap(filename) + self.__refreshSceneAndView() + + def setContent( + self, + content: bytes, + ): + self._scene = QGraphicsScene() + self._p.loadFromData(content) + self.__refreshSceneAndView() + + def setPixmap( + self, + pixmap: QPixmap, + ): + self._scene = QGraphicsScene() + self._p = pixmap + self.__refreshSceneAndView() + + def setAspectRatioMode( + self, + mode: Qt.AspectRatioMode, + ): + self.__aspectRatioMode = mode + + def __copy(self): + QApplication.clipboard().setPixmap(self._p) + + def __save(self): + filename: tuple[str, str] = QFileDialog.getSaveFileName( + self, + LangClass.TRANSLATIONS["Save"], + os.path.expanduser("~"), + "Image file (*.png)", + ) + if filename[0] and filename[0].strip(): + filename = filename[0] + if filename: + self._p.save(filename) + + def __zoomIn(self): + self.scale(self.__factor, self.__factor) + + def __zoomOut(self): + self.scale(1 / self.__factor, 1 / self.__factor) + + def enterEvent( + self, + event: QEnterEvent, + ): + # Show the button when the mouse enters the view + if self._item.pixmap().width(): + self.__controlWidget.move(self.rect().x(), self.rect().y()) + self.setForegroundBrush(self.__brush) + self.__controlWidget.show() + return super().enterEvent(event) + + def leaveEvent( + self, + event: QEvent, + ): + # Hide the button when the mouse leaves the view + self.__controlWidget.hide() + self.setForegroundBrush(self.__defaultBrush) + return super().leaveEvent(event) + + def resizeEvent( + self, + event: QResizeEvent, + ): + if self._item.pixmap().width(): + self.setScene(self._scene) + return super().resizeEvent(event) + + def mousePressEvent( + self, + event: QMouseEvent, + ): + self.clicked.emit(self._p) + return super().mousePressEvent(event) + + def wheelEvent( + self, + event: QWheelEvent, + ): + if event.modifiers() == Qt.KeyboardModifier.ControlModifier: + # Check if Ctrl key is pressed + if event.angleDelta().y() > 0: + # Ctrl + wheel up, zoom in + self.__zoomIn() + else: + # Ctrl + wheel down, zoom out + self.__zoomOut() + event.accept() # Accept the event if Ctrl is pressed + else: + super().wheelEvent(event) # Default behavior for other cases diff --git a/pyqt_openai/widgets/toast.py b/pyqt_openai/widgets/toast.py index b7b001c1..df6e1a65 100644 --- a/pyqt_openai/widgets/toast.py +++ b/pyqt_openai/widgets/toast.py @@ -1,155 +1,176 @@ -from PySide6.QtWidgets import QLabel, QWidget, QHBoxLayout, QGraphicsOpacityEffect -from PySide6.QtCore import Qt, QTimer, QPropertyAnimation, QAbstractAnimation, QPoint -from PySide6.QtGui import QFont, QColor - -from pyqt_openai import ( - TOAST_DURATION, - DEFAULT_TOAST_BACKGROUND_COLOR, - DEFAULT_TOAST_FOREGROUND_COLOR, -) - - -class Toast(QWidget): - def __init__(self, text, duration=TOAST_DURATION, parent=None): - super().__init__(parent) - self.__initVal(parent, duration) - self.__initUi(text) - - def __initVal(self, parent, duration): - self.__parent = parent - self.__parent.installEventFilter(self) - self.installEventFilter(self) - self.__timer = QTimer(self) - self.__duration = duration - self.__opacity = 0.8 - self.__foregroundColor = DEFAULT_TOAST_FOREGROUND_COLOR - self.__backgroundColor = DEFAULT_TOAST_BACKGROUND_COLOR - - def __initUi(self, text): - self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.SubWindow) - self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) - - # text in toast (toast foreground) - self.__lbl = QLabel(text) - self.__lbl.setObjectName("popupLbl") - self.__lbl.setStyleSheet( - f"QLabel#popupLbl {{ color: {self.__foregroundColor}; padding: 5px; }}" - ) - - self.__lbl.setMinimumWidth( - min(200, self.__lbl.fontMetrics().boundingRect(text).width() * 2) - ) - self.__lbl.setMinimumHeight( - self.__lbl.fontMetrics().boundingRect(text).height() * 2 - ) - self.__lbl.setWordWrap(True) - - # toast background - lay = QHBoxLayout() - lay.addWidget(self.__lbl) - lay.setAlignment(Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter) - - self.setStyleSheet( - f"QWidget {{ background: {self.__backgroundColor}; border-radius: 5px; }}" - ) - self.__setToastSizeBasedOnTextSize() - self.setLayout(lay) - - # Opacity effect - self.opacity_effect = QGraphicsOpacityEffect(self) - self.setGraphicsEffect(self.opacity_effect) - - # animation - self.__initAnimation() - - def __initAnimation(self): - self.__animation = QPropertyAnimation(self.opacity_effect, b"opacity") - self.__animation.setStartValue(0.0) - self.__animation.setDuration(200) - self.__animation.setEndValue(self.__opacity) - - def __initTimeout(self): - self.__timer = QTimer(self) - self.__timer_to_wait = self.__duration - self.__timer.setInterval(1000) - self.__timer.timeout.connect(self.__changeContent) - self.__timer.start() - - def __changeContent(self): - self.__timer_to_wait -= 1 - if self.__timer_to_wait <= 0: - self.__animation.setDirection(QAbstractAnimation.Backward) - self.__animation.start() - self.__timer.stop() - - def setPosition(self, pos): - geo = self.geometry() - geo.moveCenter(pos) - self.setGeometry(geo) - - def setAlignment(self, alignment): - self.__lbl.setAlignment(alignment) - - def show(self): - if self.__timer.isActive(): - pass - else: - self.__animation.setDirection(QAbstractAnimation.Forward) - self.__animation.start() - self.raise_() - self.__initTimeout() - return super().show() - - def isVisible(self) -> bool: - return self.__timer.isActive() - - def setFont(self, font: QFont): - self.__lbl.setFont(font) - self.__setToastSizeBasedOnTextSize() - - def __setToastSizeBasedOnTextSize(self): - self.setFixedWidth(self.__lbl.sizeHint().width() * 2) - self.setFixedHeight(self.__lbl.sizeHint().height() * 2) - - def setDuration(self, duration: int): - self.__duration = duration - self.__initAnimation() # Update animation after changing duration - - def setForegroundColor(self, color: QColor): - if isinstance(color, str): - color = QColor(color) - self.__foregroundColor = color.name() - self.__setForegroundColor() - - def setBackgroundColor(self, color: QColor): - if isinstance(color, str): - color = QColor(color) - self.__backgroundColor = color.name() - self.__setBackgroundColor() - - def __setForegroundColor(self): - self.__lbl.setStyleSheet( - f"QLabel#popupLbl {{ color: {self.__foregroundColor}; padding: 5px; }}" - ) - - def __setBackgroundColor(self): - self.setStyleSheet( - f"QWidget {{ background-color: {self.__backgroundColor}; border-radius: 5px; }}" - ) - - def setOpacity(self, opacity: float): - self.__opacity = opacity - self.__animation.setEndValue(self.__opacity) - - def eventFilter(self, obj, e) -> bool: - if e.type() == 14: - self.setPosition( - QPoint( - self.__parent.rect().center().x(), self.__parent.rect().center().y() - ) - ) - elif isinstance(obj, Toast): - if e.type() == 75: - self.__setForegroundColor() - self.__setBackgroundColor() - return super().eventFilter(obj, e) +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtCore import QAbstractAnimation, QPoint, QPropertyAnimation, QTimer, Qt +from qtpy.QtGui import QColor +from qtpy.QtWidgets import QGraphicsOpacityEffect, QHBoxLayout, QLabel, QWidget + +from pyqt_openai import DEFAULT_TOAST_BACKGROUND_COLOR, DEFAULT_TOAST_FOREGROUND_COLOR, TOAST_DURATION + +if TYPE_CHECKING: + from qtpy.QtCore import QEvent, QObject, QRect + from qtpy.QtGui import QFont + + +class Toast(QWidget): + def __init__( + self, + text: str, + duration: int = TOAST_DURATION, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.__initVal(parent, duration) + self.__initUi(text) + + def __initVal( + self, + parent: QWidget | None, + duration: int, + ): + self.__parent: QWidget | None = parent + if self.__parent: + self.__parent.installEventFilter(self) + self.installEventFilter(self) + self.__timer: QTimer = QTimer(self) + self.__duration: int = duration + self.__opacity: float = 0.8 + self.__foregroundColor: str = DEFAULT_TOAST_FOREGROUND_COLOR + self.__backgroundColor: str = DEFAULT_TOAST_BACKGROUND_COLOR + + def __initUi( + self, + text: str, + ): + self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.SubWindow) + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + + # text in toast (toast foreground) + self.__lbl: QLabel = QLabel(text) + self.__lbl.setObjectName("popupLbl") + self.__lbl.setStyleSheet(f"QLabel#popupLbl {{ color: {self.__foregroundColor}; padding: 5px; }}") + + self.__lbl.setMinimumWidth(min(200, self.__lbl.fontMetrics().boundingRect(text).width() * 2)) + self.__lbl.setMinimumHeight(self.__lbl.fontMetrics().boundingRect(text).height() * 2) + self.__lbl.setWordWrap(True) + + # toast background + lay = QHBoxLayout() + lay.addWidget(self.__lbl) + lay.setAlignment(Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter) + + self.setStyleSheet(f"QWidget {{ background: {self.__backgroundColor}; border-radius: 5px; }}") + self.__setToastSizeBasedOnTextSize() + self.setLayout(lay) + + # Opacity effect + self.opacity_effect: QGraphicsOpacityEffect = QGraphicsOpacityEffect(self) + self.setGraphicsEffect(self.opacity_effect) + + # animation + self.__initAnimation() + + def __initAnimation(self): + self.__animation: QPropertyAnimation = QPropertyAnimation(self.opacity_effect, b"opacity") + self.__animation.setStartValue(0.0) + self.__animation.setDuration(200) + self.__animation.setEndValue(self.__opacity) + + def __initTimeout(self): + self.__timer: QTimer = QTimer(self) + self.__timer_to_wait: int = self.__duration + self.__timer.setInterval(1000) + self.__timer.timeout.connect(self.__changeContent) + self.__timer.start() + + def __changeContent(self): + self.__timer_to_wait -= 1 + if self.__timer_to_wait <= 0: + self.__animation.setDirection(QAbstractAnimation.Direction.Backward) + self.__animation.start() + self.__timer.stop() + + def setPosition( + self, + pos: QPoint, + ): + geo: QRect = self.geometry() + geo.moveCenter(pos) + self.setGeometry(geo) + + def setAlignment(self, alignment): + self.__lbl.setAlignment(alignment) + + def show(self): + if self.__timer.isActive(): + pass + else: + self.__animation.setDirection(QAbstractAnimation.Direction.Forward) + self.__animation.start() + self.raise_() + self.__initTimeout() + return super().show() + + def isVisible(self) -> bool: + return self.__timer.isActive() + + def setFont( + self, + font: QFont, + ): + self.__lbl.setFont(font) + self.__setToastSizeBasedOnTextSize() + + def __setToastSizeBasedOnTextSize(self): + self.setFixedWidth(self.__lbl.sizeHint().width() * 2) + self.setFixedHeight(self.__lbl.sizeHint().height() * 2) + + def setDuration( + self, + duration: int, + ): + self.__duration = duration + self.__initAnimation() # Update animation after changing duration + + def setForegroundColor( + self, + color: QColor, + ): + if isinstance(color, str): + color = QColor(color) + self.__foregroundColor: str = color.name() + self.__setForegroundColor() + + def setBackgroundColor( + self, + color: QColor, + ): + if isinstance(color, str): + color = QColor(color) + self.__backgroundColor: str = color.name() + self.__setBackgroundColor() + + def __setForegroundColor(self): + self.__lbl.setStyleSheet(f"QLabel#popupLbl {{ color: {self.__foregroundColor}; padding: 5px; }}") + + def __setBackgroundColor(self): + self.setStyleSheet(f"QWidget {{ background-color: {self.__backgroundColor}; border-radius: 5px; }}") + + def setOpacity(self, opacity: float): + self.__opacity = opacity + self.__animation.setEndValue(self.__opacity) + + def eventFilter( + self, + obj: QObject, + e: QEvent, + ) -> bool: + if e.type() == 14: + if self.__parent: + self.setPosition(QPoint(self.__parent.rect().center().x(), self.__parent.rect().center().y())) + elif isinstance(obj, Toast): + if e.type() == 75: + self.__setForegroundColor() + self.__setBackgroundColor() + return super().eventFilter(obj, e) diff --git a/pyqt_openai/widgets/toolButton.py b/pyqt_openai/widgets/toolButton.py index 2b3d2881..adf64c55 100644 --- a/pyqt_openai/widgets/toolButton.py +++ b/pyqt_openai/widgets/toolButton.py @@ -1,30 +1,50 @@ -from PySide6.QtGui import QColor, QIcon -from PySide6.QtWidgets import QGraphicsColorizeEffect, QWidget, QToolButton - -from pyqt_openai.util.button_style_helper import ButtonStyleHelper - - -class ToolButton(QToolButton): - def __init__(self, base_widget: QWidget = None, *args, **kwargs): - super().__init__(*args, **kwargs) - self.style_helper = ButtonStyleHelper(base_widget) - self.setStyleSheet(self.style_helper.styleInit()) - self.installEventFilter(self) - - def setStyleAndIcon(self, icon: str): - self.style_helper.__icon = icon - self.setStyleSheet(self.style_helper.styleInit()) - self.setIcon(QIcon(icon)) - - def eventFilter(self, obj, event): - if obj == self: - if event.type() == 98: # Event type for EnableChange - effect = QGraphicsColorizeEffect() - effect.setColor(QColor(255, 255, 255)) - if self.isEnabled(): - effect.setStrength(0) - else: - effect.setStrength(1) - effect.setColor(QColor(150, 150, 150)) - self.setGraphicsEffect(effect) - return super().eventFilter(obj, event) +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtGui import QColor, QIcon +from qtpy.QtWidgets import QGraphicsColorizeEffect, QToolButton + +from pyqt_openai.util.button_style_helper import ButtonStyleHelper + +if TYPE_CHECKING: + from qtpy.QtCore import QEvent, QObject + from qtpy.QtWidgets import QWidget + + +class ToolButton(QToolButton): + def __init__( + self, + base_widget: QWidget | None = None, + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.style_helper: ButtonStyleHelper = ButtonStyleHelper(base_widget) + self.setStyleSheet(self.style_helper.styleInit()) + self.installEventFilter(self) + + def setStyleAndIcon( + self, + icon: str, + ): + self.style_helper.__icon = icon + self.setStyleSheet(self.style_helper.styleInit()) + self.setIcon(QIcon(icon)) + + def eventFilter( + self, + obj: QObject, + event: QEvent, + ) -> bool: + if obj == self: + if event.type() == 98: # Event type for EnableChange + effect = QGraphicsColorizeEffect() + effect.setColor(QColor(255, 255, 255)) + if self.isEnabled(): + effect.setStrength(0) + else: + effect.setStrength(1) + effect.setColor(QColor(150, 150, 150)) + self.setGraphicsEffect(effect) + return super().eventFilter(obj, event) From 413aa7a3b6691067d4744a34d3d89504514858f3 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Fri, 13 Dec 2024 05:24:09 -0600 Subject: [PATCH 2/2] Update documentation, enhance project configuration, and add VSCode settings - Updated links in CODE_OF_CONDUCT.md and README.md to use angle brackets for better formatting. - Improved formatting and readability in CONTRIBUTING.md. - Added new keywords in pyproject.toml for better package discoverability. - Introduced linter configurations and settings in pyproject.toml for improved code quality. - Added VSCode configuration files (.vscode/extensions.json and .vscode/settings.json) to recommend extensions and set up project-specific settings. - Updated requirements.txt to include 'qtpy' for enhanced compatibility. --- .vscode/extensions.json | 22 +++ .vscode/settings.json | 4 + CODE_OF_CONDUCT.md | 8 +- CONTRIBUTING.md | 4 +- README.md | 62 ++++++--- pyproject.toml | 292 +++++++++++++++++++++++++++++++++++++++- requirements.txt | 3 +- 7 files changed, 364 insertions(+), 31 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..a64e91b1 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,22 @@ +{ + "recommendations": [ + "tamasfe.even-better-toml", + "davidanson.vscode-markdownlint", + "yzhang.markdown-all-in-one", + "irony.qsseditor", + "theqtcompany.qt-core", + "dvratil.vscode-qtdoc", + "seanwu.vscode-qt-for-python", + "theqtcompany.qt-qml", + "theqtcompany.qt-ui", + "charliermarsh.ruff", + "ms-python.vscode-pylance", + "ms-python.python", + "ms-python.debugpy", + "patrick91.python-dependencies-vscode", + "donjayamanne.python-environment-manager", + "twixes.pypi-assistant", + "njqdev.vscode-python-typehint", + "matangover.mypy" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..61ce8b41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "makefile.configureOnOpen": false, + "mypy.dmypyExecutable": "${workspaceFolder}\\.venv\\Scripts\\dmypy.exe" +} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 695455c7..e23cd918 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -60,7 +60,7 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -https://www.linkedin.com/in/junggyu-yoon-295246193/. +. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the @@ -116,7 +116,7 @@ the community. This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. +. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). @@ -124,5 +124,5 @@ enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. +. Translations are available at +. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df2744e5..1c6cf6c1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,8 +11,8 @@ The following is a list of planned features and tasks that are still in progress 1. **Software Maintainer for Mac**: Seeking a maintainer to support and improve the software for Mac users. 2. **Software Maintainer for Linux**: Seeking a maintainer to support and improve the software for Linux users. -As of October 14, 2024, Windows has an updater and auto-start features; -however, these features are currently unavailable for Mac and Linux. If you are a user of either platform, please contact me! (I'm Windows user 💻) +As of October 14, 2024, Windows has an updater and auto-start features; +however, these features are currently unavailable for Mac and Linux. If you are a user of either platform, please contact me! (I'm Windows user 💻) ### TODO (Additional Improvements) diff --git a/README.md b/README.md index 24c9ae55..dea147d1 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,95 @@ # VividNode(pyqt-openai) -
- - A cross-platform AI desktop chatbot application for LLM such as GPT, Claude, Gemini, Llama chatbot interaction and image generation, offering customizable features, local chat history, and enhanced performance—no browser required!

- Basically for free, powered by GPT4Free and LiteLLM.
+![VividNode Logo](https://github.com/user-attachments/assets/ab169535-8af0-40c7-848d-59a7e5e4b304) -
+**A cross-platform AI desktop chatbot application for LLM such as GPT, Claude, Gemini, Llama chatbot interaction and image generation, offering customizable features, local chat history, and enhanced performance—no browser required! + +Basically for free, powered by GPT4Free and LiteLLM.** + +--- + +[![Discord Server](https://dcbadge.vercel.app/api/server/cHekprskVE)](https://discord.gg/cHekprskVE) - [![](https://dcbadge.vercel.app/api/server/cHekprskVE)](https://discord.gg/cHekprskVE) - [![PyPI - Version](https://img.shields.io/pypi/v/pyqt-openai?logo=pypi&logoColor=white)](https://pypi.org/project/pyqt-openai/) [![Downloads](https://static.pepy.tech/badge/pyqt-openai)](https://pepy.tech/project/pyqt-openai) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pyqt-openai?logo=python&logoColor=gold)](https://pypi.org/project/pyqt-openai/) ![commit](https://img.shields.io/github/commit-activity/w/yjg30737/pyqt-openai)
![image](https://github.com/user-attachments/assets/9f5f2ca0-b191-4655-b671-ae0834e1a0b1) -
+--- ## What is VividNode? 🤔 **VividNode** is a cross-platform desktop application that allows you to interact directly with LLM(GPT, Claude, Gemini, Llama) chatbots and generate images without needing a browser. Built with PySide6, VividNode (formerly known as pyqt-openai) supports Windows, Mac, and Linux, and securely stores your chat history locally in a database. -### Key Features: +### Key Features + - **Chat Interface**: Enjoy a seamless chat experience with a customizable interface, fast thread and message search, and advanced conversation settings. You can also import/export chat histories and use prompt management tools for efficient prompt engineering. -- **Image Generation**: Generate images using OpenAI’s DALL-E 3 or models from Replicate or GPT4Free, directly within your chat sessions. The app supports endless image generation, randomly generated prompt, automatic saving, and integrated image management. -- **Focus and Accessibility Modes**: Utilize Focus Mode, “Always on Top” Mode, transparency adjustments, and background notifications to keep the chat accessible and responsive without overwhelming system resources. +- **Image Generation**: Generate images using OpenAI's DALL-E 3 or models from Replicate or GPT4Free, directly within your chat sessions. The app supports endless image generation, randomly generated prompt, automatic saving, and integrated image management. +- **Focus and Accessibility Modes**: Utilize Focus Mode, "Always on Top" Mode, transparency adjustments, and background notifications to keep the chat accessible and responsive without overwhelming system resources. - **Customization and Shortcuts**: VividNode offers extensive customization options, including language settings, memory management, and a comprehensive list of keyboard shortcuts for faster operations. - **Support for interactive chatting (STT & TTS)**: It features a recording function using OpenAI's STT capabilities and allows responses to be heard in various voices through OpenAI Whisper or the TTS functionality of the Edge browser (using edge-tts, which is free). With VividNode, you can experience a more powerful and resource-efficient alternative to browser-based GPT interfaces, making it easier to manage both text and image-based interactions. -
+--- ## Sidenote 🗒️ + Although this is named 'pyqt-openai', the model does not use only OpenAI-related models, and the GUI is created using PySide6, not PyQt. 'pyqt-openai' was the package name decided initially, and we are still using it as changing the package name now would likely result in a huge disaster. ## How to Install + ### Standard Way + 1. git clone ~ 2. cd pyqt-openai 3. pip install -r requirements.txt --upgrade 4. cd pyqt_openai 5. python main.py + ### With Makefile + 1. make venv 2. make run -### Wanna download this without doing stuffs like above? You can download installer or zip file here. +### Wanna download this without doing stuffs like above? You can download installer or zip file [here](https://github.com/yjg30737/pyqt-openai/releases) ## How to Use 🧐 -**QuickStart** + +**[QuickStart](https://medium.com/@yjg30737/what-is-vividnode-how-to-use-it-4d8a9269a3c0)** ## Test Scenario + This is the [default test scenario page](https://github.com/yjg30737/pyqt-openai/wiki/Test-Scenario). If you want to test it out and be sure nothing is wrong, try it :) ## Troubleshooting + ### Common Issues + #### Issues Related to PyAudio + - This issue is often due to the absence of PortAudio. Make sure to install PortAudio before you install PyAudio. -#### Issues Related to PySide6 During Installation + +#### Issues Related to PySide6 During Installation + - As of October 14, 2024, PySide6 supports Python versions above 3.9 and below 3.13. If support for Python 3.13 is added in the future, you can remove this note. + #### Handling Error Messages Related to Software Updates (Windows) + - If you encounter the following error message when trying to update VividNode via the auto-update feature: **PermissionError: [Errno 13] Permission denied**, To resolve this issue, run VividNode as an administrator. - Also, if VividNode keeps asking "Wanna update?" even though you've updated it already, just install this again and everything will be fine. + #### Runtime error + ![image](https://github.com/user-attachments/assets/f53b44bb-1572-48ce-a9a0-a5da2b338d09) + - If you see the error above, run the application again. It is likely to be shown in old version(below v1.5.0) so update to the latest version. - + #### Incomplete or Inaccurate Translations + - If you come across incomplete or unnatural translations, please update the **pyqt_openai/lang/translations.json** file. -If the solutions listed here don’t resolve your issue, please report it by [opening an issue](https://github.com/yjg30737/pyqt-openai/issues). +If the solutions listed here don't resolve your issue, please report it by [opening an issue](https://github.com/yjg30737/pyqt-openai/issues). ## Help Needed 🆘 @@ -78,11 +98,13 @@ If you have experience with coding, documentation, design, or even providing con Your contribution, even just a fix a simple typo in readme or simple refactoring can be very helpful. Of course there are a lot of official TODOs i need helping hand as well. So [see here](https://github.com/yjg30737/pyqt-openai/blob/main/CONTRIBUTING.md) if you are willing to contribute. -You can contact me 24/7 by sending me an email to **yjg30737@gmail.com** or join [**Discord server**](https://discord.gg/cHekprskVE) to talk in real time +You can contact me 24/7 by sending me an email to **** or join [**Discord server**](https://discord.gg/cHekprskVE) to talk in real time ## Contributors -* **Me (WizMiner)** 😊 - * Creator of VividNode 🐐 + +- **Me (WizMiner)** 😊 + - Creator of VividNode 🐐 ## Disclaimer + Please do not distribute this commercially without my permission, by claiming it as your own creation. diff --git a/pyproject.toml b/pyproject.toml index edff1ed0..62f78d7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,9 +32,26 @@ dependencies = [ "curl_cffi", "litellm", - "edge-tts" + "edge-tts", +] +keywords = [ + 'openai', + 'pyqt', + 'pyqt5', + 'pyqt6', + 'pyside6', + 'desktop', + 'app', + 'chatbot', + 'gpt', + 'replicate', + 'gemini', + 'claude', + 'llama', + 'llm', + 'gpt4free', + 'litellm', ] -keywords = ['openai', 'pyqt', 'pyqt5', 'pyqt6', 'pyside6', 'desktop', 'app', 'chatbot', 'gpt', 'replicate', 'gemini', 'claude', 'llama', 'llm', 'gpt4free', 'litellm'] requires-python = "<3.13, >=3.10" # PySide6 is not available for Python 3.13 yet @@ -50,7 +67,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Libraries :: Application Frameworks", - "Topic :: Software Development :: User Interfaces" + "Topic :: Software Development :: User Interfaces", ] [project.urls] @@ -60,4 +77,271 @@ homepage = "https://github.com/yjg30737/pyqt-openai.git" packages = ["pyqt_openai"] [project.scripts] -pyqt-openai = "pyqt_openai.main:main" \ No newline at end of file +pyqt-openai = "pyqt_openai.main:main" + + +######################################################## +## define only linter configurations below this point ## +######################################################## + +[tool.pyright.defineConstant] +PYQT5 = false +PYSIDE2 = false +PYQT6 = false +PYSIDE6 = true + +[tool.black] +line-length = 175 +skip-magic-trailing-comma = false +target-version = ['py38'] +include = '\.pyi?$' +exclude = ''' +/( + \.git +| \.hg +| \.github +| \.mypy_cache +| \.tox +| \.venv +| venv +| _build +| buck-out +| build +| __pycache__ +| dist +| nuitka_dist +| \.history +| \.idea +| \.chat +| \.ruff_cache +| \.trunk +| .mdx +| .mdl +)/ +''' + +[tool.pyright] +include = ["*.py"] +exclude = [ + "**/node_modules/**", + "**/__pycache__/**", + "**/build/**", + "**/dist/**", + "**/venv/**", + "**/__pycache__/**", +] +reportMissingImports = true +reportMissingTypeStubs = false +pythonVersion = "3.8" +analyzeUnannotatedFunctions = true +deprecateTypingAliases = false + +[tool.isort] +atomic = true +profile = "black" +line_length = 175 +skip_gitignore = true +multi_line_output = 3 + +[tool.yapf] +column_limit = 175 +ALLOW_SPLIT_BEFORE_DEFAULT_OR_NAMED_ASSIGNS = false +ALLOW_SPLIT_BEFORE_DICT_VALUE = false +DISABLE_SPLIT_LIST_WITH_COMMENT = true +INDENT_DICTIONARY_VALUE = true +join_multiple_lines = false +no_spaces_around_selected_binary_operators = "*,/" +split_before_dict_set_generator = true +split_before_expression_after_opening_paren = false +split_complex_comprehension = true +split_penalty_after_opening_bracket = 50 +split_penalty_excess_character = 75 # Lower penalty for going over the column limit +split_penalty_for_added_line_split = 100 # Discourage adding new line splits + +[tool.ruff] +line-length = 175 + +# Enable preview features. +preview = false + +fix = true +force-exclude = true +show-fixes = true +output-format = "grouped" # Group violations by containing file. +respect-gitignore = false + +[tool.ruff.lint] +external = ["V"] +task-tags = ["TODO", "FIXME", "HACK"] +exclude = [".venv", ".venv*"] +unfixable = [ + "F841", + "ERA001", + "N815", + "RUF100", # Unused `noqa` directive + #"UP035", # replaces deprecated/outdated imports (we want to support older versions of python in the future) + #"UP038", # non-pep604-isinstance (python 3.10 syntax) +] +ignore = [ + # The following rules are too strict to be realistically used by ruff: + "ANN002", # Checks that function *args arguments have type annotations. + "ANN003", # Checks that function **kwargs arguments have type annotations. + "ANN101", # Checks that instance method self arguments have type annotations. + "ANN102", # Checks that class method cls arguments have type annotations. + "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in {name} + "ANN204", # Missing return type annotation for special method + "ARG002", # Unused method argument: `method` + "COM812", # missing-trailing-comma + "ERA001", # Found commented out code. + # "FBT001", # Boolean positional arg in function definition + # "FBT002", # Boolean default value in function definition + "D100", # Missing docstring in public module + "D101", # Missing docstring in public class + "D102", # Missing docstring in public method + "D103", # Missing docstring in public function + "D104", # Missing docstring in public package + "D105", # Missing docstring in magic method + "D107", # Missing docstring in __init__ + "D205", # 1 blank line required between summary line and description + # "D213", # Multi-line docstring summary should start at the second line + # "D400", # First line should end with a period + "D401", # First line of docstring should be in imperative mood + "D413", # Missing blank line after last section 'xyz' + # "D403", # First word of the first line should be capitalized: {} -> {} + # "D404", # First word of the docstring should not be "This" + "D417", # Missing argument description in the docstring for {definition}: {name} + "G004", # Logging statement uses f'' string. + "N802", # Function name `screenToWorld` should be lowercase + "N806", # Variable `jumpToOffset` in function should be lowercase + "N815", # Variable `var` in class scope should not be mixedCase + "PLR0904", # Too many public methods + "RET504", # Unnecessary assignment to `varname` before `return` statement + "RUF100", # Unused `noqa` directive (non-enabled: `PLC0415`) + "T201", # 'print' detected + "TD001", # Invalid TODO tag: `FIXME` + "TRY003", # Avoid specifying long messages outside the exception class + "EM101", # Exception must not use a string literal, assign to variable first + "EM102", # Exception must not use an f-string literal, assign to variable first + "SIM108", # Use ternary operator instead of 'if' 'else' block + "SIM114", # Combine `if` branches using logical `or` operator + "S101", # Use of `assert` detected. + + # The following are currently violated by the codebase. + # "D205", # 1 blank line required between summary line and description + # "E402", # Module level import not at top of file + # "FIX004", # Line contains HACK, consider resolving the issue + # "PD901", # df is a bad variable name. Be kinder to your future self. + # "PERF203", # `try`-`except` within a loop incurs performance overhead + # "PLR0913", # Too many arguments to function call (N > 5) + # "PLR2004", # Magic value used in comparison, consider replacing X with a constant variable + # "S101", # Use of assert detected + # "S314", # Using `xml` to parse untrusted data is known to be vulnerable to XML attacks; use `defusedxml` equivalents + # "S605", # Starting a process with a shell, possible injection detected + # "SLF001", # Private member accessed + # + # According to ruff documentation, the following rules should be avoided when using its formatter: + # + # "W191", # tab-indentation + # "E111", # indentation-with-invalid-multiple + # "E114", # indentation-with-invalid-multiple-comment + # "E117", # over-indented + # "D206", # indent-with-spaces + # "D300", # triple-single-quotes + # "Q000", # bad-quotes-inline-string + # "Q001", # bad-quotes-multiline-string + # "Q002", # bad-quotes-docstring + # "Q003", # avoidable-escaped-quote + # "COM812", # missing-trailing-comma + # "COM819", # prohibited-trailing-comma + "ISC001", +] + +[tool.ruff.lint.per-file-ignores] +"*.pyi" = [ + "I002", # from __future__ import annotations +] +"tests/*.py" = ["ALL"] +".github/*py" = ["INP001"] +"__init__.py" = ["I001", "I002", "TID252", "F401"] + +[tool.ruff.lint.flake8-pytest-style] +fixture-parentheses = false +mark-parentheses = false +parametrize-names-type = "list" +parametrize-values-row-type = "list" +parametrize-values-type = "list" # default +raises-require-match-for = ["requests.RequestException"] + +[tool.ruff.lint.flake8-annotations] +ignore-fully-untyped = true +mypy-init-return = true +suppress-none-returning = true + +[tool.ruff.lint.flake8-bandit] +check-typed-exception = true + +[tool.ruff.lint.flake8-quotes] +docstring-quotes = "double" +inline-quotes = "double" +multiline-quotes = "double" + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" # Disallow all relative imports. + +[tool.ruff.lint.flake8-type-checking] +# Add quotes around type annotations, if doing so would allow an import to be moved into a type-checking block. +quote-annotations = true # Does nothing when from __future__ import annotations is used +exempt-modules = ["typing", "typing_extensions"] +strict = true + +[tool.ruff.lint.flake8-unused-arguments] +ignore-variadic-names = true + +[tool.ruff.lint.isort] +case-sensitive = true +force-wrap-aliases = false +combine-as-imports = true +lines-between-types = 1 +required-imports = ["from __future__ import annotations"] + +[tool.ruff.format] +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" +preview = false + +[tool.ruff.lint.pyupgrade] +keep-runtime-typing = false + +[tool.mccabe] +max-complexity = 25 + +[tool.bandit] +exclude_dirs = ["tests"] +tests = ["B201", "B301"] +skips = ["B101", "B601"] + +[tool.ruff.lint.pycodestyle] +max-doc-length = 200 + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.style] +based_on_style = "google" +split_before_named_assigns = true +split_complex_comprehension = true +split_arguments_when_comma_terminated = true + +[tool.pylintrc] +max-line-length = 200 + +[tool.pytest.ini_options] +log_cli = true +log_cli_level = "CRITICAL" +log_cli_format = "%(message)s" + +log_file = "pytest.log" +log_file_level = "DEBUG" +log_file_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" +log_file_date_format = "%Y-%m-%d %H:%M:%S" diff --git a/requirements.txt b/requirements.txt index 03a0c3ff..e17cbc87 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,4 +21,5 @@ g4f==0.3.3.4 curl_cffi litellm -edge-tts \ No newline at end of file +edge-tts +qtpy \ No newline at end of file