abstractassistant 0.2.7__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,7 +31,7 @@ try:
31
31
  QTextEdit, QPushButton, QComboBox, QLabel, QFrame,
32
32
  QFileDialog, QMessageBox
33
33
  )
34
- from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QThread, pyqtSlot, QRect
34
+ from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QThread, pyqtSlot, QRect, QMetaObject
35
35
  from PyQt5.QtGui import QFont, QPalette, QColor, QPainter, QPen, QBrush
36
36
  from PyQt5.QtCore import QPoint
37
37
  QT_AVAILABLE = "PyQt5"
@@ -42,7 +42,7 @@ except ImportError:
42
42
  QTextEdit, QPushButton, QComboBox, QLabel, QFrame,
43
43
  QFileDialog, QMessageBox
44
44
  )
45
- from PySide2.QtCore import Qt, QTimer, Signal as pyqtSignal, QThread, Slot as pyqtSlot
45
+ from PySide2.QtCore import Qt, QTimer, Signal as pyqtSignal, QThread, Slot as pyqtSlot, QMetaObject
46
46
  from PySide2.QtGui import QFont, QPalette, QColor, QPainter, QPen, QBrush
47
47
  from PySide2.QtCore import QPoint
48
48
  QT_AVAILABLE = "PySide2"
@@ -145,7 +145,7 @@ class TTSToggle(QPushButton):
145
145
  border-radius: 12px;
146
146
  font-size: 12px;
147
147
  color: {text_color};
148
- font-family: "SF Pro Text", "Helvetica Neue", system-ui, sans-serif;
148
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
149
149
  font-weight: 600;
150
150
  }}
151
151
  QPushButton:hover {{
@@ -212,7 +212,7 @@ class FullVoiceToggle(QPushButton):
212
212
  border-radius: 12px;
213
213
  font-size: 12px;
214
214
  color: {text_color};
215
- font-family: "SF Pro Text", "Helvetica Neue", system-ui, sans-serif;
215
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
216
216
  font-weight: 600;
217
217
  }}
218
218
  QPushButton:hover {{
@@ -257,7 +257,8 @@ class LLMWorker(QThread):
257
257
  self.response_ready.emit(response_text)
258
258
 
259
259
  except Exception as e:
260
- print(f"❌ LLM Error: {e}")
260
+ if self.debug:
261
+ print(f"❌ LLM Error: {e}")
261
262
  import traceback
262
263
  traceback.print_exc()
263
264
  self.error_occurred.emit(str(e))
@@ -331,6 +332,24 @@ class QtChatBubble(QWidget):
331
332
  if self.debug:
332
333
  print("✅ QtChatBubble initialized")
333
334
 
335
+ def set_response_callback(self, callback):
336
+ """Set response callback."""
337
+ self.response_callback = callback
338
+
339
+ def set_error_callback(self, callback):
340
+ """Set error callback."""
341
+ self.error_callback = callback
342
+
343
+ def set_status_callback(self, callback):
344
+ """Set status callback."""
345
+ self.status_callback = callback
346
+ if self.debug:
347
+ print("✅ Status callback set in QtChatBubble")
348
+
349
+ def set_app_quit_callback(self, callback):
350
+ """Set app quit callback."""
351
+ self.app_quit_callback = callback
352
+
334
353
  def setup_ui(self):
335
354
  """Set up the modern user interface with SOTA UX practices."""
336
355
  self.setWindowTitle("AbstractAssistant")
@@ -341,7 +360,10 @@ class QtChatBubble(QWidget):
341
360
  )
342
361
 
343
362
  # Set optimal size for modern chat interface - much wider to nearly touch screen edge
344
- self.setFixedSize(630, 196) # Increased width from 540 to 700 for better text display
363
+ # Initial size - will be adjusted dynamically based on file attachments
364
+ self.base_width = 630
365
+ self.base_height = 196
366
+ self.setFixedSize(self.base_width, self.base_height)
345
367
  self.position_near_tray()
346
368
 
347
369
  # Main layout with minimal spacing
@@ -366,7 +388,7 @@ class QtChatBubble(QWidget):
366
388
  font-size: 14px;
367
389
  font-weight: 600;
368
390
  color: rgba(255, 255, 255, 0.9);
369
- font-family: "SF Pro Text", "Helvetica Neue", system-ui, sans-serif;
391
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
370
392
  }
371
393
  QPushButton:hover {
372
394
  background: rgba(255, 60, 60, 0.8);
@@ -398,7 +420,7 @@ class QtChatBubble(QWidget):
398
420
  border-radius: 11px;
399
421
  font-size: 10px;
400
422
  color: rgba(255, 255, 255, 0.7);
401
- font-family: "SF Pro Text", "Helvetica Neue", system-ui, sans-serif;
423
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
402
424
  padding: 0 10px;
403
425
  }
404
426
  QPushButton:hover {
@@ -421,10 +443,7 @@ class QtChatBubble(QWidget):
421
443
  self.full_voice_toggle.toggled.connect(self.on_full_voice_toggled)
422
444
  header_layout.addWidget(self.full_voice_toggle)
423
445
 
424
- # Add prominent voice control panel when TTS is active
425
- self.voice_control_panel = self.create_voice_control_panel()
426
- header_layout.addWidget(self.voice_control_panel)
427
- self.voice_control_panel.hide() # Hidden initially
446
+ # Voice control panel removed - not needed
428
447
 
429
448
  header_layout.addStretch()
430
449
 
@@ -440,7 +459,7 @@ class QtChatBubble(QWidget):
440
459
  font-size: 10px;
441
460
  font-weight: 600;
442
461
  color: #ffffff;
443
- font-family: "SF Pro Text", "Helvetica Neue", system-ui, sans-serif;
462
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
444
463
  }
445
464
  """)
446
465
  header_layout.addWidget(self.status_label)
@@ -546,8 +565,8 @@ class QtChatBubble(QWidget):
546
565
  }
547
566
  """)
548
567
  self.attached_files_layout = QHBoxLayout(self.attached_files_container)
549
- self.attached_files_layout.setContentsMargins(4, 4, 4, 4)
550
- self.attached_files_layout.setSpacing(4)
568
+ self.attached_files_layout.setContentsMargins(2, 2, 2, 2)
569
+ self.attached_files_layout.setSpacing(2)
551
570
  self.attached_files_container.hide() # Initially hidden
552
571
  input_layout.addWidget(self.attached_files_container)
553
572
  layout.addWidget(self.input_container)
@@ -570,7 +589,7 @@ class QtChatBubble(QWidget):
570
589
  padding: 0 8px;
571
590
  font-size: 11px;
572
591
  color: rgba(255, 255, 255, 0.9);
573
- font-family: "SF Pro Text", "Helvetica Neue", system-ui, sans-serif;
592
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
574
593
  }
575
594
  QComboBox:hover {
576
595
  background: rgba(255, 255, 255, 0.12);
@@ -600,7 +619,7 @@ class QtChatBubble(QWidget):
600
619
  padding: 0 8px;
601
620
  font-size: 11px;
602
621
  color: rgba(255, 255, 255, 0.9);
603
- font-family: "SF Pro Text", "Helvetica Neue", system-ui, sans-serif;
622
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
604
623
  }
605
624
  QComboBox:hover {
606
625
  background: rgba(255, 255, 255, 0.12);
@@ -631,7 +650,7 @@ class QtChatBubble(QWidget):
631
650
  border-radius: 14px;
632
651
  font-size: 12px;
633
652
  color: rgba(255, 255, 255, 0.6);
634
- font-family: "SF Pro Text", "Helvetica Neue", system-ui, sans-serif;
653
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
635
654
  }
