Rewrote a whole new dialog system cause I was bored

This commit is contained in:
jeffser 2024-10-10 22:14:08 -06:00
parent 7f042a906d
commit 3156c70260
9 changed files with 381 additions and 500 deletions

View File

@ -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])

View File

@ -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)

View File

@ -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'

View File

@ -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,

View File

@ -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])

View File

@ -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)
)

72
src/generic_actions.py Normal file
View File

@ -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)

View File

@ -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)

View File

@ -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(), ['<primary>n']],
'clear': [lambda *_: dialogs.clear_chat(self), ['<primary>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')), ['<primary>e']],
'import_chat': [lambda *_: self.chat_list_box.import_chat(), ['<primary>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)