Skip to content

Commit af52e21

Browse files
authored
Merge pull request #33 from kaylieee/main
Add Image Input Support
2 parents 84cf569 + 6a7134f commit af52e21

25 files changed

Lines changed: 893 additions & 234 deletions

gui/assistant_dialogs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -791,7 +791,7 @@ def load_completion_settings(self, text_completion_config):
791791
self.responseFormatComboBox.setCurrentText(completion_settings.get('response_format', 'text'))
792792
self.temperatureSlider.setValue(completion_settings.get('temperature', 1.0) * 100)
793793
self.topPSlider.setValue(completion_settings.get('top_p', 1.0) * 100)
794-
self.maxMessagesEdit.setValue(completion_settings.get('max_text_messages', 10))
794+
self.maxMessagesEdit.setValue(completion_settings.get('max_text_messages', 50))
795795
else:
796796
# Apply default settings if no config is found
797797
self.useDefaultSettingsCheckBox.setChecked(True)

gui/conversation.py

Lines changed: 107 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,19 @@
44
# This software uses the PySide6 library, which is licensed under the GNU Lesser General Public License (LGPL).
55
# For more details on PySide6's license, see <https://www.qt.io/licensing>
66

7-
from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit
8-
from PySide6.QtGui import QFont, QTextCursor,QDesktopServices, QMouseEvent, QGuiApplication, QPalette
9-
from PySide6.QtCore import Qt, QUrl
10-
11-
import html, os, re, subprocess, sys
12-
import base64
13-
from typing import List
14-
157
from azure.ai.assistant.management.assistant_config_manager import AssistantConfigManager
168
from azure.ai.assistant.management.message import ConversationMessage
179
from azure.ai.assistant.management.logger_module import logger
1810

11+
from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QMessageBox
12+
from PySide6.QtGui import QFont, QTextCursor,QDesktopServices, QMouseEvent, QGuiApplication, QPalette, QImage
13+
from PySide6.QtCore import Qt, QUrl, QMimeData, QIODevice, QBuffer
14+
from bs4 import BeautifulSoup
15+
16+
import html, os, re, subprocess, sys, tempfile
17+
import base64, random, time
18+
from typing import List
19+
1920

2021
class ConversationInputView(QTextEdit):
2122
PLACEHOLDER_TEXT = "Message Assistant..."
@@ -24,6 +25,7 @@ def __init__(self, parent, main_window):
2425
super().__init__(parent)
2526
self.main_window = main_window # Store a reference to the main window
2627
self.setInitialPlaceholderText()
28+
self.image_file_paths = {} # Dictionary to track image file paths
2729

2830
def setInitialPlaceholderText(self):
2931
self.setText(self.PLACEHOLDER_TEXT)
@@ -39,8 +41,23 @@ def keyPressEvent(self, event):
3941
if self.toPlainText() == self.PLACEHOLDER_TEXT and not event.text().isspace():
4042
self.clear()
4143

44+
cursor = self.textCursor()
45+
if event.key() in (Qt.Key_Delete, Qt.Key_Backspace):
46+
# Check if the cursor is positioned at an image
47+
cursor_pos = cursor.position()
48+
cursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor)
49+
if cursor.charFormat().isImageFormat():
50+
logger.debug("Image found at cursor position, deleting image...")
51+
html_before = self.toHtml()
52+
cursor.removeSelectedText()
53+
html_after = self.toHtml()
54+
self.check_for_deleted_images(html_before, html_after)
55+
else:
56+
# Let the parent class handle other delete/backspace operations
57+
cursor.setPosition(cursor_pos)
58+
super().keyPressEvent(event)
4259
# Check if Enter key is pressed
43-
if event.key() == Qt.Key_Return and not event.modifiers():
60+
elif event.key() == Qt.Key_Return and not event.modifiers():
4461
# Call on_user_input on the main window reference
4562
self.main_window.on_user_input_complete(self.toPlainText())
4663
self.clear()
@@ -52,13 +69,42 @@ def keyPressEvent(self, event):
5269
# Let the parent class handle all other key events
5370
super().keyPressEvent(event)
5471