636
655
  """)
637
656
  controls_layout.addWidget(self.token_label)
@@ -672,7 +691,7 @@ class QtChatBubble(QWidget):
672
691
  font-size: 14px;
673
692
  font-weight: 400;
674
693
  color: #ffffff;
675
- font-family: "SF Pro Text", "Helvetica Neue", system-ui, sans-serif;
694
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
676
695
  selection-background-color: #0066cc;
677
696
  line-height: 1.4;
678
697
  }
@@ -695,7 +714,7 @@ class QtChatBubble(QWidget):
695
714
  font-size: 11px;
696
715
  font-weight: 500;
697
716
  color: #ffffff;
698
- font-family: "SF Pro Text", "Helvetica Neue", system-ui, sans-serif;
717
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
699
718
  }
700
719
 
701
720
  QPushButton:hover {
@@ -723,7 +742,7 @@ class QtChatBubble(QWidget):
723
742
  font-size: 12px;
724
743
  font-weight: 400;
725
744
  color: #ffffff;
726
- font-family: "SF Pro Text", "Helvetica Neue", system-ui, sans-serif;
745
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
727
746
  letter-spacing: 0.01em;
728
747
  }
729
748
 
@@ -753,7 +772,7 @@ class QtChatBubble(QWidget):
753
772
  selection-background-color: #4299e1;
754
773
  color: #e2e8f0;
755
774
  padding: 4px;
756
- font-family: "SF Pro Text", "Helvetica Neue", "Segoe UI", Roboto, sans-serif;
775
+ font-family: "Helvetica Neue", "Helvetica", "Segoe UI", Arial, sans-serif;
757
776
  }
758
777
 
759
778
  QComboBox QAbstractItemView::item {
@@ -785,7 +804,7 @@ class QtChatBubble(QWidget):
785
804
  color: rgba(255, 255, 255, 0.8);
786
805
  font-size: 12px;
787
806
  font-weight: 500;
788
- font-family: "SF Pro Text", "Helvetica Neue", 'Segoe UI', Roboto, sans-serif;
807
+ font-family: "Helvetica Neue", "Helvetica", 'Segoe UI', Arial, sans-serif;
789
808
  letter-spacing: 0.3px;
790
809
  }
791
810
 
@@ -800,7 +819,7 @@ class QtChatBubble(QWidget):
800
819
  text-transform: uppercase;
801
820
  letter-spacing: 0.5px;
802
821
  color: #a6e3a1;
803
- font-family: "SF Pro Text", "Helvetica Neue", "Segoe UI", Roboto, sans-serif;
822
+ font-family: "Helvetica Neue", "Helvetica", "Segoe UI", Arial, sans-serif;
804
823
  }
805
824
 
806
825
  QLabel#status_generating {
@@ -813,7 +832,7 @@ class QtChatBubble(QWidget):
813
832
  text-transform: uppercase;
814
833
  letter-spacing: 0.5px;
815
834
  color: #fab387;
816
- font-family: "SF Pro Text", "Helvetica Neue", "Segoe UI", Roboto, sans-serif;
835
+ font-family: "Helvetica Neue", "Helvetica", "Segoe UI", Arial, sans-serif;
817
836
  }
818
837
 
819
838
  QLabel#status_error {
@@ -826,7 +845,7 @@ class QtChatBubble(QWidget):
826
845
  text-transform: uppercase;
827
846
  letter-spacing: 0.5px;
828
847
  color: #f38ba8;
829
- font-family: "SF Pro Text", "Helvetica Neue", "Segoe UI", Roboto, sans-serif;
848
+ font-family: "Helvetica Neue", "Helvetica", "Segoe UI", Arial, sans-serif;
830
849
  }
831
850
 
832
851
  QLabel#token_label {
@@ -834,7 +853,7 @@ class QtChatBubble(QWidget):
834
853
  border: 1px solid #4a5568;
835
854
  border-radius: 8px;
836
855
  padding: 10px 12px;
837
- font-family: "SF Pro Text", "Helvetica Neue", "Segoe UI", Roboto, sans-serif;
856
+ font-family: "Helvetica Neue", "Helvetica", "Segoe UI", Arial, sans-serif;
838
857
  font-size: 11px;
839
858
  font-weight: 500;
840
859
  color: #cbd5e0;
@@ -868,7 +887,8 @@ class QtChatBubble(QWidget):
868
887
  self.move(x, y)
869
888
 
870
889
  if self.debug:
871
- print(f"Positioned bubble at ({x}, {y})")
890
+ if self.debug:
891
+ print(f"Positioned bubble at ({x}, {y})")
872
892
 
873
893
  def load_providers(self):
874
894
  """Load available providers using ProviderManager."""
@@ -881,13 +901,15 @@ class QtChatBubble(QWidget):
881
901
  available_providers = self.provider_manager.get_available_providers(exclude_mock=True)
882
902
 
883
903
  if self.debug:
884
- print(f"🔍 ProviderManager found {len(available_providers)} available providers")
904
+ if self.debug:
905
+ print(f"🔍 ProviderManager found {len(available_providers)} available providers")
885
906
 
886
907
  # Add providers to dropdown
887
908
  for display_name, provider_key in available_providers:
888
909
  self.provider_combo.addItem(display_name, provider_key)
889
910
  if self.debug:
890
- print(f" ✅ Added: {display_name} ({provider_key})")
911
+ if self.debug:
912
+ print(f" ✅ Added: {display_name} ({provider_key})")
891
913
 
892
914
  # Set preferred provider
893
915
  preferred = self.provider_manager.get_preferred_provider(available_providers, 'lmstudio')
@@ -924,14 +946,16 @@ class QtChatBubble(QWidget):
924
946
  )
925
947
 
926
948
  if self.debug:
927
- print(f"🔍 Final selected provider: {self.current_provider}")
949
+ if self.debug:
950
+ print(f"🔍 Final selected provider: {self.current_provider}")
928
951
 
929
952
  # Load models for current provider
930
953
  self.update_models()
931
954
 
932
955
  except Exception as e:
933
956
  if self.debug:
934
- print(f"❌ Error loading providers: {e}")
957
+ if self.debug:
958
+ print(f"❌ Error loading providers: {e}")
935
959
  import traceback
936
960
  traceback.print_exc()
937
961
 
@@ -940,7 +964,8 @@ class QtChatBubble(QWidget):
940
964
  self.provider_combo.addItem("LMStudio (Local)", "lmstudio")
941
965
  self.current_provider = "lmstudio"
942
966
  if self.debug:
943
- print("🔄 Using fallback provider list")
967
+ if self.debug:
968
+ print("🔄 Using fallback provider list")
944
969
 
945
970
  def update_models(self):
946
971
  """Update model dropdown using ProviderManager."""
@@ -952,7 +977,8 @@ class QtChatBubble(QWidget):
952
977
  models = self.provider_manager.get_models_for_provider(self.current_provider)
953
978
 
954
979
  if self.debug:
955
- print(f"📋 ProviderManager loaded {len(models)} models for {self.current_provider}")
980
+ if self.debug:
981
+ print(f"📋 ProviderManager loaded {len(models)} models for {self.current_provider}")
956
982
 
957
983
  # Add models to dropdown with display names
958
984
  for model in models:
@@ -994,13 +1020,15 @@ class QtChatBubble(QWidget):
994
1020
  self.model_combo.setCurrentIndex(0)
995
1021
 
996
1022
  if self.debug:
997
- print(f"✅ Final selected model: {self.current_model}")
1023
+ if self.debug:
1024
+ print(f"✅ Final selected model: {self.current_model}")
998
1025
 
999
1026
  self.update_token_limits()
1000
1027
 
1001
1028
  except Exception as e:
1002
1029
  if self.debug:
1003
- print(f"❌ Error updating models: {e}")
1030
+ if self.debug:
1031
+ print(f"❌ Error updating models: {e}")
1004
1032
  import traceback
1005
1033
  traceback.print_exc()
1006
1034
 
@@ -1010,7 +1038,8 @@ class QtChatBubble(QWidget):
1010
1038
  self.current_model = "default-model"
1011
1039
  self.model_combo.setCurrentIndex(0)
1012
1040
  if self.debug:
1013
- print(f"🔄 Using final fallback model: {self.current_model}")
1041
+ if self.debug:
1042
+ print(f"🔄 Using final fallback model: {self.current_model}")
1014
1043
 
1015
1044
  def update_token_limits(self):
1016
1045
  """Update token limits using AbstractCore's built-in detection."""
