Rewrote a lot of code, new chat system and file uploading!

This commit is contained in:
jeffser 2024-06-04 12:07:15 -06:00
parent 82a0ab0d9e
commit f7f05a0538
9 changed files with 527 additions and 227 deletions

View File

@ -22,6 +22,8 @@
<file alias="icons/scalable/status/document-open-symbolic.svg">icons/document-open-symbolic.svg</file> <file alias="icons/scalable/status/document-open-symbolic.svg">icons/document-open-symbolic.svg</file>
<file alias="icons/scalable/status/list-add-symbolic.svg">icons/list-add-symbolic.svg</file> <file alias="icons/scalable/status/list-add-symbolic.svg">icons/list-add-symbolic.svg</file>
<file alias="icons/scalable/status/brain-augemnted-symbolic.svg">icons/brain-augemnted-symbolic.svg</file> <file alias="icons/scalable/status/brain-augemnted-symbolic.svg">icons/brain-augemnted-symbolic.svg</file>
<file alias="icons/scalable/status/chain-link-loose-symbolic.svg">icons/chain-link-loose-symbolic.svg</file>
<file alias="icons/scalable/status/document-text-symbolic.svg">icons/document-text-symbolic.svg</file>
<file preprocess="xml-stripblanks">window.ui</file> <file preprocess="xml-stripblanks">window.ui</file>
<file preprocess="xml-stripblanks">gtk/help-overlay.ui</file> <file preprocess="xml-stripblanks">gtk/help-overlay.ui</file>
</gresource> </gresource>

View File

@ -177,14 +177,14 @@ def pull_model(self, model_name):
# REMOVE IMAGE | WORKS # REMOVE IMAGE | WORKS
def remove_image_response(self, dialog, task): def remove_attached_file_response(self, dialog, task, button):
if dialog.choose_finish(task) == 'remove': if dialog.choose_finish(task) == 'remove':
self.remove_image() self.remove_attached_file(button)
def remove_image(self): def remove_attached_file(self, button):
dialog = Adw.AlertDialog( dialog = Adw.AlertDialog(
heading=_("Remove Image"), heading=_("Remove File"),
body=_("Are you sure you want to remove image?"), body=_("Are you sure you want to remove file?"),
close_response="cancel" close_response="cancel"
) )
dialog.add_response("cancel", _("Cancel")) dialog.add_response("cancel", _("Cancel"))
@ -193,7 +193,7 @@ def remove_image(self):
dialog.choose( dialog.choose(
parent = self, parent = self,
cancellable = None, cancellable = None,
callback = lambda dialog, task: remove_image_response(self, dialog, task) callback = lambda dialog, task, button=button: remove_attached_file_response(self, dialog, task, button)
) )
# RECONNECT REMOTE | WORKS # RECONNECT REMOTE | WORKS
@ -228,7 +228,7 @@ def reconnect_remote(self, current_url):
callback = lambda dialog, task, entry=entry: reconnect_remote_response(self, dialog, task, entry) callback = lambda dialog, task, entry=entry: reconnect_remote_response(self, dialog, task, entry)
) )
# CREATE MODEL | # CREATE MODEL | WORKS
def create_model_from_existing_response(self, dialog, task, dropdown): def create_model_from_existing_response(self, dialog, task, dropdown):
model = dropdown.get_selected_item().get_string() model = dropdown.get_selected_item().get_string()
@ -267,3 +267,18 @@ def create_model_from_file_response(self, file_dialog, result):
def create_model_from_file(self): def create_model_from_file(self):
file_dialog = Gtk.FileDialog(default_filter=self.file_filter_gguf) 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)) file_dialog.open(self, None, lambda file_dialog, result: create_model_from_file_response(self, file_dialog, result))
# FILE CHOOSER | WORKS
def attach_file_response(self, file_dialog, result, file_type):
try: file = file_dialog.open_finish(result)
except: return
self.attach_file(file.get_path(), file_type)
def attach_file(self, filter, file_type):
if file_type == 'image' and not self.verify_if_image_can_be_used():
self.show_toast('error', 8, self.main_overlay)
return
file_dialog = Gtk.FileDialog(default_filter=filter)
file_dialog.open(self, None, lambda file_dialog, result, file_type=file_type: attach_file_response(self, file_dialog, result, file_type))

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><g fill="#222222"><path d="m 2.683594 9.777344 c -1.570313 -0.542969 -2.683594 -2.039063 -2.683594 -3.777344 c 0 -2.199219 1.800781 -4 4 -4 h 3 c 2.199219 0 4 1.800781 4 4 c 0 1.640625 -0.992188 3.070312 -2.421875 3.679688 l -0.785156 -1.839844 c 0.710937 -0.304688 1.207031 -1 1.207031 -1.839844 c 0 -1.125 -0.875 -2 -2 -2 h -3 c -1.125 0 -2 0.875 -2 2 c 0 0.890625 0.558594 1.621094 1.339844 1.890625 z m 0 0"/><path d="m 8 14 c -2.199219 0 -4 -1.800781 -4 -4 c 0 -1.621094 0.96875 -3.03125 2.367188 -3.65625 l 0.816406 1.828125 c -0.699219 0.3125 -1.183594 1 -1.183594 1.828125 c 0 1.125 0.875 2 2 2 h 3 c 1.125 0 2 -0.875 2 -2 c 0 -0.867188 -0.53125 -1.582031 -1.277344 -1.867188 l 0.714844 -1.867187 c 1.503906 0.574219 2.5625 2.039063 2.5625 3.734375 c 0 2.199219 -1.800781 4 -4 4 z m 0 0"/></g></svg>

After

Width:  |  Height:  |  Size: 934 B

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><g fill="#222222"><path d="m 9 2 v 5 h 4 v -1 z m -4 2 v 1 h 3 v -1 z m 0 2 v 1 h 3 v -1 z m 0 2 v 1 h 6 v -1 z m 0 2 v 1 h 6 v -1 z m 0 2 v 1 h 6 v -1 z m 0 0"/><path d="m 2 13 c 0 1.660156 1.339844 3 3 3 h 6 c 1.660156 0 3 -1.339844 3 -3 v -6 c 0 -0.90625 -0.359375 -1.773438 -1 -2.414062 l -2.585938 -2.585938 c -0.640624 -0.640625 -1.507812 -1 -2.414062 -1 h -3 c -1.660156 0 -3 1.339844 -3 3 z m 3 -10 h 3 c 0.375 0 0.734375 0.148438 1 0.414062 l 2.585938 2.585938 c 0.265624 0.265625 0.414062 0.625 0.414062 1 v 6 c 0 0.546875 -0.453125 1 -1 1 h -6 c -0.546875 0 -1 -0.453125 -1 -1 v -9 c 0 -0.546875 0.453125 -1 1 -1 z m 0 0"/></g></svg>

After

Width:  |  Height:  |  Size: 771 B

View File