55-
def insertFromMimeData(self, mimeData):
56-
if mimeData.hasText():
72+
def insertFromMimeData(self, mimeData: QMimeData):
73+
IMAGE_FORMATS = ('.png', '.jpg', '.jpeg', '.gif', '.webp')
74+
if mimeData.hasImage():
75+
image = QImage(mimeData.imageData())
76+
if not image.isNull():
77+
logger.debug("Inserting image from clipboard...")
78+
temp_dir = tempfile.gettempdir()
79+
mime_file_name = self.generate_unique_filename("image.png")
80+
temp_file_path = os.path.join(temp_dir, mime_file_name)
81+
image.save(temp_file_path)
82+
self.add_image_thumbnail(image, temp_file_path)
83+
self.main_window.add_image_to_selected_thread(temp_file_path)
84+
elif mimeData.hasUrls():
85+
logger.debug("Inserting image from URL...")
86+
for url in mimeData.urls():
87+
if url.isLocalFile():
88+
file_path = url.toLocalFile()
89+
logger.debug(f"Local file path: {file_path}")
90+
if file_path.lower().endswith(IMAGE_FORMATS):
91+
image = QImage(file_path)
92+
if not image.isNull():
93+
self.add_image_thumbnail(image, file_path)
94+
self.main_window.add_image_to_selected_thread(file_path)
95+
else:
96+
logger.error(f"Could not load image from file: {file_path}")
97+
else:
98+
logger.warning(f"Unsupported file type: {file_path}")
99+
QMessageBox.warning(self, "Error", "Unsupported file type. Please only upload image files.")
100+
else:
101+
super().insertFromMimeData(mimeData)
102+
elif mimeData.hasText():
57103
text = mimeData.text()
58104
# Convert URL to local file path
59-
fileUrl = QUrl(text)
60-
if fileUrl.isLocalFile():
61-
file_path = fileUrl.toLocalFile()
105+
file_url = QUrl(text)
106+
if file_url.isLocalFile():
107+
file_path = file_url.toLocalFile()
62108
if os.path.isfile(file_path):
63109
try:
64110
with open(file_path, 'r') as file:
@@ -71,6 +117,41 @@ def insertFromMimeData(self, mimeData):
71117
else:
72118
# If it's not a file URL, proceed with the default paste operation
73119
super().insertFromMimeData(mimeData)
120+
else:
121+
super().insertFromMimeData(mimeData)
122+
123+
def generate_unique_filename(self, base_name):
124+
name, ext = os.path.splitext(base_name)
125+
unique_name = f"{name}_{int(time.time())}_{random.randint(1000, 9999)}{ext}"
126+
return unique_name
127+
128+
def add_image_thumbnail(self, image: QImage, file_path: str):
129+
image_thumbnail = image.scaled(100, 100, Qt.KeepAspectRatio) # Resize to 100x100 pixels
130+
buffer = QBuffer()
131+
buffer.open(QIODevice.WriteOnly)
132+
image_thumbnail.save(buffer, "PNG")
133+
base64_data = buffer.data().toBase64().data().decode()
134+
html = f'<img src="data:image/png;base64,{base64_data}" alt="{file_path}" />'
135+
136+
cursor = self.textCursor()
137+
cursor.insertHtml(html)
138+
self.image_file_paths[file_path] = html
139+
140+
def check_for_deleted_images(self, html_before: str, html_after: str):
141+
soup_before = BeautifulSoup(html_before, 'html.parser')
142+
soup_after = BeautifulSoup(html_after, 'html.parser')
143+
144+
file_paths_before = {img['alt'] for img in soup_before.find_all('img') if 'alt' in img.attrs}
145+
file_paths_after = {img['alt'] for img in soup_after.find_all('img') if 'alt' in img.attrs}
146+
147+
# Identify which images are missing
148+
missing_file_paths = file_paths_before - file_paths_after
149+
150+
# Remove missing images from tracked paths and attachments
151+
for file_path in missing_file_paths:
152+
if file_path in self.image_file_paths:
153+
del self.image_file_paths[file_path]
154+
self.main_window.remove_image_from_selected_thread(file_path)
74155

75156
def mouseReleaseEvent(self, event):
76157
cursor = self.cursorForPosition(event.pos())
@@ -126,9 +207,9 @@ def open_file(self, file_path):
126207
subprocess.call(["open", file_path])
127208

128209
def find_urls(self, text):
129-
url_pattern = r'https?://\S+'
210+
url_pattern = r'\b(https?://[^\s)]+)'
130211
for match in re.finditer(url_pattern, text):
131-
yield (match.group(0), match.start(), match.end())
212+
yield (match.group(1), match.start(1), match.end(1))
132213

133214

134215
class ConversationView(QWidget):
@@ -238,19 +319,19 @@ def append_messages(self, messages: List[ConversationMessage]):
238319
self.append_message(message.sender, f"File saved: {file_path}", color='green')
239320