@@ -1060,7 +1089,8 @@ class QtChatBubble(QWidget):
1060
1089
  self.update_models()
1061
1090
 
1062
1091
  if self.debug:
1063
- print(f"Provider changed to: {self.current_provider}")
1092
+ if self.debug:
1093
+ print(f"Provider changed to: {self.current_provider}")
1064
1094
 
1065
1095
  def on_model_changed(self, model_name):
1066
1096
  """Handle model change."""
@@ -1095,7 +1125,8 @@ class QtChatBubble(QWidget):
1095
1125
  if file_path not in self.attached_files:
1096
1126
  self.attached_files.append(file_path)
1097
1127
  if self.debug:
1098
- print(f"📎 Attached file: {file_path}")
1128
+ if self.debug:
1129
+ print(f"📎 Attached file: {file_path}")
1099
1130
 
1100
1131
  self.update_attached_files_display()
1101
1132
 
@@ -1109,6 +1140,7 @@ class QtChatBubble(QWidget):
1109
1140
 
1110
1141
  if not self.attached_files:
1111
1142
  self.attached_files_container.hide()
1143
+ self._adjust_window_size_for_attachments()
1112
1144
  return
1113
1145
 
1114
1146
  # Show container and add file chips
@@ -1124,14 +1156,14 @@ class QtChatBubble(QWidget):
1124
1156
  QFrame {
1125
1157
  background: rgba(0, 102, 204, 0.2);
1126
1158
  border: 1px solid rgba(0, 102, 204, 0.4);
1127
- border-radius: 10px;
1128
- padding: 2px 8px;
1159
+ border-radius: 6px;
1160
+ padding: 1px 4px;
1129
1161
  }
1130
1162
  """)
1131
1163
 
1132
1164
  chip_layout = QHBoxLayout(file_chip)
1133
- chip_layout.setContentsMargins(4, 2, 4, 2)
1134
- chip_layout.setSpacing(4)
1165
+ chip_layout.setContentsMargins(2, 1, 2, 1)
1166
+ chip_layout.setSpacing(2)
1135
1167
 
1136
1168
  # File icon based on type
1137
1169
  ext = os.path.splitext(file_name)[1].lower()
@@ -1151,18 +1183,18 @@ class QtChatBubble(QWidget):
1151
1183
  icon = "📎"
1152
1184
 
1153
1185
  file_label = QLabel(f"{icon} {file_name[:20]}{'...' if len(file_name) > 20 else ''}")
1154
- file_label.setStyleSheet("background: transparent; border: none; color: rgba(255, 255, 255, 0.9); font-size: 10px;")
1186
+ file_label.setStyleSheet("background: transparent; border: none; color: rgba(255, 255, 255, 0.9); font-size: 8px;")
1155
1187
  chip_layout.addWidget(file_label)
1156
1188
 
1157
1189
  # Remove button
1158
1190
  remove_btn = QPushButton("✕")
1159
- remove_btn.setFixedSize(16, 16)
1191
+ remove_btn.setFixedSize(12, 12)
1160
1192
  remove_btn.setStyleSheet("""
1161
1193
  QPushButton {
1162
1194
  background: transparent;
1163
1195
  border: none;
1164
1196
  color: rgba(255, 255, 255, 0.6);
1165
- font-size: 10px;
1197
+ font-size: 8px;
1166
1198
  padding: 0px;
1167
1199
  }
