abstractassistant 0.1.0__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.
- abstractassistant/__init__.py +1 -0
- abstractassistant/app.py +884 -0
- abstractassistant/cli.py +151 -0
- abstractassistant/config.py +172 -0
- abstractassistant/core/__init__.py +1 -0
- abstractassistant/core/llm_manager.py +301 -0
- abstractassistant/core/tts_manager.py +221 -0
- abstractassistant/ui/__init__.py +1 -0
- abstractassistant/ui/chat_bubble.py +335 -0
- abstractassistant/ui/history_dialog.py +384 -0
- abstractassistant/ui/provider_manager.py +244 -0
- abstractassistant/ui/qt_bubble.py +2157 -0
- abstractassistant/ui/toast_manager.py +426 -0
- abstractassistant/ui/toast_window.py +588 -0
- abstractassistant/ui/tts_state_manager.py +327 -0
- abstractassistant/ui/ui_styles.py +467 -0
- abstractassistant/utils/__init__.py +1 -0
- abstractassistant/utils/icon_generator.py +258 -0
- abstractassistant/utils/markdown_renderer.py +345 -0
- abstractassistant/web_server.py +366 -0
- abstractassistant-0.1.0.dist-info/METADATA +273 -0
- abstractassistant-0.1.0.dist-info/RECORD +26 -0
- abstractassistant-0.1.0.dist-info/WHEEL +5 -0
- abstractassistant-0.1.0.dist-info/entry_points.txt +2 -0
- abstractassistant-0.1.0.dist-info/licenses/LICENSE +22 -0
- abstractassistant-0.1.0.dist-info/top_level.txt +1 -0
abstractassistant/app.py
ADDED
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main application class for AbstractAssistant.
|
|
3
|
+
|
|
4
|
+
Handles system tray integration, UI coordination, and application lifecycle.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import pystray
|
|
12
|
+
from PIL import Image, ImageDraw
|
|
13
|
+
|
|
14
|
+
from .ui.qt_bubble import QtBubbleManager
|
|
15
|
+
from .core.llm_manager import LLMManager
|
|
16
|
+
from .utils.icon_generator import IconGenerator
|
|
17
|
+
from .config import Config
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EnhancedClickableIcon(pystray.Icon):
|
|
21
|
+
"""Custom pystray Icon that handles single/double click differentiation."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, name, image, text=None, single_click_handler=None, double_click_handler=None, debug=False):
|
|
24
|
+
# Store our handlers before calling super().__init__
|
|
25
|
+
self.single_click_handler = single_click_handler
|
|
26
|
+
self.double_click_handler = double_click_handler
|
|
27
|
+
self.debug = debug
|
|
28
|
+
self._stored_menu = None
|
|
29
|
+
|
|
30
|
+
# Click timing management
|
|
31
|
+
self.click_count = 0
|
|
32
|
+
self.click_timer = None
|
|
33
|
+
self.DOUBLE_CLICK_TIMEOUT = 300 # milliseconds
|
|
34
|
+
|
|
35
|
+
if self.debug:
|
|
36
|
+
print(f"🔄 EnhancedClickableIcon created with single_click: {single_click_handler is not None}, double_click: {double_click_handler is not None}")
|
|
37
|
+
|
|
38
|
+
# Create with no menu initially
|
|
39
|
+
super().__init__(name, image, text, menu=None)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def _menu(self):
|
|
43
|
+
"""Override _menu property to intercept access and handle click timing."""
|
|
44
|
+
if self.debug:
|
|
45
|
+
print(f"🔍 _menu property accessed! Click count: {self.click_count}")
|
|
46
|
+
|
|
47
|
+
self._handle_click_timing()
|
|
48
|
+
# Return None so no menu is displayed
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
def _handle_click_timing(self):
|
|
52
|
+
"""Handle single/double click timing logic."""
|
|
53
|
+
import threading
|
|
54
|
+
|
|
55
|
+
self.click_count += 1
|
|
56
|
+
|
|
57
|
+
if self.click_count == 1:
|
|
58
|
+
# First click - start timer for single click
|
|
59
|
+
if self.click_timer is not None:
|
|
60
|
+
self.click_timer.cancel()
|
|
61
|
+
|
|
62
|
+
self.click_timer = threading.Timer(
|
|
63
|
+
self.DOUBLE_CLICK_TIMEOUT / 1000.0, # Convert to seconds
|
|
64
|
+
self._execute_single_click
|
|
65
|
+
)
|
|
66
|
+
self.click_timer.start()
|
|
67
|
+
|
|
68
|
+
if self.debug:
|
|
69
|
+
print("🔄 First click detected, starting timer...")
|
|
70
|
+
|
|
71
|
+
elif self.click_count == 2:
|
|
72
|
+
# Second click - cancel timer and execute double click
|
|
73
|
+
if self.click_timer is not None:
|
|
74
|
+
self.click_timer.cancel()
|
|
75
|
+
self.click_timer = None
|
|
76
|
+
|
|
77
|
+
self.click_count = 0 # Reset immediately
|
|
78
|
+
self._execute_double_click()
|
|
79
|
+
|
|
80
|
+
if self.debug:
|
|
81
|
+
print("🔄 Double click detected!")
|
|
82
|
+
|
|
83
|
+
def _execute_single_click(self):
|
|
84
|
+
"""Execute single click handler after timeout."""
|
|
85
|
+
self.click_count = 0 # Reset click count
|
|
86
|
+
self.click_timer = None
|
|
87
|
+
|
|
88
|
+
if self.debug:
|
|
89
|
+
print("✅ Single click detected on system tray icon!")
|
|
90
|
+
|
|
91
|
+
if self.single_click_handler:
|
|
92
|
+
try:
|
|
93
|
+
self.single_click_handler()
|
|
94
|
+
except Exception as e:
|
|
95
|
+
print(f"❌ Single click handler error: {e}")
|
|
96
|
+
if self.debug:
|
|
97
|
+
import traceback
|
|
98
|
+
traceback.print_exc()
|
|
99
|
+
|
|
100
|
+
def _execute_double_click(self):
|
|
101
|
+
"""Execute double click handler immediately."""
|
|
102
|
+
if self.debug:
|
|
103
|
+
print("✅ Double click detected on system tray icon!")
|
|
104
|
+
|
|
105
|
+
if self.double_click_handler:
|
|
106
|
+
try:
|
|
107
|
+
self.double_click_handler()
|
|
108
|
+
except Exception as e:
|
|
109
|
+
print(f"❌ Double click handler error: {e}")
|
|
110
|
+
if self.debug:
|
|
111
|
+
import traceback
|
|
112
|
+
traceback.print_exc()
|
|
113
|
+
|
|
114
|
+
@_menu.setter
|
|
115
|
+
def _menu(self, value):
|
|
116
|
+
"""Allow setting _menu during initialization."""
|
|
117
|
+
if self.debug:
|
|
118
|
+
print(f"🔍 _menu property set to: {value}")
|
|
119
|
+
self._stored_menu = value
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class AbstractAssistantApp:
|
|
123
|
+
"""Main application class coordinating all components."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, config: Optional[Config] = None, debug: bool = False, listening_mode: str = "wait"):
|
|
126
|
+
"""Initialize the AbstractAssistant application.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
config: Configuration object (uses default if None)
|
|
130
|
+
debug: Enable debug mode
|
|
131
|
+
listening_mode: Voice listening mode (none, stop, wait, full)
|
|
132
|
+
"""
|
|
133
|
+
self.config = config or Config.default()
|
|
134
|
+
self.debug = debug
|
|
135
|
+
self.listening_mode = listening_mode
|
|
136
|
+
|
|
137
|
+
# Validate configuration
|
|
138
|
+
if not self.config.validate():
|
|
139
|
+
print("Warning: Configuration validation failed, using defaults")
|
|
140
|
+
self.config = Config.default()
|
|
141
|
+
|
|
142
|
+
# Initialize components
|
|
143
|
+
self.icon: Optional[pystray.Icon] = None
|
|
144
|
+
self.bubble_manager: Optional[QtBubbleManager] = None
|
|
145
|
+
self.llm_manager: LLMManager = LLMManager(config=self.config, debug=self.debug)
|
|
146
|
+
self.icon_generator: IconGenerator = IconGenerator(size=self.config.system_tray.icon_size)
|
|
147
|
+
|
|
148
|
+
# Application state
|
|
149
|
+
self.is_running: bool = False
|
|
150
|
+
self.bubble_visible: bool = False
|
|
151
|
+
|
|
152
|
+
if self.debug:
|
|
153
|
+
print(f"AbstractAssistant initialized with config: {self.config.to_dict()}")
|
|
154
|
+
|
|
155
|
+
def create_system_tray_icon(self) -> pystray.Icon:
|
|
156
|
+
"""Create and configure the system tray icon."""
|
|
157
|
+
# Generate a modern, clean icon - start with ready state (green, steady)
|
|
158
|
+
icon_image = self.icon_generator.create_app_icon(
|
|
159
|
+
color_scheme="green", # Ready state: steady green
|
|
160
|
+
animated=False # Ready state: no animation
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if self.debug:
|
|
164
|
+
print("🔄 Creating enhanced system tray icon with single/double click detection")
|
|
165
|
+
|
|
166
|
+
# Use our enhanced ClickableIcon for single/double click handling
|
|
167
|
+
return EnhancedClickableIcon(
|
|
168
|
+
"AbstractAssistant",
|
|
169
|
+
icon_image,
|
|
170
|
+
"AbstractAssistant - AI at your fingertips",
|
|
171
|
+
single_click_handler=self.handle_single_click,
|
|
172
|
+
double_click_handler=self.handle_double_click,
|
|
173
|
+
debug=self.debug
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def update_icon_status(self, status: str):
|
|
177
|
+
"""Update the system tray icon based on application status.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
status: 'ready', 'generating', 'executing', 'thinking'
|
|
181
|
+
"""
|
|
182
|
+
if not self.icon:
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
if status == "ready":
|
|
187
|
+
# Ready: gentle heartbeat green
|
|
188
|
+
self._stop_working_animation()
|
|
189
|
+
self._start_ready_animation()
|
|
190
|
+
elif status in ["generating", "executing", "thinking"]:
|
|
191
|
+
# Working: start continuous animation with cycling colors
|
|
192
|
+
self._start_working_animation()
|
|
193
|
+
return # Don't update icon here, let the timer handle it
|
|
194
|
+
else:
|
|
195
|
+
# Default: steady green
|
|
196
|
+
icon_image = self.icon_generator.create_app_icon(
|
|
197
|
+
color_scheme="green",
|
|
198
|
+
animated=False
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Update the icon
|
|
202
|
+
self.icon.icon = icon_image
|
|
203
|
+
|
|
204
|
+
if self.debug:
|
|
205
|
+
print(f"🎨 Updated icon status to: {status}")
|
|
206
|
+
|
|
207
|
+
except Exception as e:
|
|
208
|
+
if self.debug:
|
|
209
|
+
print(f"❌ Error updating icon status: {e}")
|
|
210
|
+
|
|
211
|
+
def _start_working_animation(self):
|
|
212
|
+
"""Start the working animation timer for continuous icon updates."""
|
|
213
|
+
try:
|
|
214
|
+
import threading
|
|
215
|
+
import time
|
|
216
|
+
|
|
217
|
+
# Stop any existing timer
|
|
218
|
+
self._stop_working_animation()
|
|
219
|
+
|
|
220
|
+
# Create a heartbeat-like animation with dynamic timing
|
|
221
|
+
def update_working_icon():
|
|
222
|
+
if self.icon:
|
|
223
|
+
try:
|
|
224
|
+
icon_image = self.icon_generator.create_app_icon(
|
|
225
|
+
color_scheme="working",
|
|
226
|
+
animated=True
|
|
227
|
+
)
|
|
228
|
+
self.icon.icon = icon_image
|
|
229
|
+
except Exception as e:
|
|
230
|
+
if self.debug:
|
|
231
|
+
print(f"❌ Error updating working icon: {e}")
|
|
232
|
+
|
|
233
|
+
# Heartbeat-like timer with dynamic intervals
|
|
234
|
+
def heartbeat_timer_loop():
|
|
235
|
+
while hasattr(self, 'working_active') and self.working_active:
|
|
236
|
+
# Fast heartbeat pattern: beat-beat-pause
|
|
237
|
+
update_working_icon()
|
|
238
|
+
time.sleep(0.1) # First beat
|
|
239
|
+
update_working_icon()
|
|
240
|
+
time.sleep(0.1) # Second beat
|
|
241
|
+
update_working_icon()
|
|
242
|
+
time.sleep(0.8) # Longer pause between heartbeats
|
|
243
|
+
|
|
244
|
+
self.working_active = True
|
|
245
|
+
self.working_timer = threading.Thread(target=heartbeat_timer_loop, daemon=True)
|
|
246
|
+
self.working_timer.start()
|
|
247
|
+
|
|
248
|
+
if self.debug:
|
|
249
|
+
print("🎨 Started working animation")
|
|
250
|
+
|
|
251
|
+
except Exception as e:
|
|
252
|
+
if self.debug:
|
|
253
|
+
print(f"❌ Error starting working animation: {e}")
|
|
254
|
+
|
|
255
|
+
def _start_ready_animation(self):
|
|
256
|
+
"""Start the gentle ready state heartbeat animation."""
|
|
257
|
+
try:
|
|
258
|
+
import threading
|
|
259
|
+
import time
|
|
260
|
+
|
|
261
|
+
# Stop any existing animations
|
|
262
|
+
self._stop_working_animation()
|
|
263
|
+
self._stop_ready_animation()
|
|
264
|
+
|
|
265
|
+
def update_ready_icon():
|
|
266
|
+
if self.icon:
|
|
267
|
+
try:
|
|
268
|
+
icon_image = self.icon_generator.create_app_icon(
|
|
269
|
+
color_scheme="green",
|
|
270
|
+
animated=True
|
|
271
|
+
)
|
|
272
|
+
self.icon.icon = icon_image
|
|
273
|
+
except Exception as e:
|
|
274
|
+
if self.debug:
|
|
275
|
+
print(f"❌ Error updating ready icon: {e}")
|
|
276
|
+
|
|
277
|
+
# Gentle heartbeat timer - slower, more subtle
|
|
278
|
+
def ready_timer_loop():
|
|
279
|
+
while hasattr(self, 'ready_active') and self.ready_active:
|
|
280
|
+
update_ready_icon()
|
|
281
|
+
time.sleep(0.1) # Update every 100ms for smooth animation
|
|
282
|
+
|
|
283
|
+
self.ready_active = True
|
|
284
|
+
self.ready_timer = threading.Thread(target=ready_timer_loop, daemon=True)
|
|
285
|
+
self.ready_timer.start()
|
|
286
|
+
|
|
287
|
+
if self.debug:
|
|
288
|
+
print("🎨 Started ready heartbeat animation")
|
|
289
|
+
|
|
290
|
+
except Exception as e:
|
|
291
|
+
if self.debug:
|
|
292
|
+
print(f"❌ Error starting ready animation: {e}")
|
|
293
|
+
|
|
294
|
+
def _stop_ready_animation(self):
|
|
295
|
+
"""Stop the ready animation."""
|
|
296
|
+
if hasattr(self, 'ready_active'):
|
|
297
|
+
self.ready_active = False
|
|
298
|
+
if self.debug:
|
|
299
|
+
print("🎨 Stopped ready animation")
|
|
300
|
+
|
|
301
|
+
def _stop_working_animation(self):
|
|
302
|
+
"""Stop the working animation."""
|
|
303
|
+
if hasattr(self, 'working_active'):
|
|
304
|
+
self.working_active = False
|
|
305
|
+
self._stop_ready_animation() # Also stop ready animation
|
|
306
|
+
if self.debug:
|
|
307
|
+
print("🎨 Stopped working animation")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def show_chat_bubble(self, icon=None, item=None):
|
|
311
|
+
"""Show the Qt chat bubble interface."""
|
|
312
|
+
try:
|
|
313
|
+
if self.debug:
|
|
314
|
+
print("🔄 show_chat_bubble called")
|
|
315
|
+
|
|
316
|
+
# Check if TTS is currently speaking and stop it
|
|
317
|
+
if self.bubble_manager and hasattr(self.bubble_manager, 'bubble') and self.bubble_manager.bubble:
|
|
318
|
+
bubble = self.bubble_manager.bubble
|
|
319
|
+
if (hasattr(bubble, 'voice_manager') and bubble.voice_manager and
|
|
320
|
+
bubble.voice_manager.is_speaking()):
|
|
321
|
+
if self.debug:
|
|
322
|
+
print("🔊 TTS is speaking, stopping voice...")
|
|
323
|
+
bubble.voice_manager.stop()
|
|
324
|
+
|
|
325
|
+
# Always show bubble after stopping TTS
|
|
326
|
+
if not self.bubble_visible:
|
|
327
|
+
if self.debug:
|
|
328
|
+
print("🔄 Showing bubble after stopping TTS...")
|
|
329
|
+
self.bubble_manager.show()
|
|
330
|
+
self.bubble_visible = True
|
|
331
|
+
if self.debug:
|
|
332
|
+
print("💬 Qt chat bubble opened after TTS stop")
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
# Show the bubble (should be instant due to preflight initialization)
|
|
336
|
+
if self.bubble_manager:
|
|
337
|
+
if self.debug:
|
|
338
|
+
print("🔄 Showing pre-initialized bubble...")
|
|
339
|
+
self.bubble_manager.show()
|
|
340
|
+
else:
|
|
341
|
+
if self.debug:
|
|
342
|
+
print("⚠️ Bubble manager not pre-initialized, creating now...")
|
|
343
|
+
# Fallback: create bubble manager if preflight failed
|
|
344
|
+
try:
|
|
345
|
+
self.bubble_manager = QtBubbleManager(
|
|
346
|
+
llm_manager=self.llm_manager,
|
|
347
|
+
config=self.config,
|
|
348
|
+
debug=self.debug,
|
|
349
|
+
listening_mode=self.listening_mode
|
|
350
|
+
)
|
|
351
|
+
self.bubble_manager.set_response_callback(self.handle_bubble_response)
|
|
352
|
+
self.bubble_manager.set_error_callback(self.handle_bubble_error)
|
|
353
|
+
self.bubble_manager.set_status_callback(self.update_icon_status)
|
|
354
|
+
self.bubble_manager.set_app_quit_callback(self.quit_application)
|
|
355
|
+
self.bubble_manager.show()
|
|
356
|
+
except Exception as e:
|
|
357
|
+
if self.debug:
|
|
358
|
+
print(f"❌ Failed to create bubble manager: {e}")
|
|
359
|
+
print("💬 AbstractAssistant: Error creating chat bubble")
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
# Mark bubble as visible
|
|
363
|
+
self.bubble_visible = True
|
|
364
|
+
|
|
365
|
+
if self.debug:
|
|
366
|
+
print("💬 Qt chat bubble opened")
|
|
367
|
+
|
|
368
|
+
except Exception as e:
|
|
369
|
+
if self.debug:
|
|
370
|
+
print(f"❌ Error in show_chat_bubble: {e}")
|
|
371
|
+
import traceback
|
|
372
|
+
traceback.print_exc()
|
|
373
|
+
print("💬 AbstractAssistant: Error opening chat bubble")
|
|
374
|
+
|
|
375
|
+
def handle_single_click(self):
|
|
376
|
+
"""Handle single click on system tray icon.
|
|
377
|
+
|
|
378
|
+
Behavior:
|
|
379
|
+
- If voice is speaking → pause voice (stay hidden)
|
|
380
|
+
- If voice is paused → resume voice (stay hidden)
|
|
381
|
+
- If voice is idle → show chat bubble
|
|
382
|
+
"""
|
|
383
|
+
try:
|
|
384
|
+
if self.debug:
|
|
385
|
+
print("🔄 Single click handler called")
|
|
386
|
+
|
|
387
|
+
# Check if we have voice manager available
|
|
388
|
+
if (self.bubble_manager and
|
|
389
|
+
hasattr(self.bubble_manager, 'bubble') and
|
|
390
|
+
self.bubble_manager.bubble and
|
|
391
|
+
hasattr(self.bubble_manager.bubble, 'voice_manager') and
|
|
392
|
+
self.bubble_manager.bubble.voice_manager):
|
|
393
|
+
|
|
394
|
+
voice_manager = self.bubble_manager.bubble.voice_manager
|
|
395
|
+
voice_state = voice_manager.get_state()
|
|
396
|
+
|
|
397
|
+
if self.debug:
|
|
398
|
+
print(f"🔊 Voice state: {voice_state}")
|
|
399
|
+
|
|
400
|
+
if voice_state == 'speaking':
|
|
401
|
+
# Pause voice, don't show bubble
|
|
402
|
+
success = voice_manager.pause()
|
|
403
|
+
if self.debug:
|
|
404
|
+
print(f"⏸ Voice pause: {'success' if success else 'failed'}")
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
elif voice_state == 'paused':
|
|
408
|
+
# Resume voice, don't show bubble
|
|
409
|
+
success = voice_manager.resume()
|
|
410
|
+
if self.debug:
|
|
411
|
+
print(f"▶ Voice resume: {'success' if success else 'failed'}")
|
|
412
|
+
return
|
|
413
|
+
|
|
414
|
+
# Voice is idle or not available - show chat bubble
|
|
415
|
+
if self.debug:
|
|
416
|
+
print("💬 Voice idle or unavailable, showing chat bubble")
|
|
417
|
+
self.show_chat_bubble()
|
|
418
|
+
|
|
419
|
+
except Exception as e:
|
|
420
|
+
if self.debug:
|
|
421
|
+
print(f"❌ Error in handle_single_click: {e}")
|
|
422
|
+
import traceback
|
|
423
|
+
traceback.print_exc()
|
|
424
|
+
# Fallback - just show chat bubble
|
|
425
|
+
self.show_chat_bubble()
|
|
426
|
+
|
|
427
|
+
def handle_double_click(self):
|
|
428
|
+
"""Handle double click on system tray icon.
|
|
429
|
+
|
|
430
|
+
Behavior:
|
|
431
|
+
- If voice is speaking/paused → stop voice + show chat bubble
|
|
432
|
+
- If voice is idle → show chat bubble
|
|
433
|
+
"""
|
|
434
|
+
try:
|
|
435
|
+
if self.debug:
|
|
436
|
+
print("🔄 Double click handler called")
|
|
437
|
+
|
|
438
|
+
# Check if we have voice manager available
|
|
439
|
+
if (self.bubble_manager and
|
|
440
|
+
hasattr(self.bubble_manager, 'bubble') and
|
|
441
|
+
self.bubble_manager.bubble and
|
|
442
|
+
hasattr(self.bubble_manager.bubble, 'voice_manager') and
|
|
443
|
+
self.bubble_manager.bubble.voice_manager):
|
|
444
|
+
|
|
445
|
+
voice_manager = self.bubble_manager.bubble.voice_manager
|
|
446
|
+
voice_state = voice_manager.get_state()
|
|
447
|
+
|
|
448
|
+
if self.debug:
|
|
449
|
+
print(f"🔊 Voice state: {voice_state}")
|
|
450
|
+
|
|
451
|
+
if voice_state in ['speaking', 'paused']:
|
|
452
|
+
# Stop voice
|
|
453
|
+
voice_manager.stop()
|
|
454
|
+
if self.debug:
|
|
455
|
+
print("⏹ Voice stopped")
|
|
456
|
+
|
|
457
|
+
# Always show chat bubble on double click
|
|
458
|
+
if self.debug:
|
|
459
|
+
print("💬 Showing chat bubble after double click")
|
|
460
|
+
self.show_chat_bubble()
|
|
461
|
+
|
|
462
|
+
except Exception as e:
|
|
463
|
+
if self.debug:
|
|
464
|
+
print(f"❌ Error in handle_double_click: {e}")
|
|
465
|
+
import traceback
|
|
466
|
+
traceback.print_exc()
|
|
467
|
+
# Fallback - just show chat bubble
|
|
468
|
+
self.show_chat_bubble()
|
|
469
|
+
|
|
470
|
+
def hide_chat_bubble(self):
|
|
471
|
+
"""Hide the chat bubble interface."""
|
|
472
|
+
self.bubble_visible = False
|
|
473
|
+
if self.bubble_manager:
|
|
474
|
+
self.bubble_manager.hide()
|
|
475
|
+
|
|
476
|
+
if self.debug:
|
|
477
|
+
print("💬 Chat bubble hidden")
|
|
478
|
+
|
|
479
|
+
def handle_bubble_response(self, response: str):
|
|
480
|
+
"""Handle AI response from bubble."""
|
|
481
|
+
if self.debug:
|
|
482
|
+
print(f"🔄 App: handle_bubble_response called with: {response[:100]}...")
|
|
483
|
+
|
|
484
|
+
# Update icon back to ready state (steady green)
|
|
485
|
+
self.update_icon_status("ready")
|
|
486
|
+
|
|
487
|
+
# Show toast notification with response
|
|
488
|
+
self.show_toast_notification(response, "success")
|
|
489
|
+
|
|
490
|
+
# Hide bubble after response
|
|
491
|
+
self.hide_chat_bubble()
|
|
492
|
+
|
|
493
|
+
def handle_bubble_error(self, error: str):
|
|
494
|
+
"""Handle error from bubble."""
|
|
495
|
+
# Show error toast notification
|
|
496
|
+
self.show_toast_notification(error, "error")
|
|
497
|
+
|
|
498
|
+
# Hide bubble after error
|
|
499
|
+
self.hide_chat_bubble()
|
|
500
|
+
|
|
501
|
+
def show_toast_notification(self, message: str, type: str = "info"):
|
|
502
|
+
"""Show a toast notification."""
|
|
503
|
+
icon = "✅" if type == "success" else "❌" if type == "error" else "ℹ️"
|
|
504
|
+
print(f"{icon} {message}")
|
|
505
|
+
|
|
506
|
+
if self.debug:
|
|
507
|
+
print(f"Toast notification: {type} - {message}")
|
|
508
|
+
|
|
509
|
+
# Show a proper macOS notification
|
|
510
|
+
try:
|
|
511
|
+
import subprocess
|
|
512
|
+
title = "AbstractAssistant"
|
|
513
|
+
subtitle = "AI Response" if type == "success" else "Error"
|
|
514
|
+
|
|
515
|
+
# Truncate message for notification
|
|
516
|
+
display_message = message[:200] + "..." if len(message) > 200 else message
|
|
517
|
+
|
|
518
|
+
# Use osascript to show macOS notification
|
|
519
|
+
script = f'''
|
|
520
|
+
display notification "{display_message}" with title "{title}" subtitle "{subtitle}"
|
|
521
|
+
'''
|
|
522
|
+
subprocess.run(["osascript", "-e", script], check=False)
|
|
523
|
+
|
|
524
|
+
if self.debug:
|
|
525
|
+
print(f"📱 macOS notification shown: {display_message[:50]}...")
|
|
526
|
+
|
|
527
|
+
except Exception as e:
|
|
528
|
+
if self.debug:
|
|
529
|
+
print(f"❌ Failed to show notification: {e}")
|
|
530
|
+
# Fallback - just print
|
|
531
|
+
print(f"💬 {title}: {message}")
|
|
532
|
+
|
|
533
|
+
def set_provider(self, provider: str):
|
|
534
|
+
"""Set the active LLM provider."""
|
|
535
|
+
self.llm_manager.set_provider(provider)
|
|
536
|
+
|
|
537
|
+
def update_status(self, status: str):
|
|
538
|
+
"""Update application status."""
|
|
539
|
+
# Status is now handled by the web interface
|
|
540
|
+
if self.debug:
|
|
541
|
+
print(f"Status update: {status}")
|
|
542
|
+
|
|
543
|
+
def clear_session(self, icon=None, item=None):
|
|
544
|
+
"""Clear the current session."""
|
|
545
|
+
try:
|
|
546
|
+
if self.debug:
|
|
547
|
+
print("🔄 Clearing session...")
|
|
548
|
+
|
|
549
|
+
self.llm_manager.clear_session()
|
|
550
|
+
|
|
551
|
+
if self.debug:
|
|
552
|
+
print("✅ Session cleared")
|
|
553
|
+
|
|
554
|
+
except Exception as e:
|
|
555
|
+
if self.debug:
|
|
556
|
+
print(f"❌ Error clearing session: {e}")
|
|
557
|
+
|
|
558
|
+
def save_session(self, icon=None, item=None):
|
|
559
|
+
"""Save the current session to file."""
|
|
560
|
+
try:
|
|
561
|
+
if self.debug:
|
|
562
|
+
print("🔄 Saving session...")
|
|
563
|
+
|
|
564
|
+
# Create sessions directory if it doesn't exist
|
|
565
|
+
import os
|
|
566
|
+
sessions_dir = os.path.join(os.path.expanduser("~"), ".abstractassistant", "sessions")
|
|
567
|
+
os.makedirs(sessions_dir, exist_ok=True)
|
|
568
|
+
|
|
569
|
+
# Generate filename with timestamp
|
|
570
|
+
from datetime import datetime
|
|
571
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
572
|
+
filename = f"session_{timestamp}.json"
|
|
573
|
+
filepath = os.path.join(sessions_dir, filename)
|
|
574
|
+
|
|
575
|
+
# Save session
|
|
576
|
+
success = self.llm_manager.save_session(filepath)
|
|
577
|
+
|
|
578
|
+
if success:
|
|
579
|
+
if self.debug:
|
|
580
|
+
print(f"✅ Session saved to: {filepath}")
|
|
581
|
+
# Show notification
|
|
582
|
+
try:
|
|
583
|
+
from .ui.toast_window import show_toast_notification
|
|
584
|
+
show_toast_notification(f"Session saved to:\n{filename}", debug=self.debug)
|
|
585
|
+
except:
|
|
586
|
+
print(f"💾 Session saved: {filename}")
|
|
587
|
+
else:
|
|
588
|
+
if self.debug:
|
|
589
|
+
print("❌ Failed to save session")
|
|
590
|
+
|
|
591
|
+
except Exception as e:
|
|
592
|
+
if self.debug:
|
|
593
|
+
print(f"❌ Error saving session: {e}")
|
|
594
|
+
|
|
595
|
+
def load_session(self, icon=None, item=None):
|
|
596
|
+
"""Load a session from file."""
|
|
597
|
+
try:
|
|
598
|
+
if self.debug:
|
|
599
|
+
print("🔄 Loading session...")
|
|
600
|
+
|
|
601
|
+
# Get sessions directory
|
|
602
|
+
import os
|
|
603
|
+
sessions_dir = os.path.join(os.path.expanduser("~"), ".abstractassistant", "sessions")
|
|
604
|
+
|
|
605
|
+
if not os.path.exists(sessions_dir):
|
|
606
|
+
if self.debug:
|
|
607
|
+
print("❌ No sessions directory found")
|
|
608
|
+
return
|
|
609
|
+
|
|
610
|
+
# Get list of session files
|
|
611
|
+
session_files = [f for f in os.listdir(sessions_dir) if f.endswith('.json')]
|
|
612
|
+
|
|
613
|
+
if not session_files:
|
|
614
|
+
if self.debug:
|
|
615
|
+
print("❌ No session files found")
|
|
616
|
+
try:
|
|
617
|
+
from .ui.toast_window import show_toast_notification
|
|
618
|
+
show_toast_notification("No saved sessions found", debug=self.debug)
|
|
619
|
+
except:
|
|
620
|
+
print("📂 No saved sessions found")
|
|
621
|
+
return
|
|
622
|
+
|
|
623
|
+
# For now, load the most recent session
|
|
624
|
+
# TODO: Add proper file picker dialog
|
|
625
|
+
session_files.sort(reverse=True) # Most recent first
|
|
626
|
+
latest_session = session_files[0]
|
|
627
|
+
filepath = os.path.join(sessions_dir, latest_session)
|
|
628
|
+
|
|
629
|
+
# Load session
|
|
630
|
+
success = self.llm_manager.load_session(filepath)
|
|
631
|
+
|
|
632
|
+
if success:
|
|
633
|
+
if self.debug:
|
|
634
|
+
print(f"✅ Session loaded from: {filepath}")
|
|
635
|
+
# Show notification
|
|
636
|
+
try:
|
|
637
|
+
from .ui.toast_window import show_toast_notification
|
|
638
|
+
show_toast_notification(f"Session loaded:\n{latest_session}", debug=self.debug)
|
|
639
|
+
except:
|
|
640
|
+
print(f"📂 Session loaded: {latest_session}")
|
|
641
|
+
else:
|
|
642
|
+
if self.debug:
|
|
643
|
+
print("❌ Failed to load session")
|
|
644
|
+
|
|
645
|
+
except Exception as e:
|
|
646
|
+
if self.debug:
|
|
647
|
+
print(f"❌ Error loading session: {e}")
|
|
648
|
+
|
|
649
|
+
def _preflight_initialization(self):
|
|
650
|
+
"""Pre-initialize components for instant bubble display on first click."""
|
|
651
|
+
if self.debug:
|
|
652
|
+
print("🚀 Starting preflight initialization...")
|
|
653
|
+
|
|
654
|
+
try:
|
|
655
|
+
# Pre-create bubble manager (this is the main bottleneck)
|
|
656
|
+
if self.bubble_manager is None:
|
|
657
|
+
if self.debug:
|
|
658
|
+
print("🔄 Pre-creating bubble manager...")
|
|
659
|
+
|
|
660
|
+
self.bubble_manager = QtBubbleManager(
|
|
661
|
+
llm_manager=self.llm_manager,
|
|
662
|
+
config=self.config,
|
|
663
|
+
debug=self.debug,
|
|
664
|
+
listening_mode=self.listening_mode
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
# Set up callbacks
|
|
668
|
+
self.bubble_manager.set_response_callback(self.handle_bubble_response)
|
|
669
|
+
self.bubble_manager.set_error_callback(self.handle_bubble_error)
|
|
670
|
+
self.bubble_manager.set_status_callback(self.update_icon_status)
|
|
671
|
+
self.bubble_manager.set_app_quit_callback(self.quit_application)
|
|
672
|
+
|
|
673
|
+
if self.debug:
|
|
674
|
+
print("✅ Bubble manager pre-created successfully")
|
|
675
|
+
|
|
676
|
+
# Pre-initialize the bubble itself (this loads UI components, TTS/STT, etc.)
|
|
677
|
+
if self.debug:
|
|
678
|
+
print("🔄 Pre-initializing chat bubble...")
|
|
679
|
+
|
|
680
|
+
# This creates the bubble without showing it
|
|
681
|
+
self.bubble_manager._prepare_bubble()
|
|
682
|
+
|
|
683
|
+
if self.debug:
|
|
684
|
+
print("✅ Preflight initialization completed - bubble ready for instant display")
|
|
685
|
+
|
|
686
|
+
except Exception as e:
|
|
687
|
+
if self.debug:
|
|
688
|
+
print(f"⚠️ Preflight initialization failed: {e}")
|
|
689
|
+
print(" First click will still work but with delay")
|
|
690
|
+
|
|
691
|
+
def quit_application(self, icon=None, item=None):
|
|
692
|
+
"""Quit the application gracefully."""
|
|
693
|
+
self.is_running = False
|
|
694
|
+
if self.icon:
|
|
695
|
+
self.icon.stop()
|
|
696
|
+
|
|
697
|
+
# Clean up bubble manager
|
|
698
|
+
if self.bubble_manager:
|
|
699
|
+
try:
|
|
700
|
+
self.bubble_manager.destroy()
|
|
701
|
+
except Exception as e:
|
|
702
|
+
if self.debug:
|
|
703
|
+
print(f"Error destroying bubble manager: {e}")
|
|
704
|
+
|
|
705
|
+
def run(self):
|
|
706
|
+
"""Start the application using Qt event loop for proper threading."""
|
|
707
|
+
self.is_running = True
|
|
708
|
+
|
|
709
|
+
try:
|
|
710
|
+
# Import Qt here to avoid conflicts
|
|
711
|
+
from PyQt5.QtWidgets import QApplication, QSystemTrayIcon
|
|
712
|
+
from PyQt5.QtCore import QTimer
|
|
713
|
+
from PyQt5.QtGui import QIcon
|
|
714
|
+
import sys
|
|
715
|
+
|
|
716
|
+
# Create Qt application in main thread
|
|
717
|
+
if not QApplication.instance():
|
|
718
|
+
self.qt_app = QApplication(sys.argv)
|
|
719
|
+
else:
|
|
720
|
+
self.qt_app = QApplication.instance()
|
|
721
|
+
|
|
722
|
+
# Check if system tray is available
|
|
723
|
+
if not QSystemTrayIcon.isSystemTrayAvailable():
|
|
724
|
+
print("❌ System tray is not available on this system")
|
|
725
|
+
return
|
|
726
|
+
|
|
727
|
+
# Create Qt-based system tray icon
|
|
728
|
+
self.qt_icon = self._create_qt_system_tray_icon()
|
|
729
|
+
|
|
730
|
+
# Preflight initialization: Pre-load bubble manager for instant display
|
|
731
|
+
self._preflight_initialization()
|
|
732
|
+
|
|
733
|
+
print("AbstractAssistant started. Check your menu bar!")
|
|
734
|
+
print("Click the icon to open the chat interface.")
|
|
735
|
+
|
|
736
|
+
# Run Qt event loop (this blocks until quit)
|
|
737
|
+
self.qt_app.exec_()
|
|
738
|
+
|
|
739
|
+
except ImportError:
|
|
740
|
+
print("❌ PyQt5 not available. Falling back to pystray...")
|
|
741
|
+
# Fallback to original pystray implementation
|
|
742
|
+
self.icon = self.create_system_tray_icon()
|
|
743
|
+
self.icon.run()
|
|
744
|
+
|
|
745
|
+
def _create_qt_system_tray_icon(self):
|
|
746
|
+
"""Create Qt-based system tray icon with proper click detection."""
|
|
747
|
+
from PyQt5.QtWidgets import QSystemTrayIcon, QMenu, QAction
|
|
748
|
+
from PyQt5.QtCore import QTimer
|
|
749
|
+
from PyQt5.QtGui import QIcon, QPixmap
|
|
750
|
+
from PIL import Image
|
|
751
|
+
import io
|
|
752
|
+
|
|
753
|
+
# Generate icon using our icon generator
|
|
754
|
+
icon_image = self.icon_generator.create_app_icon(
|
|
755
|
+
color_scheme="green", # Ready state: steady green
|
|
756
|
+
animated=False # Ready state: no animation
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
# Convert PIL image to QPixmap
|
|
760
|
+
img_buffer = io.BytesIO()
|
|
761
|
+
icon_image.save(img_buffer, format='PNG')
|
|
762
|
+
img_buffer.seek(0)
|
|
763
|
+
|
|
764
|
+
pixmap = QPixmap()
|
|
765
|
+
pixmap.loadFromData(img_buffer.getvalue())
|
|
766
|
+
qt_icon = QIcon(pixmap)
|
|
767
|
+
|
|
768
|
+
# Create system tray icon
|
|
769
|
+
tray_icon = QSystemTrayIcon(qt_icon)
|
|
770
|
+
tray_icon.setToolTip("AbstractAssistant - AI at your fingertips")
|
|
771
|
+
|
|
772
|
+
# Click detection variables
|
|
773
|
+
self.click_timer = QTimer()
|
|
774
|
+
self.click_timer.setSingleShot(True)
|
|
775
|
+
self.click_timer.timeout.connect(self._qt_handle_single_click)
|
|
776
|
+
self.pending_single_click = False
|
|
777
|
+
self.DOUBLE_CLICK_TIMEOUT = 200 # milliseconds (short period to detect double click)
|
|
778
|
+
|
|
779
|
+
# Connect click signal
|
|
780
|
+
tray_icon.activated.connect(self._qt_on_tray_activated)
|
|
781
|
+
|
|
782
|
+
# Create context menu (right-click)
|
|
783
|
+
context_menu = QMenu()
|
|
784
|
+
|
|
785
|
+
show_action = QAction("Show Chat", None)
|
|
786
|
+
show_action.triggered.connect(self.show_chat_bubble)
|
|
787
|
+
context_menu.addAction(show_action)
|
|
788
|
+
|
|
789
|
+
context_menu.addSeparator()
|
|
790
|
+
|
|
791
|
+
quit_action = QAction("Quit", None)
|
|
792
|
+
quit_action.triggered.connect(self._qt_quit_application)
|
|
793
|
+
context_menu.addAction(quit_action)
|
|
794
|
+
|
|
795
|
+
tray_icon.setContextMenu(context_menu)
|
|
796
|
+
|
|
797
|
+
# Show the tray icon
|
|
798
|
+
tray_icon.show()
|
|
799
|
+
|
|
800
|
+
if self.debug:
|
|
801
|
+
print("✅ Qt-based system tray icon created successfully")
|
|
802
|
+
|
|
803
|
+
return tray_icon
|
|
804
|
+
|
|
805
|
+
def _qt_on_tray_activated(self, reason):
|
|
806
|
+
"""Handle Qt system tray activation (clicks) with proper delay-based detection.
|
|
807
|
+
|
|
808
|
+
Logic:
|
|
809
|
+
- Single click: reason == 3 only
|
|
810
|
+
- Double click: reason == 3 followed quickly by reason == 2
|
|
811
|
+
|
|
812
|
+
Strategy:
|
|
813
|
+
- When reason == 3: Wait 200ms to see if reason == 2 follows
|
|
814
|
+
- If no reason == 2 within 200ms: Execute single click
|
|
815
|
+
- If reason == 2 arrives within 200ms: Execute ONLY double click
|
|
816
|
+
"""
|
|
817
|
+
if self.debug:
|
|
818
|
+
print(f"🖱️ Click detected - reason: {reason}")
|
|
819
|
+
|
|
820
|
+
if reason == 3: # Single click (or first part of double click)
|
|
821
|
+
if self.pending_single_click:
|
|
822
|
+
# Already have a pending single click, ignore this one
|
|
823
|
+
if self.debug:
|
|
824
|
+
print("⚠️ Ignoring additional reason=3 (already pending)")
|
|
825
|
+
return
|
|
826
|
+
|
|
827
|
+
# Mark that we have a pending single click
|
|
828
|
+
self.pending_single_click = True
|
|
829
|
+
|
|
830
|
+
# Start timer to wait for possible reason=2 (double click confirmation)
|
|
831
|
+
self.click_timer.start(self.DOUBLE_CLICK_TIMEOUT)
|
|
832
|
+
|
|
833
|
+
if self.debug:
|
|
834
|
+
print(f"🔄 Qt: reason=3 detected, waiting {self.DOUBLE_CLICK_TIMEOUT}ms for possible reason=2...")
|
|
835
|
+
|
|
836
|
+
elif reason == 2: # Double click confirmation
|
|
837
|
+
if self.pending_single_click and self.click_timer.isActive():
|
|
838
|
+
# We have a pending single click and timer is still running
|
|
839
|
+
# This means reason=2 arrived within the timeout period
|
|
840
|
+
|
|
841
|
+
# Cancel the pending single click
|
|
842
|
+
self.click_timer.stop()
|
|
843
|
+
self.pending_single_click = False
|
|
844
|
+
|
|
845
|
+
if self.debug:
|
|
846
|
+
print("✅ Qt: reason=2 detected - cancelling single click, executing double click!")
|
|
847
|
+
|
|
848
|
+
# Execute ONLY the double click
|
|
849
|
+
self._qt_handle_double_click()
|
|
850
|
+
else:
|
|
851
|
+
# Unexpected reason=2 without pending single click
|
|
852
|
+
if self.debug:
|
|
853
|
+
print("⚠️ Unexpected reason=2 without pending single click")
|
|
854
|
+
|
|
855
|
+
# Execute double click anyway (fallback)
|
|
856
|
+
self._qt_handle_double_click()
|
|
857
|
+
|
|
858
|
+
def _qt_handle_single_click(self):
|
|
859
|
+
"""Handle single click after timeout (no reason=2 detected) in Qt main thread."""
|
|
860
|
+
# Clear the pending flag
|
|
861
|
+
self.pending_single_click = False
|
|
862
|
+
|
|
863
|
+
if self.debug:
|
|
864
|
+
print("✅ Qt: Single click confirmed (no reason=2 within 200ms) - executing action!")
|
|
865
|
+
|
|
866
|
+
# This runs in Qt main thread, so it's safe to create Qt widgets
|
|
867
|
+
# Execute single click action (pause/resume voice or show bubble)
|
|
868
|
+
self.handle_single_click()
|
|
869
|
+
|
|
870
|
+
def _qt_handle_double_click(self):
|
|
871
|
+
"""Handle double click immediately in Qt main thread."""
|
|
872
|
+
if self.debug:
|
|
873
|
+
print("✅ Qt: Double click detected!")
|
|
874
|
+
|
|
875
|
+
# This runs in Qt main thread, so it's safe to create Qt widgets
|
|
876
|
+
self.handle_double_click()
|
|
877
|
+
|
|
878
|
+
def _qt_quit_application(self):
|
|
879
|
+
"""Quit the Qt application."""
|
|
880
|
+
if self.debug:
|
|
881
|
+
print("🔄 Qt: Quit requested")
|
|
882
|
+
|
|
883
|
+
if hasattr(self, 'qt_app') and self.qt_app:
|
|
884
|
+
self.qt_app.quit()
|