240321
# Handle image message content
241-
if message.image_message:
242-
image_message = message.image_message
243-
# Synchronously retrieve and process the image
244-
image_path = image_message.retrieve_image(self.file_path)
245-
if image_path:
246-
self.append_image(image_path, message.sender)
322+
if len(message.image_messages) > 0:
323+
for image_message in message.image_messages:
324+
# Synchronously retrieve and process the image
325+
image_path = image_message.retrieve_image(self.file_path)
326+
if image_path:
327+
self.append_image(image_path)
247328

248329
def convert_image_to_base64(self, image_path):
249330
with open(image_path, "rb") as image_file:
250331
encoded_string = base64.b64encode(image_file.read()).decode()
251332
return encoded_string
252333

253-
def append_image(self, image_path, assistant_name):
334+
def append_image(self, image_path):
254335
base64_image = self.convert_image_to_base64(image_path)
255336
# Move cursor to the end for each insertion
256337
cursor = self.conversationView.textCursor()
@@ -317,13 +398,13 @@ def append_message_chunk(self, sender, message_chunk, is_start_of_message):
317398
self.conversationView.update()
318399

319400
def format_urls(self, text):
320-
# Regular expression to match URLs
321-
url_pattern = r'(https?://\S+)'
401+
# Regular expression to match URLs, ensuring parentheses are handled correctly
402+
url_pattern = r'((https?://[^\s)]+))'
322403
url_regex = re.compile(url_pattern)
323404

324405
# Replace URLs with HTML anchor tags
325406
def replace_with_link(match):
326-
url = match.group(0)
407+
url = match.group(1)
327408
return f'<a href="{url}" style="color:blue;">{url}</a>'
328409

329410
return url_regex.sub(replace_with_link, text)

gui/conversation_sidebar.py

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ def contextMenuEvent(self, event):
5353
context_menu = QMenu(self)
5454
attach_file_search_action = context_menu.addAction("Attach File for File Search")
5555
attach_file_code_action = context_menu.addAction("Attach File for Code Interpreter")
56-
56+
attach_image_action = context_menu.addAction("Attach Image File")
57+
5758
current_item = self.currentItem()
5859
remove_file_menu = None
5960
if current_item:
@@ -62,7 +63,7 @@ def contextMenuEvent(self, event):
6263
remove_file_menu = context_menu.addMenu("Remove File")
6364
for file_info in self.itemToFileMap[row]:
6465
actual_file_path = file_info['file_path']
65-
tool_type = file_info['tools'][0]['type']
66+
tool_type = file_info['tools'][0]['type'] if file_info['tools'] else "Image"
6667

6768
file_label = f"{os.path.basename(actual_file_path)} ({tool_type})"
6869
action = remove_file_menu.addAction(file_label)
@@ -74,27 +75,34 @@ def contextMenuEvent(self, event):
7475
self.attach_file_to_selected_item("file_search")
7576
elif selected_action == attach_file_code_action:
7677
self.attach_file_to_selected_item("code_interpreter")
78+
elif selected_action == attach_image_action:
79+
self.attach_file_to_selected_item(None, is_image=True)
7780
elif remove_file_menu and isinstance(selected_action, QAction) and selected_action.parent() == remove_file_menu:
7881
file_info = selected_action.data()
79-
self.remove_specific_file_from_selected_item(file_info, row)
82+
self.remove_specific_file_from_selected_item(file_info, self.row(current_item))
8083

81-
def attach_file_to_selected_item(self, mode):
84+
def attach_file_to_selected_item(self, mode, is_image=False):
8285
"""Attaches a file to the selected item with a specified mode indicating its intended use."""
8386
file_dialog = QFileDialog(self)
84-
file_path, _ = file_dialog.getOpenFileName(self, "Select File")
87+
if is_image:
88+
file_path, _ = file_dialog.getOpenFileName(self, "Select Image File", filter="Images (*.png *.jpg *.jpeg *.gif *.webp)")
89+
else:
90+
file_path, _ = file_dialog.getOpenFileName(self, "Select File")
91+
8592
if file_path:
8693
current_item = self.currentItem()
8794
if current_item:
8895
row = self.row(current_item)
8996
if row not in self.itemToFileMap:
9097
self.itemToFileMap[row] = []
9198

