#message_widget.py
"""
Handles the message widget (testing)
"""
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('GtkSource', '5')
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 dialog_widget, terminal_widget
logger = logging.getLogger(__name__)
window = None
class edit_text_block(Gtk.Box):
__gtype_name__ = 'AlpacaEditTextBlock'
def __init__(self, text:str):
super().__init__(
hexpand=True,
halign=0,
margin_top=5,
margin_bottom=5,
margin_start=5,
margin_end=5,
spacing=5,
orientation=1
)
self.text_view = Gtk.TextView(
halign=0,
hexpand=True,
css_classes=["view", "editing_message_textview"],
wrap_mode=3
)
cancel_button = Gtk.Button(
vexpand=False,
valign=2,
halign=2,
tooltip_text=_("Cancel"),
css_classes=['flat', 'circular'],
icon_name='cross-large-symbolic'
)
cancel_button.connect('clicked', lambda *_: self.cancel_edit())
save_button = Gtk.Button(
vexpand=False,
valign=2,
halign=2,
tooltip_text=_("Save Message"),
css_classes=['flat', 'circular'],
icon_name='paper-plane-symbolic'
)
save_button.connect('clicked', lambda *_: self.edit_message())
self.append(self.text_view)
button_container = Gtk.Box(
halign=2,
spacing=5
)
button_container.append(cancel_button)
button_container.append(save_button)
self.append(button_container)
self.text_view.get_buffer().insert(self.text_view.get_buffer().get_start_iter(), text, len(text.encode('utf-8')))
key_controller = Gtk.EventControllerKey.new()
key_controller.connect("key-pressed", self.handle_key)
self.text_view.add_controller(key_controller)
def handle_key(self, controller, keyval, keycode, state):
if keyval==Gdk.KEY_Return and not (state & Gdk.ModifierType.SHIFT_MASK):
self.save_edit()
return True
elif keyval==Gdk.KEY_Escape:
self.cancel_edit()
return True
def save_edit(self):
message_element = self.get_parent().get_parent()
message_element.action_buttons.set_visible(True)
message_element.set_text(self.text_view.get_buffer().get_text(self.text_view.get_buffer().get_start_iter(), self.text_view.get_buffer().get_end_iter(), False))
message_element.add_footer(message_element.dt)
window.save_history(message_element.get_parent().get_parent().get_parent().get_parent())
self.get_parent().remove(self)
window.show_toast(_("Message edited successfully"), window.main_overlay)
def cancel_edit(self):
message_element = self.get_parent().get_parent()
message_element.action_buttons.set_visible(True)
message_element.set_text(message_element.text)
message_element.add_footer(message_element.dt)
self.get_parent().remove(self)
class text_block(Gtk.Label):
__gtype_name__ = 'AlpacaTextBlock'
def __init__(self, bot:bool):
super().__init__(
hexpand=True,
halign=0,
wrap=True,
wrap_mode=0,
xalign=0,
margin_top=5,
margin_start=5,
margin_end=5,
focusable=True,
selectable=True
)
self.update_property([4, 7], [_("Response message") if bot else _("User message"), False])
self.connect('notify::has-focus', lambda *_: GLib.idle_add(self.remove_selection) if self.has_focus() else None)
def remove_selection(self):
self.set_selectable(False)
self.set_selectable(True)
def insert_at_end(self, text:str, markdown:bool):
if markdown:
self.set_markup(self.get_text() + text)
else:
self.set_text(self.get_text() + text)
self.update_property([1], [self.get_text()])
def clear_text(self):
self.buffer.delete(self.textbuffer.get_start_iter(), self.textbuffer.get_end_iter())
self.update_property([1], [""])
class code_block(Gtk.Box):
__gtype_name__ = 'AlpacaCodeBlock'
def __init__(self, text:str, language_name:str=None):
super().__init__(
css_classes=["card", "code_block"],
orientation=1,
overflow=1,
margin_start=5,
margin_end=5
)
self.language = None
if language_name:
self.language = GtkSource.LanguageManager.get_default().get_language(language_name)
if self.language:
self.buffer = GtkSource.Buffer.new_with_language(self.language)
else:
self.buffer = GtkSource.Buffer()
self.buffer.set_style_scheme(GtkSource.StyleSchemeManager.get_default().get_scheme('Adwaita-dark'))
self.source_view = GtkSource.View(
auto_indent=True, indent_width=4, buffer=self.buffer, show_line_numbers=True, editable=None,
top_margin=6, bottom_margin=6, left_margin=12, right_margin=12, css_classes=["code_block"]
)
self.source_view.update_property([4], [_("{}Code Block").format('{} '.format(self.language.get_name()) if self.language else "")])
title_box = Gtk.Box(margin_start=12, margin_top=3, margin_bottom=3, margin_end=3)
title_box.append(Gtk.Label(label=self.language.get_name() if self.language else (language_name.title() if language_name else _("Code Block")), hexpand=True, xalign=0))
copy_button = Gtk.Button(icon_name="edit-copy-symbolic", css_classes=["flat", "circular"], tooltip_text=_("Copy Message"))
copy_button.connect("clicked", lambda *_: self.on_copy())
title_box.append(copy_button)
if language_name and language_name.lower() in ['bash', 'python3']:
run_button = Gtk.Button(icon_name="execute-from-symbolic", css_classes=["flat", "circular"], tooltip_text=_("Run Script"))
run_button.connect("clicked", lambda *_: self.run_script(language_name))
title_box.append(run_button)
self.append(title_box)
self.append(Gtk.Separator())
self.append(self.source_view)
self.buffer.set_text(text)
def on_copy(self):
logger.debug("Copying code")
clipboard = Gdk.Display().get_default().get_clipboard()
start = self.buffer.get_start_iter()
end = self.buffer.get_end_iter()
text = self.buffer.get_text(start, end, False)
clipboard.set(text)
window.show_toast(_("Code copied to the clipboard"), window.main_overlay)
def run_script(self, language_name):
logger.debug("Running script")
start = self.buffer.get_start_iter()
end = self.buffer.get_end_iter()
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'
def __init__(self, file_name:str, file_path:str, file_type:str):
self.file_name = file_name
self.file_path = file_path
self.file_type = file_type
directory, file_name = os.path.split(self.file_path)
head, last_dir = os.path.split(directory)
head, second_last_dir = os.path.split(head)
self.file_path = os.path.join(head, '{selected_chat}', last_dir, file_name)
button_content = Adw.ButtonContent(
label=self.file_name,
icon_name={
"plain_text": "document-text-symbolic",
"pdf": "document-text-symbolic",
"youtube": "play-symbolic",
"website": "globe-symbolic"
}[self.file_type]
)
super().__init__(
vexpand=False,
valign=3,
name=self.file_name,
css_classes=["flat"],
tooltip_text=self.file_name,
child=button_content
)
self.connect("clicked", lambda button, file_path=self.file_path, file_type=self.file_type: window.preview_file(file_path, file_type, None))
class attachment_container(Gtk.ScrolledWindow):
__gtype_name__ = 'AlpacaAttachmentContainer'
def __init__(self):
self.files = []
self.container = Gtk.Box(
orientation=0,
spacing=10,
valign=1
)
super().__init__(
margin_top=10,
margin_start=10,
margin_end=10,
hexpand=True,
child=self.container,
vscrollbar_policy=2
)
def add_file(self, file:attachment):
self.container.append(file)
self.files.append(file)
class image(Gtk.Button):
__gtype_name__ = 'AlpacaImage'
def __init__(self, image_path:str):
self.image_path = image_path
self.image_name = os.path.basename(self.image_path)
directory, file_name = os.path.split(self.image_path)
head, last_dir = os.path.split(directory)
head, second_last_dir = os.path.split(head)
try:
if not os.path.isfile(self.image_path):
raise FileNotFoundError("'{}' was not found or is a directory".format(self.image_path))
image = Gtk.Image.new_from_file(self.image_path)
image.set_size_request(240, 240)
super().__init__(
child=image,
css_classes=["flat", "chat_image_button"],
name=self.image_name,
tooltip_text=_("Image")
)
image.update_property([4], [_("Image")])
except Exception as e:
logger.error(e)
image_texture = Gtk.Image.new_from_icon_name("image-missing-symbolic")
image_texture.set_icon_size(2)
image_texture.set_vexpand(True)
image_texture.set_pixel_size(120)
image_label = Gtk.Label(
label=_("Missing Image"),
)
image_box = Gtk.Box(
spacing=10,
orientation=1,
margin_top=10,
margin_bottom=10,
margin_start=10,
margin_end=10
)
image_box.append(image_texture)
image_box.append(image_label)
image_box.set_size_request(220, 220)
super().__init__(
child=image_box,
css_classes=["flat", "chat_image_button"],
tooltip_text=_("Missing Image")
)
image_texture.update_property([4], [_("Missing image")])
self.set_overflow(1)
self.connect("clicked", lambda button, file_path=os.path.join(head, '{selected_chat}', last_dir, file_name): window.preview_file(file_path, 'image', None))
class image_container(Gtk.ScrolledWindow):
__gtype_name__ = 'AlpacaImageContainer'
def __init__(self):
self.files = []
self.container = Gtk.Box(
orientation=0,
spacing=12
)
super().__init__(
margin_top=10,
margin_start=10,
margin_end=10,
hexpand=True,
height_request = 240,
child=self.container
)
def add_image(self, img:image):
self.container.append(img)
self.files.append(img)
class footer(Gtk.Label):
__gtype_name__ = 'AlpacaMessageFooter'
def __init__(self, dt:datetime.datetime, model:str=None):
super().__init__(
hexpand=False,
halign=0,
wrap=True,
ellipsize=3,
wrap_mode=2,
xalign=0,
margin_bottom=5,
margin_start=5,
focusable=True
)
self.set_markup("{}{}".format((window.convert_model_name(model, 0) + " • ") if model else "", GLib.markup_escape_text(self.format_datetime(dt))))
def format_datetime(self, dt:datetime) -> str:
date = GLib.DateTime.new(GLib.DateTime.new_now_local().get_timezone(), dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second)
current_date = GLib.DateTime.new_now_local()
if date.format("%Y/%m/%d") == current_date.format("%Y/%m/%d"):
return date.format("%H:%M %p")
if date.format("%Y") == current_date.format("%Y"):
return date.format("%b %d, %H:%M %p")
return date.format("%b %d %Y, %H:%M %p")
class action_buttons(Gtk.Box):
__gtype_name__ = 'AlpacaActionButtonContainer'
def __init__(self, bot:bool):
super().__init__(
orientation=0,
spacing=6,
margin_end=6,
margin_bottom=6,
valign="end",
halign="end"
)
self.delete_button = Gtk.Button(
icon_name = "user-trash-symbolic",
css_classes = ["flat", "circular"],
tooltip_text = _("Remove Message")
)
self.delete_button.connect('clicked', lambda *_: self.delete_message())
self.append(self.delete_button)
self.copy_button = Gtk.Button(
icon_name = "edit-copy-symbolic",
css_classes = ["flat", "circular"],
tooltip_text = _("Copy Message")
)
self.copy_button.connect('clicked', lambda *_: self.copy_message())
self.append(self.copy_button)
self.regenerate_button = Gtk.Button(
icon_name = "update-symbolic",
css_classes = ["flat", "circular"],
tooltip_text = _("Regenerate Message")
)
self.regenerate_button.connect('clicked', lambda *_: self.regenerate_message())
self.edit_button = Gtk.Button(
icon_name = "edit-symbolic",
css_classes = ["flat", "circular"],
tooltip_text = _("Edit Message")
)
self.edit_button.connect('clicked', lambda *_: self.edit_message())
self.append(self.regenerate_button if bot else self.edit_button)
def delete_message(self):
logger.debug("Deleting message")
chat = self.get_parent().get_parent().get_parent().get_parent().get_parent()
message_id = self.get_parent().message_id
self.get_parent().get_parent().remove(self.get_parent())
if os.path.exists(os.path.join(data_dir, "chats", window.chat_list_box.get_current_chat().get_name(), self.get_parent().message_id)):
shutil.rmtree(os.path.join(data_dir, "chats", window.chat_list_box.get_current_chat().get_name(), self.get_parent().message_id))
del chat.messages[message_id]
window.save_history(chat)
if len(chat.messages) == 0:
chat.show_welcome_screen(len(window.model_manager.get_model_list()) > 0)
def copy_message(self):
logger.debug("Copying message")
clipboard = Gdk.Display().get_default().get_clipboard()
clipboard.set(self.get_parent().text)
window.show_toast(_("Message copied to the clipboard"), window.main_overlay)
def regenerate_message(self):
chat = self.get_parent().get_parent().get_parent().get_parent().get_parent()
message_element = self.get_parent()
if message_element.spinner:
message_element.container.remove(message_element.spinner)
message_element.spinner = None
if not chat.busy:
message_element.set_text()
if message_element.footer:
message_element.container.remove(message_element.footer)
message_element.remove_overlay(self)
message_element.action_buttons = None
history = window.convert_history_to_ollama(chat)[:list(chat.messages).index(message_element.message_id)]
data = {
"model": window.model_manager.get_selected_model(),
"messages": history,
"options": {"temperature": window.ollama_instance.tweaks["temperature"], "seed": window.ollama_instance.tweaks["seed"]},
"keep_alive": f"{window.ollama_instance.tweaks['keep_alive']}m"
}
thread = threading.Thread(target=window.run_message, args=(data, message_element, chat))
thread.start()
else:
window.show_toast(_("Message cannot be regenerated while receiving a response"), window.main_overlay)
def edit_message(self):
logger.debug("Editing message")
self.get_parent().action_buttons.set_visible(False)
for child in self.get_parent().content_children:
self.get_parent().container.remove(child)
self.get_parent().content_children = []
self.get_parent().container.remove(self.get_parent().footer)
self.get_parent().footer = None
edit_text_b = edit_text_block(self.get_parent().text)
self.get_parent().container.append(edit_text_b)
window.set_focus(edit_text_b)
class message(Gtk.Overlay):
__gtype_name__ = 'AlpacaMessage'
def __init__(self, message_id:str, model:str=None):
self.message_id = message_id
self.bot = model != None
self.dt = None
self.model = model
self.action_buttons = None
self.content_children = [] #These are the code blocks, text blocks and tables
self.footer = None
self.image_c = None
self.attachment_c = None
self.spinner = None
self.text = None
self.container = Gtk.Box(
orientation=1,
halign='fill',
css_classes=["response_message"] if self.bot else ["card", "user_message"],
spacing=5,
width_request=-1 if self.bot else 375
)
super().__init__(
css_classes=["message"],
name=message_id,
halign=0 if self.bot else 2
)
self.set_child(self.container)
def add_attachments(self, attachments:dict):
self.attachment_c = attachment_container()
self.container.append(self.attachment_c)
for file_path, file_type in attachments.items():
file = attachment(os.path.basename(file_path), file_path, file_type)
self.attachment_c.add_file(file)
def add_images(self, images:list):
self.image_c = image_container()
self.container.append(self.image_c)
for image_path in images:
image_element = image(image_path)
self.image_c.add_image(image_element)
def add_footer(self, dt:datetime.datetime):
self.dt = dt
self.footer = footer(self.dt, self.model)
self.container.append(self.footer)
def add_action_buttons(self):
if not self.action_buttons:
self.action_buttons = action_buttons(self.bot)
self.add_overlay(self.action_buttons)
if not self.text:
self.action_buttons.set_visible(False)
def update_message(self, data:dict):
chat = self.get_parent().get_parent().get_parent().get_parent()
if chat.busy:
vadjustment = chat.get_vadjustment()
if self.spinner:
self.container.remove(self.spinner)
self.spinner = None
self.content_children[-1].set_visible(True)
GLib.idle_add(vadjustment.set_value, vadjustment.get_upper())
elif vadjustment.get_value() + 50 >= vadjustment.get_upper() - vadjustment.get_page_size():
GLib.idle_add(vadjustment.set_value, vadjustment.get_upper() - vadjustment.get_page_size())
GLib.idle_add(self.content_children[-1].insert_at_end, data['message']['content'], False)
if 'done' in data and data['done']:
window.chat_list_box.get_tab_by_name(chat.get_name()).spinner.set_visible(False)
if window.chat_list_box.get_current_chat().get_name() != chat.get_name():
window.chat_list_box.get_tab_by_name(chat.get_name()).indicator.set_visible(True)
if chat.welcome_screen:
chat.container.remove(chat.welcome_screen)
chat.welcome_screen = None
chat.stop_message()
self.text = self.content_children[-1].get_label()
GLib.idle_add(self.set_text, self.content_children[-1].get_label())
self.dt = datetime.datetime.now()
GLib.idle_add(self.add_footer, self.dt)
window.show_notification(chat.get_name(), self.text[:200] + (self.text[200:] and '...'), Gio.ThemedIcon.new("chat-message-new-symbolic"))
GLib.idle_add(window.save_history, chat)
else:
if self.spinner:
GLib.idle_add(self.container.remove, self.spinner)
self.spinner = None
chat_tab = window.chat_list_box.get_tab_by_name(chat.get_name())
if chat_tab.spinner:
GLib.idle_add(chat_tab.spinner.set_visible, False)
sys.exit()
def set_text(self, text:str=None):
self.text = text
for child in self.content_children:
self.container.remove(child)
self.content_children = []
if text:
self.content_children = []
code_block_pattern = re.compile(r'```(\w*)\n(.*?)\n\s*```', re.DOTALL)
no_language_code_block_pattern = re.compile(r'`(\w*)\n(.*?)\n\s*`', re.DOTALL)
table_pattern = re.compile(r'((\r?\n){2}|^)([^\r\n]*\|[^\r\n]*(\r?\n)?)+(?=(\r?\n){2}|$)', re.MULTILINE)
markup_pattern = re.compile(r'<(b|u|tt|span.*)>(.*?)<\/(b|u|tt|span)>') #heh butt span, I'm so funny
parts = []
pos = 0
# Code blocks
for match in code_block_pattern.finditer(self.text[pos:]):
start, end = match.span()
if pos < start:
normal_text = self.text[pos:start]
parts.append({"type": "normal", "text": normal_text.strip()})
language = match.group(1)
code_text = match.group(2)
parts.append({"type": "code", "text": code_text, "language": 'python3' if language == 'python' else language})
pos = end
for match in no_language_code_block_pattern.finditer(self.text[pos:]):
start, end = match.span()
if pos < start:
normal_text = self.text[pos:start]
parts.append({"type": "normal", "text": normal_text.strip()})
language = match.group(1)
code_text = match.group(2)
parts.append({"type": "code", "text": code_text, "language": None})
pos = end
# Tables
for match in table_pattern.finditer(self.text[pos:]):
start, end = match.span()
if pos < start:
normal_text = self.text[pos:start]
parts.append({"type": "normal", "text": normal_text.strip()})
table_text = match.group(0)
parts.append({"type": "table", "text": table_text})
pos = end
# Text blocks
if pos < len(self.text):
normal_text = self.text[pos:]
if normal_text.strip():
parts.append({"type": "normal", "text": normal_text.strip()})
for part in parts:
if part['type'] == 'normal':
text_b = text_block(self.bot)
part['text'] = part['text'].replace("\n* ", "\n• ")
part['text'] = re.sub(r'`([^`\n]*?)`', r'\1', part['text'])
part['text'] = re.sub(r'\*\*(.*?)\*\*', r'\1', part['text'], flags=re.MULTILINE)
part['text'] = re.sub(r'^#\s+(.*)', r'\1', part['text'], flags=re.MULTILINE)
part['text'] = re.sub(r'^##\s+(.*)', r'\1', part['text'], flags=re.MULTILINE)
part['text'] = re.sub(r'_(\((.*?)\)|\d+)', r'\2\1', part['text'], flags=re.MULTILINE)
part['text'] = re.sub(r'\^(\((.*?)\)|\d+)', r'\2\1', part['text'], flags=re.MULTILINE)
pos = 0
for match in markup_pattern.finditer(part['text']):
start, end = match.span()
if pos < start:
text_b.insert_at_end(part['text'][pos:start], False)
text_b.insert_at_end(match.group(0), True)
pos = end
if pos < len(part['text']):
text_b.insert_at_end(part['text'][pos:], False)
self.content_children.append(text_b)
self.container.append(text_b)
elif part['type'] == 'code':
code_b = code_block(part['text'], part['language'])
self.content_children.append(code_b)
self.container.append(code_b)
elif part['type'] == 'table':
table_w = TableWidget(part['text'])
self.content_children.append(table_w)
self.container.append(table_w)
self.add_action_buttons()
else:
text_b = text_block(self.bot)
text_b.set_visible(False)
self.content_children.append(text_b)
if self.spinner:
self.container.remove(self.spinner)
self.spinner = None
self.spinner = Gtk.Spinner(spinning=True, margin_top=10, margin_bottom=10, hexpand=True)
self.container.append(self.spinner)
self.container.append(text_b)
self.container.queue_draw()