1168
1200
  QPushButton:hover {
@@ -1175,13 +1207,38 @@ class QtChatBubble(QWidget):
1175
1207
  self.attached_files_layout.addWidget(file_chip)
1176
1208
 
1177
1209
  self.attached_files_layout.addStretch()
1210
+
1211
+ # Adjust window size to accommodate file attachments
1212
+ self._adjust_window_size_for_attachments()
1213
+
1214
+ def _adjust_window_size_for_attachments(self):
1215
+ """Dynamically adjust window size based on file attachments presence."""
1216
+ attachment_height = 28 # Height needed for file attachment container (reduced for compact chips)
1217
+
1218
+ if self.attached_files and self.attached_files_container.isVisible():
1219
+ # Files are attached - expand window
1220
+ new_height = self.base_height + attachment_height
1221
+ if self.debug:
1222
+ print(f"📏 Expanding window for attachments: {self.base_height} -> {new_height}")
1223
+ else:
1224
+ # No files attached - use base size
1225
+ new_height = self.base_height
1226
+ if self.debug:
1227
+ print(f"📏 Contracting window (no attachments): -> {new_height}")
1228
+
1229
+ # Apply new size
1230
+ self.setFixedSize(self.base_width, new_height)
1231
+
1232
+ # Reposition to maintain alignment with system tray
1233
+ self.position_near_tray()
1178
1234
 
1179
1235
  def remove_attached_file(self, file_path):
1180
1236
  """Remove a file from the attached files list."""
1181
1237
  if file_path in self.attached_files:
1182
1238
  self.attached_files.remove(file_path)
1183
1239
  if self.debug:
1184
- print(f"🗑️ Removed attached file: {file_path}")
1240
+ if self.debug:
1241
+ print(f"🗑️ Removed attached file: {file_path}")
1185
1242
  self.update_attached_files_display()
1186
1243
 
1187
1244
  def send_message(self):
@@ -1228,7 +1285,8 @@ class QtChatBubble(QWidget):
1228
1285
  if self.status_callback:
1229
1286
  self.status_callback("generating")
1230
1287
 
1231
- print("🔄 QtChatBubble: UI updated, creating worker thread...")
1288
+ if self.debug:
1289
+ print("🔄 QtChatBubble: UI updated, creating worker thread...")
1232
1290
 
1233
1291
  # 5. Start worker thread to send request with optional media files
1234
1292
  self.worker = LLMWorker(
@@ -1241,17 +1299,20 @@ class QtChatBubble(QWidget):
1241
1299
  self.worker.response_ready.connect(self.on_response_ready)
1242
1300
  self.worker.error_occurred.connect(self.on_error_occurred)
1243
1301
 
1244
- print("🔄 QtChatBubble: Starting worker thread...")
1302
+ if self.debug:
1303
+ print("🔄 QtChatBubble: Starting worker thread...")
1245
1304
  self.worker.start()
1246
1305
 
1247
- print("🔄 QtChatBubble: Worker thread started, hiding bubble...")
1306
+ if self.debug:
1307
+ print("🔄 QtChatBubble: Worker thread started, hiding bubble...")
1248
1308
  # Hide bubble after sending (like the original design)
1249
1309
  QTimer.singleShot(500, self.hide)
1250
1310
 
1251
1311
  @pyqtSlot(str)
1252
1312
  def on_response_ready(self, response):
1253
1313
  """Handle LLM response."""
1254
- print(f"✅ QtChatBubble: on_response_ready called with response: {response[:100]}...")
1314
+ if self.debug:
1315
+ print(f"✅ QtChatBubble: on_response_ready called with response: {response[:100]}...")
1255
1316
 
1256
1317
  self.send_button.setEnabled(True)
1257
1318
  self.send_button.setText("→")
@@ -1271,6 +1332,10 @@ class QtChatBubble(QWidget):
1271
1332
  }
1272
1333
  """)
1273
1334
 
1335
+ # Notify main app about status change (for icon animation)
1336
+ if self.status_callback:
1337
+ self.status_callback("ready")
1338
+
1274
1339
  # Get updated message history from AbstractCore session
1275
1340
  self._update_message_history_from_session()
1276
1341
 
@@ -1280,72 +1345,113 @@ class QtChatBubble(QWidget):
1280
1345
  # Handle TTS if enabled (AbstractVoice integration)
1281
1346
  if self.tts_enabled and self.voice_manager and self.voice_manager.is_available():
1282
1347
  if self.debug:
1283
- print("🔊 TTS enabled, speaking response...")
1348
+ if self.debug:
1349
+ print("🔊 TTS enabled, speaking response...")
1284
1350
 
1285
1351
  # Don't show toast when TTS is enabled
1286
1352
  try:
1287
1353
  # Clean response for voice synthesis
1288
1354
  clean_response = self._clean_response_for_voice(response)
1289
1355
 
1356
+ # Set up callbacks to detect when speech actually starts/ends
1357
+ # Use QMetaObject.invokeMethod to ensure callbacks run on main thread
1358
+ def on_speech_start():
1359
+ if self.debug:
1360
+ print("🔊 QtChatBubble: Speech actually started (background thread)")
1361
+ # Schedule status update on main thread
1362
+ QMetaObject.invokeMethod(self, "_on_speech_started_main_thread", Qt.QueuedConnection)
1363
+
1364
+ def on_speech_end():
1365
+ if self.debug:
1366
+ print("🔊 QtChatBubble: Speech ended (background thread)")
1367
+ # Schedule completion handling on main thread
1368
+ QMetaObject.invokeMethod(self, "_on_speech_ended_main_thread", Qt.QueuedConnection)
1369
+
1370
+ # Set the callbacks on the voice manager
1371
+ self.voice_manager.on_speech_start = on_speech_start
1372
+ self.voice_manager.on_speech_end = on_speech_end
1373
+
1290
1374
  # Speak the cleaned response using AbstractVoice-compatible interface
1375
+ # Note: We don't set "speaking" status here anymore - we wait for the callback
1291
1376
  self.voice_manager.speak(clean_response)
1292
1377
 
1293
1378
  # Update toggle state to 'speaking'
1294
1379
  self._update_tts_toggle_state()
1295
-
1296
- # Wait for speech to complete in a separate thread
1297
- def wait_for_speech():
1298
- while self.voice_manager.is_speaking():
1299
- time.sleep(0.1)
1300
- # Update toggle state when speech completes
1301
- self._update_tts_toggle_state()
1302
- if self.debug:
1303
- print("🔊 TTS completed")
1304
1380
 
1305
- speech_thread = threading.Thread(target=wait_for_speech, daemon=True)
1306
- speech_thread.start()
1381
+ # Store response for callback when TTS completes
1382
+ self._pending_response = response
1307
1383
 
1308
1384
  # Show chat history after TTS starts (small delay) - only if voice mode is OFF
1309
1385
  QTimer.singleShot(800, self._show_history_if_voice_mode_off)
1310
1386
 
1311
1387
  except Exception as e:
1312
1388
  if self.debug:
1313
- print(f"❌ TTS error: {e}")
1389
+ if self.debug:
1390
+ print(f"❌ TTS error: {e}")
1314
1391
  # Show chat history as fallback - only if voice mode is OFF
1315
1392
  QTimer.singleShot(100, self._show_history_if_voice_mode_off)
1316
1393
  else:
1317
1394
  # Show chat history instead of toast when TTS is disabled - only if voice mode is OFF
1318
1395
  self._show_history_if_voice_mode_off()
1319
1396
 
1320
- # Also call response callback if set
1321
- if self.response_callback:
1322
- print(f"🔄 QtChatBubble: Response callback exists, calling it...")
1323
- self.response_callback(response)
1397
+ # Handle status transitions based on TTS mode
1398
+ tts_will_handle = self.tts_enabled and self.voice_manager and self.voice_manager.is_available()
1399
+ if self.debug:
1400
+ print(f"🔍 QtChatBubble: TTS decision - tts_enabled={self.tts_enabled}, voice_manager={self.voice_manager is not None}, is_available={self.voice_manager.is_available() if self.voice_manager else False}")
1401
+ print(f"🔍 QtChatBubble: TTS will handle callbacks: {tts_will_handle}")
1402
+
1403
+ if not tts_will_handle:
1404
+ # Non-TTS path: Go directly to ready mode
1405
+ if self.debug:
1406
+ print(f"🔄 QtChatBubble: Non-TTS path - going to ready mode immediately")
1407
+ if self.response_callback:
1408
+ self.response_callback(response)
1409
+ if self.status_callback:
1410
+ self.status_callback("ready")
1411
+ else:
1412
+ # TTS path: Stay in thinking mode until audio actually starts
1413
+ if self.debug:
1414
+ print(f"🔊 QtChatBubble: TTS path - staying in thinking mode until audio starts")
1415
+ print(f"🔊 QtChatBubble: v0.5.1 callbacks will handle status transitions")
1416
+ # DON'T call response_callback or set "ready" status here!
1417
+ # The v0.5.1 callbacks will handle everything
1324
1418
 
1325
1419
  def on_tts_toggled(self, enabled: bool):
1326
1420
  """Handle TTS toggle state change."""
1327
1421
  self.tts_enabled = enabled
1328
1422
  if self.debug:
1329
- print(f"🔊 TTS {'enabled' if enabled else 'disabled'}")
1423
+ if self.debug:
1424
+ print(f"🔊 TTS {'enabled' if enabled else 'disabled'}")
1330
1425
 
1331
1426
  # Stop any current speech when disabling
1332
1427
  if not enabled and self.voice_manager:
1333
1428
  try:
1334
1429
  self.voice_manager.stop()
1335
1430
  self._update_tts_toggle_state()
1431
+
1432
+ # Manually trigger status update to "ready" since v0.5.1 callback won't fire
1433
+ # when we manually stop the audio
1434
+ if self.status_callback:
1435
+ if self.debug:
1436
+ print("🔊 QtChatBubble: TTS disabled, setting ready status")
1437
+ self.status_callback("ready")
1438
+
1336
1439
  except Exception as e:
1337
1440
  if self.debug:
1338
- print(f"❌ Error stopping TTS: {e}")
1441
+ if self.debug:
1442
+ print(f"❌ Error stopping TTS: {e}")
1339
1443
 
1340
- # Update LLM session with TTS-appropriate system prompt
1444
+ # Update LLM session mode while preserving chat history
1341
1445
  if self.llm_manager:
1342
1446
  try:
1343
- self.llm_manager.create_new_session(tts_mode=enabled)
1447
+ self.llm_manager.update_session_mode(tts_mode=enabled)
1344
1448
  if self.debug:
1345
- print(f"🔄 LLM session updated for {'TTS' if enabled else 'normal'} mode")
1449
+ if self.debug:
1450
+ print(f"🔄 LLM session mode updated for {'TTS' if enabled else 'normal'} mode (history preserved)")
1346
1451
  except Exception as e:
1347
1452
  if self.debug:
1348
- print(f"❌ Error updating LLM session: {e}")
1453
+ if self.debug:
1454
+ print(f"❌ Error updating LLM session: {e}")
1349
1455
 
1350
1456
  def on_tts_single_click(self):
1351
1457
  """Handle single click on TTS toggle - pause/resume functionality."""
@@ -1359,27 +1465,33 @@ class QtChatBubble(QWidget):
1359
1465
  # Pause the speech - may need multiple attempts if audio stream just started
1360
1466
  success = self._attempt_pause_with_retry()
1361
1467
  if success and self.debug:
1362
- print("🔊 TTS paused via single click")
1468
+ if self.debug:
1469
+ print("🔊 TTS paused via single click")
1363
1470
  elif self.debug:
1364
- print("🔊 TTS pause failed - audio stream may not be ready yet")
1471
+ if self.debug:
1472
+ print("🔊 TTS pause failed - audio stream may not be ready yet")
1365
1473
  elif current_state == 'paused':
1366
1474
  # Resume the speech
1367
1475
  success = self.voice_manager.resume()
1368
1476
  if success and self.debug:
1369
- print("🔊 TTS resumed via single click")
1477
+ if self.debug:
1478
+ print("🔊 TTS resumed via single click")
1370
1479
  elif self.debug:
1371
- print("🔊 TTS resume failed")
1480
+ if self.debug:
1481
+ print("🔊 TTS resume failed")
1372
1482
  else:
1373
1483
  # If idle, do nothing or could show a message
1374
1484
  if self.debug:
1375
- print("🔊 TTS single click - no active speech to pause/resume")
1485
+ if self.debug:
1486
+ print("🔊 TTS single click - no active speech to pause/resume")
1376
1487
 
1377
1488
  # Update visual state
1378
1489
  self._update_tts_toggle_state()
1379
1490
 
1380
1491
  except Exception as e:
1381
1492
  if self.debug:
1382
- print(f"❌ Error handling TTS single click: {e}")
1493
+ if self.debug:
1494
+ print(f"❌ Error handling TTS single click: {e}")
1383
1495
 
1384
1496
  def _attempt_pause_with_retry(self, max_attempts=5):
1385
1497
  """Attempt to pause with retry logic for timing issues.
@@ -1402,7 +1514,8 @@ class QtChatBubble(QWidget):
1402
1514
  return True
1403
1515
 
1404
1516
  if self.debug:
1405
- print(f"🔊 Pause attempt {attempt + 1}/{max_attempts} failed, retrying...")
1517
+ if self.debug:
1518
+ print(f"🔊 Pause attempt {attempt + 1}/{max_attempts} failed, retrying...")
1406
1519
 
1407
1520
  # Short delay before retry
1408
1521
  time.sleep(0.1)
@@ -1412,7 +1525,8 @@ class QtChatBubble(QWidget):
1412
1525
  def on_tts_double_click(self):
1413
1526
  """Handle double click on TTS toggle - stop TTS and open chat bubble."""
1414
1527
  if self.debug:
1415
- print("🔊 TTS double click - stopping speech and showing chat")
1528
+ if self.debug:
1529
+ print("🔊 TTS double click - stopping speech and showing chat")
1416
1530
 
1417
1531
  # Prevent double-free errors by checking if objects are still valid
1418
1532
  try:
@@ -1426,10 +1540,18 @@ class QtChatBubble(QWidget):
1426
1540
  # Safely update TTS toggle state
1427
1541
  if hasattr(self, '_update_tts_toggle_state'):
1428
1542
  self._update_tts_toggle_state()
1543
+
1544
+ # Manually trigger status update to "ready" since v0.5.1 callback won't fire
1545
+ # when we manually stop the audio
1546
+ if hasattr(self, 'status_callback') and self.status_callback:
1547
+ if self.debug:
1548
+ print("🔊 QtChatBubble: Manually stopped TTS, setting ready status")
1549
+ self.status_callback("ready")
1429
1550
 
1430
1551
  except Exception as e:
1431
1552
  if self.debug:
1432
- print(f"❌ Error stopping TTS on double click: {e}")
1553
+ if self.debug:
1554
+ print(f"❌ Error stopping TTS on double click: {e}")
1433
1555
 
1434
1556
  # Show the chat bubble with safety checks
1435
1557
  if hasattr(self, 'show') and not self.isVisible():
@@ -1463,12 +1585,14 @@ class QtChatBubble(QWidget):
1463
1585
  try:
1464
1586
  # Ensure voice manager is available
1465
1587
  if not self.voice_manager or not self.voice_manager.is_available():
1466
- print("❌ Voice manager not available for Full Voice Mode")
1588
+ if self.debug:
1589
+ print("❌ Voice manager not available for Full Voice Mode")
1467
1590
  self.full_voice_toggle.set_enabled(False)
1468
1591
  return
1469
1592
 
1470
1593
  if self.debug:
1471
- print("🚀 Starting Full Voice Mode...")
1594
+ if self.debug:
1595
+ print("🚀 Starting Full Voice Mode...")
1472
1596
 
1473
1597
  # Hide text input UI
1474
1598
  self.hide_text_ui()
@@ -1480,9 +1604,9 @@ class QtChatBubble(QWidget):
1480
1604
  # Set up voice mode based on CLI parameter
1481
1605
  self.voice_manager.set_voice_mode(self.listening_mode)
1482
1606
 
1483
- # Update LLM session for voice-optimized responses
1607
+ # Update LLM session mode for voice-optimized responses (preserve history)
1484
1608
  if self.llm_manager:
1485
- self.llm_manager.create_new_session(tts_mode=True)
1609
+ self.llm_manager.update_session_mode(tts_mode=True)
1486
1610
 
1487
1611
  # Start listening
1488
1612
  self.voice_manager.listen(
@@ -1497,11 +1621,13 @@ class QtChatBubble(QWidget):
1497
1621
  self.voice_manager.speak("Full voice mode activated. I'm listening...")
1498
1622
 
1499
1623
  if self.debug:
1500
- print("✅ Full Voice Mode started successfully")
1624
+ if self.debug:
1625
+ print("✅ Full Voice Mode started successfully")
1501
1626
 
1502
1627
  except Exception as e:
1503
1628
  if self.debug:
1504
- print(f"❌ Error starting Full Voice Mode: {e}")
1629
+ if self.debug:
1630
+ print(f"❌ Error starting Full Voice Mode: {e}")
1505
1631
  import traceback
1506
1632
  traceback.print_exc()
1507
1633
 
@@ -1513,7 +1639,8 @@ class QtChatBubble(QWidget):
1513
1639
  """Stop Full Voice Mode and return to normal text mode."""
1514
1640
  try:
1515
1641
  if self.debug:
1516
- print("🛑 Stopping Full Voice Mode...")
1642
+ if self.debug:
1643
+ print("🛑 Stopping Full Voice Mode...")
1517
1644
 
1518
1645
  # Stop listening
1519
1646
  if self.voice_manager:
@@ -1527,11 +1654,13 @@ class QtChatBubble(QWidget):
1527
1654
  self.update_status("READY")
1528
1655
 
1529
1656
  if self.debug:
1530
- print("✅ Full Voice Mode stopped")
1657
+ if self.debug:
1658
+ print("✅ Full Voice Mode stopped")
1531
1659
 
1532
1660
  except Exception as e:
1533
1661
  if self.debug:
1534
- print(f"❌ Error stopping Full Voice Mode: {e}")
1662
+ if self.debug:
1663
+ print(f"❌ Error stopping Full Voice Mode: {e}")
1535
1664
  import traceback
1536
1665
  traceback.print_exc()
1537
1666
 
@@ -1539,7 +1668,8 @@ class QtChatBubble(QWidget):
1539
1668
  """Handle speech-to-text input from the user."""
1540
1669
  try:
1541
1670
  if self.debug:
1542
- print(f"👤 Voice input: {transcribed_text}")
1671
+ if self.debug:
1672
+ print(f"👤 Voice input: {transcribed_text}")
1543
1673
 
1544
1674
  # No longer updating voice toggle appearance - it's a simple user control
1545
1675
  self.update_status("PROCESSING")
@@ -1555,7 +1685,8 @@ class QtChatBubble(QWidget):
1555
1685
  self._update_message_history_from_session()
1556
1686
 
1557
1687
  if self.debug:
1558
- print(f"🤖 AI response: {response[:100]}...")
1688
+ if self.debug:
1689
+ print(f"🤖 AI response: {response[:100]}...")
1559
1690
 
1560
1691
  # Speak the response
1561
1692
  self.voice_manager.speak(response)
@@ -1565,7 +1696,8 @@ class QtChatBubble(QWidget):
1565
1696
 
1566
1697
  except Exception as e:
1567
1698
  if self.debug:
1568
- print(f"❌ Error handling voice input: {e}")
1699
+ if self.debug:
1700
+ print(f"❌ Error handling voice input: {e}")
1569
1701
  import traceback
1570
1702
  traceback.print_exc()
1571
1703
 
@@ -1575,7 +1707,8 @@ class QtChatBubble(QWidget):
1575
1707
  def handle_voice_stop(self):
1576
1708
  """Handle when user says 'stop' to exit Full Voice Mode."""
1577
1709
  if self.debug:
1578
- print("🛑 User said 'stop' - exiting Full Voice Mode")
1710
+ if self.debug:
1711
+ print("🛑 User said 'stop' - exiting Full Voice Mode")
1579
1712
 
1580
1713
  # Disable Full Voice Mode
1581
1714
  self.full_voice_toggle.set_enabled(False)
@@ -1587,7 +1720,10 @@ class QtChatBubble(QWidget):
1587
1720
  self.input_container.hide()
1588
1721
 
1589
1722
  # Update window size to be smaller but maintain wider width
1590
- self.setFixedSize(630, 120) # Reduced width by 10% to match normal size
1723
+ voice_base_height = 120
1724
+ attachment_height = 28 if (self.attached_files and self.attached_files_container.isVisible()) else 0
1725
+ voice_height = voice_base_height + attachment_height
1726
+ self.setFixedSize(self.base_width, voice_height) # Dynamic height for voice mode
1591
1727
 
1592
1728
  def show_text_ui(self):
1593
1729
  """Show the text input interface when exiting Full Voice Mode."""
@@ -1595,8 +1731,8 @@ class QtChatBubble(QWidget):
1595
1731
  if hasattr(self, 'input_container'):
1596
1732
  self.input_container.show()
1597
1733
 
1598
- # Restore normal window size with wider width
1599
- self.setFixedSize(630, 196)
1734
+ # Restore normal window size with wider width - use dynamic sizing
1735
+ self._adjust_window_size_for_attachments()
1600
1736
 
1601
1737
  def update_status(self, status_text: str):
1602
1738
  """Update the status label with the given text."""
@@ -1623,7 +1759,7 @@ class QtChatBubble(QWidget):
1623
1759
  font-size: 10px;
1624
1760
  font-weight: 600;
1625
1761
  color: #ffffff;
1626
- font-family: "SF Pro Text", "Helvetica Neue", system-ui, sans-serif;
1762
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
1627
1763
  }}