92-
self.itemToFileMap[row].append({
99+
file_info = {
93100
"file_id": None, # This will be updated later
94101
"file_path": file_path,
95-
"tools": [{"type": mode}] # Store the tool type for later use
96-
})
97-
102+
"attachment_type": "image_file" if is_image else "document_file",
103+
"tools": [] if is_image else [{"type": mode}] # No tools for image files
104+
}
105+
self.itemToFileMap[row].append(file_info)
98106
self.update_item_icon(current_item, self.itemToFileMap[row])
99107

100108
def remove_specific_file_from_selected_item(self, file_info, row):
@@ -128,17 +136,19 @@ def get_attachments_for_selected_item(self):
128136
file_name = os.path.basename(file_path)
129137
file_id = file_info.get('file_id', None)
130138
tools = file_info.get('tools', [])
139+
attachment_type = file_info.get('attachment_type', 'document_file')
131140

132141
# Create a structured entry for the attachments list including file_path
133142
attachments.append({
134143
"file_name": file_name,
135144
"file_id": file_id,
136145
"file_path": file_path, # Include the full file path for upload or further processing
146+
"attachment_type": attachment_type,
137147
"tools": tools
138148
})
139149
return attachments
140150
return []
141-
151+
142152
def set_attachments_for_selected_item(self, attachments):
143153
"""Set the attachments for the currently selected item."""
144154
current_item = self.currentItem()
@@ -151,10 +161,11 @@ def set_attachments_for_selected_item(self, attachments):
151161

152162
def load_threads_with_attachments(self, threads):
153163
"""Load threads into the list widget, adding icons for attached files only, based on attachments info."""
164+
self.clear_files() # Clear itemToFileMap before loading new threads
154165
for thread in threads:
155166
item = QListWidgetItem(thread['thread_name'])
156167
self.addItem(item)
157-
thread_tooltip_text = "You can add/remove files by right-clicking this item. NOTE: ChatAssistant will not be able to access the files."
168+
thread_tooltip_text = "You can add/remove files by right-clicking this item."
158169
item.setToolTip(thread_tooltip_text)
159170

160171
# Get attachments from the thread data
@@ -181,6 +192,9 @@ def keyPressEvent(self, event):
181192
row = self.row(current_item)
182193
item_text = current_item.text()
183194
self.takeItem(row)
195+
# delete the attachments for the deleted item
196+
if row in self.itemToFileMap:
197+
del self.itemToFileMap[row]
184198
self.itemDeleted.emit(item_text)
185199
else:
186200
super().keyPressEvent(event)
@@ -474,7 +488,7 @@ def create_conversation_thread(self, threads_client : ConversationThreadClient,
474488
logger.debug(f"Total time taken to create a new conversation thread: {end_time - start_time} seconds")
475489
new_item = QListWidgetItem(unique_thread_name)
476490
self.threadList.addItem(new_item)
477-
thread_tooltip_text = f"You can add/remove files by right-clicking this item. NOTE: ChatAssistant will not be able to access the files."
491+
thread_tooltip_text = f"You can add/remove files by right-clicking this item."
478492
new_item.setToolTip(thread_tooltip_text)
479493

480494
if not is_scheduled_task:
@@ -528,12 +542,32 @@ def _select_thread(self, unique_thread_name):
528542

529543
def on_selected_thread_delete(self, thread_name):
530544
try:
545+
# Get current scroll position and selected row
546+
current_scroll_position = self.threadList.verticalScrollBar().value()
547+
current_row = self.threadList.currentRow()
548+
531549
# Remove the selected thread from the assistant manager
532550
threads_client = ConversationThreadClient.get_instance(self._ai_client_type)
533551
threads_client.delete_conversation_thread(thread_name)
552+
threads_client.save_conversation_threads()
553+
554+
# Clear and reload the thread list
555+
self.threadList.clear()
556+
threads = threads_client.get_conversation_threads()
557+
self.threadList.load_threads_with_attachments(threads)
558+
559+
# Restore the scroll position
560+
self.threadList.verticalScrollBar().setValue(current_scroll_position)
561+
562+
# Restore the selected row
563+
if current_row >= self.threadList.count():
564+
current_row = self.threadList.count() - 1
565+
self.threadList.setCurrentRow(current_row)
566+
534567
# Clear the selection in the sidebar
535568
self.threadList.clearSelection()
569+
536570
# Clear the conversation area
537571
self.main_window.conversation_view.conversationView.clear()
538572
except Exception as e:
539-
QMessageBox.warning(self, "Error", f"An error occurred while deleting the thread: {e}")
573+
QMessageBox.warning(self, "Error", f"An error occurred while deleting the thread: {e}")

0 commit comments

Comments
 (0)