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-
157from azure .ai .assistant .management .assistant_config_manager import AssistantConfigManager
168from azure .ai .assistant .management .message import ConversationMessage
179from 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
2021class 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
134215class 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 )
0 commit comments