1628
1764
  """)
1629
1765
 
@@ -1634,103 +1770,17 @@ class QtChatBubble(QWidget):
1634
1770
  current_state = self.voice_manager.get_state()
1635
1771
  # No longer updating tts_toggle appearance - it's a simple user control
1636
1772
 
1637
- # Show/hide voice control panel based on TTS state
1638
- if hasattr(self, 'voice_control_panel'):
1639
- if current_state in ['speaking', 'paused']:
1640
- self.voice_control_panel.show()
1641
- self._update_voice_control_panel(current_state)
1642
- else:
1643
- self.voice_control_panel.hide()
1773
+ # Voice control panel removed - no longer needed
1644
1774
 
1645
1775
  if self.debug:
1646
- print(f"🔊 TTS toggle state updated to: {current_state}")
1776
+ if self.debug:
1777
+ print(f"🔊 TTS toggle state updated to: {current_state}")
1647
1778
  except Exception as e:
1648
1779
  if self.debug:
1649
- print(f"❌ Error updating TTS toggle state: {e}")
1650
-
1651
- def create_voice_control_panel(self):
1652
- """Create a prominent voice control panel that appears when TTS is active."""
1653
- panel = QWidget()
1654
- layout = QHBoxLayout()
1655
- layout.setContentsMargins(4, 2, 4, 2)
1656
- layout.setSpacing(4)
1657
-
1658
- # Pause/Resume button
1659
- self.voice_pause_button = QPushButton("⏸")
1660
- self.voice_pause_button.setFixedSize(24, 24)
1661
- self.voice_pause_button.setToolTip("Pause/Resume TTS (Space)")
1662
- self.voice_pause_button.clicked.connect(self.on_tts_single_click)
1663
- self.voice_pause_button.setStyleSheet("""
1664
- QPushButton {
1665
- background: rgba(255, 255, 255, 0.1);
1666
- border: 1px solid rgba(255, 255, 255, 0.2);
1667
- border-radius: 12px;
1668
- font-size: 12px;
1669
- color: rgba(255, 255, 255, 0.9);
1670
- font-weight: bold;
1671
- }
1672
- QPushButton:hover {
1673
- background: rgba(255, 255, 255, 0.2);
1674
- border: 1px solid rgba(255, 255, 255, 0.3);
1675
- }
1676
- QPushButton:pressed {
1677
- background: rgba(255, 255, 255, 0.05);
1678
- }
1679
- """)
1680
- layout.addWidget(self.voice_pause_button)
1681
-
1682
- # Stop button
1683
- self.voice_stop_button = QPushButton("⏹")
1684
- self.voice_stop_button.setFixedSize(24, 24)
1685
- self.voice_stop_button.setToolTip("Stop TTS (Escape)")
1686
- self.voice_stop_button.clicked.connect(self.on_tts_double_click)
1687
- self.voice_stop_button.setStyleSheet("""
1688
- QPushButton {
1689
- background: rgba(255, 100, 100, 0.1);
1690
- border: 1px solid rgba(255, 100, 100, 0.3);
1691
- border-radius: 12px;
1692
- font-size: 12px;
1693
- color: rgba(255, 200, 200, 0.9);
1694
- font-weight: bold;
1695
- }
1696
- QPushButton:hover {
1697
- background: rgba(255, 100, 100, 0.2);
1698
- border: 1px solid rgba(255, 100, 100, 0.4);
1699
- }
1700
- QPushButton:pressed {
1701
- background: rgba(255, 100, 100, 0.05);
1702
- }
1703
- """)
1704
- layout.addWidget(self.voice_stop_button)
1705
-
1706
- # Status text
1707
- self.voice_status_label = QLabel("Speaking...")
1708
- self.voice_status_label.setStyleSheet("""
1709
- QLabel {
1710
- color: rgba(255, 255, 255, 0.8);
1711
- font-size: 10px;
1712
- font-weight: 500;
1713
- padding: 2px 4px;
1714
- }
1715
- """)
1716
- layout.addWidget(self.voice_status_label)
1717
-
1718
- panel.setLayout(layout)
1719
- return panel
1720
-
1721
- def _update_voice_control_panel(self, state):
1722
- """Update the voice control panel based on TTS state."""
1723
- if not hasattr(self, 'voice_control_panel'):
1724
- return
1780
+ if self.debug:
1781
+ print(f"❌ Error updating TTS toggle state: {e}")
1725
1782
 
1726
- if state == 'speaking':
1727
- self.voice_pause_button.setText("⏸")
1728
- self.voice_pause_button.setToolTip("Pause TTS (Space)")
1729
- self.voice_status_label.setText("Speaking...")
1730
- elif state == 'paused':
1731
- self.voice_pause_button.setText("▶")
1732
- self.voice_pause_button.setToolTip("Resume TTS (Space)")
1733
- self.voice_status_label.setText("Paused")
1783
+ # Voice control panel methods removed - not needed
1734
1784
 
1735
1785
  def setup_keyboard_shortcuts(self):
1736
1786
  """Setup keyboard shortcuts for voice control."""
@@ -1747,11 +1797,13 @@ class QtChatBubble(QWidget):
1747
1797
  self.escape_shortcut.activated.connect(self.handle_escape_shortcut)
1748
1798
 
1749
1799
  if self.debug:
1750
- print("✅ Keyboard shortcuts setup: Space (pause/resume), Escape (stop)")
1800
+ if self.debug:
1801
+ print("✅ Keyboard shortcuts setup: Space (pause/resume), Escape (stop)")
1751
1802
 
1752
1803
  except Exception as e:
1753
1804
  if self.debug:
1754
- print(f"❌ Error setting up keyboard shortcuts: {e}")
1805
+ if self.debug:
1806
+ print(f"❌ Error setting up keyboard shortcuts: {e}")
1755
1807
 
1756
1808
  def handle_space_shortcut(self):
1757
1809
  """Handle space bar shortcut for pause/resume."""
@@ -1760,14 +1812,16 @@ class QtChatBubble(QWidget):
1760
1812
  not self.input_text.hasFocus()):
1761
1813
  self.on_tts_single_click()
1762
1814
  if self.debug:
1763
- print("🔊 Space shortcut triggered pause/resume")
1815
+ if self.debug:
1816
+ print("🔊 Space shortcut triggered pause/resume")
1764
1817
 
1765
1818
  def handle_escape_shortcut(self):
1766
1819
  """Handle escape key shortcut for stop."""
1767
1820
  if self.voice_manager and self.voice_manager.get_state() in ['speaking', 'paused']:
1768
1821
  self.on_tts_double_click()
1769
1822
  if self.debug:
1770
- print("🔊 Escape shortcut triggered stop")
1823
+ if self.debug:
1824
+ print("🔊 Escape shortcut triggered stop")
1771
1825
 
1772
1826
  def _clean_response_for_voice(self, text: str) -> str:
1773
1827
  """Clean response text for voice synthesis - remove formatting and make conversational."""
@@ -1817,7 +1871,8 @@ class QtChatBubble(QWidget):
1817
1871
  # NO TRUNCATION - let the LLM decide response length based on system prompt
1818
1872
 
1819
1873
  if self.debug:
1820
- print(f"🔊 Cleaned text for TTS: {text[:100]}{'...' if len(text) > 100 else ''}")
1874
+ if self.debug:
1875
+ print(f"🔊 Cleaned text for TTS: {text[:100]}{'...' if len(text) > 100 else ''}")
1821
1876
 
1822
1877
  return text
1823
1878
 
@@ -1843,11 +1898,13 @@ class QtChatBubble(QWidget):
1843
1898
  """)
