From 3156c70260ab821c71d7af47fadbc62351748bde Mon Sep 17 00:00:00 2001 From: jeffser Date: Thu, 10 Oct 2024 22:14:08 -0600 Subject: [PATCH] Rewrote a whole new dialog system cause I was bored --- src/custom_widgets/chat_widget.py | 8 +- src/custom_widgets/dialog_widget.py | 173 ++++++++++ src/custom_widgets/message_widget.py | 10 +- src/custom_widgets/model_widget.py | 11 +- src/custom_widgets/terminal_widget.py | 47 +++ src/dialogs.py | 474 -------------------------- src/generic_actions.py | 72 ++++ src/meson.build | 7 +- src/window.py | 79 ++++- 9 files changed, 381 insertions(+), 500 deletions(-) create mode 100644 src/custom_widgets/dialog_widget.py delete mode 100644 src/dialogs.py create mode 100644 src/generic_actions.py diff --git a/src/custom_widgets/chat_widget.py b/src/custom_widgets/chat_widget.py index b25f4a7..092bc83 100644 --- a/src/custom_widgets/chat_widget.py +++ b/src/custom_widgets/chat_widget.py @@ -86,6 +86,8 @@ class chat(Gtk.ScrolledWindow): self.stop_message() for widget in list(self.container): self.container.remove(widget) + self.show_welcome_screen(len(window.model_manager.get_model_list()) > 0) + print('clear chat for some reason') def add_message(self, message_id:str, model:str=None): msg = message(message_id, model) @@ -102,7 +104,9 @@ class chat(Gtk.ScrolledWindow): if self.welcome_screen: self.container.remove(self.welcome_screen) self.welcome_screen = None - self.clear_chat() + if len(list(self.container)) > 0: + self.clear_chat() + return button_container = Gtk.Box( orientation=1, spacing=10, @@ -333,6 +337,8 @@ class chat_list(Gtk.ListBox): window.save_history() def rename_chat(self, old_chat_name:str, new_chat_name:str): + if new_chat_name == old_chat_name: + return tab = self.get_tab_by_name(old_chat_name) if tab: new_chat_name = window.generate_numbered_name(new_chat_name, [tab.chat_window.get_name() for tab in self.tab_list]) diff --git a/src/custom_widgets/dialog_widget.py b/src/custom_widgets/dialog_widget.py new file mode 100644 index 0000000..ccefd61 --- /dev/null +++ b/src/custom_widgets/dialog_widget.py @@ -0,0 +1,173 @@ +#dialog_widget.py +""" +Handles all dialogs +""" + +import gi +gi.require_version('Gtk', '4.0') +gi.require_version('GtkSource', '5') +from gi.repository import Gtk, Gio, Adw, Gdk, GLib + +window=None + +button_appearance={ + 'suggested': Adw.ResponseAppearance.SUGGESTED, + 'destructive': Adw.ResponseAppearance.DESTRUCTIVE +} + +# Don't call this directly outside this script +class baseDialog(Adw.AlertDialog): + __gtype_name__ = 'AlpacaDialogBase' + + def __init__(self, heading:str, body:str, close_response:str, options:dict): + self.options = options + super().__init__( + heading=heading, + body=body, + close_response=close_response + ) + for option, data in self.options.items(): + self.add_response(option, option) + if 'appearance' in data: + self.set_response_appearance(option, button_appearance[data['appearance']]) + if 'default' in data and data['default']: + self.set_default_response(option) + + +class Options(baseDialog): + __gtype_name__ = 'AlpacaDialogOptions' + + def __init__(self, heading:str, body:str, close_response:str, options:dict): + super().__init__( + heading, + body, + close_response, + options + ) + self.choose( + parent = window, + cancellable = None, + callback = self.response + ) + + def response(self, dialog, task): + result = dialog.choose_finish(task) + if result in self.options and 'callback' in self.options[result]: + self.options[result]['callback']() + +class Entry(baseDialog): + __gtype_name__ = 'AlpacaDialogEntry' + + def __init__(self, heading:str, body:str, close_response:str, options:dict, entries:list or dict): + super().__init__( + heading, + body, + close_response, + options + ) + + self.container = Gtk.Box( + orientation=1, + spacing=10 + ) + + if isinstance(entries, dict): + entries = [entries] + + for data in entries: + entry = Gtk.Entry() + if 'placeholder' in data and data['placeholder']: + entry.set_placeholder_text(data['placeholder']) + if 'css' in data and data['css']: + entry.set_css_classes(data['css']) + if 'text' in data and data['text']: + entry.set_text(data['text']) + self.container.append(entry) + + self.set_extra_child(self.container) + + self.connect('realize', lambda *_: list(self.container)[0].grab_focus()) + self.choose( + parent = window, + cancellable = None, + callback = self.response + ) + + def response(self, dialog, task): + result = dialog.choose_finish(task) + if result in self.options and 'callback' in self.options[result]: + entry_results = [] + for entry in list(self.container): + entry_results.append(entry.get_text()) + self.options[result]['callback'](*entry_results) + +class DropDown(baseDialog): + __gtype_name__ = 'AlpacaDialogDropDown' + + def __init__(self, heading:str, body:str, close_response:str, options:dict, items:list): + super().__init__( + heading, + body, + close_response, + options + ) + string_list = Gtk.StringList() + for item in items: + string_list.append(item) + self.set_extra_child(Gtk.DropDown( + enable_search=len(items) > 10, + model=string_list + )) + + self.connect('realize', lambda *_: self.get_extra_child().grab_focus()) + self.choose( + parent = window, + cancellable = None, + callback = lambda dialog, task, dropdown=self.get_extra_child(): self.response(dialog, task, dropdown.get_selected_item().get_string()) + ) + + def response(self, dialog, task, item:str): + result = dialog.choose_finish(task) + if result in self.options and 'callback' in self.options[result]: + self.options[result]['callback'](item) + +def simple(heading:str, body:str, callback:callable, button_name:str=_('Accept'), button_appearance:str='suggested'): + options = { + _('Cancel'): {}, + button_name: { + 'appearance': button_appearance, + 'callback': callback, + 'default': True + } + } + + return Options(heading, body, 'cancel', options) + +def simple_entry(heading:str, body:str, callback:callable, entries:list or dict, button_name:str=_('Accept'), button_appearance:str='suggested'): + options = { + _('Cancel'): {}, + button_name: { + 'appearance': button_appearance, + 'callback': callback, + 'default': True + } + } + + return Entry(heading, body, 'cancel', options, entries) + +def simple_dropdown(heading:str, body:str, callback:callable, items:list, button_name:str=_('Accept'), button_appearance:str='suggested'): + options = { + _('Cancel'): {}, + button_name: { + 'appearance': button_appearance, + 'callback': callback, + 'default': True + } + } + + return DropDown(heading, body, 'cancel', options, items) + +def simple_file(file_filter:Gtk.FileFilter, callback:callable): + file_dialog = Gtk.FileDialog(default_filter=file_filter) + file_dialog.open(window, None, lambda file_dialog, result: callback(file_dialog.open_finish(result)) if result else None) + diff --git a/src/custom_widgets/message_widget.py b/src/custom_widgets/message_widget.py index d612bb1..02a3e81 100644 --- a/src/custom_widgets/message_widget.py +++ b/src/custom_widgets/message_widget.py @@ -10,7 +10,7 @@ from gi.repository import Gtk, GObject, Gio, Adw, GtkSource, GLib, Gdk import logging, os, datetime, re, shutil, threading, sys from ..internal import config_dir, data_dir, cache_dir, source_dir from .table_widget import TableWidget -from .. import dialogs +from . import dialog_widget, terminal_widget logger = logging.getLogger(__name__) @@ -180,7 +180,13 @@ class code_block(Gtk.Box): logger.debug("Running script") start = self.buffer.get_start_iter() end = self.buffer.get_end_iter() - dialogs.run_script(window, self.buffer.get_text(start, end, False), language_name) + dialog_widget.simple( + _('Run Script'), + _('Make sure you understand what this script does before running it, Alpaca is not responsible for any damages to your device or data'), + lambda script=self.buffer.get_text(start, end, False), language_name=language_name: terminal_widget.run_terminal(script, language_name), + _('Execute'), + 'destructive' + ) class attachment(Gtk.Button): __gtype_name__ = 'AlpacaAttachment' diff --git a/src/custom_widgets/model_widget.py b/src/custom_widgets/model_widget.py index b3cedbe..9de8001 100644 --- a/src/custom_widgets/model_widget.py +++ b/src/custom_widgets/model_widget.py @@ -10,6 +10,7 @@ from gi.repository import Gtk, GObject, Gio, Adw, GtkSource, GLib, Gdk import logging, os, datetime, re, shutil, threading, json, sys, glob from ..internal import config_dir, data_dir, cache_dir, source_dir from .. import available_models_descriptions, dialogs +from . import dialog_widget logger = logging.getLogger(__name__) @@ -178,7 +179,7 @@ class pulling_model(Gtk.ListBoxRow): css_classes = ["error", "circular"], tooltip_text = _("Stop Pulling '{}'").format(window.convert_model_name(model_name, 0)) ) - stop_button.connect('clicked', lambda *_: dialogs.stop_pull_model(window, self)) + stop_button.connect('clicked', lambda *i: dialog_widget.simple(_('Stop Download?'), _("Are you sure you want to stop pulling '{}'?").format(window.convert_model_name(self.get_name(), 0)), self.stop, _('Stop'), 'destructive')) container_box = Gtk.Box( hexpand=True, @@ -201,6 +202,11 @@ class pulling_model(Gtk.ListBoxRow): self.error = None self.digests = [] + def stop(self): + if len(list(self.get_parent())) == 1: + self.get_parent().set_visible(False) + self.get_parent().remove(self) + def update(self, data): if 'digest' in data and data['digest'] not in self.digests: self.digests.append(data['digest'].replace(':', '-')) @@ -270,7 +276,8 @@ class local_model(Gtk.ListBoxRow): css_classes = ["error", "circular"], tooltip_text = _("Remove '{}'").format(window.convert_model_name(model_name, 0)) ) - delete_button.connect('clicked', lambda *_, model_name=model_name: dialogs.delete_model(window, model_name)) + + delete_button.connect('clicked', lambda *i: dialog_widget.simple(_('Delete Model?'), _("Are you sure you want to delete '{}'?").format(model_title), lambda model_name=model_name: window.model_manager.remove_local_model(model_name), _('Delete'), 'destructive')) container_box = Gtk.Box( hexpand=True, diff --git a/src/custom_widgets/terminal_widget.py b/src/custom_widgets/terminal_widget.py index 50d1e53..dd008ea 100644 --- a/src/custom_widgets/terminal_widget.py +++ b/src/custom_widgets/terminal_widget.py @@ -7,6 +7,12 @@ import gi gi.require_version('Gtk', '4.0') gi.require_version('Vte', '3.91') from gi.repository import Gtk, Vte, GLib, Pango, GLib, Gdk +import logging, os, shutil, subprocess +from ..internal import data_dir + +logger = logging.getLogger(__name__) + +window = None class terminal(Vte.Terminal): __gtype_name__ = 'AlpacaTerminal' @@ -42,3 +48,44 @@ class terminal(Vte.Terminal): self.copy_clipboard() return True return False + +def show_terminal(script): + window.terminal_scroller.set_child(terminal(script)) + window.terminal_dialog.present(window) + +def run_terminal(script:str, language_name:str): + logger.info('Running: \n{}'.format(language_name)) + if language_name == 'python3': + if not os.path.isdir(os.path.join(data_dir, 'pyenv')): + os.mkdir(os.path.join(data_dir, 'pyenv')) + with open(os.path.join(data_dir, 'pyenv', 'main.py'), 'w') as f: + f.write(script) + script = [ + 'echo "šŸ {}\n"'.format(_('Setting up Python environment...')), + 'python3 -m venv "{}"'.format(os.path.join(data_dir, 'pyenv')), + '{} {}'.format(os.path.join(data_dir, 'pyenv', 'bin', 'python3').replace(' ', '\\ '), os.path.join(data_dir, 'pyenv', 'main.py').replace(' ', '\\ ')) + ] + if os.path.isfile(os.path.join(data_dir, 'pyenv', 'requirements.txt')): + script.insert(1, '{} install -r {} | grep -v "already satisfied"; clear'.format(os.path.join(data_dir, 'pyenv', 'bin', 'pip3'), os.path.join(data_dir, 'pyenv', 'requirements.txt'))) + else: + with open(os.path.join(data_dir, 'pyenv', 'requirements.txt'), 'w') as f: + f.write('') + script = ';\n'.join(script) + + script += '; echo "\nšŸ¦™ {}"'.format(_('Script exited')) + if language_name == 'bash': + script = re.sub(r'(?m)^\s*sudo', 'pkexec', script) + if shutil.which('flatpak-spawn') and language_name == 'bash': + sandbox = True + try: + process = subprocess.run(['flatpak-spawn', '--host', 'bash', '-c', 'echo "test"'], check=True) + sandbox = False + except Exception as e: + pass + if sandbox: + script = 'echo "šŸ¦™ {}\n";'.format(_('The script is contained inside Flatpak')) + script + show_terminal(['bash', '-c', script]) + else: + show_terminal(['flatpak-spawn', '--host', 'bash', '-c', script]) + else: + show_terminal(['bash', '-c', script]) diff --git a/src/dialogs.py b/src/dialogs.py deleted file mode 100644 index bcda64a..0000000 --- a/src/dialogs.py +++ /dev/null @@ -1,474 +0,0 @@ -# dialogs.py -""" -Handles UI dialogs -""" -import os -import logging, requests, threading, shutil, subprocess, re -from pytube import YouTube -from html2text import html2text -from gi.repository import Adw, Gtk -from .internal import cache_dir, data_dir - -logger = logging.getLogger(__name__) -# CLEAR CHAT | WORKS - -def clear_chat_response(self, dialog, task): - if dialog.choose_finish(task) == "clear": - self.chat_list_box.get_current_chat().show_welcome_screen(len(self.model_manager.get_model_list()) > 0) - self.save_history(self.chat_list_box.get_current_chat()) - -def clear_chat(self): - if self.chat_list_box.get_current_chat().busy: - self.show_toast(_("Chat cannot be cleared while receiving a message"), self.main_overlay) - return - dialog = Adw.AlertDialog( - heading=_("Clear Chat?"), - body=_("Are you sure you want to clear the chat?"), - close_response="cancel" - ) - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("clear", _("Clear")) - dialog.set_response_appearance("clear", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.set_default_response("clear") - dialog.choose( - parent = self, - cancellable = None, - callback = lambda dialog, task: clear_chat_response(self, dialog, task) - ) - -# DELETE CHAT | WORKS - -def delete_chat_response(self, dialog, task, chat_name): - if dialog.choose_finish(task) == "delete": - self.chat_list_box.delete_chat(chat_name) - -def delete_chat(self, chat_name): - dialog = Adw.AlertDialog( - heading=_("Delete Chat?"), - body=_("Are you sure you want to delete '{}'?").format(chat_name), - close_response="cancel" - ) - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("delete", _("Delete")) - dialog.set_response_appearance("delete", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.set_default_response("delete") - dialog.choose( - parent = self, - cancellable = None, - callback = lambda dialog, task, chat_name=chat_name: delete_chat_response(self, dialog, task, chat_name) - ) - -# RENAME CHAT | WORKS - -def rename_chat_response(self, dialog, task, old_chat_name, entry): - if not entry: - return - new_chat_name = entry.get_text() - if old_chat_name == new_chat_name: - return - if new_chat_name and (task is None or dialog.choose_finish(task) == "rename"): - self.chat_list_box.rename_chat(old_chat_name, new_chat_name) - -def rename_chat(self, chat_name): - entry = Gtk.Entry() - dialog = Adw.AlertDialog( - heading=_("Rename Chat?"), - body=_("Renaming '{}'").format(chat_name), - extra_child=entry, - close_response="cancel" - ) - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("rename", _("Rename")) - dialog.set_response_appearance("rename", Adw.ResponseAppearance.SUGGESTED) - dialog.set_default_response("rename") - dialog.choose( - parent = self, - cancellable = None, - callback = lambda dialog, task, old_chat_name=chat_name, entry=entry: rename_chat_response(self, dialog, task, old_chat_name, entry) - ) - -# NEW CHAT | WORKS | UNUSED REASON: The 'Add Chat' button now creates a chat without a name AKA "New Chat" - -def new_chat_response(self, dialog, task, entry): - chat_name = _("New Chat") - if entry is not None and entry.get_text() != "": - chat_name = entry.get_text() - if chat_name and (task is None or dialog.choose_finish(task) == "create"): - self.new_chat(chat_name) - - -def new_chat(self): - entry = Gtk.Entry() - dialog = Adw.AlertDialog( - heading=_("Create Chat?"), - body=_("Enter name for new chat"), - extra_child=entry, - close_response="cancel" - ) - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("create", _("Create")) - dialog.set_response_appearance("create", Adw.ResponseAppearance.SUGGESTED) - dialog.set_default_response("create") - dialog.choose( - parent = self, - cancellable = None, - callback = lambda dialog, task, entry=entry: new_chat_response(self, dialog, task, entry) - ) - -# STOP PULL MODEL | WORKS - -def stop_pull_model_response(self, dialog, task, pulling_model): - if dialog.choose_finish(task) == "stop": - if len(list(pulling_model.get_parent())) == 1: - pulling_model.get_parent().set_visible(False) - pulling_model.get_parent().remove(pulling_model) - -def stop_pull_model(self, pulling_model): - dialog = Adw.AlertDialog( - heading=_("Stop Download?"), - body=_("Are you sure you want to stop pulling '{}'?").format(self.convert_model_name(pulling_model.get_name(), 0)), - close_response="cancel" - ) - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("stop", _("Stop")) - dialog.set_response_appearance("stop", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.set_default_response("stop") - dialog.choose( - parent = self.manage_models_dialog, - cancellable = None, - callback = lambda dialog, task, model=pulling_model: stop_pull_model_response(self, dialog, task, model) - ) - -# DELETE MODEL | WORKS - -def delete_model_response(self, dialog, task, model_name): - if dialog.choose_finish(task) == "delete": - self.model_manager.remove_local_model(model_name) - -def delete_model(self, model_name): - dialog = Adw.AlertDialog( - heading=_("Delete Model?"), - body=_("Are you sure you want to delete '{}'?").format(self.convert_model_name(model_name, 0)), - close_response="cancel" - ) - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("delete", _("Delete")) - dialog.set_response_appearance("delete", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.set_default_response("delete") - dialog.choose( - parent = self.manage_models_dialog, - cancellable = None, - callback = lambda dialog, task, model_name = model_name: delete_model_response(self, dialog, task, model_name) - ) - -# REMOVE IMAGE | WORKS - -def remove_attached_file_response(self, dialog, task, name): - if dialog.choose_finish(task) == 'remove': - self.file_preview_dialog.close() - self.remove_attached_file(name) - -def remove_attached_file(self, name): - dialog = Adw.AlertDialog( - heading=_("Remove Attachment?"), - body=_("Are you sure you want to remove attachment?"), - close_response="cancel" - ) - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("remove", _("Remove")) - dialog.set_response_appearance("remove", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.set_default_response("remove") - dialog.choose( - parent = self, - cancellable = None, - callback = lambda dialog, task, name=name: remove_attached_file_response(self, dialog, task, name) - ) - -# RECONNECT REMOTE | WORKS - -def reconnect_remote_response(self, dialog, task, url_entry, bearer_entry): - response = dialog.choose_finish(task) - if not task or response == "remote": - self.remote_connection_entry.set_text(url_entry.get_text()) - self.remote_connection_switch.set_sensitive(url_entry.get_text()) - self.remote_bearer_token_entry.set_text(bearer_entry.get_text()) - self.remote_connection_switch.set_active(True) - self.model_manager.update_local_list() - elif response == "local": - self.ollama_instance.remote = False - self.ollama_instance.start() - self.model_manager.update_local_list() - elif response == "close": - self.destroy() - -def reconnect_remote(self): - entry_url = Gtk.Entry( - css_classes = ["error"], - text = self.ollama_instance.remote_url, - placeholder_text = "URL" - ) - entry_bearer_token = Gtk.Entry( - css_classes = ["error"] if self.ollama_instance.bearer_token else None, - text = self.ollama_instance.bearer_token, - placeholder_text = "Bearer Token (Optional)" - ) - container = Gtk.Box( - orientation = 1, - spacing = 10 - ) - container.append(entry_url) - container.append(entry_bearer_token) - dialog = Adw.AlertDialog( - heading=_("Connection Error"), - body=_("The remote instance has disconnected"), - extra_child=container - ) - dialog.add_response("close", _("Close Alpaca")) - if shutil.which('ollama'): - dialog.add_response("local", _("Use local instance")) - dialog.add_response("remote", _("Connect")) - dialog.set_response_appearance("remote", Adw.ResponseAppearance.SUGGESTED) - dialog.set_default_response("remote") - dialog.choose( - parent = self, - cancellable = None, - callback = lambda dialog, task, url_entry=entry_url, bearer_entry=entry_bearer_token: reconnect_remote_response(self, dialog, task, url_entry, bearer_entry) - ) - -# CREATE MODEL | WORKS - -def create_model_from_existing_response(self, dialog, task, dropdown): - model = dropdown.get_selected_item().get_string() - if dialog.choose_finish(task) == 'accept' and model: - self.create_model(model, False) - -def create_model_from_existing(self): - string_list = Gtk.StringList() - for model in self.model_manager.get_model_list(): - string_list.append(self.convert_model_name(model, 0)) - - dropdown = Gtk.DropDown() - dropdown.set_model(string_list) - dialog = Adw.AlertDialog( - heading=_("Select Model"), - body=_("This model will be used as the base for the new model"), - extra_child=dropdown - ) - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("accept", _("Accept")) - dialog.set_response_appearance("accept", Adw.ResponseAppearance.SUGGESTED) - dialog.set_default_response("accept") - dialog.choose( - parent = self, - cancellable = None, - callback = lambda dialog, task, dropdown=dropdown: create_model_from_existing_response(self, dialog, task, dropdown) - ) - -def create_model_from_file_response(self, file_dialog, result): - try: - file = file_dialog.open_finish(result) - try: - self.create_model(file.get_path(), True) - except Exception as e: - logger.error(e) - self.show_toast(_("An error occurred while creating the model"), self.main_overlay) - except Exception as e: - logger.error(e) - -def create_model_from_file(self): - file_dialog = Gtk.FileDialog(default_filter=self.file_filter_gguf) - file_dialog.open(self, None, lambda file_dialog, result: create_model_from_file_response(self, file_dialog, result)) - -def create_model_from_name_response(self, dialog, task, entry): - model = entry.get_text().lower().strip() - if dialog.choose_finish(task) == 'accept' and model: - threading.Thread(target=self.model_manager.pull_model, kwargs={"model_name": model}).start() - -def create_model_from_name(self): - entry = Gtk.Entry() - entry.get_delegate().connect("insert-text", lambda *_ : self.check_alphanumeric(*_, ['-', '.', ':', '_', '/'])) - dialog = Adw.AlertDialog( - heading=_("Pull Model"), - body=_("Input the name of the model in this format\nname:tag"), - extra_child=entry - ) - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("accept", _("Accept")) - dialog.set_response_appearance("accept", Adw.ResponseAppearance.SUGGESTED) - dialog.set_default_response("accept") - dialog.choose( - parent = self, - cancellable = None, - callback = lambda dialog, task, entry=entry: create_model_from_name_response(self, dialog, task, entry) - ) -# FILE CHOOSER | WORKS - -def attach_file_response(self, file_dialog, result): - file_types = { - "plain_text": ["txt", "md", "html", "css", "js", "py", "java", "json", "xml"], - "image": ["png", "jpeg", "jpg", "webp", "gif"], - "pdf": ["pdf"] - } - try: - file = file_dialog.open_finish(result) - except Exception as e: - logger.error(e) - return - extension = file.get_path().split(".")[-1] - file_type = next(key for key, value in file_types.items() if extension in value) - if not file_type: - return - if file_type == 'image' and not self.model_manager.verify_if_image_can_be_used(): - self.show_toast(_("Image recognition is only available on specific models"), self.main_overlay) - return - self.attach_file(file.get_path(), file_type) - -def attach_file(self, file_filter): - file_dialog = Gtk.FileDialog(default_filter=file_filter) - file_dialog.open(self, None, lambda file_dialog, result: attach_file_response(self, file_dialog, result)) - -# YouTube caption | WORKS - -def youtube_caption_response(self, dialog, task, video_url, caption_drop_down): - if dialog.choose_finish(task) == "accept": - buffer = self.message_text_view.get_buffer() - text = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), False).replace(video_url, "") - buffer.delete(buffer.get_start_iter(), buffer.get_end_iter()) - buffer.insert(buffer.get_start_iter(), text, len(text)) - - yt = YouTube(video_url) - text = "{}\n{}\n{}\n\n".format(yt.title, yt.author, yt.watch_url) - selected_caption = caption_drop_down.get_selected_item().get_string() - for event in yt.captions[selected_caption.split('(')[-1][:-1]].json_captions['events']: - text += "{}\n".format(event['segs'][0]['utf8'].replace('\n', '\\n')) - if not os.path.exists(os.path.join(cache_dir, 'tmp/youtube')): - os.makedirs(os.path.join(cache_dir, 'tmp/youtube')) - file_path = os.path.join(os.path.join(cache_dir, 'tmp/youtube'), f'{yt.title} ({selected_caption.split(" (")[0]})') - with open(file_path, 'w+', encoding="utf-8") as f: - f.write(text) - self.attach_file(file_path, 'youtube') - -def youtube_caption(self, video_url): - yt = YouTube(video_url) - video_title = yt.title - captions = yt.captions - if len(captions) == 0: - self.show_toast(_("This video does not have any transcriptions"), self.main_overlay) - return - caption_list = Gtk.StringList() - for caption in captions: - caption_list.append("{} ({})".format(caption.name.title(), caption.code)) - caption_drop_down = Gtk.DropDown( - enable_search=len(captions) > 10, - model=caption_list - ) - dialog = Adw.AlertDialog( - heading=_("Attach YouTube Video?"), - body=_("{}\n\nPlease select a transcript to include").format(video_title), - extra_child=caption_drop_down, - close_response="cancel" - ) - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("accept", _("Accept")) - dialog.set_response_appearance("accept", Adw.ResponseAppearance.SUGGESTED) - dialog.set_default_response("accept") - dialog.choose( - parent = self, - cancellable = None, - callback = lambda dialog, task, video_url = video_url, caption_drop_down = caption_drop_down: youtube_caption_response(self, dialog, task, video_url, caption_drop_down) - ) - -# Website extraction | - -def attach_website_response(self, dialog, task, url): - if dialog.choose_finish(task) == "accept": - response = requests.get(url) - if response.status_code == 200: - html = response.text - md = html2text(html) - buffer = self.message_text_view.get_buffer() - textview_text = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), False).replace(url, "") - buffer.delete(buffer.get_start_iter(), buffer.get_end_iter()) - buffer.insert(buffer.get_start_iter(), textview_text, len(textview_text)) - if not os.path.exists('/tmp/alpaca/websites/'): - os.makedirs('/tmp/alpaca/websites/') - md_name = self.generate_numbered_name('website.md', os.listdir('/tmp/alpaca/websites')) - file_path = os.path.join('/tmp/alpaca/websites/', md_name) - with open(file_path, 'w+', encoding="utf-8") as f: - f.write('{}\n\n{}'.format(url, md)) - self.attach_file(file_path, 'website') - else: - self.show_toast(_("An error occurred while extracting text from the website"), self.main_overlay) - - -def attach_website(self, url): - dialog = Adw.AlertDialog( - heading=_("Attach Website? (Experimental)"), - body=_("Are you sure you want to attach\n'{}'?").format(url), - close_response="cancel" - ) - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("accept", _("Accept")) - dialog.set_response_appearance("accept", Adw.ResponseAppearance.SUGGESTED) - dialog.set_default_response("accept") - dialog.choose( - parent = self, - cancellable = None, - callback = lambda dialog, task, url=url: attach_website_response(self, dialog, task, url) - ) - -# Run Script - -def run_script_response(self, dialog, task, script, language_name): - if dialog.choose_finish(task) == "accept": - logger.info('Running: \n{}'.format(script)) - if language_name == 'python3': - if not os.path.isdir(os.path.join(data_dir, 'pyenv')): - os.mkdir(os.path.join(data_dir, 'pyenv')) - with open(os.path.join(data_dir, 'pyenv', 'main.py'), 'w') as f: - f.write(script) - script = [ - 'echo "šŸ {}\n"'.format(_('Setting up Python environment...')), - 'python3 -m venv "{}"'.format(os.path.join(data_dir, 'pyenv')), - '{} {}'.format(os.path.join(data_dir, 'pyenv', 'bin', 'python3').replace(' ', '\\ '), os.path.join(data_dir, 'pyenv', 'main.py').replace(' ', '\\ ')) - ] - if os.path.isfile(os.path.join(data_dir, 'pyenv', 'requirements.txt')): - script.insert(1, '{} install -r {} | grep -v "already satisfied"; clear'.format(os.path.join(data_dir, 'pyenv', 'bin', 'pip3'), os.path.join(data_dir, 'pyenv', 'requirements.txt'))) - else: - with open(os.path.join(data_dir, 'pyenv', 'requirements.txt'), 'w') as f: - f.write('') - script = ';\n'.join(script) - - script += '; echo "\nšŸ¦™ {}"'.format(_('Script exited')) - if language_name == 'bash': - script = re.sub(r'(?m)^\s*sudo', 'pkexec', script) - if shutil.which('flatpak-spawn') and language_name == 'bash': - sandbox = True - try: - process = subprocess.run(['flatpak-spawn', '--host', 'bash', '-c', 'echo "test"'], check=True) - sandbox = False - except Exception as e: - pass - if sandbox: - script = 'echo "šŸ¦™ {}\n";'.format(_('The script is contained inside Flatpak')) + script - self.run_terminal(['bash', '-c', script]) - else: - self.run_terminal(['flatpak-spawn', '--host', 'bash', '-c', script]) - else: - self.run_terminal(['bash', '-c', script]) - -def run_script(self, script:str, language_name:str): - dialog = Adw.AlertDialog( - heading=_("Run Script"), - body=_("Make sure you understand what this script does before running it, Alpaca is not responsible for any damages to your device or data"), - close_response="cancel" - ) - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("accept", _("Accept")) - dialog.set_response_appearance("accept", Adw.ResponseAppearance.SUGGESTED) - dialog.set_default_response("accept") - dialog.choose( - parent = self, - cancellable = None, - callback = lambda dialog, task, script=script, language_name=language_name: run_script_response(self, dialog, task, script, language_name) - ) diff --git a/src/generic_actions.py b/src/generic_actions.py new file mode 100644 index 0000000..7ec8e2a --- /dev/null +++ b/src/generic_actions.py @@ -0,0 +1,72 @@ +#generic_actions.py +""" +Working on organizing the code +""" + +import os, requests +from pytube import YouTube +from html2text import html2text +from .internal import cache_dir + +window = None + +def connect_local(): + window.remote_connection_switch.set_active(False) + +def connect_remote(url:str, bearer:str): + window.remote_connection_entry.set_text(url) + window.remote_bearer_token_entry.set_text(bearer) + window.remote_connection_switch.set_active(True) + +def attach_youtube(video_url:str, caption_name:str): + buffer = window.message_text_view.get_buffer() + text = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), False).replace(video_url, "") + buffer.delete(buffer.get_start_iter(), buffer.get_end_iter()) + buffer.insert(buffer.get_start_iter(), text, len(text)) + + yt = YouTube(video_url) + text = "{}\n{}\n{}\n\n".format(yt.title, yt.author, yt.watch_url) + + for event in yt.captions[caption_name.split('(')[-1][:-1]].json_captions['events']: + text += "{}\n".format(event['segs'][0]['utf8'].replace('\n', '\\n')) + if not os.path.exists(os.path.join(cache_dir, 'tmp/youtube')): + os.makedirs(os.path.join(cache_dir, 'tmp/youtube')) + file_path = os.path.join(os.path.join(cache_dir, 'tmp/youtube'), f'{yt.title} ({caption_name.split(" (")[0]})') + with open(file_path, 'w+', encoding="utf-8") as f: + f.write(text) + + window.attach_file(file_path, 'youtube') + +def attach_website(url:str): + response = requests.get(url) + if response.status_code == 200: + html = response.text + md = html2text(html) + buffer = window.message_text_view.get_buffer() + textview_text = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), False).replace(url, "") + buffer.delete(buffer.get_start_iter(), buffer.get_end_iter()) + buffer.insert(buffer.get_start_iter(), textview_text, len(textview_text)) + if not os.path.exists('/tmp/alpaca/websites/'): + os.makedirs('/tmp/alpaca/websites/') + md_name = window.generate_numbered_name('website.md', os.listdir('/tmp/alpaca/websites')) + file_path = os.path.join('/tmp/alpaca/websites/', md_name) + with open(file_path, 'w+', encoding="utf-8") as f: + f.write('{}\n\n{}'.format(url, md)) + window.attach_file(file_path, 'website') + else: + window.show_toast(_("An error occurred while extracting text from the website"), window.main_overlay) + +def attach_file(file): + file_types = { + "plain_text": ["txt", "md", "html", "css", "js", "py", "java", "json", "xml"], + "image": ["png", "jpeg", "jpg", "webp", "gif"], + "pdf": ["pdf"] + } + extension = file.get_path().split(".")[-1] + file_type = next(key for key, value in file_types.items() if extension in value) + if not file_type: + return + if file_type == 'image' and not window.model_manager.verify_if_image_can_be_used(): + window.show_toast(_("Image recognition is only available on specific models"), window.main_overlay) + return + window.attach_file(file.get_path(), file_type) diff --git a/src/meson.build b/src/meson.build index 5de47c2..9dd2cec 100644 --- a/src/meson.build +++ b/src/meson.build @@ -40,10 +40,10 @@ alpaca_sources = [ 'main.py', 'window.py', 'connection_handler.py', - 'dialogs.py', 'available_models.json', 'available_models_descriptions.py', - 'internal.py' + 'internal.py', + 'generic_actions.py' ] custom_widgets = [ @@ -51,7 +51,8 @@ custom_widgets = [ 'custom_widgets/message_widget.py', 'custom_widgets/chat_widget.py', 'custom_widgets/model_widget.py', - 'custom_widgets/terminal_widget.py' + 'custom_widgets/terminal_widget.py', + 'custom_widgets/dialog_widget.py' ] install_data(alpaca_sources, install_dir: moduledir) diff --git a/src/window.py b/src/window.py index 75df5fe..714cfde 100644 --- a/src/window.py +++ b/src/window.py @@ -24,6 +24,7 @@ from io import BytesIO from PIL import Image from pypdf import PdfReader from datetime import datetime +from pytube import YouTube import gi gi.require_version('GtkSource', '5') @@ -31,8 +32,8 @@ gi.require_version('GdkPixbuf', '2.0') from gi.repository import Adw, Gtk, Gdk, GLib, GtkSource, Gio, GdkPixbuf -from . import dialogs, connection_handler -from .custom_widgets import message_widget, chat_widget, model_widget, terminal_widget +from . import dialogs, connection_handler, generic_actions +from .custom_widgets import message_widget, chat_widget, model_widget, terminal_widget, dialog_widget from .internal import config_dir, data_dir, cache_dir, source_dir logger = logging.getLogger(__name__) @@ -371,10 +372,6 @@ class AlpacaWindow(Adw.ApplicationWindow): clipboard.read_text_async(None, self.cb_text_received) clipboard.read_texture_async(None, self.cb_image_received) - def run_terminal(self, script:list): - self.terminal_scroller.set_child(terminal_widget.terminal(script)) - self.terminal_dialog.present(self) - def convert_model_name(self, name:str, mode:int) -> str: # mode=0 name:tag -> Name (tag) | mode=1 Name (tag) -> name:tag try: if mode == 0: @@ -669,7 +666,34 @@ Generate a title following these rules: def connection_error(self): logger.error("Connection error") if self.ollama_instance.remote: - dialogs.reconnect_remote(self) + #dialogs.reconnect_remote(self) + options = { + _("Close Alpaca"): { + "callback": lambda *_: self.get_application().quit(), + "appearance": "destructive" + }, + _("Use Local Instance"): { + "callback": lambda *_: window.remote_connection_switch.set_active(False) + }, + _("Connect"): { + "callback": lambda url, bearer: generic_actions.connect_remote(url,bearer), + "appearance": "suggested" + } + } + entries = [ + { + "text": self.ollama_instance.remote_url, + "css": ['error'], + "placeholder": _('Server URL') + }, + { + "text": self.ollama_instance.bearer_token, + "css": ['error'] if self.ollama_instance.bearer_token else None, + "placeholder": _('Bearer Token (Optional)') + } + ] + + else: self.ollama_instance.reset() self.show_toast(_("There was an error with the local Ollama instance, so it has been reset"), self.main_overlay) @@ -714,6 +738,8 @@ Generate a title following these rules: del self.attachments[name] if len(self.attachments) == 0: self.attachment_box.set_visible(False) + if self.file_preview_dialog.get_visible(): + self.file_preview_dialog.close() def attach_file(self, file_path, file_type): logger.debug(f"Attaching file: {file_path}") @@ -739,7 +765,6 @@ Generate a title following these rules: child=button_content ) self.attachments[file_name] = {"path": file_path, "type": file_type, "content": content, "button": button} - #button.connect("clicked", lambda button: dialogs.remove_attached_file(self, button)) button.connect("clicked", lambda button : self.preview_file(file_path, file_type, file_name)) self.attachment_container.append(button) self.attachment_box.set_visible(True) @@ -749,11 +774,11 @@ Generate a title following these rules: chat_name = chat_row.label.get_label() action_name = action.get_name() if action_name in ('delete_chat', 'delete_current_chat'): - dialogs.delete_chat(self, chat_name) + dialog_widget.simple(_('Delete Chat?'), _("Are you sure you want to delete '{}'?").format(chat_name), lambda chat_name=chat_name, *_: self.chat_list_box.delete_chat(chat_name), _('Delete'), 'destructive') elif action_name in ('duplicate_chat', 'duplicate_current_chat'): self.chat_list_box.duplicate_chat(chat_name) elif action_name in ('rename_chat', 'rename_current_chat'): - dialogs.rename_chat(self, chat_name) + dialog_widget.simple_entry(_('Rename Chat?'), _("Renaming '{}'").format(chat_name), lambda new_chat_name, old_chat_name=chat_name, *_: self.chat_list_box.rename_chat(old_chat_name, new_chat_name), {'placeholder': _('Chat name')}, _('Rename')) elif action_name in ('export_chat', 'export_current_chat'): self.chat_list_box.export_chat(chat_name) @@ -777,12 +802,27 @@ Generate a title following these rules: ) if youtube_regex.match(text): try: - dialogs.youtube_caption(self, text) + yt = YouTube(text) + captions = yt.captions + if len(captions) == 0: + self.show_toast(_("This video does not have any transcriptions"), self.main_overlay) + return + video_title = yt.title + dialog_widget.simple_dropdown( + _('Attach YouTube Video?'), + _('{}\n\nPlease select a transcript to include').format(video_title), + lambda caption_name, video_url=text: generic_actions.attach_youtube(video_url, caption_name), + ["{} ({})".format(caption.name.title(), caption.code) for caption in captions] + ) except Exception as e: logger.error(e) self.show_toast(_("This video is not available"), self.main_overlay) elif url_regex.match(text): - dialogs.attach_website(self, text) + dialog_widget.simple( + _('Attach Website? (Experimental)'), + _("Are you sure you want to attach\n'{}'?").format(text), + lambda url=text: generic_actions.attach_website(url) + ) except Exception as e: logger.error(e) @@ -865,6 +905,9 @@ Generate a title following these rules: message_widget.window = self chat_widget.window = self model_widget.window = self + dialog_widget.window = self + terminal_widget.window = self + generic_actions.window = self connection_handler.window = self drop_target = Gtk.DropTarget.new(Gdk.FileList, Gdk.DragAction.COPY) @@ -884,11 +927,11 @@ Generate a title following these rules: universal_actions = { 'new_chat': [lambda *_: self.chat_list_box.new_chat(), ['n']], - 'clear': [lambda *_: dialogs.clear_chat(self), ['e']], + 'clear': [lambda *i: dialog_widget.simple(_('Clear Chat?'), _('Are you sure you want to clear the chat?'), self.chat_list_box.get_current_chat().clear_chat, _('Clear')), ['e']], 'import_chat': [lambda *_: self.chat_list_box.import_chat(), ['i']], - 'create_model_from_existing': [lambda *_: dialogs.create_model_from_existing(self)], - 'create_model_from_file': [lambda *_: dialogs.create_model_from_file(self)], - 'create_model_from_name': [lambda *_: dialogs.create_model_from_name(self)], + 'create_model_from_existing': [lambda *i: dialog_widget.simple_dropdown(_('Select Model'), _('This model will be used as the base for the new model'), lambda model: self.create_model(model, False), self.model_manager.get_model_list())], + 'create_model_from_file': [lambda *i, file_filter=self.file_filter_gguf: dialog_widget.simple_file(file_filter, lambda file: self.create_model(file.get_path(), True))], + 'create_model_from_name': [lambda *i: dialog_widget.simple_entry(_('Pull Model'), _('Input the name of the model in this format\nname:tag'), lambda model: threading.Thread(target=self.model_manager.pull_model, kwargs={"model_name": model}).start(), {'placeholder': 'llama3.2:latest'})], 'duplicate_chat': [self.chat_actions], 'duplicate_current_chat': [self.current_chat_actions], 'delete_chat': [self.chat_actions], @@ -908,8 +951,8 @@ Generate a title following these rules: self.get_application().lookup_action('manage_models').set_enabled(False) self.get_application().lookup_action('preferences').set_enabled(False) - self.file_preview_remove_button.connect('clicked', lambda button : dialogs.remove_attached_file(self, button.get_name())) - self.attachment_button.connect("clicked", lambda button, file_filter=self.file_filter_attachments: dialogs.attach_file(self, file_filter)) + self.file_preview_remove_button.connect('clicked', lambda button : dialog_widget.simple(_('Remove Attachment?'), _("Are you sure you want to remove attachment?"), lambda button=button: self.remove_attached_file(button.get_name()), _('Remove'), 'destructive')) + self.attachment_button.connect("clicked", lambda button, file_filter=self.file_filter_attachments: dialog_widget.simple_file(file_filter, generic_actions.attach_file)) self.create_model_name.get_delegate().connect("insert-text", lambda *_: self.check_alphanumeric(*_, ['-', '.', '_'])) self.remote_connection_entry.connect("entry-activated", lambda entry : entry.set_css_classes([])) self.set_focus(self.message_text_view)