16 Commits
0.9.3 ... 0.9.4

Author SHA1 Message Date
jeffser
a4d26b2bda Added feature to description 2024-06-04 14:15:50 -06:00
jeffser
d7961f2510 Last minute fix 2024-06-04 14:14:09 -06:00
jeffser
45f5214ec9 Preparing for 0.9.4 (again) 2024-06-04 13:49:25 -06:00
jeffser
5e1e770aee Preparing for 0.9.4 2024-06-04 13:46:15 -06:00
jeffser
5eb18d00d3 Fix export 2024-06-04 13:39:50 -06:00
jeffser
bcbfd44e1f Fixed overrides 2024-06-04 13:34:48 -06:00
jeffser
073c619d89 New system for override and new overrides 2024-06-04 12:55:31 -06:00
jeffser
71ab8cfba4 Remove old file 2024-06-04 12:13:48 -06:00
jeffser
0ec812099c Update readme 2024-06-04 12:11:36 -06:00
jeffser
f7f05a0538 Rewrote a lot of code, new chat system and file uploading! 2024-06-04 12:07:15 -06:00
jeffser
82a0ab0d9e Preserve changes to override and added link button 2024-06-03 17:01:27 -06:00
jeffser
60b24da482 Fixed mistakes on readme 2024-06-03 16:37:03 -06:00
jeffser
66d209e4c6 New tip 2024-06-03 16:34:35 -06:00
jeffser
0c9ab4e17e Merge branch 'main' of github.com-jeffser:Jeffser/Alpaca 2024-06-03 16:31:09 -06:00
jeffser
fff3a68b29 Added overrides system to Ollama instance 2024-06-03 16:31:03 -06:00
Louis Chauvet-Villaret
580a104894 the regular french update (baguette) (#87) 2024-06-03 15:51:44 -06:00
21 changed files with 2732 additions and 1935 deletions

View File

@@ -15,6 +15,7 @@ Alpaca is an [Ollama](https://github.com/ollama/ollama) client where you can man
- Talk to multiple models in the same conversation - Talk to multiple models in the same conversation
- Pull and delete models from the app - Pull and delete models from the app
- Image recognition - Image recognition
- Document recognition (plain text files)
- Code highlighting - Code highlighting
- Multiple conversations - Multiple conversations
- Notifications - Notifications
@@ -22,13 +23,12 @@ Alpaca is an [Ollama](https://github.com/ollama/ollama) client where you can man
- Delete messages - Delete messages
## Future features! ## Future features!
- Document recognition (Probably only Markdown because it's really easy for AI to read)
- YouTube recognition (Ask questions about a YouTube video using the transcript) - YouTube recognition (Ask questions about a YouTube video using the transcript)
- Edit messages - Edit messages
- Snap Package (maybe) - Snap Package (maybe)
## Screenies ## Screenies
Login to Ollama instance | Chatting with models | Managing models Code highlighting | Chatting with models | Managing models
:-------------------------:|:-------------------------:|:-------------------------: :-------------------------:|:-------------------------:|:-------------------------:
![Screenshot from 2024-05-12 19-58-28](https://jeffser.com/images/alpaca/screenie1.png) | ![Screenshot from 2024-05-12 20-01-08](https://jeffser.com/images/alpaca/screenie2.png) | ![Screenshot from 2024-05-12 20-01-31](https://jeffser.com/images/alpaca/screenie3.png) ![Screenshot from 2024-05-12 19-58-28](https://jeffser.com/images/alpaca/screenie1.png) | ![Screenshot from 2024-05-12 20-01-08](https://jeffser.com/images/alpaca/screenie2.png) | ![Screenshot from 2024-05-12 20-01-31](https://jeffser.com/images/alpaca/screenie3.png)
@@ -50,11 +50,14 @@ For now Alpaca doesn't offer a way to do this from the GUI but it's really simpl
Go to `~/.var/app/com.jeffser.Alpaca/config/server.json` and change the `"local_port"` value, by default it is `11435`. Go to `~/.var/app/com.jeffser.Alpaca/config/server.json` and change the `"local_port"` value, by default it is `11435`.
### Backup all the chats ### Backup all the chats
The chat data is located in `~/.var/app/com.jeffser.Alpaca/config/chats.json` you can copy that file wherever you want to. The chat data is located in `~/.var/app/com.jeffser.Alpaca/data/chats` you can copy that directory wherever you want to.
### Force showing the welcome dialog ### Force showing the welcome dialog
To do that you just need to delete the file `~/.var/app/com.jeffser.Alpaca/config/server.json`, this won't affect your saved chats or models. To do that you just need to delete the file `~/.var/app/com.jeffser.Alpaca/config/server.json`, this won't affect your saved chats or models.
### Add/Change environment variables for Ollama
You can change anything except `$HOME` and `$OLLAMA_HOST`, to do this go to `~/.var/app/com.jeffser.Alpaca/config/server.json` and change `ollama_overrides` accordingly, some overrides are available to change on the GUI.
--- ---
## Thanks ## Thanks

View File

@@ -15,6 +15,7 @@
<li>Pull and delete models from the app</li> <li>Pull and delete models from the app</li>
<li>Have multiple conversations</li> <li>Have multiple conversations</li>
<li>Image recognition (Only available with compatible models)</li> <li>Image recognition (Only available with compatible models)</li>
<li>Plain text documents recognition</li>
<li>Import and export chats</li> <li>Import and export chats</li>
</ul> </ul>
<p>Disclaimer</p> <p>Disclaimer</p>
@@ -63,6 +64,18 @@
<url type="homepage">https://github.com/Jeffser/Alpaca</url> <url type="homepage">https://github.com/Jeffser/Alpaca</url>
<url type="donation">https://github.com/sponsors/Jeffser</url> <url type="donation">https://github.com/sponsors/Jeffser</url>
<releases> <releases>
<release version="0.9.4" date="2024-06-04">
<url type="details">https://github.com/Jeffser/Alpaca/releases/tag/0.9.4</url>
<description>
<p>Huge Update</p>
<ul>
<li>Added: Support for plain text files</li>
<li>Added: New backend system for storing messages</li>
<li>Added: Support for changing Ollama's overrides</li>
<li>General Optimization</li>
</ul>
</description>
</release>
<release version="0.9.3" date="2024-06-01"> <release version="0.9.3" date="2024-06-01">
<url type="details">https://github.com/Jeffser/Alpaca/releases/tag/0.9.3</url> <url type="details">https://github.com/Jeffser/Alpaca/releases/tag/0.9.3</url>
<description> <description>

View File

@@ -1,5 +1,5 @@
project('Alpaca', 'c', project('Alpaca', 'c',
version: '0.9.3', version: '0.9.4',
meson_version: '>= 0.62.0', meson_version: '>= 0.62.0',
default_options: [ 'warning_level=2', 'werror=false', ], default_options: [ 'warning_level=2', 'werror=false', ],
) )

File diff suppressed because it is too large Load Diff

539
po/es.po

File diff suppressed because it is too large Load Diff

544
po/es.po~

File diff suppressed because it is too large Load Diff

571
po/fr.po

File diff suppressed because it is too large Load Diff

566
po/fr.po~

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,9 @@
<file alias="icons/scalable/status/view-more-symbolic.svg">icons/view-more-symbolic.svg</file> <file alias="icons/scalable/status/view-more-symbolic.svg">icons/view-more-symbolic.svg</file>
<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/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 5.976562 2 c 0.546876 0 1 0.453125 1 1 v 10 c 0 0.546875 -0.453124 1 -1 1 h -0.976562 c -1.652344 0 -3 -1.347656 -3 -3 v -6 c 0 -1.652344 1.347656 -3 3 -3 z m -5.976562 3 v 6 c 0 2.765625 2.234375 5 5 5 h 0.976562 c 1.660157 0 3 -1.339844 3 -3 v -10 c 0 -1.660156 -1.339843 -3 -3 -3 h -0.976562 c -2.765625 0 -5 2.234375 -5 5 z m 0 0"/><path d="m 1.488281 8.996094 h 1.511719 c 1.101562 0 1.988281 -0.886719 1.988281 -1.984375 v -0.515625 c 0 -0.273438 -0.222656 -0.5 -0.5 -0.5 c -0.273437 0 -0.5 0.226562 -0.5 0.5 v 0.515625 c 0 0.542969 -0.445312 0.984375 -0.988281 0.984375 h -1.511719 c -0.273437 0 -0.5 0.226562 -0.5 0.5 c 0 0.277344 0.226563 0.5 0.5 0.5 z m 0 0"/><path d="m 7.5 9.992188 h -1.511719 c -1.101562 0 -1.988281 0.886718 -1.988281 1.984374 v 0.515626 c 0 0.273437 0.222656 0.5 0.5 0.5 s 0.5 -0.226563 0.5 -0.5 v -0.515626 c 0 -0.539062 0.445312 -0.984374 0.988281 -0.984374 h 1.511719 c 0.277344 0 0.5 -0.226563 0.5 -0.5 c 0 -0.277344 -0.222656 -0.5 -0.5 -0.5 z m 0 0"/><path d="m 11.015625 14 h -1.035156 c -0.546875 0 -1 -0.453125 -1 -1 v -10 c 0 -0.546875 0.453125 -1 1 -1 h 1.035156 v -2 h -1.035156 c -1.664063 0 -3 1.339844 -3 3 v 10 c 0 1.660156 1.335937 3 3 3 h 1.035156 z m 0 0"/><path d="m 10 5 h 2.242188 l 2.148437 -2.6875 l -0.78125 -0.625 l -2 2.5 l 0.390625 -0.1875 h -2 z m 0 0"/><path d="m 10 11 h 2 l -0.390625 -0.1875 l 2 2.5 l 0.78125 -0.625 l -2.148437 -2.6875 h -2.242188 z m 0 0"/><path d="m 14.488281 1.976562 c -0.265625 0 -0.488281 -0.21875 -0.488281 -0.488281 c 0 -0.265625 0.222656 -0.488281 0.488281 -0.488281 c 0.269531 0 0.488281 0.222656 0.488281 0.488281 c 0 0.269531 -0.21875 0.488281 -0.488281 0.488281 z m 0 -1.976562 c -0.824219 0 -1.488281 0.664062 -1.488281 1.488281 s 0.664062 1.488281 1.488281 1.488281 s 1.488281 -0.664062 1.488281 -1.488281 s -0.664062 -1.488281 -1.488281 -1.488281 z m 0 0"/><path d="m 14.488281 13.976562 c -0.265625 0 -0.488281 -0.21875 -0.488281 -0.488281 c 0 -0.265625 0.222656 -0.488281 0.488281 -0.488281 c 0.269531 0 0.488281 0.222656 0.488281 0.488281 c 0 0.269531 -0.21875 0.488281 -0.488281 0.488281 z m 0 -1.976562 c -0.824219 0 -1.488281 0.664062 -1.488281 1.488281 s 0.664062 1.488281 1.488281 1.488281 s 1.488281 -0.664062 1.488281 -1.488281 s -0.664062 -1.488281 -1.488281 -1.488281 z m 0 0"/><path d="m 14.488281 7.976562 c -0.265625 0 -0.488281 -0.21875 -0.488281 -0.488281 c 0 -0.265625 0.222656 -0.488281 0.488281 -0.488281 c 0.269531 0 0.488281 0.222656 0.488281 0.488281 c 0 0.269531 -0.21875 0.488281 -0.488281 0.488281 z m 0 -1.976562 c -0.824219 0 -1.488281 0.664062 -1.488281 1.488281 s 0.664062 1.488281 1.488281 1.488281 s 1.488281 -0.664062 1.488281 -1.488281 s -0.664062 -1.488281 -1.488281 -1.488281 z m 0 0"/></g><path d="m 10 7.53125 h 4" fill="none" stroke="#222222"/><path d="m 4.5 4 h 3 c 0.277344 0 0.5 0.222656 0.5 0.5 s -0.222656 0.5 -0.5 0.5 h -3 c -0.277344 0 -0.5 -0.222656 -0.5 -0.5 s 0.222656 -0.5 0.5 -0.5 z m 0 0" fill="#222222"/></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

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

@@ -5,10 +5,14 @@ from time import sleep
instance = None instance = None
port = 11435 port = 11435
data_dir = os.getenv("XDG_DATA_HOME") data_dir = os.getenv("XDG_DATA_HOME")
overrides = {}
def start(): def start():
global instance global instance, overrides
instance = subprocess.Popen(["/app/bin/ollama", "serve"], env={**os.environ, 'OLLAMA_HOST': f"127.0.0.1:{port}", "HOME": data_dir}, stderr=subprocess.PIPE, text=True) params = overrides.copy()
params["OLLAMA_HOST"] = f"127.0.0.1:{port}" # You can't change this directly sorry :3
params["HOME"] = data_dir
instance = subprocess.Popen(["/app/bin/ollama", "serve"], env={**os.environ, **params}, stderr=subprocess.PIPE, text=True)
print("Starting Alpaca's Ollama instance...") print("Starting Alpaca's Ollama instance...")
sleep(1) sleep(1)
print("Started Alpaca's Ollama instance") print("Started Alpaca's Ollama instance")

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
@@ -47,7 +47,7 @@ class AlpacaApplication(Adw.Application):
application_name='Alpaca', application_name='Alpaca',
application_icon='com.jeffser.Alpaca', application_icon='com.jeffser.Alpaca',
developer_name='Jeffry Samuel Eduarte Rojas', developer_name='Jeffry Samuel Eduarte Rojas',
version='0.9.3', version='0.9.4',
developers=['Jeffser https://jeffser.com'], developers=['Jeffser https://jeffser.com'],
designers=['Jeffser https://jeffser.com'], designers=['Jeffser https://jeffser.com'],
translator_credits='Alex K (Russian) https://github.com/alexkdeveloper\nJeffser (Spanish) https://jeffser.com\nDaimar Stein (Brazilian Portuguese) https://github.com/not-a-dev-stein\nLouis Chauvet-Villaret (French) https://github.com/loulou64490', translator_credits='Alex K (Russian) https://github.com/alexkdeveloper\nJeffser (Spanish) https://jeffser.com\nDaimar Stein (Brazilian Portuguese) https://github.com/not-a-dev-stein\nLouis Chauvet-Villaret (French) https://github.com/loulou64490',

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)

38
src/update_history.py Normal file
View File

@@ -0,0 +1,38 @@
# 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)
os.remove(os.path.join(self.config_dir, "chats.json"))

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,13 @@ 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_HSA_OVERRIDE_GFX_VERSION = Gtk.Template.Child()
override_CUDA_VISIBLE_DEVICES = Gtk.Template.Child()
override_HIP_VISIBLE_DEVICES = Gtk.Template.Child()
#Elements #Elements
create_model_base = Gtk.Template.Child() create_model_base = Gtk.Template.Child()
@@ -68,6 +74,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()
@@ -79,10 +87,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()
@@ -109,7 +120,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"),
@@ -134,22 +146,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
@@ -158,40 +167,59 @@ 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 = {}
self.attachment_box.set_visible(False)
#{"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()
@@ -218,22 +246,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
@@ -314,6 +334,19 @@ class AlpacaWindow(Adw.ApplicationWindow):
self.manage_models_dialog.present(self) self.manage_models_dialog.present(self)
thread.start() thread.start()
@Gtk.Template.Callback()
def override_changed(self, entry):
name = entry.get_name()
value = entry.get_text()
if (not value and name not in local_instance.overrides) or (value and value in local_instance.overrides and local_instance.overrides[name] == value): return
if not value: del local_instance.overrides[name]
else: local_instance.overrides[name] = value
self.save_server_config()
if not self.run_remote: local_instance.reset()
@Gtk.Template.Callback()
def link_button_handler(self, button):
webbrowser.open(button.get_name())
def check_alphanumeric(self, editable, text, length, position): def check_alphanumeric(self, editable, text, length, position):
new_text = ''.join([char for char in text if char.isalnum() or char in ['-', '_']]) new_text = ''.join([char for char in text if char.isalnum() or char in ['-', '_']])
@@ -365,19 +398,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)):
self.save_history() print("deleting " + id)
shutil.rmtree(os.path.join(self.data_dir, "chats", self.chats['selected_chat'], id))
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,
@@ -420,23 +483,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(
loader = GdkPixbuf.PixbufLoader.new() orientation=0,
loader.write(image_data) spacing=12
loader.close() )
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.write(image_data)
loader.close()
pixbuf = loader.get_pixbuf()
texture = Gdk.Texture.new_for_pixbuf(pixbuf)
image = Gtk.Image.new_from_paintable(texture)
image.set_size_request(240, 240)
image.set_hexpand(False)
image.set_css_classes(["flat"])
image_container.append(image)
message_box.append(image_scroller)
pixbuf = loader.get_pixbuf() if files and len(files) > 0:
texture = Gdk.Texture.new_for_pixbuf(pixbuf) 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]}"
image = Gtk.Image.new_from_paintable(texture) button_content = Adw.ButtonContent(
image.set_size_request(240, 240) label=shown_name,
image.set_margin_top(10) icon_name="document-text-symbolic"
image.set_margin_start(10) )
image.set_margin_end(10) button = Gtk.Button(
image.set_hexpand(False) vexpand=False,
image.set_css_classes(["flat"]) valign=3,
message_box.append(image) 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)
@@ -490,7 +593,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}, 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)
@@ -614,12 +717,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")
@@ -627,34 +730,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
@@ -754,70 +854,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) break
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
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"] = []
@@ -825,22 +902,26 @@ 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)
@@ -946,35 +1027,74 @@ 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
file.replace_contents_async( json_data = json.dumps({self.chats["selected_chat"]: self.chats["chats"][self.chats["selected_chat"]]}, indent=4).encode("UTF-8")
json.dumps(data_to_export, indent=4).encode("UTF-8"),
etag=None, with tempfile.TemporaryDirectory() as temp_dir:
make_backup=False, json_path = os.path.join(temp_dir, "data.json")
flags=Gio.FileCreateFlags.NONE, with open(json_path, "wb") as json_file:
cancellable=None, json_file.write(json_data)
callback=self.on_replace_contents
) 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'])
if os.path.exists(directory) and os.path.isdir(directory):
tar.add(directory, arcname=os.path.basename(directory))
with open(tar_path, "rb") as tar:
tar_content = tar.read()
file.replace_contents_async(
tar_content,
etag=None,
make_backup=False,
flags=Gio.FileCreateFlags.NONE,
cancellable=None,
callback=self.on_replace_contents
)
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)
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):
@@ -982,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'])
@@ -994,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)
@@ -1012,6 +1189,16 @@ class AlpacaWindow(Adw.ApplicationWindow):
self.temperature_spin.set_value(self.model_tweaks['temperature']) self.temperature_spin.set_value(self.model_tweaks['temperature'])
self.seed_spin.set_value(self.model_tweaks['seed']) self.seed_spin.set_value(self.model_tweaks['seed'])
self.keep_alive_spin.set_value(self.model_tweaks['keep_alive']) self.keep_alive_spin.set_value(self.model_tweaks['keep_alive'])
#Overrides
if "ollama_overrides" in data: local_instance.overrides = data['ollama_overrides']
for element in [
self.override_HSA_OVERRIDE_GFX_VERSION,
self.override_CUDA_VISIBLE_DEVICES,
self.override_HIP_VISIBLE_DEVICES]:
override = element.get_name()
if override in local_instance.overrides:
element.set_text(local_instance.overrides[override])
self.background_switch.set_active(self.run_on_background) self.background_switch.set_active(self.run_on_background)
self.set_hide_on_close(self.run_on_background) self.set_hide_on_close(self.run_on_background)

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">
@@ -163,92 +164,115 @@
<object class="AdwClamp"> <object class="AdwClamp">
<property name="maximum-size">1000</property> <property name="maximum-size">1000</property>
<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>
<property name="vexpand">false</property> <child>
<property name="valign">3</property> <object class="GtkBox" id="attachment_container">
<property name="sensitive">false</property> <property name="orientation">0</property>
<property name="tooltip-text" translatable="yes">Only available on selected models</property> <property name="vexpand">false</property>
<child> <property name="spacing">12</property>
<object class="AdwButtonContent"> </object>
<property name="icon-name">image-x-generic-symbolic</property> </child>
</object> </object>
</child> </child>
</object> <child>
</child> <object class="GtkBox">
<child> <property name="orientation">0</property>
<object class="GtkBox"> <property name="spacing">12</property>
<style> <child>
<class name="card"/> <object class="GtkMenuButton" id="attachment_button">
<class name="message-input"/> <property name="menu-model">attachment_menu</property>
</style> <property name="direction">0</property>
<child> <property name="vexpand">false</property>
<object class="GtkScrolledWindow"> <property name="valign">3</property>
<property name="margin-start">12</property> <property name="tooltip-text" translatable="yes">Attach file</property>
<property name="margin-end">12</property> <style>
<style> <class name="circular"/>
<class name="message-input"/> </style>
<class name="undershoot-bottom"/> <child>
<class name="undershoot-top"/> <object class="AdwButtonContent">
</style> <property name="icon-name">chain-link-loose-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox">
<style>
<class name="card"/>
<class name="message-input"/>
</style>
<child>
<object class="GtkScrolledWindow">
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<style>
<class name="message-input"/>
<class name="undershoot-bottom"/>
<class name="undershoot-top"/>
</style>
<child>
<object class="GtkTextView" id="message_text_view">
<property name="wrap-mode">word</property>
<property name="top-margin">6</property>
<property name="bottom-margin">6</property>
<property name="hexpand">true</property>
<style>
<class name="message-input"/>
</style>
</object>
</child>
</object>
</child>
</object>
</child>
<child> <child>
<object class="GtkTextView" id="message_text_view"> <object class="GtkButton" id="send_button">
<property name="wrap-mode">word</property> <signal name="clicked" handler="send_message"/>
<property name="top-margin">6</property> <property name="vexpand">false</property>
<property name="bottom-margin">6</property> <property name="valign">3</property>
<property name="hexpand">true</property> <property name="tooltip-text" translatable="yes">Send message</property>
<style> <style>
<class name="message-input"/> <class name="suggested-action"/>
<class name="circular"/>
</style> </style>
<child>
<object class="AdwButtonContent">
<property name="icon-name">paper-plane-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkButton" id="stop_button">
<signal name="clicked" handler="stop_message"/>
<property name="vexpand">false</property>
<property name="valign">3</property>
<property name="visible">false</property>
<style>
<class name="destructive-action"/>
<class name="circular"/>
</style>
<child>
<object class="AdwButtonContent">
<property name="icon-name">media-playback-stop-symbolic</property>
</object>
</child>
</object> </object>
</child> </child>
</object> </object>
</child>
</object>
</child>
<child>
<object class="GtkButton" id="send_button">
<signal name="clicked" handler="send_message"/>
<property name="vexpand">false</property>
<property name="valign">3</property>
<style>
<class name="suggested-action"/>
<class name="circular"/>
</style>
<child>
<object class="AdwButtonContent">
<property name="icon-name">paper-plane-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkButton" id="stop_button">
<signal name="clicked" handler="stop_message"/>
<property name="vexpand">false</property>
<property name="valign">3</property>
<property name="visible">false</property>
<style>
<class name="destructive-action"/>
<class name="circular"/>
</style>
<child>
<object class="AdwButtonContent">
<property name="icon-name">media-playback-stop-symbolic</property>
</object>
</child>
</object>
</child> </child>
</object> </object>
</child> </child>
</object> </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">
@@ -358,6 +382,78 @@
</child> </child>
</object> </object>
</child> </child>
<child>
<object class="AdwPreferencesPage" id="instance_page">
<property name="title" translatable="yes">Ollama Instance</property>
<property name="icon-name">brain-augemnted-symbolic</property>
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="yes">Ollama Overrides</property>
<property name="description" translatable="yes">Manage the arguments used on Ollama, any changes on this page only applies to the integrated instance, the instance will restart if you make changes</property>
<child>
<object class="AdwEntryRow" id="override_HSA_OVERRIDE_GFX_VERSION">
<signal name="apply" handler="override_changed"/>
<property name="name">HSA_OVERRIDE_GFX_VERSION</property>
<property name="title" translatable="no">HSA_OVERRIDE_GFX_VERSION</property>
<property name="show-apply-button">true</property>
<child type="prefix">
<object class="GtkButton">
<signal name="clicked" handler="link_button_handler"/>
<property name="name">https://github.com/ollama/ollama/blob/main/docs/gpu.md#overrides</property>
<property name="vexpand">false</property>
<property name="valign">3</property>
<property name="icon-name">globe-symbolic</property>
<style>
<class name="success"/>
</style>
</object>
</child>
</object>
</child>
<child>
<object class="AdwEntryRow" id="override_CUDA_VISIBLE_DEVICES">
<signal name="apply" handler="override_changed"/>
<property name="name">CUDA_VISIBLE_DEVICES</property>
<property name="title" translatable="no">CUDA_VISIBLE_DEVICES</property>
<property name="show-apply-button">true</property>
<child type="prefix">
<object class="GtkButton">
<signal name="clicked" handler="link_button_handler"/>
<property name="name">https://github.com/ollama/ollama/blob/main/docs/gpu.md#gpu-selection</property>
<property name="vexpand">false</property>
<property name="valign">3</property>
<property name="icon-name">globe-symbolic</property>
<style>
<class name="success"/>
</style>
</object>
</child>
</object>
</child>
<child>
<object class="AdwEntryRow" id="override_HIP_VISIBLE_DEVICES">
<signal name="apply" handler="override_changed"/>
<property name="name">HIP_VISIBLE_DEVICES</property>
<property name="title" translatable="no">HIP_VISIBLE_DEVICES</property>
<property name="show-apply-button">true</property>
<child type="prefix">
<object class="GtkButton">
<signal name="clicked" handler="link_button_handler"/>
<property name="name">https://github.com/ollama/ollama/blob/main/docs/gpu.md#gpu-selection-1</property>
<property name="vexpand">false</property>
<property name="valign">3</property>
<property name="icon-name">globe-symbolic</property>
<style>
<class name="success"/>
</style>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object> </object>
<object class="AdwDialog" id="create_model_dialog"> <object class="AdwDialog" id="create_model_dialog">
@@ -555,6 +651,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>
@@ -776,6 +903,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>
@@ -785,9 +938,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">