1844
1899
 
1845
1900
  if self.debug:
1846
- print(f"Error occurred: {error}")
1901
+ if self.debug:
1902
+ print(f"Error occurred: {error}")
1847
1903
 
1848
1904
  # Show chat history instead of error toast
1849
1905
  if self.debug:
1850
- print(f"❌ AI Error: {error}")
1906
+ if self.debug:
1907
+ print(f"❌ AI Error: {error}")
1851
1908
 
1852
1909
  # Show history so user can see the error context - only if voice mode is OFF
1853
1910
  QTimer.singleShot(100, self._show_history_if_voice_mode_off)
@@ -1873,7 +1930,7 @@ class QtChatBubble(QWidget):
1873
1930
  reply = QMessageBox.question(
1874
1931
  self,
1875
1932
  "Clear Session",
1876
- "Are you sure you want to clear the current session?\nThis will remove all messages and reset the token count.",
1933
+ "Are you sure you want to clear the current session?\nThis will remove all messages, attached files, and reset the token count.",
1877
1934
  QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
1878
1935
  QMessageBox.StandardButton.No
1879
1936
  )
@@ -1887,14 +1944,20 @@ class QtChatBubble(QWidget):
1887
1944
  if self.llm_manager:
1888
1945
  self.llm_manager.create_new_session()