@ -1,6 +1,6 @@
# main.py # main.py
# #
# Copyright 2024 Unknown # Copyright 2024 Jeffser
# #
# This program is free software: you can redistribute it and/or modify # This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by

View File

@ -42,7 +42,8 @@ alpaca_sources = [
'connection_handler.py', 'connection_handler.py',
'available_models.py', 'available_models.py',
'dialogs.py', 'dialogs.py',
'local_instance.py' 'local_instance.py',
'update_history.py'
] ]
install_data(alpaca_sources, install_dir: moduledir) install_data(alpaca_sources, install_dir: moduledir)

37
src/update_history.py Normal file
View File

@ -0,0 +1,37 @@
# update_history.py
# This script updates the old chats.json file to the structure needed for the new version
import os, json, base64
from PIL import Image
import io
def update(self):
old_data = None
new_data = {"chats": {}}
with open(os.path.join(self.config_dir, "chats.json"), 'r') as f:
old_data = json.load(f)["chats"]
for chat_name, content in old_data.items():
directory = os.path.join(self.data_dir, "chats", chat_name)
if not os.path.exists(directory): os.makedirs(directory)
new_messages = {}
for message in content['messages']:
message_id = self.generate_uuid()
if 'images' in message:
if not os.path.exists(os.path.join(directory, message_id)): os.makedirs(os.path.join(directory, message_id))
new_images = []
for image in message['images']:
file_name = f"{self.generate_uuid()}.png"
decoded = base64.b64decode(image)
buffer = io.BytesIO(decoded)
im = Image.open(buffer)
im.save(os.path.join(directory, message_id, file_name))
new_images.append(file_name)
message['images'] = new_images
new_messages[message_id] = message
new_data['chats'][chat_name] = {}
new_data['chats'][chat_name]['messages'] = new_messages
with open(os.path.join(self.data_dir, "chats", "chats.json"), "w+") as f:
json.dump(new_data, f, indent=6)

View File

