diff --git a/art/OmegaLogo.png b/art/OmegaLogo.png new file mode 100644 index 0000000..66afeaa Binary files /dev/null and b/art/OmegaLogo.png differ diff --git a/art/OmegaLogoIcon.png b/art/OmegaLogoIcon.png new file mode 100644 index 0000000..7370d42 Binary files /dev/null and b/art/OmegaLogoIcon.png differ diff --git a/meta.yaml b/meta.yaml new file mode 100644 index 0000000..4178778 --- /dev/null +++ b/meta.yaml @@ -0,0 +1,75 @@ +{% set name = "napari-chatgpt" %} +{% set version = "2024.2.19" %} + +package: + name: {{ name|lower }} + version: {{ version }} + +source: + url: https://pypi.io/packages/source/{{ name[0] }}/{{ name }}/napari-chatgpt-{{ version }}.tar.gz + sha256: 4b7238d46766db40fff48735028ff17cd4229f1028a212646f381d459ab89625 + +build: + noarch: python + script: {{ PYTHON }} -m pip install . -vv --no-deps --no-build-isolation + number: 0 + +requirements: + host: + - python >=3.9 + - setuptools >=42.0.0 + - wheel + - setuptools-scm + - pip + run: + - python >=3.9 + - numpy + - magicgui + - scikit-image + - qtpy + - qtawesome + - langchain ==0.1.5 + - langchain-openai ==0.0.5 + - openai + - anthropic + - fastapi + - uvicorn + - websockets + - tiktoken + - wikipedia + - lxml + - gtts + - playsound + - matplotlib-base + - xarray + - arbol + - microsoft::playwright + - duckduckgo-search + - ome-zarr + - transformers + - cryptography + - tabulate + - numba + - imageio + - notebook + - nbformat + - jedi + - black + +#test: +# imports: +# - napari_chatgpt +# commands: +# - pip check +# requires: +# - pip + +about: + home: https://github.com/royerlab/napari-chatgpt + summary: A napari plugin to process and analyse images with chatGPT. + license: BSD-3-Clause + license_file: LICENSE + +extra: + recipe-maintainers: + - royerloic diff --git a/setup.cfg b/setup.cfg index 09ae717..4e3cc22 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = napari-chatgpt -version = v2024.2.4 +version = v2024.2.24 description = A napari plugin to process and analyse images with chatGPT. long_description = file: README.md long_description_content_type = text/markdown @@ -35,6 +35,7 @@ install_requires = magicgui scikit-image qtpy + QtAwesome langchain==0.1.5 langchain-openai==0.0.5 openai @@ -60,6 +61,8 @@ install_requires = imageio[ffmpeg,pyav] notebook nbformat + jedi + black python_requires = >=3.9 diff --git a/src/microplugin/__init__.py b/src/microplugin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/microplugin/code_editor/__init__.py b/src/microplugin/code_editor/__init__.py new file mode 100644 index 0000000..6e4b0de --- /dev/null +++ b/src/microplugin/code_editor/__init__.py @@ -0,0 +1 @@ +# hello diff --git a/src/microplugin/code_editor/clickable_icon.py b/src/microplugin/code_editor/clickable_icon.py new file mode 100644 index 0000000..78123ac --- /dev/null +++ b/src/microplugin/code_editor/clickable_icon.py @@ -0,0 +1,100 @@ +from typing import Union + +from qtpy.QtCore import Qt, Signal +from qtpy.QtGui import QIcon, QPixmap, QColor, QImage +from qtpy.QtWidgets import QLabel + + +class ClickableIcon(QLabel): + clicked = Signal() # Signal to emit when the label is clicked + + def __init__( + self, + icon: Union[QIcon, QPixmap, str], + size: int = 24, + invert_colors: bool = True, + parent=None + ): + """ + Create a clickable icon label. + The icon can be: QIcon, QPixmap, or a string with the path to the image. + For example: + icon = QApplication.style().standardIcon(QStyle.SP_FileIcon) + + Parameters + ---------- + icon : QIcon, QPixmap, or str + The icon to display + parent : QWidget, optional + The parent widget, by default None + + """ + super().__init__(parent) + + # Store the size for use in scaling the icon: + self.size = size + + # Convert the icon to QIcon if it is a string: + if isinstance(icon, str): + icon = QIcon(icon) + + # Convert the icon to QPixmap if it is a QIcon or use it directly if it's already a QPixmap: + if isinstance(icon, QIcon): + # Get the pixmap from the icon, scaled isotropically: + pixmap = icon.pixmap(self.size, self.size) + elif isinstance(icon, QPixmap): + pixmap = icon.scaled(self.size, + self.size, + Qt.KeepAspectRatio, + Qt.SmoothTransformation) + + # Invert colors if requested: + if invert_colors: + pixmap = self._modify_pixmap_for_dark_ui(pixmap) + + # If the icon is a QPixmap, use it directly: + self.setPixmap(pixmap) + + # Change cursor to hand pointer when hovering over the label: + self.setCursor(Qt.PointingHandCursor) + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + self.clicked.emit() + + @staticmethod + def _modify_pixmap_for_dark_ui(pixmap): + # Convert QPixmap to QImage + image = pixmap.toImage() + + # Ensure image supports alpha channel: + image = image.convertToFormat(QImage.Format_ARGB32) + + # Access image pixels to invert colors and rotate hue, preserving transparency + for x in range(image.width()): + for y in range(image.height()): + # Get the current pixel's color with alpha channel + color = QColor(image.pixel(x, y)) + + # Invert colors + color.setRed(255 - color.red()) + color.setGreen(255 - color.green()) + color.setBlue(255 - color.blue()) + + # # Rotate hue by 180 degrees + # if ( + # color.hue() != -1 + # ): # Check if color is not grayscale (hue is undefined for grayscale) + # hue = (color.hue() + 180) % 360 + # color.setHsv( + # hue, color.saturation(), color.value(), color.alpha() + # ) # Preserve alpha + # + # # Set the modified color back to the image + # image.setPixel( + # x, y, color.rgba() + # ) # Use rgba() to include the alpha channel + + # Convert QImage back to QPixmap + modified_pixmap = QPixmap.fromImage(image) + return modified_pixmap diff --git a/src/microplugin/code_editor/code_drop_send_widget.py b/src/microplugin/code_editor/code_drop_send_widget.py new file mode 100644 index 0000000..4f946b0 --- /dev/null +++ b/src/microplugin/code_editor/code_drop_send_widget.py @@ -0,0 +1,197 @@ +from typing import Callable, Tuple + +from arbol import aprint +from qtpy.QtCore import QTimer +from qtpy.QtWidgets import QWidget, QPushButton, QComboBox, \ + QSizePolicy, QHBoxLayout + +from microplugin.network.code_drop_client import CodeDropClient + + +class CodeDropSendWidget(QWidget): + def __init__(self, + code_drop_client: CodeDropClient, + max_height: int = 50, + margin: int = 0, + refresh_interval: int = 1): + + super().__init__() + + # Code Drop Client: + self.code_drop_client = code_drop_client + + # Initialize widgets: + self.initUI(max_height=max_height, + margin=margin) + + # Refresh interval, converting from seconds to milliseconds: + self.refresh_interval = refresh_interval * 1000 + + # field for timer: + self.timer = None + + + def initUI(self, max_height: int, + margin: int): + + # Layout: + layout = QHBoxLayout(self) + + # Server selection dropdown + self.username_address_port_combo_box = QComboBox() + layout.addWidget(self.username_address_port_combo_box) + + # Send message button + self.send_button = QPushButton('Send') + self.send_button.clicked.connect(self.send_code) + self.send_button.setSizePolicy(QSizePolicy.Maximum, + QSizePolicy.Maximum) # Adjust size policy + layout.addWidget(self.send_button) + + # Cancel message button + self.cancel_button = QPushButton('Cancel') + self.cancel_button.clicked.connect(self.canceled) + self.cancel_button.setSizePolicy(QSizePolicy.Maximum, + QSizePolicy.Maximum) # Adjust size policy + layout.addWidget(self.cancel_button) + + # Set the layout margins: + layout.setContentsMargins(margin, + margin, + margin, + margin) + # Set the layout: + self.setLayout(layout) + + # Set the vertical size policy to Minimum so it takes the least vertical space + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) + + # Attempt to directly control the widget's size + self.setMaximumHeight(max_height) # Adjust 100 to your needs + + # Hide the widget initially: + self.hide() + + def update_server_list(self): + + def _identifier(username_address_port): + if not username_address_port: + return None + username, address, port = username_address_port + return f'{username}:{address}:{str(port)}' + + # Assume that the combination of address and port is unique for each server and remains constant. + # Save the current selection based on a unique identifier (e.g., address and port): + current_selection = self.username_address_port_combo_box.currentData() + current_identifier = _identifier(current_selection) if current_selection else None + + # Clear the combo box before updating: + self.username_address_port_combo_box.clear() + + # Re-populate the combo box with updated server list: + for key, username_address_port in self.code_drop_client.servers.items(): + string_to_display = f"{username_address_port[0]} at {key} ({username_address_port[1]}:{username_address_port[2]})" + + # Add the server to the combo box, using the identifier as the item data for easy lookup: + self.username_address_port_combo_box.addItem(string_to_display, + username_address_port) + + # Restore the current selection by finding the item with the matching unique identifier: + if current_identifier: + for index in range(self.username_address_port_combo_box.count()): + item_data = self.username_address_port_combo_box.itemData( + index) + item_identifier = _identifier(item_data) + if item_identifier == current_identifier: + self.username_address_port_combo_box.setCurrentIndex( + index) + break + else: + # If there was no selection or the selected server is no longer available, default to the first item: + self.username_address_port_combo_box.setCurrentIndex(0) + + def send_code(self): + + # Get the selected server address and port: + username_address_port = self.username_address_port_combo_box.currentData() + + if username_address_port: + # Split address from port: + username, server_address, server_port = username_address_port + + # Logging": + aprint( + f"Sending code to {username} at {server_address}:{server_port}.") + + # Get the filename and code: + filename, code = self.get_code_callable() + + if filename is not None and code is not None: + # Send message: + self.code_drop_client.send_code_message(server_address=server_address, + server_port=server_port, + filename=filename, + code=code) + else: + aprint("No code to send, no code could be obtained.") + else: + aprint("Please select a recipient.") + + # To avoid code duplication, we call canceled to hide widget and stop discovery: + self.canceled() + + + def canceled(self): + + # Hide: + self.hide() + + # Disable discovery worker: + self.code_drop_client.discover_worker.is_enabled = False + + # Stop refreshing server list: + self.stop_server_list_refresh() + + def start_server_list_refresh(self): + + # Initial refresh: + self.update_server_list() + + # Start the timer: + self.timer = QTimer(self) + self.timer.timeout.connect(self.update_server_list) + self.timer.start(self.refresh_interval) + + # Ensure the timer is stopped when the widget is closed: + self.destroyed.connect(self.stop_server_list_refresh) + + def stop_server_list_refresh(self): + # Stop the timer: + if self.timer: + self.timer.stop() + self.timer.deleteLater() + self.timer=None + + def show_send_dialog(self, + get_code_callable: Callable[[], Tuple[str, str]]): + + # Store the filename and code: + self.get_code_callable = get_code_callable + + # Enable discovery worker: + self.code_drop_client.discover_worker.is_enabled = True + + # Start refreshing server list: + self.start_server_list_refresh() + + # show widget: + self.show() + + + def stop(self): + # Stop refreshing server list: + self.stop_server_list_refresh() + + def close(self): + self.stop() + super().close() diff --git a/src/microplugin/code_editor/code_snippet_editor_widget.py b/src/microplugin/code_editor/code_snippet_editor_widget.py new file mode 100644 index 0000000..9555dc3 --- /dev/null +++ b/src/microplugin/code_editor/code_snippet_editor_widget.py @@ -0,0 +1,890 @@ +import json +import os +import sys +from datetime import datetime +from typing import Optional + +import qtawesome +from arbol import aprint +from qtpy.QtCore import Qt +from qtpy.QtGui import QFontMetrics +from qtpy.QtWidgets import ( + QApplication, + QSplitter, + QListWidget, + QWidget, + QMainWindow, + QVBoxLayout, + QToolBar, + QMenu, + QAction, QSizePolicy, QListWidgetItem, ) + +from microplugin.code_editor.text_dialog import TextDialog +from microplugin.code_editor.clickable_icon import ClickableIcon +from microplugin.code_editor.code_drop_send_widget import \ + CodeDropSendWidget +from microplugin.code_editor.console_widget import ConsoleWidget +from microplugin.code_editor.python_code_editor_manager import \ + MultiEditorManager +from microplugin.code_editor.python_code_editor_widget import \ + PythonCodeEditor +from microplugin.code_editor.text_input_widget import TextInputWidget +from microplugin.code_editor.yes_no_cancel_question_widget import \ + YesNoCancelQuestionWidget +from microplugin.formating.black_formating import format_code +from microplugin.network.code_drop_client import CodeDropClient +from microplugin.network.code_drop_server import CodeDropServer + + +class CodeSnippetEditorWidget(QWidget): + def __init__(self, + folder_path: str, + variables: Optional[dict] = None, + parent=None): + """ + Create a widget for editing Python code snippets. + + Parameters + ---------- + folder_path : str + The path to the folder containing the Python code snippets. + """ + super().__init__(parent) + self.folder_path = folder_path + self.filename_to_displayname = {} + self.displayname_to_filename = {} + self.currently_open_filename = None + + # Dictionary to hold undo stacks for each file + self.undo_stacks = {} + + # Set the variables: + self.variables = variables or {} + + # Start the network client and server: + self.client = CodeDropClient() + self.client.discover_worker.server_discovered.connect( + self.on_server_discovered) + self.client.start_discovering() + + # Start the server: + self.server = CodeDropServer(self.server_message_received) + self.server.start_broadcasting() + self.server.start_receiving() + + # is OpenAI available? + from napari_chatgpt.utils.api_keys.api_key import is_api_key_available + self.is_openai_available = is_api_key_available('OpenAI') + + # Initialize the UI: + self.init_UI() + + # Set the default model name for the language model: + self.llm_model_name = None + + + + + + def init_UI(self): + main_layout = QVBoxLayout(self) + + # Initialize the toolbar + self.toolbar = QToolBar("Main Toolbar") + main_layout.addWidget(self.toolbar) + + # Icon color: + icon_color = '#5E636F' + + # function to get icon from fontawesome: + def _get_icon(icon_name: str): + return qtawesome.icon(icon_name, color=icon_color) + + # New file button with a standard icon: + new_file_clickable_icon = ClickableIcon(_get_icon('fa5s.file-alt')) + new_file_clickable_icon.setToolTip("New file") + self.toolbar.addWidget(new_file_clickable_icon) + new_file_clickable_icon.clicked.connect(self.new_file_dialog) + + # Duplicate file button with a custom icon: + duplicate_file_clickable_icon = ClickableIcon(_get_icon('fa5s.copy')) + duplicate_file_clickable_icon.setToolTip("Duplicate file") + self.toolbar.addWidget(duplicate_file_clickable_icon) + duplicate_file_clickable_icon.clicked.connect(self.duplicate_file) + + # Delete file button with a custom icon: + delete_file_clickable_icon = ClickableIcon(_get_icon('fa5s.trash-alt')) + delete_file_clickable_icon.setToolTip("Delete file") + self.toolbar.addWidget(delete_file_clickable_icon) + delete_file_clickable_icon.clicked.connect(self.delete_file) + + # Clean and reformat code in file button with a custom icon: + clean_file_clickable_icon = ClickableIcon(_get_icon('fa5s.hand-sparkles')) + clean_file_clickable_icon.setToolTip("Clean and reformat code") + self.toolbar.addWidget(clean_file_clickable_icon) + clean_file_clickable_icon.clicked.connect(self.clean_and_reformat_current_file) + + # Check if the OpenAI API key is available: + if self.is_openai_available: + + # Check if the file is 'safe': + check_code_safety_clickable_icon = ClickableIcon(_get_icon('fa5s.virus-slash')) + check_code_safety_clickable_icon.setToolTip("Check code safety") + self.toolbar.addWidget(check_code_safety_clickable_icon) + check_code_safety_clickable_icon.clicked.connect( + self.check_code_safety_with_AI) + + # Improve code in file button with a custom icon: + comment_code_clickable_icon = ClickableIcon(_get_icon('fa5s.edit')) + comment_code_clickable_icon.setToolTip("Improve code comments and explanations") + self.toolbar.addWidget(comment_code_clickable_icon) + comment_code_clickable_icon.clicked.connect(self.comment_code_with_AI) + + # Use AI to change code based on prompt: + modify_code_clickable_icon = ClickableIcon(_get_icon('fa5s.robot')) + modify_code_clickable_icon.setToolTip("Modify code with AI") + self.toolbar.addWidget(modify_code_clickable_icon) + modify_code_clickable_icon.clicked.connect(self.modify_code_with_AI) + + # Send file button with a custom icon: + send_file_clickable_icon = ClickableIcon(_get_icon('fa5s.wifi')) + send_file_clickable_icon.setToolTip("Send file") + self.toolbar.addWidget(send_file_clickable_icon) + send_file_clickable_icon.clicked.connect(self.send_current_file) + + # Create a spacer widget and set its size policy to expanding + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + self.toolbar.addWidget(spacer) + + # Run file button with a custom icon: + run_file_clickable_icon = ClickableIcon(_get_icon('fa5s.play-circle')) + run_file_clickable_icon.setToolTip("Run file") + self.toolbar.addWidget(run_file_clickable_icon) + run_file_clickable_icon.clicked.connect(self.run_current_file) + + # Splitter for the list widget and the code editor: + self.splitter = QSplitter(Qt.Horizontal) + + # List widget for the file names: + self.list_widget = QListWidget() + self.list_widget.setContextMenuPolicy(Qt.CustomContextMenu) + self.list_widget.customContextMenuRequested.connect( + self.show_context_menu) + + # Code editor widget: + self.editor_manager = MultiEditorManager(self.on_text_modified) + + # Add widgets to the splitter: + self.splitter.addWidget(self.list_widget) + self.splitter.addWidget(self.editor_manager) + + # Add the splitter to the main layout: + main_layout.addWidget(self.splitter) + + # Add YesNoCancelQuestionWidget to the main layout: + self.yes_no_cancel_question_widget = YesNoCancelQuestionWidget() + main_layout.addWidget(self.yes_no_cancel_question_widget) + + # Add TextInputQuestionWidget to the main layout: + self.text_input_widget = TextInputWidget() + main_layout.addWidget(self.text_input_widget) + + # Add CodeDropSendWidget to the main layout: + self.code_drop_send_widget = CodeDropSendWidget(self.client) + main_layout.addWidget(self.code_drop_send_widget) + + # Add ConsoleWidget to the main layout: + self.console_widget = ConsoleWidget() + main_layout.addWidget(self.console_widget) + + + # Set the layout for the main widget: + self.setLayout(main_layout) + + # Connect signals and slots: + self.list_widget.currentItemChanged.connect( + self.current_list_item_changed) + + + self.populate_list() + + + def show_context_menu(self, position): + + # Create the context menu: + context_menu = QMenu(self) + + # Instantiate actions for the context menu: + rename_action = QAction("Rename", self) + duplicate_action = QAction("Duplicate", self) + delete_action = QAction("Delete", self) + open_in_system = QAction("Open in system", self) + find_in_system = QAction("Find in system", self) + + # Instantiate AI actions for the context menu: + if self.is_openai_available: + clean_action = QAction("Clean", self) + check_action = QAction("Check", self) + comment_action = QAction("Comment", self) + modify_action = QAction("Modify", self) + + # Add actions to the context menu: + context_menu.addAction(rename_action) + context_menu.addAction(duplicate_action) + context_menu.addAction(delete_action) + context_menu.addAction(open_in_system) + context_menu.addAction(find_in_system) + + # Add AI actions to the context menu: + if self.is_openai_available: + context_menu.addAction(clean_action) + context_menu.addAction(check_action) + context_menu.addAction(comment_action) + context_menu.addAction(modify_action) + + # Connect the actions to the corresponding slots: + rename_action.triggered.connect(self.rename_file) + duplicate_action.triggered.connect(self.duplicate_file) + delete_action.triggered.connect(self.delete_file_from_context_menu) + open_in_system.triggered.connect(self.open_file_in_system) + find_in_system.triggered.connect(self.find_file_in_system) + + # Connect AI actions to the corresponding slots: + if self.is_openai_available: + clean_action.triggered.connect(self.clean_and_reformat_current_file) + check_action.triggered.connect(self.check_code_safety_with_AI) + comment_action.triggered.connect(self.comment_code_with_AI) + modify_action.triggered.connect(self.modify_code_with_AI) + + # Show the context menu: + context_menu.exec_(self.list_widget.mapToGlobal(position)) + + def on_server_discovered(self, server_name, server_address, port_number): + aprint( + f"Discovered server: {server_name} at {server_address}:{port_number}") + + def server_message_received(self, addr, message): + + # Get IP address and port number: + ip_address, port = addr + + aprint( + f"Message of length: {len(message)} received from {ip_address}:{port}") + + # Parse the message in JSON format: + message_dict = json.loads(message) + hostname = message_dict['hostname'] + username = message_dict['username'] + filename = message_dict['filename'] + code = message_dict['code'] + + # Prepend a comment line to the code that gives the date and time it was recieved and from where? + code = f"# File '{filename}' of length: {len(code)} received from {username} at {hostname} ({ip_address}:{port}) at {datetime.now()}\n" + code + + # Ask for confirmation: + message = f"Accept file '{filename}' sent by {username} at {hostname} ({ip_address}:{port})?" + + def _accept_file(): + self.new_file(filename=filename, + code=code, + postfix_if_exists='received') + + # Show the question widget: + self.yes_no_cancel_question_widget.show_question(message=message, + yes_callback=_accept_file, + cancel_text=None) + + def populate_list(self, selected_filename: Optional[str] = None): + + # Clear the list widget and dictionaries: + self.list_widget.clear() + self.filename_to_displayname.clear() + self.displayname_to_filename.clear() + self.currently_open_filename = None + + # Get the list of files in the folder: + file_list = os.listdir(self.folder_path) + + # Sort the list of files: + file_list.sort() + + # Reset the index for duplicated display names: + display_name_index = 0 + + # Populate the list widget with the Python files in the folder: + max_width = 0 + fm = QFontMetrics(self.list_widget.font()) + for filename in file_list: + if filename.endswith(".py"): + # Add the file to the list widget: + display_name = self.truncate_filename(filename) + + # If there is already a file with the same display name, add a number to the display name: + if display_name in self.displayname_to_filename: + display_name_index += 1 + display_name = self.truncate_filename(filename, display_name_index) + else: + display_name_index = 0 + + # Add the file to the dictionaries: + self.filename_to_displayname[filename] = display_name + self.displayname_to_filename[display_name] = filename + + # Create a QListWidgetItem for the file: + item = QListWidgetItem(display_name) + # Set the tooltip to the full filename if it has been truncated: + if display_name != filename: + item.setToolTip(filename) + + # Add the item to the list widget: + self.list_widget.addItem(item) + + # Update the maximum width: + item_width = fm.width(display_name) + 20 + max_width = max(max_width, item_width) + + # Ensure that the list widget is wide enough to show the longest file name: + self.list_widget.setMaximumWidth(max_width) + + # Ensure that the list widget cannot be too small + self.list_widget.setMinimumWidth(fm.width('some_file.py') + 20) + + # Ensure that the selected file is loaded: + if selected_filename: + # Make sure to select the right row: + for i in range(self.list_widget.count()): + if self.list_widget.item(i).text() == self.filename_to_displayname.get(selected_filename, ""): + self.list_widget.setCurrentRow(i) + self.load_snippet_by_filename(selected_filename) + else: + # Otherwise select the first file in the list if there is one using load_snippet_by_filename: + if self.list_widget.count() > 0: + self.list_widget.setCurrentRow(0) + self.load_snippet() + + def truncate_filename(self, + filename: str, + index: Optional[int] = None, + max_length: int = 40) -> str: + + # Convert index to string if it is not None: + if index is not None: + index_str = str(index) + else: + index_str = "" + + # Truncate the filename if it is too long: + if len(filename) > max_length: + extension = filename.split(".")[-1] + base_length = max_length - len(extension) - 3 + return f"{filename[:base_length]}…{index_str}.{extension}" + return filename + + def on_text_modified(self): + # Save the file when the text is modified: + self.save_current_file() + + def load_snippet(self): + + # Get the current item: + current = self.list_widget.currentItem() + + # If there is a current item: + if current: + # Get the filename from the display name: + display_name = current.text() + filename = self.displayname_to_filename.get(display_name, "") + + # Load the snippet by filename: + self.load_snippet_by_filename(filename) + + def load_snippet_by_filename(self, filename): + + # Switch editor to the file: + self.editor_manager.switch_to(filename) + + # Set the currently open filename: + self.currently_open_filename = filename + + # Load the snippet from the file: + with open(os.path.join(self.folder_path, filename), "r") as file: + self.editor_manager.current_editor.setPlainTextUndoable(file.read()) + + def save_current_file(self): + + # If there is a currently open file, save it: + if self.currently_open_filename: + + # Make sure that there is an editor <=> at least one file in the list: + if not self.editor_manager.current_editor: + return + + # Get the full path to the file: + full_path = os.path.join(self.folder_path, + self.currently_open_filename) + + # Save the file: + with open(full_path, "w") as file: + file.write(self.editor_manager.current_editor.toPlainText()) + + def new_file(self, + filename: str, + code: Optional[str] = "", + postfix_if_exists = '_copy'): + + # Make sure the file has '.py' extension: + if not filename.endswith(".py"): + filename = f"{filename}.py" + + # Full path to the file: + full_path = os.path.join(self.folder_path, filename) + + # Make sure the file does not already exist: + while os.path.exists(full_path): + # Get filename from fullpath: + _, filename = os.path.split(full_path) + + # Get the filename without extension: + base_name, ext = os.path.splitext(filename) + + # If the filename already contains the postfix, the we check if there is number after the postfix: + if postfix_if_exists in base_name: + # Get the number after the postfix: + postfix_number = base_name.split(postfix_if_exists)[-1] + try: + postfix_number = int(postfix_number) + except ValueError: + postfix_number = 0 + + # Increase the number and add it to the base name: + base_name = base_name.split(postfix_if_exists)[0] + base_name = f"{base_name}{postfix_if_exists}{postfix_number + 1}" + + # Change the filename to avoid overwriting the existing file: + filename = f"{base_name}{ext}" + else: + # Add the postfix to the base name: + base_name = f"{base_name}{postfix_if_exists}" + # Change the filename to avoid overwriting the existing file: + filename = f"{base_name}{ext}" + + # Update the full path: + full_path = os.path.join(self.folder_path, filename) + + # Create the file and write the text to it: + with open(full_path, "w") as file: + file.write(code) + + # Repopulate the list: + self.populate_list(filename) + + def new_file_dialog(self): + + def _new_file(filename_text: str): + # if no file is selected but the editor has contents, use it as the new file's content: + + if self.editor_manager.current_editor: + current_editor = self.editor_manager.current_editor + current_text_in_editor = current_editor.toPlainText() + if self.currently_open_filename is None and current_text_in_editor and len( + current_text_in_editor) > 0: + code = current_editor.toPlainText() + else: + code = "" + else: + code = "" + + self.new_file(filename=filename_text, code=code) + + # Show the text input widget: + self.text_input_widget.show_input( + message="Enter file name:", + enter_text="Create", + cancel_text="Cancel", + enter_callback=_new_file, + cancel_callback=None, + do_after_callable=None + ) + + def duplicate_file(self): + + # get current item: + current_item = self.list_widget.currentItem() + + if current_item: + + # Save current file: + self.save_current_file() + + # Get original filename and display name: + original_display_name = current_item.text() + original_filename = self.displayname_to_filename[ + original_display_name] + + # get base name and extension: + base_name, ext = os.path.splitext(original_filename) + + # New filename: + new_filename = f"{base_name}_copy{ext}" + # new_display_name = f"{base_name}_copy" + + # Ensure the new filename does not already exist + counter = 1 + while os.path.exists(os.path.join(self.folder_path, new_filename)): + counter += 1 + new_filename = f"{base_name}_copy{counter}{ext}" + # new_display_name = f"{base_name}_copy{counter}" + + # Copy the file on disk + with open( + os.path.join(self.folder_path, original_filename), "r" + ) as original_file: + with open( + os.path.join(self.folder_path, new_filename), "w" + ) as new_file: + new_file.write(original_file.read()) + + # Update the list and dictionaries: + self.populate_list(new_filename) + + def delete_file_from_context_menu(self): + + # Get current item: + current_item = self.list_widget.currentItem() + + # If there is a current item: + if current_item: + # Get the filename from the display name: + filename_to_delete = self.displayname_to_filename[ + current_item.text()] + + # Delete the file: + self.delete_file(filename_to_delete) + + def delete_file(self, filename=None): + # Determine which file to delete: + file_to_delete = filename or self.currently_open_filename + + # If there is a file to delete: + if file_to_delete: + + # Ask for confirmation: + message = f"Are you sure you want to delete file {file_to_delete}?" + + def _delete_file(): + # Get the full path to the file: + file_to_delete_path = os.path.join(self.folder_path, + file_to_delete) + + # remove file: + os.remove(file_to_delete_path) + + # Refresh the list after deletion: + self.populate_list() + + if self.list_widget.count() > 0: + self.list_widget.setCurrentRow(0) + self.load_snippet() + else: + self.editor_manager.current_editor.clear() + self.currently_open_filename = None + + # Show the question widget: + self.yes_no_cancel_question_widget.show_question(message=message, + yes_callback=_delete_file, + cancel_text=None) + + def rename_file(self): + + # Get current line: + current_item = self.list_widget.currentItem() + + if current_item: + # Save current file: + self.save_current_file() + + # Get original filename and display name: + old_display_name = current_item.text() + old_filename = self.displayname_to_filename[old_display_name] + + def _rename_file(new_name: str): + # New filename: + new_filename = f"{new_name}.py" + + # Rename file on disk + os.rename( + os.path.join(self.folder_path, old_filename), + os.path.join(self.folder_path, new_filename), + ) + + # Update the list and dictionaries: + self.populate_list(new_filename) + + self.text_input_widget.show_input( + message="Enter new file name:", + placeholder_text="Enter new name here", + default_text=old_filename[:-3], + enter_text="Rename", + cancel_text="Cancel", + enter_callback=_rename_file, + cancel_callback=None, + do_after_callable=None + ) + + # # Ask for new name: + # new_name, ok = QInputDialog.getText( + # self, "Rename file", "New name:", text=old_filename[:-3] + # ) # Exclude '.py' extension + + def clean_and_reformat_current_file(self): + if self.currently_open_filename: + + # Make sure that there is an editor <=> at least one file in the list: + if not self.editor_manager.current_editor: + return + + # Get the code from the editor: + code = self.editor_manager.current_editor.toPlainText() + + # Uses black to format the code: + formatted_code = format_code(code) + + # Set the formatted code in the editor: + self.editor_manager.current_editor.setPlainTextUndoable(formatted_code) + + # Save the file after improving it: + self.save_current_file() + + def open_file_in_system(self): + # Get current item: + current_item = self.list_widget.currentItem() + + if current_item: + # Get the filename from the display name: + filename_to_open = self.displayname_to_filename[ + current_item.text()] + + # Open the file in the system for different OS: + # First OSX: + if sys.platform == "darwin": + os.system(f'open {os.path.join(self.folder_path, filename_to_open)}') + # Then Windows: + elif sys.platform == "win32" or sys.platform == "cygwin" or sys.platform == "msys" or sys.platform == "win64": + os.system(f'start {os.path.join(self.folder_path, filename_to_open)}') + # Then Linux: + else: + os.system(f'xdg-open {os.path.join(self.folder_path, filename_to_open)}') + + def find_file_in_system(self): + + # Open the folder in the system for different OS: + # First OSX: + if sys.platform == "darwin": + os.system(f'open {self.folder_path}') + # Then Windows: + elif sys.platform == "win32" or sys.platform == "cygwin" or sys.platform == "msys" or sys.platform == "win64": + os.system(f'start {self.folder_path}') + # Then Linux: + else: + os.system(f'xdg-open {self.folder_path}') + + + def check_code_safety_with_AI(self): + if self.currently_open_filename: + + # Make sure that there is an editor <=> at least one file in the list: + if not self.editor_manager.current_editor: + return + + # Get the code from the editor: + code = self.editor_manager.current_editor.toPlainText() + + # Check the code for safety by calling ChatGPT with a custom prompt: + from napari_chatgpt.utils.python.check_code_safety import \ + check_code_safety + response, safety_rank = check_code_safety(code, + model_name=self.llm_model_name, + verbose=True) + + # Create the icons for the dialog: + InformationIcon = qtawesome.icon('fa5s.info-circle') + WarningIcon = qtawesome.icon('fa5s.exclamation-triangle') + + # Create and show the custom dialog + dialog = TextDialog( + f"Code Safety Report for: {self.currently_open_filename}", + response, + InformationIcon if safety_rank in ['A', 'B'] else WarningIcon + ) + + # Show the dialog: + dialog.exec_() + + + def comment_code_with_AI(self): + if self.currently_open_filename: + + # Make sure that there is an editor <=> at least one file in the list: + if not self.editor_manager.current_editor: + return + + # Get the code from the editor: + code = self.editor_manager.current_editor.toPlainText() + + # Add comments to the code: + from napari_chatgpt.utils.python.add_comments import add_comments + code = add_comments(code, model_name=self.llm_model_name, verbose=True) + + # Set the commented code in the editor: + self.editor_manager.current_editor.setPlainTextUndoable(code) + + + def modify_code_with_AI(self): + if self.currently_open_filename: + + def _modify_code(request: str): + + # If there is no currently open file, return: + if not self.currently_open_filename: + return + + # Get the code from the editor: + code = self.editor_manager.current_editor.toPlainText() + + # Modify the code based on the request: + from napari_chatgpt.utils.python.modify_code import modify_code + modified_code = modify_code(code=code, + request=request, + model_name=self.llm_model_name, + verbose=True) + + # Request without new line characters: + request_nonl = request.replace('\n', ' ') + + # Add comment to the code that explains what as changed: + modified_code = f"# Code modified by Omega at {datetime.now()}.\n# Request:{request_nonl}.\n\n{modified_code}" + + # Set the commented code in the editor: + self.editor_manager.current_editor.setPlainTextUndoable(modified_code) + + + placeholder_text = ("Explain how you you want to modify the code of the currently selected file.\n" + "For example:\n" + " 'Make a widget from this code',\n" + " 'Make the code work for 3d stacks',\n" + " etc...\n" + "You can also place 'TODO's or 'FIXME' in the code, in that case no prompt is required.\n" + "Finally, you can undo the changes with CTRL+Z.\n") + + + # Show the text input widget: + self.text_input_widget.show_input( + message="Prompt:", + placeholder_text=placeholder_text, + enter_text="Modify", + cancel_text="Cancel", + enter_callback=_modify_code, + cancel_callback=None, + do_after_callable=None, + multi_line=True, + max_height=200 + ) + + def send_current_file(self): + + # Send the file if there is a currently open file: + if self.currently_open_filename: + + def _get_current_code_and_filename(): + # Get the code from the editor: + if self.editor_manager.current_editor: + code = self.editor_manager.current_editor.toPlainText() + return self.currently_open_filename, code + return None, None + + # Show the send dialog: + self.code_drop_send_widget.show_send_dialog( + get_code_callable=_get_current_code_and_filename + ) + + def run_current_file(self): + + # Run the file if there is a currently open file: + if self.currently_open_filename: + + # Save the file before running it: + self.save_current_file() + + # Make sure that there is an editor <=> at least one file in the list: + if not self.editor_manager.current_editor: + return + + # Get code from the editor: + code = self.editor_manager.current_editor.toPlainText() + + try: + # Local import to avoid circular import: + from napari_chatgpt.utils.python.dynamic_import import \ + execute_as_module + + # Run the code as a module: + captured_output = execute_as_module(code, **self.variables) + + # Show the output in the console: + self.console_widget.append_message(captured_output) + + aprint(f"Tool completed task successfully:\n {captured_output}") + + except Exception as e: + aprint(f"Error running file: {e}") + import traceback + traceback.print_exc() + + # String that contains stacktrace: + captured_stacktrace = traceback.format_exc() + + # Show the output in the console: + self.console_widget.append_message(captured_stacktrace, message_type='error') + + + + + + def current_list_item_changed(self, current, previous): + + # If the current item is different from the previous one, load the snippet: + if current and (previous == None or current.text != previous.text()): + # Load the snippet: + self.load_snippet() + + def close(self): + # Stop the server: + self.server.stop() + + # Stop the client: + self.client.stop() + + # Close the manager: + self.editor_manager.close() + + # Close the widgets: + self.yes_no_cancel_question_widget.close() + self.text_input_widget.close() + self.code_drop_send_widget.close() + + # Close the widget itself. + super().close() + + +if __name__ == "__main__": + app = QApplication(sys.argv) + + mainWindow = QMainWindow() + codeEditorWidget = CodeSnippetEditorWidget("") + mainWindow.setCentralWidget(codeEditorWidget) + mainWindow.setWindowTitle("Python Code Snippet Editor") + mainWindow.resize(800, 600) + mainWindow.show() + + sys.exit(app.exec_()) diff --git a/src/microplugin/code_editor/code_snippet_editor_window.py b/src/microplugin/code_editor/code_snippet_editor_window.py new file mode 100644 index 0000000..e5a364c --- /dev/null +++ b/src/microplugin/code_editor/code_snippet_editor_window.py @@ -0,0 +1,80 @@ +import sys +from typing import Tuple, Optional + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QApplication +from qtpy.QtWidgets import QMainWindow + +from microplugin.code_editor.code_snippet_editor_widget import \ + CodeSnippetEditorWidget + +# Enable High DPI display with PyQt5 +if hasattr(Qt, 'AA_UseHighDpiPixmaps'): + QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) +if hasattr(Qt, 'AA_EnableHighDpiScaling'): + QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) + + +class CodeSnippetEditorWindow(QMainWindow): + def __init__(self, + folder_path: str, + title: Optional[str] = "Python Code Snippet Editor", + size: Optional[Tuple[int, int]] = None, + variables: Optional[dict] = None, + parent=None, + *args, **kwargs): + """ + Create a main window for the Python code snippet editor. + + Parameters + ---------- + folder_path : str + The path to the folder containing the Python code snippets. + + args : list + Positional arguments to pass to the parent class. + + kwargs : dict + Keyword arguments to pass to the parent class. + + """ + super(CodeSnippetEditorWindow, self).__init__(parent=parent, + *args, + **kwargs) + + # Create the code snippet editor widget: + self.code_editor_widget = CodeSnippetEditorWidget(folder_path, + variables=variables, + parent=self) + self.setCentralWidget(self.code_editor_widget) + + + # get this machine's hostname: + server_hostname = self.code_editor_widget.server.server_hostname + + # Get the server port: + server_port = self.code_editor_widget.server.server_port + + # Shorten the folder path to the last two directories: + splitted_folder_path = folder_path.split('/') + if len(splitted_folder_path) > 2: + folder_path = '...' + '/'.join(splitted_folder_path[-2:]) + + # Set the window title: + self.setWindowTitle( + f"{title} - {folder_path} - {server_hostname}:{server_port}") + + # Set the window size: + self.resize(size or self.code_editor_widget.sizeHint()) + + def close(self): + self.code_editor_widget.close() + super().close() + +if __name__ == "__main__": + app = QApplication(sys.argv) + + mainWindow = CodeSnippetEditorWindow('', size=(800, 600)) + mainWindow.show() + + sys.exit(app.exec_()) diff --git a/src/microplugin/code_editor/console_widget.py b/src/microplugin/code_editor/console_widget.py new file mode 100644 index 0000000..fd8eac2 --- /dev/null +++ b/src/microplugin/code_editor/console_widget.py @@ -0,0 +1,124 @@ +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QVBoxLayout, QWidget, QTextEdit, QApplication, \ + QHBoxLayout, QPushButton + + +class ConsoleWidget(QWidget): + def __init__(self, + margin: int = 0, + icon_size: int = 20, + parent=None): + + super().__init__(parent=parent) + + # This helps in auto-hiding when losing focus: + self.setWindowFlags(Qt.Popup) + + # Initialize the UI + self.initUI(margin=margin, + icon_size=icon_size) + + def initUI(self, + margin: int, + icon_size: int + ): + + # Main layout + self.layout = QVBoxLayout() + + # Top bar layout for the close and clear buttons + self.topBarLayout = QHBoxLayout() + + # Set spacing between widgets in the layout to a smaller value + self.topBarLayout.setSpacing(1) # Adjust this value as needed + + # Close and clear buttons + self.closeButton = QPushButton("X") + self.clearButton = QPushButton("C") + + # Set the size of the buttons + self.closeButton.setFixedSize(icon_size, icon_size) # Make the button small + self.clearButton.setFixedSize(icon_size, icon_size) # Make the button small + + # Connect the close button to the hide method + self.closeButton.clicked.connect(self.hide) + self.clearButton.clicked.connect(self.clear_console) # Connect clear button + + # Add a spacer to push the close button to the right + self.topBarLayout.addStretch() + self.topBarLayout.addWidget(self.clearButton) # Add clear button + self.topBarLayout.addWidget(self.closeButton) + + # Create a read-only QTextEdit widget to act as the console output + self.console_output = QTextEdit() + self.console_output.setReadOnly(True) + self.console_output.setLineWrapMode(QTextEdit.NoWrap) + + # Add the top bar layout and the console output to the main layout + self.layout.addLayout(self.topBarLayout) + self.layout.addWidget(self.console_output) + + # Set the layout margins: + self.layout.setContentsMargins(margin, margin, margin, + margin) + + # Set the layout to the widget + self.setLayout(self.layout) + + # Hide the widget initially: + self.hide() + + def append_message(self, message: str, message_type: str = 'info'): + """ + Append a message to the console output. + + Args: + message (str): The message to append. + message_type (str): The type of message ('info', 'error', etc.). Can be used to format messages differently. + """ + + # Clean the message: + message = message.strip() + + # if the message is empty, do nothing: + if len(message) == 0: + return + + # Replace '\n' with '
' to display newlines in the QTextEdit: + message = message.replace('\n', '
') + + # Replace '\t' with '    ' to display tabs in the QTextEdit: + message = message.replace('\t', '    ') + + # Replace ' ' with ' ' to display spaces in the QTextEdit: + message = message.replace(' ', ' ') + + if message_type == 'error': + message = f"{message}" + elif message_type == 'info': + message = f"{message}" + + # Append the message to the console + self.console_output.append(message) + + # show the widget: + self.show() + + def clear_console(self): + """ + Clear the console output. + """ + self.console_output.clear() + + +# Example usage +if __name__ == "__main__": + import sys + + app = QApplication(sys.argv) + consoleWidget = ConsoleWidget() + consoleWidget.show() + consoleWidget.append_message("This is an info message.", "info") + consoleWidget.append_message("This is an error message.", "error") + + sys.exit(app.exec_()) diff --git a/src/microplugin/code_editor/python_code_completer.py b/src/microplugin/code_editor/python_code_completer.py new file mode 100644 index 0000000..33cd8c2 --- /dev/null +++ b/src/microplugin/code_editor/python_code_completer.py @@ -0,0 +1,25 @@ +from jedi import Interpreter +from qtpy.QtCore import QStringListModel, Qt +from qtpy.QtWidgets import QCompleter + + +class PythonCodeCompleter(QCompleter): + def __init__(self, parent=None): + super(PythonCodeCompleter, self).__init__(parent) + + # Set the completion mode to PopupCompletion to display the completions in a popup: + self.setCompletionMode(QCompleter.PopupCompletion) + + # Set the case sensitivity to case insensitive: + self.setCaseSensitivity(Qt.CaseInsensitive) + + # Set the model to an empty QStringListModel: + self.interpreter = None + + def updateCompletions(self, text): + if self.interpreter is None: + self.interpreter = Interpreter(text, []) + else: + self.interpreter = Interpreter(text, self.interpreter.namespaces) + completions = [c.name for c in self.interpreter.complete()] + self.setModel(QStringListModel(completions)) diff --git a/src/microplugin/code_editor/python_code_editor_manager.py b/src/microplugin/code_editor/python_code_editor_manager.py new file mode 100644 index 0000000..dd73e17 --- /dev/null +++ b/src/microplugin/code_editor/python_code_editor_manager.py @@ -0,0 +1,59 @@ +from qtpy.QtWidgets import QWidget, QVBoxLayout + +from microplugin.code_editor.python_code_editor_widget import \ + PythonCodeEditor + + +class MultiEditorManager(QWidget): + def __init__(self, + on_text_modified_callback, + editor_widget_class: type[QWidget] = PythonCodeEditor, + parent=None): + super().__init__(parent) + self.on_text_modified_callback = on_text_modified_callback + self.editor_widget_class = editor_widget_class + self.layout = QVBoxLayout(self) + self.editors = {} # Key: filename, Value: PythonCodeEditor instance + self.current_editor_name = None + self.current_editor = None + + + def switch_to(self, filename): + + # If we are already editing the filename, do nothing: + if filename == self.current_editor_name: + return + + if self.current_editor: + # Disconnect the previous editor's textChanged signal + self.current_editor.textChanged.disconnect(self.on_text_modified_callback) + + # Hide the previous editor: + self.current_editor.hide() + + # If the editor for the filename does not exist, create it: + if filename not in self.editors: + editor = self.editor_widget_class(self) + self.editors[filename] = editor + self.layout.addWidget(editor) + + # Set the current editor to the editor for the filename: + self.current_editor = self.editors[filename] + + # Set the current editor name to the filename: + self.current_editor_name = filename + + # Connect the current editor's textChanged signal to the callback: + self.current_editor.textChanged.connect(self.on_text_modified_callback) + + # Show the current editor: + self.current_editor.show() + + def close(self): + + # Close all the editors: + for editor in self.editors.values(): + editor.close() + + # Close the parent widget + super().close() diff --git a/src/microplugin/code_editor/python_code_editor_widget.py b/src/microplugin/code_editor/python_code_editor_widget.py new file mode 100644 index 0000000..0b2b9ad --- /dev/null +++ b/src/microplugin/code_editor/python_code_editor_widget.py @@ -0,0 +1,145 @@ +import jedi # Make sure jedi is installed +from qtpy.QtCore import QStringListModel +from qtpy.QtCore import Qt +from qtpy.QtGui import QTextCursor +from qtpy.QtWidgets import ( + QPlainTextEdit, + QCompleter, +) + +from microplugin.code_editor.python_syntax_highlighting import \ + PythonSyntaxHighlighter + + +class PythonCodeEditor(QPlainTextEdit): + def __init__(self, parent=None): + super().__init__(parent) + + # Tab Length Customization + self.tab_length = 4 # Default to 4 spaces + self.setTabStopDistance(self.fontMetrics().width(" ") * self.tab_length) + + # Set placeholder text: + self.setPlaceholderText("Type your code here...") + + # Syntax highlighter: + self.python_syntax_highlighter = PythonSyntaxHighlighter( + self.document()) + + # Completer setup: + self.completer = QCompleter(self) + self.completer.setWidget(self) + self.completer.setCompletionMode(QCompleter.PopupCompletion) + self.completer.setCaseSensitivity(Qt.CaseInsensitive) + self.completer.activated.connect(self.insertCompletion) + + def textUnderCursor(self): + text_cursor = self.textCursor() + text_cursor.select(QTextCursor.WordUnderCursor) + return text_cursor.selectedText() + + def insertCompletion(self, completion): + if self.completer.widget() != self: + return + tc = self.textCursor() + extra = len(completion) - len(self.completer.completionPrefix()) + tc.movePosition(QTextCursor.Left) + tc.movePosition(QTextCursor.EndOfWord) + tc.insertText(completion[-extra:]) # insert the selected completion + self.setTextCursor(tc) + + def keyPressEvent(self, event): + if self.completer: + if self.completer.popup().isVisible(): + if event.key() in ( + Qt.Key_Enter, + Qt.Key_Return, + Qt.Key_Escape, + Qt.Key_Tab, + Qt.Key_Backtab, + ): + # Ignore the event if the completer is visible and the key is one of the above: + event.ignore() + return + # Hide the completer when the user types a character: + self.completer.popup().hide() + + if event.text() == ".": + # Show completions after typing a dot: + super().keyPressEvent(event) + self.updateCompleter(show_completions=True) + elif event.key() in (Qt.Key_Return, Qt.Key_Enter): + # Indent new lines after certain keywords: + cursor = self.textCursor() + line = cursor.block().text() + indentation = len(line) - len(line.lstrip(" ")) + should_indent = any( + kw in line + for kw in [ + "if", + "elif", + "else", + "for", + "while", + "try", + "except", + "finally", + "with", + "def", + "class", + ] + ) or line.endswith(":") + + # Insert new line and indentation: + super().keyPressEvent(event) + self.insertPlainText( + " " * (indentation + (self.tab_length if should_indent else 0)) + ) + else: + super().keyPressEvent(event) + self.updateCompleter() + + def updateCompleter(self, show_completions=False): + text_under_cursor = self.textUnderCursor() + if text_under_cursor != "" or show_completions: + + # Get completions from Jedi: + script = jedi.Script(code=self.toPlainText(), path="temp.py") + completions = script.complete( + line=self.textCursor().blockNumber() + 1, + column=self.textCursor().columnNumber(), + ) + completion_list = [c.name for c in completions] + + # Update the completer model and show it: + self.completer.setModel(QStringListModel(completion_list)) + if completion_list: + self.completer.setCompletionPrefix(text_under_cursor) + cr = self.cursorRect() + cr.setWidth( + self.completer.popup().sizeHintForColumn(0) + + self.completer.popup().verticalScrollBar().sizeHint().width() + ) + self.completer.complete(cr) # popup it up! + elif show_completions: + # Close the completer if there's no text under cursor and no force show + self.completer.popup().hide() + + def setPlainTextUndoable(self, text): + # Obtain the current text cursor from the editor + tc = self.textCursor() + + # Start an undoable operation + tc.beginEditBlock() + + # Select all text + tc.select(QTextCursor.Document) + + # Remove the selected text (the entire document content) + tc.removeSelectedText() + + # Insert the new text + tc.insertText(text) + + # End the undoable operation + tc.endEditBlock() \ No newline at end of file diff --git a/src/microplugin/code_editor/python_syntax_highlighting.py b/src/microplugin/code_editor/python_syntax_highlighting.py new file mode 100644 index 0000000..800a74d --- /dev/null +++ b/src/microplugin/code_editor/python_syntax_highlighting.py @@ -0,0 +1,201 @@ +# syntax.py + +from qtpy import QtCore +from qtpy import QtGui + + +def format(color, style=''): + """Return a QTextCharFormat with the given attributes. + """ + _color = QtGui.QColor() + _color.setNamedColor(color) + + _format = QtGui.QTextCharFormat() + _format.setForeground(_color) + + if 'bold' in style: + _format.setFontWeight(QtGui.QFont.Bold) + if 'italic' in style: + _format.setFontItalic(True) + + return _format + + +# Syntax styles that can be shared by all languages +STYLES = { + 'keyword': format('#C68261'), + 'operator': format('#F0F0F0'), + 'brace': format('lightGray'), + 'defclass': format('#B4B6BC', 'bold'), + 'string': format('#5FA167'), + 'string2': format('#5FA167', 'italic'), + 'comment': format('#547760', 'italic'), + 'self': format('#864A7E'), + 'numbers': format('#25A2AF'), +} + + +class PythonSyntaxHighlighter(QtGui.QSyntaxHighlighter): + """Syntax highlighter for the Python language. + """ + # Python keywords + keywords = [ + 'and', 'assert', 'break', 'class', 'continue', 'def', + 'del', 'elif', 'else', 'except', 'exec', 'finally', + 'for', 'from', 'global', 'if', 'import', 'in', + 'is', 'lambda', 'not', 'or', 'pass', 'print', + 'raise', 'return', 'try', 'while', 'yield', + 'None', 'True', 'False', + ] + + # Python operators + operators = [ + '=', + # Comparison + '==', '!=', '<', '<=', '>', '>=', + # Arithmetic + '\+', '-', '\*', '/', '//', '\%', '\*\*', + # In-place + '\+=', '-=', '\*=', '/=', '\%=', + # Bitwise + '\^', '\|', '\&', '\~', '>>', '<<', + # Dict: + '\|=' + ] + + # Python braces + braces = [ + '\{', '\}', '\(', '\)', '\[', '\]', + ] + + def __init__(self, parent: QtGui.QTextDocument) -> None: + super().__init__(parent) + + # Multi-line strings (expression, flag, style) + self.tri_single = (QtCore.QRegExp("'''"), 1, STYLES['string2']) + self.tri_double = (QtCore.QRegExp('"""'), 2, STYLES['string2']) + + rules = [] + + # Keyword, operator, and brace rules + rules += [(r'\b%s\b' % w, 0, STYLES['keyword']) + for w in PythonSyntaxHighlighter.keywords] + rules += [(r'%s' % o, 0, STYLES['operator']) + for o in PythonSyntaxHighlighter.operators] + rules += [(r'%s' % b, 0, STYLES['brace']) + for b in PythonSyntaxHighlighter.braces] + + # All other rules + rules += [ + # 'self' + (r'\bself\b', 0, STYLES['self']), + + # 'def' followed by an identifier + (r'\bdef\b\s*(\w+)', 1, STYLES['defclass']), + # 'class' followed by an identifier + (r'\bclass\b\s*(\w+)', 1, STYLES['defclass']), + + # Numeric literals + (r'\b[+-]?[0-9]+[lL]?\b', 0, STYLES['numbers']), + (r'\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b', 0, STYLES['numbers']), + (r'\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b', 0, + STYLES['numbers']), + + # Double-quoted string, possibly containing escape sequences + (r'"[^"\\]*(\\.[^"\\]*)*"', 0, STYLES['string']), + # Single-quoted string, possibly containing escape sequences + (r"'[^'\\]*(\\.[^'\\]*)*'", 0, STYLES['string']), + + # From '#' until a newline + (r'#[^\n]*', 0, STYLES['comment']), + + ] + + # Build a QRegExp for each pattern + self.rules = [(QtCore.QRegExp(pat), index, fmt) + for (pat, index, fmt) in rules] + + def highlightBlock(self, text): + """Apply syntax highlighting to the given block of text. + """ + self.tripleQuoutesWithinStrings = [] + # Do other syntax formatting + for expression, nth, format in self.rules: + index = expression.indexIn(text, 0) + if index >= 0: + # if there is a string we check + # if there are some triple quotes within the string + # they will be ignored if they are matched again + if expression.pattern() in [r'"[^"\\]*(\\.[^"\\]*)*"', + r"'[^'\\]*(\\.[^'\\]*)*'"]: + innerIndex = self.tri_single[0].indexIn(text, index + 1) + if innerIndex == -1: + innerIndex = self.tri_double[0].indexIn(text, index + 1) + + if innerIndex != -1: + tripleQuoteIndexes = range(innerIndex, innerIndex + 3) + self.tripleQuoutesWithinStrings.extend( + tripleQuoteIndexes) + + while index >= 0: + # skipping triple quotes within strings + if index in self.tripleQuoutesWithinStrings: + index += 1 + expression.indexIn(text, index) + continue + + # We actually want the index of the nth match + index = expression.pos(nth) + length = len(expression.cap(nth)) + self.setFormat(index, length, format) + index = expression.indexIn(text, index + length) + + self.setCurrentBlockState(0) + + # Do multi-line strings + in_multiline = self.match_multiline(text, *self.tri_single) + if not in_multiline: + in_multiline = self.match_multiline(text, *self.tri_double) + + def match_multiline(self, text, delimiter, in_state, style): + """Do highlighting of multi-line strings. ``delimiter`` should be a + ``QRegExp`` for triple-single-quotes or triple-double-quotes, and + ``in_state`` should be a unique integer to represent the corresponding + state changes when inside those strings. Returns True if we're still + inside a multi-line string when this function is finished. + """ + # If inside triple-single quotes, start at 0 + if self.previousBlockState() == in_state: + start = 0 + add = 0 + # Otherwise, look for the delimiter on this line + else: + start = delimiter.indexIn(text) + # skipping triple quotes within strings + if start in self.tripleQuoutesWithinStrings: + return False + # Move past this match + add = delimiter.matchedLength() + + # As long as there's a delimiter match on this line... + while start >= 0: + # Look for the ending delimiter + end = delimiter.indexIn(text, start + add) + # Ending delimiter on this line? + if end >= add: + length = end - start + add + delimiter.matchedLength() + self.setCurrentBlockState(0) + # No; multi-line string + else: + self.setCurrentBlockState(in_state) + length = len(text) - start + add + # Apply formatting + self.setFormat(start, length, style) + # Look for the next match + start = delimiter.indexIn(text, start + length) + + # Return True if still inside a multi-line string, False otherwise + if self.currentBlockState() == in_state: + return True + else: + return False diff --git a/src/microplugin/code_editor/text_dialog.py b/src/microplugin/code_editor/text_dialog.py new file mode 100644 index 0000000..e2b25b4 --- /dev/null +++ b/src/microplugin/code_editor/text_dialog.py @@ -0,0 +1,37 @@ +# Custom dialog for displaying large amounts of text within reasonable limits +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QDialog, QTextEdit, QSizePolicy, QVBoxLayout, \ + QPushButton + + +class TextDialog(QDialog): + def __init__(self, title, text, icon=None, + parent=None): # icon parameter is optional and can be None + super().__init__(parent) + self.setWindowTitle(title) + self.setWindowModality(Qt.ApplicationModal) + + # Use a QTextEdit inside the dialog to display long messages + self.textEdit = QTextEdit() + self.textEdit.setText(text) + self.textEdit.setReadOnly(True) + self.textEdit.setSizePolicy(QSizePolicy.Expanding, + QSizePolicy.Expanding) + + # "OK" button to close the dialog + self.okButton = QPushButton("OK") + + # Connect the button's clicked signal to the dialog's close slot: + self.okButton.clicked.connect(self.close) + + # Layout + layout = QVBoxLayout() + layout.addWidget(self.textEdit) + + # Add the button to the layout, aligned at the center + layout.addWidget(self.okButton, 0, Qt.AlignCenter) + self.setLayout(layout) + + # Set the window icon if an icon is provided + if icon is not None: + self.setWindowIcon(icon) \ No newline at end of file diff --git a/src/microplugin/code_editor/text_input_widget.py b/src/microplugin/code_editor/text_input_widget.py new file mode 100644 index 0000000..a47bf36 --- /dev/null +++ b/src/microplugin/code_editor/text_input_widget.py @@ -0,0 +1,148 @@ +from typing import Optional + +from arbol import aprint +from qtpy.QtWidgets import QHBoxLayout, QWidget, QPushButton, QLabel, \ + QSizePolicy, QLineEdit, QTextEdit + + +class TextInputWidget(QWidget): + def __init__(self, + max_height: int = 50, + margin: int = 0, + parent=None): + + super().__init__(parent=parent) + + # Initialize callbacks: + self.enter_callback = None + self.cancel_callback = None + self.do_after_callable = None + + # + self.multi_line = False + + # Initialize widgets: + self.initUI(max_height=max_height, + margin=margin) + + def initUI(self, + max_height: int, + margin: int): + + # Layout: + self.layout = QHBoxLayout(self) + + # Message label: + self.message_label = QLabel() + self.layout.addWidget(self.message_label) + + # Input fields (both single-line and multi-line, hidden by default): + self.single_line_input = QLineEdit() + self.single_line_input.setPlaceholderText("Enter text here") + self.single_line_input.setSizePolicy(QSizePolicy.Expanding, + QSizePolicy.Preferred) + self.single_line_input.returnPressed.connect(self.on_enter) + self.layout.addWidget(self.single_line_input) + + self.multi_line_input = QTextEdit() + self.multi_line_input.setPlaceholderText("Enter text here") + self.multi_line_input.setSizePolicy(QSizePolicy.Expanding, + QSizePolicy.Preferred) + self.layout.addWidget(self.multi_line_input) + self.multi_line_input.hide() # Initially hidden + + # Buttons: + self.enter_button = QPushButton('Enter') + self.enter_button.clicked.connect(self.on_enter) + self.layout.addWidget(self.enter_button) + + self.cancel_button = QPushButton('Cancel') + self.cancel_button.clicked.connect(self.on_cancel) + self.layout.addWidget(self.cancel_button) + + self.layout.setContentsMargins(margin, margin, margin, margin) + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) + self.setMaximumHeight(max_height) + self.hide() + + + def show_input(self, message: str, + placeholder_text: str = "Enter input here", + default_text: str = '', + enter_text: str = "Enter", + cancel_text: str = "Cancel", + enter_callback=None, + cancel_callback=None, + do_after_callable=None, + multi_line: bool = False, + max_height: Optional[int] = None): + + # Set multi_line: + self.multi_line = multi_line + + # Set the maximum height if provided: + if max_height: + self.setMaximumHeight(max_height) + + # Hide both inputs initially: + self.single_line_input.hide() + self.multi_line_input.hide() + + # Determine which input field to show based on multi_line: + if multi_line: + self.current_input = self.multi_line_input + else: + self.current_input = self.single_line_input + + # Configure the chosen input field and show it: + self.current_input.setText(default_text) + self.current_input.setPlaceholderText(placeholder_text) + self.current_input.show() + + # Update labels and buttons: + self.message_label.setText(message) + self.enter_button.setText(enter_text) + self.cancel_button.setText(cancel_text) + + # Show or hide the cancel button based on cancel_text: + if cancel_text: + self.cancel_button.show() + else: + self.cancel_button.hide() + + # Set the callbacks: + self.enter_callback = enter_callback + self.cancel_callback = cancel_callback + self.do_after_callable = do_after_callable + + # Show the widget: + self.show() + + def on_enter(self): + input_text = self.current_input.text() if not self.multi_line else self.current_input.toPlainText() + try: + if self.enter_callback: + self.enter_callback(input_text) + except Exception as e: + aprint(f'Error in on_enter: {e}') + import traceback + traceback.print_exc() + finally: + self.hide() + if self.do_after_callable: + self.do_after_callable(input_text) + + def on_cancel(self): + try: + if self.cancel_callback: + self.cancel_callback('') + except Exception as e: + aprint(f'Error in on_enter: {e}') + import traceback + traceback.print_exc() + finally: + self.hide() + if self.do_after_callable: + self.do_after_callable('') + + diff --git a/src/microplugin/code_editor/yes_no_cancel_question_widget.py b/src/microplugin/code_editor/yes_no_cancel_question_widget.py new file mode 100644 index 0000000..6a1b322 --- /dev/null +++ b/src/microplugin/code_editor/yes_no_cancel_question_widget.py @@ -0,0 +1,125 @@ +from typing import Optional + +from arbol import aprint +from qtpy.QtWidgets import QHBoxLayout, QWidget, QPushButton, QLabel, \ + QSizePolicy + + +class YesNoCancelQuestionWidget(QWidget): + def __init__(self, + max_height: int = 50, + margin: int = 0, + parent=None): + + super().__init__(parent=parent) + + # Initialize callbacks: + self.yes_callback = None + self.no_callback = None + self.cancel_callback = None + self.do_after_callable = None + + # Initialize widgets: + self.initUI(max_height=max_height, + margin=margin) + + def initUI(self, max_height: int, + margin: int): + layout = QHBoxLayout() + + # Set message: + self.message_label = QLabel() + self.message_label.setText('') + + # Buttons: + self.yes_button = QPushButton('') + self.no_button = QPushButton('') + self.cancel_button = QPushButton('') + + # Connect buttons to callbacks: + self.yes_button.clicked.connect(self.on_yes) + self.no_button.clicked.connect(self.on_no) + self.cancel_button.clicked.connect(self.on_cancel) + + # Add widgets to layout: + layout.addWidget(self.message_label, 1) + layout.addWidget(self.yes_button) + layout.addWidget(self.no_button) + layout.addWidget(self.cancel_button) + + layout.setContentsMargins(margin, margin, margin, + margin) # Reduce margins if necessary + self.setLayout(layout) + + # Set the vertical size policy to Minimum so it takes the least vertical space + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) + + # Attempt to directly control the widget's size + self.setMaximumHeight(max_height) # Adjust 100 to your needs + + # Hide the widget initially: + self.hide() + + def show_question(self, message: str, + yes_text: str = "Yes", + no_text: str = "No", + cancel_text: Optional[str] = "Cancel", + yes_callback=None, + no_callback=None, + cancel_callback=None, + do_after_callable=None): + + self.message_label.setText(message) + self.yes_button.setText(yes_text) + self.no_button.setText(no_text) + + if cancel_text: + self.cancel_button.setText(cancel_text) + self.cancel_button.show() + else: + self.cancel_button.hide() + + self.yes_callback = yes_callback + self.no_callback = no_callback + self.cancel_callback = cancel_callback + self.do_after_callable = do_after_callable + self.show() + + def on_yes(self): + try: + if self.yes_callback: + self.yes_callback() + except Exception as e: + aprint(f'Error in on_yes: {e}') + import traceback + traceback.print_exc() + finally: + self.hide() + if self.do_after_callable: + self.do_after_callable() + + def on_no(self): + try: + if self.no_callback: + self.no_callback() + except Exception as e: + aprint(f'Error in on_no: {e}') + import traceback + traceback.print_exc() + finally: + self.hide() + if self.do_after_callable: + self.do_after_callable() + + def on_cancel(self): + try: + if self.cancel_callback: + self.cancel_callback() + except Exception as e: + aprint(f'Error in on_cancel: {e}') + import traceback + traceback.print_exc() + finally: + self.hide() + if self.do_after_callable: + self.do_after_callable() diff --git a/src/microplugin/demo/__init__.py b/src/microplugin/demo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/microplugin/demo/microplugin_demo.py b/src/microplugin/demo/microplugin_demo.py new file mode 100644 index 0000000..552cda0 --- /dev/null +++ b/src/microplugin/demo/microplugin_demo.py @@ -0,0 +1,37 @@ +import sys +import tempfile +import traceback + +from qtpy.QtWidgets import QApplication + +from microplugin.microplugin_window import MicroPluginMainWindow + + +def myExceptionHook(exctype, value, tb): + traceback.print_exception(exctype, value, tb) + # You can also log to a file here or show a dialog to the user + + +# Override the default excepthook with our custom function +sys.excepthook = myExceptionHook + +# Enable garbage collection debugging: +#gc.set_debug(gc.DEBUG_LEAK) + +if __name__ == "__main__": + app = QApplication(sys.argv) + + # temp folder as obtained with tempfile.TemporaryDirectory() in the main script: + temp_folder = tempfile.TemporaryDirectory() + + # Turn off the singleton pattern: + MicroPluginMainWindow._singleton_pattern_active = False + + # Instantiate the MicroPluginMainWindow: + mainWindow = MicroPluginMainWindow(napari_viewer=None, + folder_path=temp_folder.name) + + # Show the MicroPluginMainWindow: + mainWindow.show() + + sys.exit(app.exec_()) diff --git a/src/microplugin/formating/__init__.py b/src/microplugin/formating/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/microplugin/formating/black_formating.py b/src/microplugin/formating/black_formating.py new file mode 100644 index 0000000..a1f43e2 --- /dev/null +++ b/src/microplugin/formating/black_formating.py @@ -0,0 +1,43 @@ +from pathlib import Path +from typing import Union + +from black import FileMode, format_file_in_place, WriteBack + + +def format_code(code: str) -> str: + """Format the code using black.""" + try: + + from black import format_str, FileMode, InvalidInput + + # Format the code string using Black + formatted_code_str = format_str(code, mode=FileMode()) + + return formatted_code_str + + except Exception as e: + import traceback + print(traceback.format_exc()) + return code + + +def format_file(file_path: Union[str, Path]) -> None: + """Format the file using black.""" + + try: + # Ensure file_path is a Path object + if isinstance(file_path, str): + file_path = Path(file_path) + + # Format the file using Black + format_file_in_place(file_path, + fast=False, + mode=FileMode(), + write_back=WriteBack.YES) + + except Exception as e: + import traceback + print(traceback.format_exc()) + + finally: + pass diff --git a/src/microplugin/microplugin_window.py b/src/microplugin/microplugin_window.py new file mode 100644 index 0000000..795bf02 --- /dev/null +++ b/src/microplugin/microplugin_window.py @@ -0,0 +1,120 @@ +import os +import sys +from typing import Tuple, Optional + +from arbol import aprint +from napari._qt.qt_resources import get_current_stylesheet +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QApplication + +from microplugin.code_editor.code_snippet_editor_window import \ + CodeSnippetEditorWindow + + +class MicroPluginMainWindow(CodeSnippetEditorWindow): + + _singleton_pattern_active = True + _singleton_instance = None + _singleton_instance_initialized = False + + def __new__(cls, *args, **kwargs): + + if cls._singleton_pattern_active: + if cls._singleton_instance is None: + + # Call __new__ of the parent class and save the instance: + cls._singleton_instance = super(MicroPluginMainWindow, cls).__new__(cls) + + return cls._singleton_instance + + else: + # We still want the last instance to be recorded: + cls._singleton_instance = super().__new__(cls) + return cls._singleton_instance + + + def __init__(self, + napari_viewer, + folder_path: Optional[str] = None, + size: Optional[Tuple[int, int]] = None, + parent=None, + *args, + **kwargs): + + # If the singleton instance is already initialized, just return it: + if MicroPluginMainWindow._singleton_pattern_active and MicroPluginMainWindow._singleton_instance_initialized: + return + + if folder_path is None: + + # Local import to avoid circular import: + from napari_chatgpt.utils.configuration.app_configuration import \ + AppConfiguration + + # Get configuration + config = AppConfiguration('microplugins') + + # default folder is microplugins in the user's home directory: + default_folder_path = '~/microplugins' + + # Get the folder path: + folder_path = config.get('folder', default_folder_path) + + # Make sure that the folder exists: + folder_path = os.path.expanduser(folder_path) + if not os.path.exists(folder_path): + os.makedirs(folder_path) + + super(MicroPluginMainWindow, self).__init__(folder_path=folder_path, + title="Micro-Plugins Editor", + size=size, + variables={'viewer':napari_viewer}, + parent=parent, + *args, + **kwargs) + + # Disable discovery worker until we send: + self.code_editor_widget.client.discover_worker.is_enabled = False + + # Make sure that when user closes the window, it just hides the window, it does not really close it: + self.setAttribute(Qt.WA_DeleteOnClose, False) + + # Get current stylesheet from napari: + current_style = get_current_stylesheet() + self.setStyleSheet(current_style) + + # Set window size and position to be centered and occupy specified screen space + try: + screen = QApplication.primaryScreen().geometry() + desired_width = screen.width() * 0.5 # 50% of screen width + desired_height = screen.height() * 0.6 # 60% of screen height + left = (screen.width() - desired_width) / 2 + top = (screen.height() - desired_height) / 2 + self.setGeometry(int(left), int(top), int(desired_width), int(desired_height)) + except Exception as e: + aprint(f'Error setting window size and position: {e}') + import traceback + traceback.print_exc() + + # LLM settings: + self.llm_model_name = None + + # Set the instance as initialized: + MicroPluginMainWindow._singleton_instance_initialized = True + + + + @staticmethod + def add_snippet(filename: str, + code: Optional[str] = None): + + # Create a new file with the given code: + MicroPluginMainWindow._singleton_instance.code_editor_widget.new_file(filename=filename, code=code, postfix_if_exists='_new_from_omega') + +if __name__ == "__main__": + app = QApplication(sys.argv) + + mainWindow = MicroPluginMainWindow() + mainWindow.show() + + sys.exit(app.exec_()) diff --git a/src/microplugin/network/__init__.py b/src/microplugin/network/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/microplugin/network/broadcast_worker.py b/src/microplugin/network/broadcast_worker.py new file mode 100644 index 0000000..6f8c982 --- /dev/null +++ b/src/microplugin/network/broadcast_worker.py @@ -0,0 +1,71 @@ +import os +import socket +import time + +from qtpy.QtCore import Slot, QObject, Signal + + +class BroadcastWorker(QObject): + error = Signal(Exception) + + def __init__(self, + sock, + multicast_groups, + port, + broadcast_interval: int = 1): + super().__init__() + + # Store the socket object: + self.sock = sock + + # Multicast group address: + self.multicast_groups = multicast_groups + + # Port number that the server listens on: + self.port = port + + # Broadcast interval: + self.broadcast_interval = broadcast_interval + + # Flags to control the worker: + self.is_enabled = True + self.is_running = True + + def set_enabled(self, enabled): + self.is_enabled = enabled + + def stop(self): + self.is_running = False + + @Slot() + def broadcast(self): + + # Run the broadcast loop: + while self.is_running: + try: + if self.is_enabled: + for self.multicast_group in self.multicast_groups: + # get hostname: + hostname = socket.gethostname() + + # get username: + username = os.getlogin() + + # Format the message to include hostname and port + message = f"{username}:{hostname}:{self.port}".encode() + + # Send the message to the multicast group: + self.sock.sendto(message, self.multicast_group) + else: + # If the broadcast is disabled, just sleep for a while + pass + + time.sleep(self.broadcast_interval) + + # Handle exceptions and emit an error signal: + except Exception as e: + import traceback + traceback.print_exc() + self.error.emit(e) + + # Note: exception handling is within the loop so that the thread doesn't die diff --git a/src/microplugin/network/code_drop_client.py b/src/microplugin/network/code_drop_client.py new file mode 100644 index 0000000..5878a56 --- /dev/null +++ b/src/microplugin/network/code_drop_client.py @@ -0,0 +1,182 @@ +import json +import os +import socket +from threading import Lock +from time import sleep + +from arbol import aprint +from qtpy.QtCore import QObject, Signal, Slot, QThread + +from microplugin.network.code_drop_server import CodeDropServer +from microplugin.network.discover_worker import DiscoverWorker + + +class CodeDropClient(QObject): + def __init__(self, multicast_groups=None): + super().__init__() + + if multicast_groups is None: + multicast_groups = CodeDropServer._code_drop_multicast_groups + + self.multicast_groups = multicast_groups + self.servers = {} # Mapping server names to addresses + + # Store thread and worker references to prevent premature garbage collection + self.discover_thread = None + self.discover_worker = None + self.send_thread = None + self.send_worker = None + + # initialise locks: + self.sending_lock = Lock() + + self.init_discovery() + + def init_discovery(self): + + # Create a worker and move it to a thread + + self.discover_thread = QThread() + self.discover_thread.setTerminationEnabled(True) + self.discover_thread.setObjectName("DiscoverThread") + self.discover_worker = DiscoverWorker(self.multicast_groups) + self.discover_worker.moveToThread(self.discover_thread) + + # Ensure the thread is properly stopped and cleaned up before exiting + self.discover_thread.started.connect( + self.discover_worker.discover_servers) + self.discover_worker.server_discovered.connect(self.update_servers) + self.discover_worker.error.connect(self.handle_error) + + # Cleanup on completion + # self.discover_worker.finished.connect(self.discover_thread.quit) + # self.discover_worker.finished.connect(self.discover_worker.deleteLater) + # self.discover_thread.finished.connect(self.discover_thread.deleteLater) + + def start_discovering(self): + if self.discover_thread is not None: + # Start the thread and begin discovering servers: + self.discover_thread.start() + + def stop_discovering(self): + if self.discover_worker and self.discover_thread: + # Ensure there's a stop method to signal the worker to terminate: + self.discover_worker.stop() + self.discover_thread.quit() + self.discover_thread.wait() + self.discover_thread = None + + def update_servers(self, user_name, server_name, server_address, + server_port): + + # Server name and port are the key: + key = f"{server_name}:{server_port}" + + self.servers[key] = (user_name, server_address, server_port) + # Update your GUI or data structure with new server information here + + def send_code_message(self, + server_address: str, + server_port: int, + filename: str, + code: str): + + # get hostname: + hostname = socket.gethostname() + + # Get username (login) from the system: + username = os.getlogin() + + # Message dict: + message_dict = { + 'hostname': hostname, + 'username': username, + 'filename': filename, + 'code': code} + + # Convert dict to JSON string: + message_str = json.dumps(message_dict) + + # Send message: + self.send_message_by_address(server_address, + server_port, + message_str) + + def send_message_by_address(self, + server_address: str, + server_port: int, + message: str): + + with self.sending_lock: + # Check if there's already a thread running for sending messages: + max_number_of_attempts: int = 10 + while self.send_thread is not None and self.send_thread.isRunning(): + self.send_thread.quit() + self.send_thread.wait() + + aprint( + "A send thread is already running. Wait for it to finish.") + sleep(0.1) + max_number_of_attempts -= 1 + + # If the thread is taking too long, stop waiting and don't send the message: + if max_number_of_attempts == 0: + aprint( + "Max number of attempts reached. Can't send message.") + return + + # Create a QThread each time for sending messages + self.send_thread = QThread() + self.send_thread.setTerminationEnabled(True) + self.send_thread.setObjectName("SendThread") + + # Create a worker to send the message and move it to the thread: + self.send_worker = self.create_send_worker(server_address, + server_port, message) + self.send_worker.moveToThread(self.send_thread) + + # Connect the thread started signal to the worker's send method: + self.send_thread.started.connect(self.send_worker.send) + + # Start the thread: + self.send_thread.start() + aprint( + f"Sending message of length: {len(message)} to {server_address}:{server_port}") + + def create_send_worker(self, server_address, server_port, message): + + parent_self = self + + class SendWorker(QObject): + finished = Signal() + + @Slot() + def send(self): + with parent_self.sending_lock: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((server_address, server_port)) + sock.sendall(message.encode()) + aprint( + f"Message of length: {len(message)} sent to {server_address}:{server_port}") + + except Exception as e: + aprint(f"Error sending message: {e}") + import traceback + traceback.print_exc() + parent_self.handle_error(e) + finally: + sock.close() + self.finished.emit() + parent_self.send_worker = None + + return SendWorker() + + def handle_error(self, e): + aprint(f"Error: {e}") + + def stop(self): + self.stop_discovering() + + + diff --git a/src/microplugin/network/code_drop_server.py b/src/microplugin/network/code_drop_server.py new file mode 100644 index 0000000..a68b94a --- /dev/null +++ b/src/microplugin/network/code_drop_server.py @@ -0,0 +1,113 @@ +import random +import socket +import struct +from typing import Optional, Callable + +from arbol import aprint +from qtpy.QtCore import QObject, QThread + +from microplugin.network.broadcast_worker import BroadcastWorker +from microplugin.network.receive_worker import ReceiveWorker + + +class CodeDropServer(QObject): + _code_drop_multicast_groups = [('224.1.1.1', 5007), ('224.1.1.1', 5008)] + + def __init__(self, + callback: Callable, + multicast_groups=None, + server_port: Optional[int] = None): + super().__init__() + + if multicast_groups is None: + # choose a random multicast group: + multicast_groups = CodeDropServer._code_drop_multicast_groups + aprint(f"Multicast group: {multicast_groups}") + + if server_port is None: + # choose a random valid port number: + server_port = self._find_port() + aprint(f"Server port chosen: {server_port}") + + # Store the multicast group: + self.multicast_groups = multicast_groups + + # This machine's hostname: + self.server_hostname = socket.gethostname() + + # Store the server port number: + self.server_port = server_port + + # Store the callback function: + self.callback = callback + + # Create a socket for broadcasting: + self.broadcast_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.broadcast_socket.setsockopt(socket.IPPROTO_IP, + socket.IP_MULTICAST_TTL, struct.pack('b', 32) ) + + # Setup the broadcast worker and thread: + self.broadcast_thread = QThread() + self.broadcast_thread.setTerminationEnabled(True) + self.broadcast_thread.setObjectName("BroadcastThread") + self.broadcast_worker = BroadcastWorker(self.broadcast_socket, + self.multicast_groups, + self.server_port) + self.broadcast_worker.moveToThread(self.broadcast_thread) + self.broadcast_thread.started.connect(self.broadcast_worker.broadcast) + self.broadcast_worker.error.connect(self.handle_error) + + # Setup the receive worker and thread: + self.receive_thread = QThread() + self.receive_thread.setTerminationEnabled(True) + self.receive_thread.setObjectName("ReceiveThread") + self.receive_worker = ReceiveWorker(self.server_port) + self.receive_worker.moveToThread(self.receive_thread) + self.receive_thread.started.connect( + self.receive_worker.receive_messages) + self.receive_worker.message_received.connect( + lambda addr, msg: self.callback(addr, msg)) + self.receive_worker.error.connect(self.handle_error) + + def _find_port(self): + port = 5000 + random.randint(0, 100) + while True: + try: + # check if the port is already in use: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(('', port)) + s.close() + break + except OSError: + port = 5000 + random.randint(0, 100) + return port + + def start_broadcasting(self): + self.broadcast_thread.start() + + def start_receiving(self): + self.receive_thread.start() + + def stop_broadcasting(self): + if self.broadcast_worker and self.broadcast_thread: + # Stop the broadcast worker and thread: + self.broadcast_worker.stop() + self.broadcast_thread.quit() + self.broadcast_thread.wait() + + def stop_receiving(self): + if self.receive_worker and self.receive_thread: + # Stop the receive worker and thread: + self.receive_worker.stop() + self.receive_thread.quit() + self.receive_thread.wait() + + def stop(self): + self.stop_broadcasting() + self.stop_receiving() + + + def handle_error(self, e): + aprint(f"Error: {e}") + + diff --git a/src/microplugin/network/demo/__init__.py b/src/microplugin/network/demo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/microplugin/network/demo/code_drop_client_demo.py b/src/microplugin/network/demo/code_drop_client_demo.py new file mode 100644 index 0000000..e1c0efe --- /dev/null +++ b/src/microplugin/network/demo/code_drop_client_demo.py @@ -0,0 +1,28 @@ +import sys + +from qtpy.QtWidgets import QApplication + +from microplugin.network.code_drop_client import CodeDropClient + + +def on_server_discovered(server_name, server_address, server_port): + print(f"Discovered server: {server_name} at {server_address}") + client.send_message_by_address(server_address, server_port, + "Hello, Server!") + + +if __name__ == "__main__": + app = QApplication(sys.argv) + + client = CodeDropClient() + client.discover_worker.server_discovered.connect(on_server_discovered) + client.start_discovering() + + # open a widget to send messages: + # client_ui = CodeDropClientWidget(client) + + # client_ui.show() + + sys.exit(app.exec_()) + + print(client) diff --git a/src/microplugin/network/demo/code_drop_server_demo.py b/src/microplugin/network/demo/code_drop_server_demo.py new file mode 100644 index 0000000..bd073ef --- /dev/null +++ b/src/microplugin/network/demo/code_drop_server_demo.py @@ -0,0 +1,17 @@ +import sys + +from qtpy.QtWidgets import QApplication + +from microplugin.network.code_drop_server import CodeDropServer + + +def server_message_received(addr, message): + print(f"Message from {addr}: {message}") + + +if __name__ == "__main__": + app = QApplication(sys.argv) + server = CodeDropServer(server_message_received) + server.start_broadcasting() + server.start_receiving() + sys.exit(app.exec_()) diff --git a/src/microplugin/network/discover_worker.py b/src/microplugin/network/discover_worker.py new file mode 100644 index 0000000..71d92e6 --- /dev/null +++ b/src/microplugin/network/discover_worker.py @@ -0,0 +1,125 @@ +import socket +import struct +from time import sleep + +from arbol import aprint +from qtpy.QtCore import Slot, QObject, Signal + + +class DiscoverWorker(QObject): + # Signal for discovered servers: + server_discovered = Signal(str, str, str, + int) # user_name, server_name, server_addr, server_port + + # Signal for errors: + error = Signal(Exception) + + # Signal to indicate the worker has finished its work + finished = Signal() + + def __init__(self, multicast_groups): + super().__init__() + self.multicast_groups = multicast_groups + self.is_running = True + self.is_enabled = True + + def stop(self): + self.is_running = False + + def close(self): + self.stop() + + @Slot() + def discover_servers(self): + try: + available_multicast_group = None + # Trying to bind to any of multicast groups (usefull for testing purposes): + for multicast_group in self.multicast_groups: + + try: + # Create a socket to listen for multicast messages: + broadcast_listening_socket = socket.socket(socket.AF_INET, + socket.SOCK_DGRAM, + socket.IPPROTO_UDP) + broadcast_listening_socket.setsockopt(socket.SOL_SOCKET, + socket.SO_REUSEADDR, + 1) + + # Bind the socket to the multicast group: + broadcast_listening_socket.bind(('', multicast_group[1])) + + # Store the multicast group that worked: + available_multicast_group = multicast_group + + aprint(f"Bound to multicast group: {multicast_group}") + + # If we reach this point without any exceptions, we can assume the socket is ready to receive messages: + break + except OSError as e: + aprint( + f"Error binding to multicast group {multicast_group}: {e}") + import traceback + traceback.print_exc() + aprint(f"Most likely the multicast group is already in use by another instance of Omega! Only affects sending of code snippets.") + + # Tell the kernel to add the multicast group to the multicast group: + mreq = struct.pack("4sl", + socket.inet_aton(available_multicast_group[0]), + socket.INADDR_ANY) + + # Join the multicast group: + broadcast_listening_socket.setsockopt(socket.IPPROTO_IP, + socket.IP_ADD_MEMBERSHIP, + mreq) + + # Set a timeout of 5 seconds + broadcast_listening_socket.settimeout(1.0) + + # Counter use to keep track of how many times we timedout + counter = 0 + + while self.is_running: + + try: + + if self.is_enabled: + try: + # Receive the data and sender's address: + data, addr = broadcast_listening_socket.recvfrom(1024) + if data: + # Directly use the received data, assuming it's in 'hostname:port' format + server_info = data.decode().strip() + user_name, server_name, server_port = server_info.split( + ':') + server_addr = addr[0] + server_port = int(server_port) + self.server_discovered.emit(user_name, server_name, + server_addr, server_port) + else: + break # No more data, stop the loop + except socket.timeout: + counter = counter + 1 + if counter > 30: + aprint("No servers discovered received within the last 30 seconds.") + counter = 0 + + else: + # If discovery is disabled, just sleep for a while: + sleep(0.5) + + except Exception as e: + import traceback + traceback.print_exc() + self.error.emit(e) + + # Note: exception handling is within the loop so that the thread doesn't die + + + except Exception as e: + import traceback + traceback.print_exc() + self.error.emit(e) + finally: + broadcast_listening_socket.close() + self.finished.emit() # Emit finished signal when done + return diff --git a/src/microplugin/network/receive_worker.py b/src/microplugin/network/receive_worker.py new file mode 100644 index 0000000..d155e1b --- /dev/null +++ b/src/microplugin/network/receive_worker.py @@ -0,0 +1,81 @@ +import socket + +from arbol import aprint +from qtpy.QtCore import Slot, QObject, Signal + + +class ReceiveWorker(QObject): + # Signal for received messages: + message_received = Signal(tuple, str) # Signal for received messages + + # Signal for errors: + error = Signal(Exception) + + def __init__(self, port): + super().__init__() + self.port = port + self.is_running = True + + def stop(self): + self.is_running = False + + @Slot() + def receive_messages(self): + try: + aprint(f"Listening for messages on port: {self.port}") + + # Create a socket to listen for incoming connections: + server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + # Bind the socket to the port: + server_socket.bind(('', self.port)) + + # Listen for incoming connections: + server_socket.listen(5) + + # Set a timeout of 1 seconds + server_socket.settimeout(1.0) + + while self.is_running: + try: + client_socket = None + + # Accept the connection: + client_socket, addr = server_socket.accept() + aprint(f"Connection from: {addr}") + + chunks = [] + while True: + chunk = client_socket.recv(1024) + if not chunk: + break # Connection is closed, and we've received the complete message. + chunks.append(chunk) + + # Combine the chunks to form the complete message: + message = b''.join(chunks).decode() + + # Emit the message received signal: + self.message_received.emit(addr, message) + + except socket.timeout: + # No biggie! Just keep listening: + pass + + except Exception as e: + # That's a biggie! Emit the error signal: + import traceback + traceback.print_exc() + self.error.emit(e) + + finally: + # Close the client socket: + if client_socket: + client_socket.close() + + + except Exception as e: + import traceback + traceback.print_exc() + self.error.emit(e) + finally: + server_socket.close() diff --git a/src/napari_chatgpt/_sandbox/all_qstyle_icons.py b/src/napari_chatgpt/_sandbox/all_qstyle_icons.py new file mode 100644 index 0000000..df38fda --- /dev/null +++ b/src/napari_chatgpt/_sandbox/all_qstyle_icons.py @@ -0,0 +1,34 @@ +import sys + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QApplication +from PyQt5.QtWidgets import QGridLayout, QPushButton, QStyle, QWidget + +QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) # Enable high-DPI scaling +QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) # Use high-DPI icons + + +class Window(QWidget): + def __init__(self): + super(Window, self).__init__() + + icons = sorted([attr for attr in dir(QStyle) if attr.startswith("SP_")]) + layout = QGridLayout() + + for n, name in enumerate(icons): + btn = QPushButton(name) + + pixmapi = getattr(QStyle, name) + icon = self.style().standardIcon(pixmapi) + btn.setIcon(icon) + layout.addWidget(btn, n / 4, n % 4) + + self.setLayout(layout) + + +app = QApplication(sys.argv) + +w = Window() +w.show() + +app.exec_() \ No newline at end of file diff --git a/src/napari_chatgpt/_sandbox/lanchain_openai.py b/src/napari_chatgpt/_sandbox/lanchain_openai.py index 2f5f54c..519fbe6 100644 --- a/src/napari_chatgpt/_sandbox/lanchain_openai.py +++ b/src/napari_chatgpt/_sandbox/lanchain_openai.py @@ -33,4 +33,4 @@ HumanMessage(content=prompt) ] result = chat(messages) -print(result.content) +print(result.code) diff --git a/src/napari_chatgpt/_sandbox/new_agent_2.py b/src/napari_chatgpt/_sandbox/new_agent_2.py index 10391cb..a3e6698 100644 --- a/src/napari_chatgpt/_sandbox/new_agent_2.py +++ b/src/napari_chatgpt/_sandbox/new_agent_2.py @@ -10,7 +10,7 @@ set_api_key('OpenAI') -from langchain.agents import initialize_agent, AgentType, AgentExecutor +from langchain.agents import AgentExecutor from langchain_community.chat_models import ChatOpenAI from napari_chatgpt.omega.tools.search.web_search_tool import WebSearchTool @@ -23,8 +23,6 @@ from napari_chatgpt.omega.tools.special.python_repl import \ PythonCodeExecutionTool -from langchain.tools import BearlyInterpreterTool, DuckDuckGoSearchRun - llm = ChatOpenAI(temperature=0, model="gpt-4-1106-preview") # Define a list of tools offered by the agent diff --git a/src/napari_chatgpt/_sandbox/openai_vision.py b/src/napari_chatgpt/_sandbox/openai_vision.py index d031991..ad4f8d1 100644 --- a/src/napari_chatgpt/_sandbox/openai_vision.py +++ b/src/napari_chatgpt/_sandbox/openai_vision.py @@ -1,9 +1,6 @@ import base64 -import openai -import os from openai.resources.chat import Completions -from openai.types.chat import ChatCompletion # Updated file path to a JPEG image #image_path = "/Users/royer/Downloads/image.jpeg" diff --git a/src/napari_chatgpt/_sandbox/qtawesome_demo.py b/src/napari_chatgpt/_sandbox/qtawesome_demo.py new file mode 100644 index 0000000..3dab95f --- /dev/null +++ b/src/napari_chatgpt/_sandbox/qtawesome_demo.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*- + +# Standard library imports +import sys + +from qtawesome import icon, IconWidget, Spin, Pulse, font, set_global_defaults +# Third party imports +from qtpy import QtCore, QtWidgets + + +class AwesomeExample(QtWidgets.QDialog): + + def __init__(self): + super().__init__() + + # Label for supported fonts + supported_fonts_label = QtWidgets.QLabel('Supported fonts (prefix)') + supported_fonts_label.setAlignment(QtCore.Qt.AlignCenter) + + # Get FontAwesome 5.x icons by name in various styles by name + fa5_icon = icon('fa5.file-alt') + fa5_button = QtWidgets.QPushButton(fa5_icon, 'Font Awesome regular (fa5)') + + fa5s_icon = icon('fa5s.flag') + fa5s_button = QtWidgets.QPushButton(fa5s_icon, 'Font Awesome solid (fa5s)') + + fa5b_icon = icon('fa5b.github') + fa5b_button = QtWidgets.QPushButton(fa5b_icon, 'Font Awesome brands (fa5b)') + + # Get Elusive icons by name + asl_icon = icon('ei.asl') + elusive_button = QtWidgets.QPushButton(asl_icon, 'Elusive Icons (ei)') + + # Get Material Design icons by name + apn_icon = icon('mdi6.access-point-network') + mdi6_button = QtWidgets.QPushButton(apn_icon, 'Material Design (mdi, mdi6)') + + # Get Phosphor by name + mic_icon = icon('ph.microphone-fill') + ph_button = QtWidgets.QPushButton(mic_icon, 'Phosphor Icons (ph)') + + # Get Remix Icon by name + truck_icon = icon('ri.truck-fill') + ri_button = QtWidgets.QPushButton(truck_icon, 'Remix Icons (ri)') + + # Get Microsoft's Codicons by name + squirrel_icon = icon('msc.squirrel') + msc_button = QtWidgets.QPushButton(squirrel_icon, 'Codicons (msc)') + + # Label for style options and animations + styles_label = QtWidgets.QLabel('Styles') + styles_label.setAlignment(QtCore.Qt.AlignCenter) + + # Rotated + rot_icon = icon('mdi.access-point-network', rotated=45) + rot_button = QtWidgets.QPushButton(rot_icon, 'Rotated Icons') + + # Horizontal flip + hflip_icon = icon('mdi.account-alert', hflip=True) + hflip_button = QtWidgets.QPushButton(hflip_icon, 'Horizontally Flipped Icons') + + # Vertical flip + vflip_icon = icon('mdi.account-alert', vflip=True) + vflip_button = QtWidgets.QPushButton(vflip_icon, 'Vertically Flipped Icons') + + # Styling + styling_icon = icon('fa5s.music', + active='fa5s.balance-scale', + color='blue', + color_active='orange') + music_button = QtWidgets.QPushButton(styling_icon, 'Changing colors') + + # Setting an alpha of 165 to the color of this icon. Alpha must be a number + # between 0 and 255. + icon_with_alpha = icon('mdi.heart', color=('red', 120)) + heart_button = QtWidgets.QPushButton(icon_with_alpha, 'Setting alpha') + + # Toggle + toggle_icon = icon('fa5s.home', selected='fa5s.balance-scale', + color_off='black', + color_off_active='blue', + color_on='orange', + color_on_active='yellow') + toggle_button = QtWidgets.QPushButton(toggle_icon, 'Toggle') + toggle_button.setCheckable(True) + + iconwidget = IconWidget() + spin_icon = icon('mdi.loading', color='red', + animation=Spin(iconwidget)) + iconwidget.setIcon(spin_icon) + iconwidget.setIconSize(QtCore.QSize(32, 32)) + iconwidgetholder = QtWidgets.QWidget() + lo = QtWidgets.QHBoxLayout() + lo.addWidget(iconwidget) + lo.addWidget(QtWidgets.QLabel('IconWidget')) + iconwidgetholder.setLayout(lo) + iconwidget2 = IconWidget('mdi.web', color='blue', size=QtCore.QSize(16, 16)) + + # Icon drawn with the `image` option + drawn_image_icon = icon('ri.truck-fill', + options=[{'draw': 'image'}]) + drawn_image_button = QtWidgets.QPushButton(drawn_image_icon, + 'Icon drawn as an image') + + # Stack icons + camera_ban = icon('fa5s.camera', 'fa5s.ban', + options=[{'scale_factor': 0.5, + 'active': 'fa5s.balance-scale'}, + {'color': 'red', 'opacity': 0.7}]) + stack_button = QtWidgets.QPushButton(camera_ban, 'Stack') + stack_button.setIconSize(QtCore.QSize(32, 32)) + + # Stack and offset icons + saveall = icon('fa5.save', 'fa5.save', + options=[{'scale_factor': 0.8, + 'offset': (0.2, 0.2), + 'color': 'gray'}, + {'scale_factor': 0.8}]) + saveall_button = QtWidgets.QPushButton(saveall, 'Stack, offset') + + # Spin icons + spin_button = QtWidgets.QPushButton(' Spinning icon') + animation1 = Spin(spin_button) + spin_icon = icon('fa5s.spinner', color='red', animation=animation1) + spin_button.setIcon(spin_icon) + + timer1 = QtCore.QTimer() + timer1.singleShot(3000, animation1.stop) + + # Pulse icons + pulse_button = QtWidgets.QPushButton(' Pulsing icon') + animation2 = Pulse(pulse_button, autostart=False) + pulse_icon = icon('fa5s.spinner', color='green', + animation=animation2) + pulse_button.setIcon(pulse_icon) + + timer2 = QtCore.QTimer() + timer2.singleShot(1500, animation2.start) + timer3 = QtCore.QTimer() + timer3.singleShot(6000, animation2.stop) + + # Stacked spin icons + stack_spin_button = QtWidgets.QPushButton('Stack spin') + options = [{'scale_factor': 0.4, + 'animation': Spin(stack_spin_button)}, + {'color': 'blue'}] + stack_spin_icon = icon('ei.asl', 'fa5.square', + options=options) + stack_spin_button.setIcon(stack_spin_icon) + stack_spin_button.setIconSize(QtCore.QSize(32, 32)) + + # Render a label with this font + label = QtWidgets.QLabel(chr(0xf19c) + ' ' + 'Label') + label.setFont(font('fa', 16)) + + # Layout + grid = QtWidgets.QGridLayout() + fonts_widgets = [ + supported_fonts_label, + fa5_button, + fa5s_button, + fa5b_button, + elusive_button, + mdi6_button, + ph_button, + ri_button, + msc_button, + ] + styled_widgets = [ + styles_label, + music_button, + heart_button, + rot_button, + hflip_button, + vflip_button, + toggle_button, + drawn_image_button + ] + animated_widgets = [ + spin_button, + pulse_button, + stack_button, + saveall_button, + stack_spin_button, + ] + other_widgets = [ + label, + iconwidgetholder, + iconwidget2 + ] + + for idx, w in enumerate(fonts_widgets): + grid.addWidget(w, idx, 0) + + for idx, w in enumerate(styled_widgets): + grid.addWidget(w, idx, 1) + + for idx, w in enumerate(animated_widgets): + grid.addWidget(w, idx + len(styled_widgets), 1) + + for idx, w in enumerate(other_widgets): + grid.addWidget(w, idx + len(styled_widgets) + len(animated_widgets), 1) + + title = 'Awesome' + args = ' '.join(sys.argv[1:]).strip() + if args: + title += ' (' + args + ')' + + self.setLayout(grid) + self.setWindowTitle(title) + self.setMinimumWidth(520) + self.show() + + +def main(): + + global_defaults = {} + for arg in sys.argv[1:]: + try: + key, val = arg.split('=', maxsplit=1) + global_defaults[key] = val + except: + pass + if global_defaults: + set_global_defaults(**global_defaults) + + app = QtWidgets.QApplication(sys.argv) + + # Enable High DPI display with PyQt5 + if hasattr(QtCore.Qt, 'AA_UseHighDpiPixmaps'): + app.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps) + + # Timer needed to close the example application + # when testing + QtCore.QTimer.singleShot(10000, app.exit) + _ = AwesomeExample() + sys.exit(app.exec_()) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/src/napari_chatgpt/_sandbox/test.py b/src/napari_chatgpt/_sandbox/test.py index 55fd83e..a3b9051 100644 --- a/src/napari_chatgpt/_sandbox/test.py +++ b/src/napari_chatgpt/_sandbox/test.py @@ -1,11 +1,13 @@ import sys -from PyQt5.QtWidgets import QApplication, QVBoxLayout, QWidget, QSlider, QLabel, QHBoxLayout -from PyQt5.QtCore import Qt import numpy as np +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QApplication, QVBoxLayout, QWidget, QSlider, QLabel, \ + QHBoxLayout from vispy import scene from vispy.scene import visuals + class OrthoViewWidget(QWidget): def __init__(self, data, parent=None): super(OrthoViewWidget, self).__init__(parent) diff --git a/src/napari_chatgpt/_tests/test_widget.py b/src/napari_chatgpt/_tests/test_widget.py index 08daa1f..84167df 100644 --- a/src/napari_chatgpt/_tests/test_widget.py +++ b/src/napari_chatgpt/_tests/test_widget.py @@ -10,13 +10,9 @@ def test_omega_q_widget(make_napari_viewer, capsys): viewer = make_napari_viewer() viewer.add_image(np.random.random((100, 100))) - # create our widget, passing in the viewer - my_widget = OmegaQWidget(viewer) + my_widget = OmegaQWidget(viewer, add_code_editor=False) - # # call our widget method - # my_widget._on_click() - # - # # read captured output and check that it's as we expected - # captured = capsys.readouterr() - # assert 'Omega' in captured.out + # read captured output and check that it's as we expected + captured = capsys.readouterr() + assert 'Omega' in captured.out diff --git a/src/napari_chatgpt/_widget.py b/src/napari_chatgpt/_widget.py index 7b136fa..8a25ef4 100644 --- a/src/napari_chatgpt/_widget.py +++ b/src/napari_chatgpt/_widget.py @@ -10,13 +10,13 @@ import traceback from typing import TYPE_CHECKING, List -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QApplication, QLabel, QCheckBox -from PyQt5.QtWidgets import QVBoxLayout, QComboBox from napari.viewer import Viewer +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QApplication, QLabel, QCheckBox from qtpy.QtWidgets import QPushButton, QWidget +from qtpy.QtWidgets import QVBoxLayout, QComboBox -from napari_chatgpt.chat_server.chat_server import NapariChatServer +from microplugin.microplugin_window import MicroPluginMainWindow from napari_chatgpt.utils.configuration.app_configuration import \ AppConfiguration from napari_chatgpt.utils.ollama.ollama_server import is_ollama_running, \ @@ -24,6 +24,8 @@ from napari_chatgpt.utils.openai.model_list import get_openai_model_list from napari_chatgpt.utils.python.installed_packages import \ is_package_installed +from napari_chatgpt.utils.qt.one_time_disclaimer_dialog import \ + show_one_time_disclaimer_dialog from napari_chatgpt.utils.qt.warning_dialog import show_warning_dialog if TYPE_CHECKING: @@ -37,13 +39,15 @@ _creativity_mapping['moderately creative'] = 0.05 _creativity_mapping['creative'] = 0.1 +# Ensure the singleton pattern is on: +MicroPluginMainWindow._singleton_pattern_active = True class OmegaQWidget(QWidget): # your QWidget.__init__ can optionally request the napari viewer instance # in one of two ways: # 1. use a parameter called `napari_viewer`, as done here # 2. use a type annotation of 'napari.viewer.Viewer' for any parameter - def __init__(self, napari_viewer): + def __init__(self, napari_viewer, add_code_editor=True): super().__init__() aprint("OmegaQWidget instantiated!") @@ -54,6 +58,7 @@ def __init__(self, napari_viewer): self.viewer = napari_viewer # Napari chat server instance: + from napari_chatgpt.chat_server.chat_server import NapariChatServer self.server: NapariChatServer = None # Create a QVBoxLayout instance @@ -76,11 +81,28 @@ def __init__(self, napari_viewer): self._save_chats_as_notebooks() self._verbose() + # Instantiate the MicroPluginMainWindow: + if add_code_editor: + self.micro_plugin_main_window = MicroPluginMainWindow(napari_viewer=napari_viewer) + + # Add the start Omega: self._start_omega_button() + # Add the show editor button: + if add_code_editor: + self._show_editor_button() + # Set the layout on the application's window self.setLayout(self.layout) + # Make sure that when the viewer window closes this widget closes too: + try: + self.viewer.window._qt_window.destroyed.connect(self.close) + except Exception as e: + aprint(f"Error: {e}") + aprint("Could not connect to viewer's closed signal.") + traceback.print_exc() + def _model_selection(self): aprint("Setting up model selection UI.") @@ -305,7 +327,7 @@ def _autofix_widgets(self): "When checked Omega will try to fix its own \n" "coding mistakes when making widgets. \n" "Works so-so with ChatGPT 3.5, but works well with ChatGPT 4.\n" - "This involves a LLM call which can incur additional\n" + "This requires API calls which may incur additional\n" "cost in time and possibly money.") # Add the install_missing_packages checkbox to the layout: self.layout.addWidget(self.autofix_widgets_checkbox) @@ -322,8 +344,8 @@ def _tutorial_mode(self): self.tutorial_mode_checkbox.setChecked(config.get('tutorial_mode_checkbox', False)) self.tutorial_mode_checkbox.setToolTip( "When checked Omega will actively asks questions \n" - "to clarify and disambiguate the request, and \n" - "will propose multiple options and be didactic. ") + "to clarify and disambiguate the request, will propose \n" + "multiple options and try to be as didactic as possible. ") # Add the install_missing_packages checkbox to the layout: self.layout.addWidget(self.tutorial_mode_checkbox) @@ -367,8 +389,8 @@ def _start_omega_button(self): aprint("Setting up start Omega button UI.") # Start Omega button: - self.start_omega_button = QPushButton("Start Omega") - self.start_omega_button.clicked.connect(self._on_click) + self.start_omega_button = QPushButton("Start Conversing with Omega") + self.start_omega_button.clicked.connect(self._start_omega) self.start_omega_button.setToolTip( "Start Omega, this will open a browser window.\n" "You can restart Omega with new settings by\n" @@ -377,10 +399,41 @@ def _start_omega_button(self): # Omega button: self.layout.addWidget(self.start_omega_button) - def _on_click(self): + def _show_editor_button(self): + aprint("Setting up start Omega button UI.") + + # Start Omega button: + self.show_editor_button = QPushButton("Show Omega's Code Editor") + self.show_editor_button.clicked.connect(self._show_editor) + self.show_editor_button.setToolTip( + "Shows Omega's microplugin code snippet editor. \n" + "All code generated by Omega is added to the editor\n" + "so you can conveniently find it again after restarting\n" + "napari, edit the code, reformat it, check for 'safety',\n" + "send it to colleagues across the local network, and\n" + "rerun the code. Running code for widgets adds the widget\n" + "back to the viewer.\n") + # Omega button: + self.layout.addWidget(self.show_editor_button) + + def _start_omega(self): try: with asection("Starting Omega now!"): + # First we show the Omega's disclaimer that explains that + # Omega is research software that can make changes to your data + # and machine if instructed to do so or if it misunderstands the + # requests. + show_one_time_disclaimer_dialog( + "Omega is research software that can make changes to your data " + "and machine if instructed to do so or if it misunderstands " + "your requests. It is not perfect and can make mistakes. " + "By clicking 'I agree' you acknowledge that you understand " + "the potential dangers and agree to use Omega at your own risk. " + "You can find more information about Omega's disclaimer " + "and terms of use at disclaimer." + ) + # Stop previous instance if it exists: if self.server: aprint("Server already started") @@ -433,6 +486,48 @@ def _on_click(self): aprint("Omega failed to start. Please check the console for more information.") traceback.print_exc() + def _show_editor(self): + try: + if not self.micro_plugin_main_window: + aprint("MicroPluginMainWindow not instantiated.") + return + + with asection("Showing editor now!"): + + # Set LLM parameters to self.micro_plugin_main_window: + self.micro_plugin_main_window.code_editor_widget.llm_model_name = self.model_combo_box.currentText() + + # Show the editor: + self.micro_plugin_main_window.show() + + # Make sure to bring the window to the front: + self.micro_plugin_main_window.raise_() + + except Exception as e: + aprint(f"Error: {e}") + aprint("Omega failed to start. Please check the console for more information.") + traceback.print_exc() + + + def setStyleSheet(self, style): + + # Set the stylesheet for the micro plugin main window: + if self.micro_plugin_main_window: + self.micro_plugin_main_window.setStyleSheet(style) + + # Call the parent class method: + super().setStyleSheet(style) + + def close(self): + + if self.server: + self.server.stop() + + if self.micro_plugin_main_window: + self.micro_plugin_main_window.hide() + self.micro_plugin_main_window.close() + + super().close() def main(): app = QApplication(sys.argv) diff --git a/src/napari_chatgpt/chat_server/chat_server.py b/src/napari_chatgpt/chat_server/chat_server.py index b9b8f8b..9e6840c 100644 --- a/src/napari_chatgpt/chat_server/chat_server.py +++ b/src/napari_chatgpt/chat_server/chat_server.py @@ -6,13 +6,13 @@ from time import sleep import napari -from PyQt5.QtCore import QTimer from arbol import aprint, asection from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect from fastapi.templating import Jinja2Templates from langchain.memory import ConversationTokenBufferMemory, \ ConversationBufferMemory from langchain.schema import get_buffer_string, BaseMemory +from qtpy.QtCore import QTimer from starlette.staticfiles import StaticFiles from uvicorn import Config, Server @@ -26,7 +26,6 @@ from napari_chatgpt.llm.llms import instantiate_LLMs from napari_chatgpt.omega.memory.memory import OmegaMemory from napari_chatgpt.omega.napari_bridge import NapariBridge, _set_viewer_info - from napari_chatgpt.omega.omega_init import initialize_omega_agent from napari_chatgpt.utils.api_keys.api_key import set_api_key from napari_chatgpt.utils.configuration.app_configuration import \ @@ -69,7 +68,7 @@ def __init__(self, # Instantiate FastAPI: self.app = FastAPI() - # get configuration + # Get configuration config = AppConfiguration('omega') # port: diff --git a/src/napari_chatgpt/chat_server/static/OmegaLogoIcon.png b/src/napari_chatgpt/chat_server/static/OmegaLogoIcon.png new file mode 100644 index 0000000..7370d42 Binary files /dev/null and b/src/napari_chatgpt/chat_server/static/OmegaLogoIcon.png differ diff --git a/src/napari_chatgpt/chat_server/templates/index.html b/src/napari_chatgpt/chat_server/templates/index.html index 1f393c8..f87a6ef 100644 --- a/src/napari_chatgpt/chat_server/templates/index.html +++ b/src/napari_chatgpt/chat_server/templates/index.html @@ -6,6 +6,7 @@ napari + -->