1889
1946
  if self.debug:
1890
- print("🧹 AbstractCore session cleared and recreated")
1947
+ if self.debug:
1948
+ print("🧹 AbstractCore session cleared and recreated")
1891
1949
 
1892
1950
  self.message_history.clear()
1893
1951
  self.token_count = 0
1894
1952
  self.update_token_display()
1895
1953
 
1954
+ # Clear attached files as part of session clearing
1955
+ self.attached_files.clear()
1956
+ self.update_attached_files_display()
1957
+
1896
1958
  if self.debug:
1897
- print("🧹 Session cleared")
1959
+ if self.debug:
1960
+ print("🧹 Session cleared (including attached files)")
1898
1961
 
1899
1962
  def load_session(self):
1900
1963
  """Load a session using AbstractCore via LLMManager."""
@@ -1931,7 +1994,8 @@ class QtChatBubble(QWidget):
1931
1994
  )
1932
1995
 
1933
1996
  if self.debug:
1934
- print(f"📂 Loaded session via AbstractCore from {file_path}")
1997
+ if self.debug:
1998
+ print(f"📂 Loaded session via AbstractCore from {file_path}")
1935
1999
  else:
1936
2000
  raise Exception("Session loaded but not available in LLMManager")
1937
2001
  else:
@@ -1944,7 +2008,8 @@ class QtChatBubble(QWidget):
1944
2008
  f"Failed to load session via AbstractCore:\n{str(e)}"
1945
2009
  )
1946
2010
  if self.debug:
1947
- print(f"❌ Failed to load session: {e}")
2011
+ if self.debug:
2012
+ print(f"❌ Failed to load session: {e}")
1948
2013
 
1949
2014
  def save_session(self):
1950
2015
  """Save the current session using AbstractCore via LLMManager."""
@@ -1980,7 +2045,8 @@ class QtChatBubble(QWidget):
1980
2045
  )
1981
2046
 
1982
2047
  if self.debug:
1983
- print(f"💾 Saved session via AbstractCore to {file_path}")
2048
+ if self.debug:
2049
+ print(f"💾 Saved session via AbstractCore to {file_path}")
1984
2050
  else:
1985
2051
  raise Exception("AbstractCore session saving failed")
1986
2052
 
@@ -1991,7 +2057,8 @@ class QtChatBubble(QWidget):
1991
2057
  f"Failed to save session via AbstractCore:\n{str(e)}"
1992
2058
  )
1993
2059
  if self.debug:
1994
- print(f"❌ Failed to save session: {e}")
2060
+ if self.debug:
2061
+ print(f"❌ Failed to save session: {e}")
1995
2062
 
1996
2063
  def _is_voice_mode_active(self):
1997
2064
  """Centralized source of truth: Check if ANY voice mode is active."""
@@ -2038,11 +2105,34 @@ class QtChatBubble(QWidget):
2038
2105
  self.message_history.append(message)
2039
2106
 
2040
2107
  if self.debug:
2041
- print(f"📚 Updated message history from AbstractCore: {len(self.message_history)} messages")
2108
+ if self.debug:
2109
+ print(f"📚 Updated message history from AbstractCore: {len(self.message_history)} messages")
2042
2110
 
2043
2111
  except Exception as e:
2044
2112
  if self.debug:
2045
- print(f"❌ Error updating message history from session: {e}")
2113
+ if self.debug:
2114
+ print(f"❌ Error updating message history from session: {e}")
2115
+
2116
+ def _rebuild_chat_display(self):
2117
+ """Rebuild chat display after session loading.
2118
+
2119
+ Since the main bubble doesn't have a chat display area, this method
2120
+ updates the history dialog if it's currently open.
2121
+ """
2122
+ try:
2123
+ # If history dialog is open, refresh it with new message history
2124
+ if self.history_dialog and self.history_dialog.isVisible():
2125
+ self.history_dialog.refresh_messages(self.message_history)
2126
+ if self.debug:
2127
+ print("🔄 Refreshed history dialog with loaded session messages")
2128
+
2129
+ # No action needed if history dialog is closed since main bubble has no chat display
2130
+ if self.debug:
2131
+ print("✅ Chat display rebuild completed")
2132
+
2133
+ except Exception as e:
2134
+ if self.debug:
2135
+ print(f"❌ Error rebuilding chat display: {e}")
2046
2136
 
2047
2137
  def _update_token_count_from_session(self):
2048
2138
  """Update token count from AbstractCore session."""
@@ -2053,10 +2143,12 @@ class QtChatBubble(QWidget):
2053
2143
  self.update_token_display()
2054
2144
 
2055
2145
  if self.debug:
2056
- print(f"📊 Updated token count from AbstractCore: {self.token_count}")
2146
+ if self.debug:
2147
+ print(f"📊 Updated token count from AbstractCore: {self.token_count}")
2057
2148
  except Exception as e:
2058
2149
  if self.debug:
2059
- print(f"❌ Error updating token count from session: {e}")
2150
+ if self.debug:
2151
+ print(f"❌ Error updating token count from session: {e}")
2060
2152
 
2061
2153
  def _show_history_if_voice_mode_off(self):
2062
2154
  """Show chat history only if voice mode is OFF."""
@@ -2125,7 +2217,7 @@ class QtChatBubble(QWidget):
2125
2217
  border-radius: 11px;
2126
2218
  font-size: 10px;
2127
2219
  color: #ffffff;
2128
- font-family: "SF Pro Text", "Helvetica Neue", system-ui, sans-serif;
2220
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
2129
2221
  padding: 0 10px;
2130
2222
  font-weight: 600;
2131
2223
  }
@@ -2142,7 +2234,7 @@ class QtChatBubble(QWidget):
2142
2234
  border-radius: 11px;
2143
2235
  font-size: 10px;
2144
2236
  color: rgba(255, 255, 255, 0.7);
2145
- font-family: "SF Pro Text", "Helvetica Neue", system-ui, sans-serif;
2237
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
2146
2238
  padding: 0 10px;
2147
2239
  }