@ -1,6 +1,6 @@
# window.py # window.py
# #
# Copyright 2024 Unknown # Copyright 2024 Jeffser
# #
# This program is free software: you can redistribute it and/or modify # This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
@ -21,17 +21,18 @@ import gi
gi.require_version('GtkSource', '5') gi.require_version('GtkSource', '5')
gi.require_version('GdkPixbuf', '2.0') gi.require_version('GdkPixbuf', '2.0')
from gi.repository import Adw, Gtk, Gdk, GLib, GtkSource, Gio, GdkPixbuf from gi.repository import Adw, Gtk, Gdk, GLib, GtkSource, Gio, GdkPixbuf
import json, requests, threading, os, re, base64, sys, gettext, locale, webbrowser, subprocess import json, requests, threading, os, re, base64, sys, gettext, locale, webbrowser, subprocess, uuid, shutil, tarfile, tempfile
from time import sleep from time import sleep
from io import BytesIO from io import BytesIO
from PIL import Image from PIL import Image
from datetime import datetime from datetime import datetime
from .available_models import available_models from .available_models import available_models
from . import dialogs, local_instance, connection_handler from . import dialogs, local_instance, connection_handler, update_history
@Gtk.Template(resource_path='/com/jeffser/Alpaca/window.ui') @Gtk.Template(resource_path='/com/jeffser/Alpaca/window.ui')
class AlpacaWindow(Adw.ApplicationWindow): class AlpacaWindow(Adw.ApplicationWindow):
config_dir = os.getenv("XDG_CONFIG_HOME") config_dir = os.getenv("XDG_CONFIG_HOME")
data_dir = os.getenv("XDG_DATA_HOME")
app_dir = os.getenv("FLATPAK_DEST") app_dir = os.getenv("FLATPAK_DEST")
cache_dir = os.getenv("XDG_CACHE_HOME") cache_dir = os.getenv("XDG_CACHE_HOME")
@ -51,8 +52,8 @@ class AlpacaWindow(Adw.ApplicationWindow):
model_tweaks = {"temperature": 0.7, "seed": 0, "keep_alive": 5} model_tweaks = {"temperature": 0.7, "seed": 0, "keep_alive": 5}
local_models = [] local_models = []
pulling_models = {} pulling_models = {}
chats = {"chats": {_("New Chat"): {"messages": []}}, "selected_chat": "New Chat"} chats = {"chats": {_("New Chat"): {"messages": {}}}, "selected_chat": "New Chat"}
attached_image = {"path": None, "base64": None} attachments = {}
#Override elements #Override elements
override_HSA_OVERRIDE_GFX_VERSION = Gtk.Template.Child() override_HSA_OVERRIDE_GFX_VERSION = Gtk.Template.Child()
@ -71,6 +72,8 @@ class AlpacaWindow(Adw.ApplicationWindow):
bot_message : Gtk.TextBuffer = None bot_message : Gtk.TextBuffer = None
bot_message_box : Gtk.Box = None bot_message_box : Gtk.Box = None
bot_message_view : Gtk.TextView = None bot_message_view : Gtk.TextView = None
file_preview_dialog = Gtk.Template.Child()
file_preview_text_view = Gtk.Template.Child()
welcome_dialog = Gtk.Template.Child() welcome_dialog = Gtk.Template.Child()
welcome_carousel = Gtk.Template.Child() welcome_carousel = Gtk.Template.Child()
welcome_previous_button = Gtk.Template.Child() welcome_previous_button = Gtk.Template.Child()
@ -82,10 +85,13 @@ class AlpacaWindow(Adw.ApplicationWindow):
message_text_view = Gtk.Template.Child() message_text_view = Gtk.Template.Child()
send_button = Gtk.Template.Child() send_button = Gtk.Template.Child()
stop_button = Gtk.Template.Child() stop_button = Gtk.Template.Child()
image_button = Gtk.Template.Child() chats_menu_button = Gtk.Template.Child()
attachment_container = Gtk.Template.Child()
attachment_box = Gtk.Template.Child()
file_filter_image = Gtk.Template.Child() file_filter_image = Gtk.Template.Child()
file_filter_json = Gtk.Template.Child() file_filter_tar = Gtk.Template.Child()
file_filter_gguf = Gtk.Template.Child() file_filter_gguf = Gtk.Template.Child()
file_filter_text = Gtk.Template.Child()
model_drop_down = Gtk.Template.Child() model_drop_down = Gtk.Template.Child()
model_string_list = Gtk.Template.Child() model_string_list = Gtk.Template.Child()
@ -112,7 +118,8 @@ class AlpacaWindow(Adw.ApplicationWindow):
_("Could not pull model"), _("Could not pull model"),
_("Cannot open image"), _("Cannot open image"),
_("Cannot delete chat because it's the only one left"), _("Cannot delete chat because it's the only one left"),
_("There was an error with the local Ollama instance, so it has been reset") _("There was an error with the local Ollama instance, so it has been reset"),
_("Image recognition is only available on specific models")
], ],
"info": [ "info": [
_("Please select a model before chatting"), _("Please select a model before chatting"),
@ -137,22 +144,19 @@ class AlpacaWindow(Adw.ApplicationWindow):
if self.model_drop_down.get_selected_item() == None: return True if self.model_drop_down.get_selected_item() == None: return True
selected = self.model_drop_down.get_selected_item().get_string().split(":")[0] selected = self.model_drop_down.get_selected_item().get_string().split(":")[0]
if selected in ['llava', 'bakllava', 'moondream', 'llava-llama3']: if selected in ['llava', 'bakllava', 'moondream', 'llava-llama3']:
self.image_button.set_sensitive(True) for name, content in self.attachments.items():
self.image_button.set_tooltip_text(_("Upload image")) if content['type'] == 'image':
content['button'].set_css_classes(["flat"])
return True return True
else: else:
self.image_button.set_sensitive(False) for name, content in self.attachments.items():
self.image_button.set_tooltip_text(_("Only available on selected models")) if content['type'] == 'image':
self.image_button.set_css_classes(["circular"]) content['button'].set_css_classes(["flat", "error"])
self.attached_image = {"path": None, "base64": None}
return False return False
@Gtk.Template.Callback() @Gtk.Template.Callback()
def stop_message(self, button=None): def stop_message(self, button=None):
if self.loading_spinner: self.chat_container.remove(self.loading_spinner) if self.loading_spinner: self.chat_container.remove(self.loading_spinner)
if self.verify_if_image_can_be_used(): self.image_button.set_sensitive(True)
self.image_button.set_css_classes(["circular"])
self.attached_image = {"path": None, "base64": None}
self.toggle_ui_sensitive(True) self.toggle_ui_sensitive(True)
self.switch_send_stop_button() self.switch_send_stop_button()
self.bot_message = None self.bot_message = None
@ -161,40 +165,58 @@ class AlpacaWindow(Adw.ApplicationWindow):
@Gtk.Template.Callback() @Gtk.Template.Callback()
def send_message(self, button=None): def send_message(self, button=None):
if self.bot_message: return
if not self.message_text_view.get_buffer().get_text(self.message_text_view.get_buffer().get_start_iter(), self.message_text_view.get_buffer().get_end_iter(), False): return if not self.message_text_view.get_buffer().get_text(self.message_text_view.get_buffer().get_start_iter(), self.message_text_view.get_buffer().get_end_iter(), False): return
current_model = self.model_drop_down.get_selected_item() current_model = self.model_drop_down.get_selected_item()
if current_model is None: if current_model is None:
self.show_toast("info", 0, self.main_overlay) self.show_toast("info", 0, self.main_overlay)
return return
id = self.generate_uuid()
attached_images = []
attached_files = {}
can_use_images = self.verify_if_image_can_be_used()
for name, content in self.attachments.items():
if content["type"] == 'image' and can_use_images: attached_images.append(name)
else: attached_files[name] = content['type']
if not os.path.exists(os.path.join(self.data_dir, "chats", self.chats['selected_chat'], id)):
os.makedirs(os.path.join(self.data_dir, "chats", self.chats['selected_chat'], id))
shutil.copy(content['path'], os.path.join(self.data_dir, "chats", self.chats['selected_chat'], id, name))
content["button"].get_parent().remove(content["button"])
self.attachments = {}
#{"path": file_path, "type": file_type, "content": content}
formated_datetime = datetime.now().strftime("%Y/%m/%d %H:%M") formated_datetime = datetime.now().strftime("%Y/%m/%d %H:%M")
self.chats["chats"][self.chats["selected_chat"]]["messages"].append({
self.chats["chats"][self.chats["selected_chat"]]["messages"][id] = {
"role": "user", "role": "user",
"model": "User", "model": "User",
"date": formated_datetime, "date": formated_datetime,
"content": self.message_text_view.get_buffer().get_text(self.message_text_view.get_buffer().get_start_iter(), self.message_text_view.get_buffer().get_end_iter(), False) "content": self.message_text_view.get_buffer().get_text(self.message_text_view.get_buffer().get_start_iter(), self.message_text_view.get_buffer().get_end_iter(), False)
}) }
messages_to_send = [] if len(attached_images) > 0:
for message in self.chats["chats"][self.chats["selected_chat"]]["messages"]: self.chats["chats"][self.chats["selected_chat"]]["messages"][id]['images'] = attached_images
if message: messages_to_send.append(message) if len(attached_files.keys()) > 0:
self.chats["chats"][self.chats["selected_chat"]]["messages"][id]['files'] = attached_files
data = { data = {
"model": current_model.get_string(), "model": current_model.get_string(),
"messages": messages_to_send, "messages": self.convert_history_to_ollama(),
"options": {"temperature": self.model_tweaks["temperature"], "seed": self.model_tweaks["seed"]}, "options": {"temperature": self.model_tweaks["temperature"], "seed": self.model_tweaks["seed"]},
"keep_alive": f"{self.model_tweaks['keep_alive']}m" "keep_alive": f"{self.model_tweaks['keep_alive']}m"
} }
if self.verify_if_image_can_be_used() and self.attached_image["base64"] is not None:
data["messages"][-1]["images"] = [self.attached_image["base64"]]
self.switch_send_stop_button() self.switch_send_stop_button()
self.toggle_ui_sensitive(False) self.toggle_ui_sensitive(False)
self.image_button.set_sensitive(False)
self.show_message(self.message_text_view.get_buffer().get_text(self.message_text_view.get_buffer().get_start_iter(), self.message_text_view.get_buffer().get_end_iter(), False), False, f"\n\n<small>{formated_datetime}</small>", self.attached_image["base64"], id=len(self.chats["chats"][self.chats["selected_chat"]]["messages"])-1) #self.attachments[name] = {"path": file_path, "type": file_type, "content": content}
self.show_message(self.message_text_view.get_buffer().get_text(self.message_text_view.get_buffer().get_start_iter(), self.message_text_view.get_buffer().get_end_iter(), False), False, f"\n\n<small>{formated_datetime}</small>", attached_images, attached_files, id=id)
self.message_text_view.get_buffer().set_text("", 0) self.message_text_view.get_buffer().set_text("", 0)
self.loading_spinner = Gtk.Spinner(spinning=True, margin_top=12, margin_bottom=12, hexpand=True) self.loading_spinner = Gtk.Spinner(spinning=True, margin_top=12, margin_bottom=12, hexpand=True)
self.chat_container.append(self.loading_spinner) self.chat_container.append(self.loading_spinner)
self.show_message("", True, id=len(self.chats["chats"][self.chats["selected_chat"]]["messages"])) bot_id=self.generate_uuid()
self.show_message("", True, id=bot_id)
thread = threading.Thread(target=self.run_message, args=(data['messages'], data['model'])) thread = threading.Thread(target=self.run_message, args=(data['messages'], data['model'], bot_id))
thread.start() thread.start()
@Gtk.Template.Callback() @Gtk.Template.Callback()
@ -221,22 +243,14 @@ class AlpacaWindow(Adw.ApplicationWindow):
if not self.verify_connection(): if not self.verify_connection():
self.connection_error() self.connection_error()
@Gtk.Template.Callback()
def open_image(self, button):
if "destructive-action" in button.get_css_classes():
dialogs.remove_image(self)
else:
file_dialog = Gtk.FileDialog(default_filter=self.file_filter_image)
file_dialog.open(self, None, self.load_image)
@Gtk.Template.Callback() @Gtk.Template.Callback()
def chat_changed(self, listbox, row): def chat_changed(self, listbox, row):
if row and row.get_name() != self.chats["selected_chat"]: if row and row.get_name() != self.chats["selected_chat"]:
self.chats["selected_chat"] = row.get_name() self.chats["selected_chat"] = row.get_name()
self.load_history_into_chat() self.load_history_into_chat()
if len(self.chats["chats"][self.chats["selected_chat"]]["messages"]) > 0: if len(self.chats["chats"][self.chats["selected_chat"]]["messages"].keys()) > 0:
for i in range(self.model_string_list.get_n_items()): for i in range(self.model_string_list.get_n_items()):
if self.model_string_list.get_string(i) == self.chats["chats"][self.chats["selected_chat"]]["messages"][-1]["model"]: if self.model_string_list.get_string(i) == self.chats["chats"][self.chats["selected_chat"]]["messages"][list(self.chats["chats"][self.chats["selected_chat"]]["messages"].keys())[-1]]["model"]:
self.model_drop_down.set_selected(i) self.model_drop_down.set_selected(i)
break break
@ -382,19 +396,49 @@ class AlpacaWindow(Adw.ApplicationWindow):
self.get_application().send_notification(None, notification) self.get_application().send_notification(None, notification)
def delete_message(self, message_element): def delete_message(self, message_element):
message_index = int(message_element.get_name()) id = message_element.get_name()
if message_index < len(self.chats["chats"][self.chats["selected_chat"]]["messages"]): del self.chats["chats"][self.chats["selected_chat"]]["messages"][id]
self.chats["chats"][self.chats["selected_chat"]]["messages"][message_index] = None
self.chat_container.remove(message_element) self.chat_container.remove(message_element)
if os.path.exists(os.path.join(self.data_dir, "chats", self.chats['selected_chat'], id)):
print("deleting " + id)
shutil.rmtree(os.path.join(self.data_dir, "chats", self.chats['selected_chat'], id))
self.save_history() self.save_history()
def copy_message(self, message_element): def copy_message(self, message_element):
message_index = int(message_element.get_name()) id = message_element.get_name()
clipboard = Gdk.Display().get_default().get_clipboard() clipboard = Gdk.Display().get_default().get_clipboard()
clipboard.set(self.chats["chats"][self.chats["selected_chat"]]["messages"][message_index]["content"]) clipboard.set(self.chats["chats"][self.chats["selected_chat"]]["messages"][id]["content"])
self.show_toast("info", 5, self.main_overlay) self.show_toast("info", 5, self.main_overlay)
def show_message(self, msg:str, bot:bool, footer:str=None, image_base64:str=None, id:int=-1): def preview_file(self, file_path, file_type):
content = self.get_content_of_file(file_path, file_type)
buffer = self.file_preview_text_view.get_buffer()
buffer.delete(buffer.get_start_iter(), buffer.get_end_iter())
buffer.insert(buffer.get_start_iter(), content, len(content))
self.file_preview_dialog.set_title(os.path.basename(file_path))
self.file_preview_dialog.present(self)
def convert_history_to_ollama(self):
messages = []
for id, message in self.chats["chats"][self.chats["selected_chat"]]["messages"].items():
new_message = message.copy()
if 'files' in message and len(message['files']) > 0:
del new_message['files']
new_message['content'] = ''
for name, file_type in message['files'].items():
file_path = os.path.join(self.data_dir, "chats", self.chats['selected_chat'], id, name)
new_message['content'] += f"```[{name}]\n{self.get_content_of_file(file_path, file_type)}\n```"
new_message['content'] += message['content']
if 'images' in message and len(message['images']) > 0:
new_message['images'] = []
for name in message['images']:
file_path = os.path.join(self.data_dir, "chats", self.chats['selected_chat'], id, name)
new_message['images'].append(self.get_content_of_file(file_path, 'image'))
messages.append(new_message)
return messages
def show_message(self, msg:str, bot:bool, footer:str=None, images:list=None, files:dict=None, id:str=None):
message_text = Gtk.TextView( message_text = Gtk.TextView(
editable=False, editable=False,
focusable=True, focusable=True,
@ -437,23 +481,63 @@ class AlpacaWindow(Adw.ApplicationWindow):
) )
message_text.set_valign(Gtk.Align.CENTER) message_text.set_valign(Gtk.Align.CENTER)
if image_base64 is not None: if images and len(images) > 0:
image_data = base64.b64decode(image_base64) image_container = Gtk.Box(
orientation=0,
spacing=12
)
image_scroller = Gtk.ScrolledWindow(
margin_top=10,
margin_start=10,
margin_end=10,
hexpand=True,
height_request = 240,
child=image_container
)
for image in images:
image_data = base64.b64decode(self.get_content_of_file(os.path.join(self.data_dir, "chats", self.chats['selected_chat'], id, image), "image"))
loader = GdkPixbuf.PixbufLoader.new() loader = GdkPixbuf.PixbufLoader.new()
loader.write(image_data) loader.write(image_data)
loader.close() loader.close()
pixbuf = loader.get_pixbuf() pixbuf = loader.get_pixbuf()
texture = Gdk.Texture.new_for_pixbuf(pixbuf) texture = Gdk.Texture.new_for_pixbuf(pixbuf)
image = Gtk.Image.new_from_paintable(texture) image = Gtk.Image.new_from_paintable(texture)
image.set_size_request(240, 240) image.set_size_request(240, 240)
image.set_margin_top(10)
image.set_margin_start(10)
image.set_margin_end(10)
image.set_hexpand(False) image.set_hexpand(False)
image.set_css_classes(["flat"]) image.set_css_classes(["flat"])
message_box.append(image) image_container.append(image)
message_box.append(image_scroller)
if files and len(files) > 0:
file_container = Gtk.Box(
orientation=0,
spacing=12
)
file_scroller = Gtk.ScrolledWindow(
margin_top=10,
margin_start=10,
margin_end=10,
hexpand=True,
child=file_container
)
for name, file_type in files.items():
shown_name='.'.join(name.split(".")[:-1])[:20] + (name[20:] and '..') + f".{name.split('.')[-1]}"
button_content = Adw.ButtonContent(
label=shown_name,
icon_name="document-text-symbolic"
)
button = Gtk.Button(
vexpand=False,
valign=3,
name=name,
css_classes=["flat"],
tooltip_text=name,
child=button_content
)
button.connect("clicked", lambda button, file_path=os.path.join(self.data_dir, "chats", self.chats['selected_chat'], id, name), file_type=file_type: self.preview_file(file_path, file_type))
file_container.append(button)
message_box.append(file_scroller)
message_box.append(message_text) message_box.append(message_text)
overlay = Gtk.Overlay(css_classes=["message"], name=id) overlay = Gtk.Overlay(css_classes=["message"], name=id)
@ -507,7 +591,7 @@ class AlpacaWindow(Adw.ApplicationWindow):
def save_server_config(self): def save_server_config(self):
with open(os.path.join(self.config_dir, "server.json"), "w+") as f: with open(os.path.join(self.config_dir, "server.json"), "w+") as f:
json.dump({'remote_url': self.remote_url, 'run_remote': self.run_remote, 'local_port': local_instance.port, 'run_on_background': self.run_on_background, 'model_tweaks': self.model_tweaks, 'ollama_overrides': local_instance.overrides}, f) json.dump({'remote_url': self.remote_url, 'run_remote': self.run_remote, 'local_port': local_instance.port, 'run_on_background': self.run_on_background, 'model_tweaks': self.model_tweaks, 'ollama_overrides': local_instance.overrides}, f, indent=6)
def verify_connection(self): def verify_connection(self):
response = connection_handler.simple_get(connection_handler.url) response = connection_handler.simple_get(connection_handler.url)
@ -631,12 +715,12 @@ class AlpacaWindow(Adw.ApplicationWindow):
clipboard.set(text) clipboard.set(text)
self.show_toast("info", 4, self.main_overlay) self.show_toast("info", 4, self.main_overlay)
def update_bot_message(self, data): def update_bot_message(self, data, id):
if self.bot_message is None: if self.bot_message is None:
self.save_history() self.save_history()
sys.exit() sys.exit()
vadjustment = self.chat_window.get_vadjustment() vadjustment = self.chat_window.get_vadjustment()
if self.chats["chats"][self.chats["selected_chat"]]["messages"][-1]['role'] == "user" or vadjustment.get_value() + 50 >= vadjustment.get_upper() - vadjustment.get_page_size(): if (id in self.chats["chats"][self.chats["selected_chat"]]["messages"] and self.chats["chats"][self.chats["selected_chat"]]["messages"][id]['role'] == "user") or vadjustment.get_value() + 50 >= vadjustment.get_upper() - vadjustment.get_page_size():
GLib.idle_add(vadjustment.set_value, vadjustment.get_upper()) GLib.idle_add(vadjustment.set_value, vadjustment.get_upper())
if data['done']: if data['done']:
formated_datetime = datetime.now().strftime("%Y/%m/%d %H:%M") formated_datetime = datetime.now().strftime("%Y/%m/%d %H:%M")
@ -644,34 +728,31 @@ class AlpacaWindow(Adw.ApplicationWindow):
GLib.idle_add(self.bot_message.insert_markup, self.bot_message.get_end_iter(), text, len(text)) GLib.idle_add(self.bot_message.insert_markup, self.bot_message.get_end_iter(), text, len(text))
self.save_history() self.save_history()
else: else:
if self.chats["chats"][self.chats["selected_chat"]]["messages"][-1]['role'] == "user": if id not in self.chats["chats"][self.chats["selected_chat"]]["messages"]:
GLib.idle_add(self.chat_container.remove, self.loading_spinner) GLib.idle_add(self.chat_container.remove, self.loading_spinner)
self.loading_spinner = None self.loading_spinner = None
self.chats["chats"][self.chats["selected_chat"]]["messages"].append({ self.chats["chats"][self.chats["selected_chat"]]["messages"][id] = {
"role": "assistant", "role": "assistant",
"model": data['model'], "model": data['model'],
"date": datetime.now().strftime("%Y/%m/%d %H:%M"), "date": datetime.now().strftime("%Y/%m/%d %H:%M"),
"content": '' "content": ''
}) }
GLib.idle_add(self.bot_message.insert, self.bot_message.get_end_iter(), data['message']['content']) GLib.idle_add(self.bot_message.insert, self.bot_message.get_end_iter(), data['message']['content'])
self.chats["chats"][self.chats["selected_chat"]]["messages"][-1]['content'] += data['message']['content'] self.chats["chats"][self.chats["selected_chat"]]["messages"][id]['content'] += data['message']['content']
def toggle_ui_sensitive(self, status): def toggle_ui_sensitive(self, status):
for element in [self.chat_list_box, self.add_chat_button]: for element in [self.chat_list_box, self.add_chat_button, self.chats_menu_button]:
element.set_sensitive(status) element.set_sensitive(status)
def switch_send_stop_button(self): def switch_send_stop_button(self):
self.stop_button.set_visible(self.send_button.get_visible()) self.stop_button.set_visible(self.send_button.get_visible())
self.send_button.set_visible(not self.send_button.get_visible()) self.send_button.set_visible(not self.send_button.get_visible())
def run_message(self, messages, model): def run_message(self, messages, model, id):
response = connection_handler.stream_post(f"{connection_handler.url}/api/chat", data=json.dumps({"model": model, "messages": messages}), callback=self.update_bot_message) response = connection_handler.stream_post(f"{connection_handler.url}/api/chat", data=json.dumps({"model": model, "messages": messages}), callback=lambda data, id=id: self.update_bot_message(data, id))
GLib.idle_add(self.add_code_blocks) GLib.idle_add(self.add_code_blocks)
GLib.idle_add(self.switch_send_stop_button) GLib.idle_add(self.switch_send_stop_button)
GLib.idle_add(self.toggle_ui_sensitive, True) GLib.idle_add(self.toggle_ui_sensitive, True)
if self.verify_if_image_can_be_used(): GLib.idle_add(self.image_button.set_sensitive, True)
GLib.idle_add(self.image_button.set_css_classes, ["circular"])
self.attached_image = {"path": None, "base64": None}
if self.loading_spinner: if self.loading_spinner:
GLib.idle_add(self.chat_container.remove, self.loading_spinner) GLib.idle_add(self.chat_container.remove, self.loading_spinner)
self.loading_spinner = None self.loading_spinner = None
@ -771,70 +852,47 @@ class AlpacaWindow(Adw.ApplicationWindow):
self.available_model_list_box.append(model) self.available_model_list_box.append(model)
def save_history(self): def save_history(self):
with open(os.path.join(self.config_dir, "chats.json"), "w+") as f: with open(os.path.join(self.data_dir, "chats", "chats.json"), "w+") as f:
json.dump(self.chats, f, indent=4) json.dump(self.chats, f, indent=4)
def load_history_into_chat(self): def load_history_into_chat(self):
for widget in list(self.chat_container): self.chat_container.remove(widget) for widget in list(self.chat_container): self.chat_container.remove(widget)
for i, message in enumerate(self.chats['chats'][self.chats["selected_chat"]]['messages']): for key, message in self.chats['chats'][self.chats["selected_chat"]]['messages'].items():
if message: if message:
if message['role'] == 'user': if message['role'] == 'user':
self.show_message(message['content'], False, f"\n\n<small>{message['date']}</small>", message['images'][0] if 'images' in message and len(message['images']) > 0 else None, id=i) self.show_message(message['content'], False, f"\n\n<small>{message['date']}</small>", message['images'] if 'images' in message else None, message['files'] if 'files' in message else None, id=key)
else: else:
self.show_message(message['content'], True, f"\n\n<small>{message['model']}\t|\t{message['date']}</small>", id=i) self.show_message(message['content'], True, f"\n\n<small>{message['model']}\t|\t{message['date']}</small>", id=key)
self.add_code_blocks() self.add_code_blocks()
self.bot_message = None self.bot_message = None
def load_history(self): def load_history(self):
if os.path.exists(os.path.join(self.config_dir, "chats.json")): if os.path.exists(os.path.join(self.data_dir, "chats", "chats.json")):
try: try:
with open(os.path.join(self.config_dir, "chats.json"), "r") as f: with open(os.path.join(self.data_dir, "chats", "chats.json"), "r") as f:
self.chats = json.load(f) self.chats = json.load(f)
if "selected_chat" not in self.chats or self.chats["selected_chat"] not in self.chats["chats"]: self.chats["selected_chat"] = list(self.chats["chats"].keys())[0] if "selected_chat" not in self.chats or self.chats["selected_chat"] not in self.chats["chats"]: self.chats["selected_chat"] = list(self.chats["chats"].keys())[0]
if len(list(self.chats["chats"].keys())) == 0: self.chats["chats"][_("New Chat")] = {"messages": []} if len(list(self.chats["chats"].keys())) == 0: self.chats["chats"][_("New Chat")] = {"messages": {}}
for chat_name, content in self.chats['chats'].items():
for i, content in enumerate(content['messages']):
if not content: del self.chats['chats'][chat_name]['messages'][i]
except Exception as e: except Exception as e:
self.chats = {"chats": {_("New Chat"): {"messages": []}}, "selected_chat": _("New Chat")} self.chats = {"chats": {_("New Chat"): {"messages": {}}}, "selected_chat": _("New Chat")}
self.load_history_into_chat() self.load_history_into_chat()
def load_image(self, file_dialog, result): def generate_numbered_name(self, chat_name:str, compare_list:list) -> str:
try: file = file_dialog.open_finish(result) if chat_name in compare_list:
except: return for i in range(len(compare_list)):
try: if "." in chat_name:
self.attached_image["path"] = file.get_path() if f"{'.'.join(chat_name.split('.')[:-1])} {i+1}.{chat_name.split('.')[-1]}" not in compare_list:
with Image.open(self.attached_image["path"]) as img: chat_name = f"{'.'.join(chat_name.split('.')[:-1])} {i+1}.{chat_name.split('.')[-1]}"
width, height = img.size break
max_size = 240
if width > height:
new_width = max_size
new_height = int((max_size / width) * height)
else: else:
new_height = max_size if f"{chat_name} {i+1}" not in compare_list:
new_width = int((max_size / height) * width) chat_name = f"{chat_name} {i+1}"
resized_img = img.resize((new_width, new_height), Image.LANCZOS)
with BytesIO() as output:
resized_img.save(output, format="PNG")
image_data = output.getvalue()
self.attached_image["base64"] = base64.b64encode(image_data).decode("utf-8")
self.image_button.set_css_classes(["destructive-action", "circular"])
except Exception as e:
self.show_toast("error", 5, self.main_overlay)
def remove_image(self):
self.image_button.set_css_classes(["circular"])
self.attached_image = {"path": None, "base64": None}
def generate_numbered_chat_name(self, chat_name) -> str:
if chat_name in self.chats["chats"]:
for i in range(len(list(self.chats["chats"].keys()))):
if chat_name + f" {i+1}" not in self.chats["chats"]:
chat_name += f" {i+1}"
break break
return chat_name return chat_name
def generate_uuid(self) -> str:
return f"{datetime.today().strftime('%Y%m%d%H%M%S%f')}{uuid.uuid4().hex}"
def clear_chat(self): def clear_chat(self):
for widget in list(self.chat_container): self.chat_container.remove(widget) for widget in list(self.chat_container): self.chat_container.remove(widget)
self.chats["chats"][self.chats["selected_chat"]]["messages"] = [] self.chats["chats"][self.chats["selected_chat"]]["messages"] = []
@ -842,21 +900,25 @@ class AlpacaWindow(Adw.ApplicationWindow):
def delete_chat(self, chat_name): def delete_chat(self, chat_name):
del self.chats['chats'][chat_name] del self.chats['chats'][chat_name]
if os.path.exists(os.path.join(self.data_dir, "chats", self.chats['selected_chat'])):
shutil.rmtree(os.path.join(self.data_dir, "chats", self.chats['selected_chat']))
self.save_history() self.save_history()
self.update_chat_list() self.update_chat_list()
if len(self.chats['chats'])==0: if len(self.chats['chats'])==0:
self.new_chat() self.new_chat()
def rename_chat(self, old_chat_name, new_chat_name, label_element): def rename_chat(self, old_chat_name, new_chat_name, label_element):
new_chat_name = self.generate_numbered_chat_name(new_chat_name) new_chat_name = self.generate_numbered_name(new_chat_name, self.chats["chats"].keys())
self.chats["chats"][new_chat_name] = self.chats["chats"][old_chat_name] self.chats["chats"][new_chat_name] = self.chats["chats"][old_chat_name]
del self.chats["chats"][old_chat_name] del self.chats["chats"][old_chat_name]
if os.path.exists(os.path.join(self.data_dir, "chats", old_chat_name)):
shutil.move(os.path.join(self.data_dir, "chats", old_chat_name), os.path.join(self.data_dir, "chats", new_chat_name))
label_element.set_label(new_chat_name) label_element.set_label(new_chat_name)
label_element.get_parent().set_name(new_chat_name) label_element.get_parent().set_name(new_chat_name)
self.save_history() self.save_history()
def new_chat(self): def new_chat(self):
chat_name = self.generate_numbered_chat_name(_("New Chat")) chat_name = self.generate_numbered_name(_("New Chat"), self.chats["chats"].keys())
self.chats["chats"][chat_name] = {"messages": []} self.chats["chats"][chat_name] = {"messages": []}
self.save_history() self.save_history()
self.new_chat_element(chat_name, True) self.new_chat_element(chat_name, True)
@ -963,9 +1025,25 @@ class AlpacaWindow(Adw.ApplicationWindow):
def on_export_current_chat(self, file_dialog, result): def on_export_current_chat(self, file_dialog, result):
file = file_dialog.save_finish(result) file = file_dialog.save_finish(result)
data_to_export = {self.chats["selected_chat"]: self.chats["chats"][self.chats["selected_chat"]]} if not file: return
json_data = json.dumps({self.chats["selected_chat"]: self.chats["chats"][self.chats["selected_chat"]]}, indent=4).encode("UTF-8")
with tempfile.TemporaryDirectory() as temp_dir:
json_path = os.path.join(temp_dir, "data.json")
with open(json_path, "wb") as json_file:
json_file.write(json_data)
tar_path = os.path.join(temp_dir, f"{self.chats['selected_chat']}")
with tarfile.open(tar_path, "w") as tar:
tar.add(json_path, arcname="data.json")
directory = os.path.join(self.data_dir, "chats", self.chats['selected_chat'])
tar.add(directory, arcname=os.path.basename(directory))
with open(tar_path, "rb") as tar:
tar_content = tar.read()
file.replace_contents_async( file.replace_contents_async(
json.dumps(data_to_export, indent=4).encode("UTF-8"), tar_content,
etag=None, etag=None,
make_backup=False, make_backup=False,
flags=Gio.FileCreateFlags.NONE, flags=Gio.FileCreateFlags.NONE,
@ -974,24 +1052,49 @@ class AlpacaWindow(Adw.ApplicationWindow):
) )
def export_current_chat(self): def export_current_chat(self):
file_dialog = Gtk.FileDialog(initial_name=f"{self.chats['selected_chat']}.json") file_dialog = Gtk.FileDialog(initial_name=f"{self.chats['selected_chat']}.tar")
file_dialog.save(parent=self, cancellable=None, callback=self.on_export_current_chat) file_dialog.save(parent=self, cancellable=None, callback=self.on_export_current_chat)
def on_chat_imported(self, file_dialog, result): def on_chat_imported(self, file_dialog, result):
file = file_dialog.open_finish(result) file = file_dialog.open_finish(result)
if not file: return
stream = file.read(None) stream = file.read(None)
data_stream = Gio.DataInputStream.new(stream) data_stream = Gio.DataInputStream.new(stream)
data, _ = data_stream.read_until('\0', None) tar_content = data_stream.read_bytes(1024 * 1024, None)
data = json.loads(data)
chat_name = list(data.keys())[0] with tempfile.TemporaryDirectory() as temp_dir:
chat_content = data[chat_name] tar_filename = os.path.join(temp_dir, "imported_chat.tar")
self.chats['chats'][chat_name] = chat_content
with open(tar_filename, "wb") as tar_file:
tar_file.write(tar_content.get_data())
with tarfile.open(tar_filename, "r") as tar:
tar.extractall(path=temp_dir)
chat_name = None
chat_content = None
for member in tar.getmembers():
if member.name == "data.json":
json_filepath = os.path.join(temp_dir, member.name)
with open(json_filepath, "r") as json_file:
data = json.load(json_file)
for chat_name, chat_content in data.items():
new_chat_name = self.generate_numbered_name(chat_name, list(self.chats['chats'].keys()))
self.chats['chats'][new_chat_name] = chat_content
src_path = os.path.join(temp_dir, chat_name)
print(src_path)
print(os.path.exists(src_path))
print(os.path.isdir(src_path))
if os.path.exists(src_path) and os.path.isdir(src_path):
dest_path = os.path.join(self.data_dir, "chats", new_chat_name)
shutil.copytree(src_path, dest_path)
self.update_chat_list() self.update_chat_list()
self.save_history() self.save_history()
self.show_toast("good", 3, self.main_overlay) self.show_toast("good", 3, self.main_overlay)
def import_chat(self): def import_chat(self):
file_dialog = Gtk.FileDialog(default_filter=self.file_filter_json) file_dialog = Gtk.FileDialog(default_filter=self.file_filter_tar)
file_dialog.open(self, None, self.on_chat_imported) file_dialog.open(self, None, self.on_chat_imported)
def switch_run_on_background(self): def switch_run_on_background(self):
@ -999,9 +1102,64 @@ class AlpacaWindow(Adw.ApplicationWindow):
self.set_hide_on_close(self.run_on_background) self.set_hide_on_close(self.run_on_background)
self.verify_connection() self.verify_connection()
def get_content_of_file(self, file_path, file_type):
if file_type == 'image':
try:
with Image.open(file_path) as img:
width, height = img.size
max_size = 240
if width > height:
new_width = max_size
new_height = int((max_size / width) * height)
else:
new_height = max_size
new_width = int((max_size / height) * width)
resized_img = img.resize((new_width, new_height), Image.LANCZOS)
with BytesIO() as output:
resized_img.save(output, format="PNG")
image_data = output.getvalue()
return base64.b64encode(image_data).decode("utf-8")
except Exception as e:
self.show_toast("error", 5, self.main_overlay)
elif file_type == 'plain_text':
with open(file_path, 'r') as f:
return f.read()
def remove_attached_file(self, button):
del self.attachments[button.get_name()]
button.get_parent().remove(button)
if len(self.attachments) == 0: self.attachment_box.set_visible(False)
def attach_file(self, file_path, file_type):
name = self.generate_numbered_name(os.path.basename(file_path), self.attachments.keys())
content = self.get_content_of_file(file_path, file_type)
shown_name='.'.join(name.split(".")[:-1])[:20] + (name[20:] and '..') + f".{name.split('.')[-1]}"
button_content = Adw.ButtonContent(
label=shown_name,
icon_name={"image": "image-x-generic-symbolic", "plain_text": "document-text-symbolic"}[file_type]
)
button = Gtk.Button(
vexpand=True,
valign=3,
name=name,
css_classes=["flat"],
tooltip_text=name,
child=button_content
)
self.attachments[name] = {"path": file_path, "type": file_type, "content": content, "button": button}
button.connect("clicked", lambda button: dialogs.remove_attached_file(self, button))
self.attachment_container.append(button)
self.attachment_box.set_visible(True)
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
GtkSource.init() GtkSource.init()
if os.path.exists(os.path.join(self.config_dir, "chats.json")) and not os.path.exists(os.path.join(self.data_dir, "chats", "chats.json")):
update_history.update(self)
self.set_help_overlay(self.shortcut_window) self.set_help_overlay(self.shortcut_window)
self.get_application().set_accels_for_action("win.show-help-overlay", ['<primary>slash']) self.get_application().set_accels_for_action("win.show-help-overlay", ['<primary>slash'])
self.get_application().create_action('new_chat', lambda *_: self.new_chat(), ['<primary>n']) self.get_application().create_action('new_chat', lambda *_: self.new_chat(), ['<primary>n'])
@ -1011,6 +1169,8 @@ class AlpacaWindow(Adw.ApplicationWindow):
self.get_application().create_action('import_chat', lambda *_: self.import_chat()) self.get_application().create_action('import_chat', lambda *_: self.import_chat())
self.get_application().create_action('create_model_from_existing', lambda *_: dialogs.create_model_from_existing(self)) self.get_application().create_action('create_model_from_existing', lambda *_: dialogs.create_model_from_existing(self))
self.get_application().create_action('create_model_from_file', lambda *_: dialogs.create_model_from_file(self)) self.get_application().create_action('create_model_from_file', lambda *_: dialogs.create_model_from_file(self))
self.get_application().create_action('attach_image', lambda *_: dialogs.attach_file(self, self.file_filter_image, "image"))
self.get_application().create_action('attach_plain_text', lambda *_: dialogs.attach_file(self, self.file_filter_text, "plain_text"))
self.add_chat_button.connect("clicked", lambda button : self.new_chat()) self.add_chat_button.connect("clicked", lambda button : self.new_chat())
self.create_model_name.get_delegate().connect("insert-text", self.check_alphanumeric) self.create_model_name.get_delegate().connect("insert-text", self.check_alphanumeric)

View File

@ -26,6 +26,7 @@
<setter object="manage_models_dialog" property="width-request">360</setter> <setter object="manage_models_dialog" property="width-request">360</setter>
<setter object="create_model_dialog" property="width-request">360</setter> <setter object="create_model_dialog" property="width-request">360</setter>
<setter object="preferences_dialog" property="width-request">360</setter> <setter object="preferences_dialog" property="width-request">360</setter>
<setter object="file_preview_dialog" property="width-request">360</setter>
</object> </object>
</child> </child>
<property name="content"> <property name="content">
@ -165,22 +166,41 @@
<property name="tightening-threshold">800</property> <property name="tightening-threshold">800</property>
<child> <child>
<object class="GtkBox"> <object class="GtkBox">
<property name="orientation">0</property> <property name="orientation">1</property>
<property name="spacing">12</property> <property name="spacing">12</property>
<property name="margin-top">12</property> <property name="margin-top">12</property>
<property name="margin-bottom">12</property> <property name="margin-bottom">12</property>
<property name="margin-start">12</property> <property name="margin-start">12</property>
<property name="margin-end">12</property> <property name="margin-end">12</property>
<child> <child>
<object class="GtkButton" id="image_button"> <object class="GtkScrolledWindow" id="attachment_box">
<signal name="clicked" handler="open_image"/> <property name="visible">false</property>
<child>
<object class="GtkBox" id="attachment_container">
<property name="orientation">0</property>
<property name="vexpand">false</property>
<property name="spacing">12</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox">
<property name="orientation">0</property>
<property name="spacing">12</property>
<child>
<object class="GtkMenuButton" id="attachment_button">
<property name="menu-model">attachment_menu</property>
<property name="direction">0</property>
<property name="vexpand">false</property> <property name="vexpand">false</property>
<property name="valign">3</property> <property name="valign">3</property>
<property name="sensitive">false</property> <property name="tooltip-text" translatable="yes">Attach file</property>
<property name="tooltip-text" translatable="yes">Only available on selected models</property> <style>
<class name="circular"/>
</style>
<child> <child>
<object class="AdwButtonContent"> <object class="AdwButtonContent">
<property name="icon-name">image-x-generic-symbolic</property> <property name="icon-name">chain-link-loose-symbolic</property>
</object> </object>
</child> </child>
</object> </object>
@ -220,6 +240,7 @@
<signal name="clicked" handler="send_message"/> <signal name="clicked" handler="send_message"/>
<property name="vexpand">false</property> <property name="vexpand">false</property>
<property name="valign">3</property> <property name="valign">3</property>
<property name="tooltip-text" translatable="yes">Send message</property>
<style> <style>
<class name="suggested-action"/> <class name="suggested-action"/>
<class name="circular"/> <class name="circular"/>
@ -251,6 +272,9 @@
</object> </object>
</child> </child>
</object> </object>
</child>
</object>
</child> </child>
@ -302,7 +326,7 @@
</child> </child>
<child> <child>
<object class="AdwPreferencesPage" id="model_page"> <object class="AdwPreferencesPage" id="model_page">
<property name="title" translatable="yes">Advanced Model Settings</property> <property name="title" translatable="yes">Model</property>
<property name="icon-name">preferences-other-symbolic</property> <property name="icon-name">preferences-other-symbolic</property>
<child> <child>
<object class="AdwPreferencesGroup"> <object class="AdwPreferencesGroup">
@ -605,6 +629,37 @@
</child> </child>
</object> </object>
<object class="AdwDialog" id="file_preview_dialog">
<property name="can-close">true</property>
<property name="width-request">450</property>
<property name="height-request">450</property>
<child>
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar">
</object>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="hexpand">true</property>
<property name="vexpand">true</property>
<property name="margin-top">12</property>
<property name="margin-bottom">12</property>
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<child>
<object class="GtkTextView" id="file_preview_text_view">
<property name="hexpand">true</property>
<property name="vexpand">true</property>
<property name="editable">false</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
<object class="AdwDialog" id="welcome_dialog"> <object class="AdwDialog" id="welcome_dialog">
<property name="can-close">false</property> <property name="can-close">false</property>
<property name="width-request">450</property> <property name="width-request">450</property>
@ -826,6 +881,32 @@
</item> </item>
</section> </section>
</menu> </menu>
<menu id="attachment_menu">
<section>
<item>
<attribute name="label" translatable="yes">Plain text file</attribute>
<attribute name="action">app.attach_plain_text</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Image</attribute>
<attribute name="action">app.attach_image</attribute>
</item>
</section>
</menu>
<object class="GtkFileFilter" id="file_filter_text">
<suffixes>
<suffix></suffix>
<suffix>txt</suffix>
<suffix>md</suffix>
<suffix>html</suffix>
<suffix>css</suffix>
<suffix>js</suffix>
<suffix>py</suffix>
<suffix>java</suffix>
<suffix>json</suffix>
<suffix>xml</suffix>
</suffixes>
</object>
<object class="GtkFileFilter" id="file_filter_image"> <object class="GtkFileFilter" id="file_filter_image">
<mime-types> <mime-types>
<mime-type>image/svg+xml</mime-type> <mime-type>image/svg+xml</mime-type>
@ -835,9 +916,9 @@
<mime-type>image/gif</mime-type> <mime-type>image/gif</mime-type>
</mime-types> </mime-types>
</object> </object>
<object class="GtkFileFilter" id="file_filter_json"> <object class="GtkFileFilter" id="file_filter_tar">
<mime-types> <mime-types>
<mime-type>application/json</mime-type> <mime-type>application/x-tar</mime-type>
</mime-types> </mime-types>
</object> </object>
<object class="GtkFileFilter" id="file_filter_gguf"> <object class="GtkFileFilter" id="file_filter_gguf">