2148
2240
  QPushButton:hover {
@@ -2154,7 +2246,8 @@ class QtChatBubble(QWidget):
2154
2246
  def close_app(self):
2155
2247
  """Close the entire application completely."""
2156
2248
  if self.debug:
2157
- print("🔄 Close button clicked - shutting down application")
2249
+ if self.debug:
2250
+ print("🔄 Close button clicked - shutting down application")
2158
2251
 
2159
2252
  # Stop TTS if running
2160
2253
  if hasattr(self, 'voice_manager') and self.voice_manager:
@@ -2170,16 +2263,19 @@ class QtChatBubble(QWidget):
2170
2263
  # ALWAYS try to call the app quit callback first
2171
2264
  if hasattr(self, 'app_quit_callback') and self.app_quit_callback:
2172
2265
  if self.debug:
2173
- print("🔄 Calling app quit callback")
2266
+ if self.debug:
2267
+ print("🔄 Calling app quit callback")
2174
2268
  try:
2175
2269
  self.app_quit_callback()
2176
2270
  except Exception as e:
2177
2271
  if self.debug:
2178
- print(f"❌ App callback failed: {e}")
2272
+ if self.debug:
2273
+ print(f"❌ App callback failed: {e}")
2179
2274
 
2180
2275
  # ALWAYS force quit as well to ensure the app terminates
2181
2276
  if self.debug:
2182
- print("🔄 Force quitting application")
2277
+ if self.debug:
2278
+ print("🔄 Force quitting application")
2183
2279
 
2184
2280
  # Get the QApplication instance
2185
2281
  app = QApplication.instance()
@@ -2193,7 +2289,8 @@ class QtChatBubble(QWidget):
2193
2289
  import sys
2194
2290
  import os
2195
2291
  if self.debug:
2196
- print("🔄 Force exit with sys.exit and os._exit")
2292
+ if self.debug:
2293
+ print("🔄 Force exit with sys.exit and os._exit")
2197
2294
  try:
2198
2295
  sys.exit(0)
2199
2296
  except:
@@ -2204,6 +2301,53 @@ class QtChatBubble(QWidget):
2204
2301
  """Set callback to properly quit the main application."""
2205
2302
  self.app_quit_callback = callback
2206
2303
 
2304
+ @pyqtSlot()
2305
+ def _on_speech_started_main_thread(self):
2306
+ """Handle speech start on main thread (called via QMetaObject.invokeMethod)."""
2307
+ if self.debug:
2308
+ print("🔊 QtChatBubble: Speech started - updating status on main thread")
2309
+ if self.status_callback:
2310
+ self.status_callback("speaking")
2311
+
2312
+ @pyqtSlot()
2313
+ def _on_speech_ended_main_thread(self):
2314
+ """Handle speech end on main thread (called via QMetaObject.invokeMethod)."""
2315
+ if self.debug:
2316
+ print("🔊 QtChatBubble: Speech ended - handling completion on main thread")
2317
+
2318
+ # Update toggle state when speech completes
2319
+ self._update_tts_toggle_state()
2320
+
2321
+ # Call response callback now that TTS is done
2322
+ if self.response_callback and hasattr(self, '_pending_response'):
2323
+ if self.debug:
2324
+ print(f"🔄 QtChatBubble: TTS completed, calling response callback...")
2325
+ self.response_callback(self._pending_response)
2326
+ delattr(self, '_pending_response')
2327
+
2328
+ # Notify main app that speaking is done (back to ready)
2329
+ if self.status_callback:
2330
+ if self.debug:
2331
+ print("🔊 QtChatBubble: Speech ended, setting ready status")
2332
+ self.status_callback("ready")
2333
+
2334
+ @pyqtSlot()
2335
+ def _execute_tts_completion_callbacks(self):
2336
+ """Execute TTS completion callbacks on the main thread."""
2337
+ if hasattr(self, '_tts_completion_callback') and self._tts_completion_callback:
2338
+ if self.debug:
2339
+ print("🔊 QtChatBubble: Executing TTS completion callbacks on main thread...")
2340
+
2341
+ # Execute the stored callback
2342
+ try:
2343
+ self._tts_completion_callback()
2344
+ except Exception as e:
2345
+ if self.debug:
2346
+ print(f"❌ Error executing TTS completion callback: {e}")
2347
+ finally:
2348
+ # Clear the callback
2349
+ self._tts_completion_callback = None
2350
+
2207
2351
 
2208
2352
  def closeEvent(self, event):
2209
2353
  """Handle close event."""
@@ -2217,7 +2361,8 @@ class QtChatBubble(QWidget):
2217
2361
  self.voice_manager.cleanup()
2218
2362
  except Exception as e:
2219
2363
  if self.debug:
2220
- print(f"❌ Error cleaning up voice manager: {e}")
2364
+ if self.debug:
2365
+ print(f"❌ Error cleaning up voice manager: {e}")
2221
2366
 
2222
2367
  event.accept()
2223
2368
 
@@ -2241,20 +2386,21 @@ class QtBubbleManager:
2241
2386
  raise RuntimeError("No Qt library available. Install PyQt5, PySide2, or PyQt6")
2242
2387
 
2243
2388
  if self.debug:
2244
- print(f"✅ QtBubbleManager initialized with {QT_AVAILABLE}")
2389
+ if self.debug:
2390
+ print(f"✅ QtBubbleManager initialized with {QT_AVAILABLE}")
2245
2391
 
2246
2392
  def _prepare_bubble(self):
2247
2393
  """Pre-initialize the bubble for instant display later."""
2248
2394
  if not self.app:
2249
- # Create QApplication if it doesn't exist
2250
- if not QApplication.instance():
2251
- self.app = QApplication(sys.argv)
2252
- else:
2253
- self.app = QApplication.instance()
2395
+ # Always use existing QApplication instance (never create a new one)
2396
+ self.app = QApplication.instance()
2397
+ if not self.app:
2398
+ raise RuntimeError("No QApplication instance found. This should be created by the main app first.")
2254
2399
 
2255
2400
  if not self.bubble:
2256
2401
  if self.debug:
2257
- print("🔄 Pre-creating QtChatBubble...")
2402
+ if self.debug:
2403
+ print("🔄 Pre-creating QtChatBubble...")
2258
2404
 
2259
2405
  # Create the bubble but don't show it yet
2260
2406
  self.bubble = QtChatBubble(self.llm_manager, self.config, self.debug, self.listening_mode)
@@ -2268,7 +2414,8 @@ class QtBubbleManager:
2268
2414
  self.bubble.set_status_callback(self.status_callback)
2269
2415
 
2270
2416
  if self.debug:
2271
- print("✅ QtChatBubble pre-created and ready")
2417
+ if self.debug:
2418
+ print("✅ QtChatBubble pre-created and ready")
2272
2419
 
2273
2420
  def show(self):
2274
2421
  """Show the chat bubble (instantly if pre-initialized)."""
@@ -2286,7 +2433,8 @@ class QtBubbleManager:
2286
2433
  self.bubble.activateWindow()
2287
2434
 
2288
2435
  if self.debug:
2289
- print("💬 Qt chat bubble shown")
2436
+ if self.debug:
2437
+ print("💬 Qt chat bubble shown")
2290
2438
 
2291
2439
  def hide(self):
2292
2440
  """Hide the chat bubble."""
@@ -2294,7 +2442,8 @@ class QtBubbleManager:
2294
2442
  self.bubble.hide()
2295
2443
 
2296
2444
  if self.debug:
2297
- print("💬 Qt chat bubble hidden")
2445
+ if self.debug:
2446
+ print("💬 Qt chat bubble hidden")
2298
2447
 
2299
2448
  def destroy(self):
2300
2449
  """Destroy the chat bubble."""
@@ -2303,7 +2452,8 @@ class QtBubbleManager:
2303
2452
  self.bubble = None
2304
2453
 
2305
2454
  if self.debug:
2306
- print("💬 Qt chat bubble destroyed")
2455
+ if self.debug:
2456
+ print("💬 Qt chat bubble destroyed")
2307
2457
 
2308
2458
  def set_response_callback(self, callback):
2309
2459
  """Set response callback."""