abstractassistant 0.3.4__py3-none-any.whl → 0.4.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/app.py +69 -6
- abstractassistant/cli.py +104 -85
- abstractassistant/core/agent_host.py +583 -0
- abstractassistant/core/llm_manager.py +338 -431
- abstractassistant/core/session_index.py +293 -0
- abstractassistant/core/session_store.py +79 -0
- abstractassistant/core/tool_policy.py +58 -0
- abstractassistant/core/transcript_summary.py +434 -0
- abstractassistant/ui/history_dialog.py +504 -29
- abstractassistant/ui/provider_manager.py +2 -2
- abstractassistant/ui/qt_bubble.py +2289 -489
- abstractassistant-0.4.0.dist-info/METADATA +168 -0
- abstractassistant-0.4.0.dist-info/RECORD +32 -0
- {abstractassistant-0.3.4.dist-info → abstractassistant-0.4.0.dist-info}/WHEEL +1 -1
- {abstractassistant-0.3.4.dist-info → abstractassistant-0.4.0.dist-info}/entry_points.txt +1 -0
- abstractassistant-0.3.4.dist-info/METADATA +0 -297
- abstractassistant-0.3.4.dist-info/RECORD +0 -27
- {abstractassistant-0.3.4.dist-info → abstractassistant-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {abstractassistant-0.3.4.dist-info → abstractassistant-0.4.0.dist-info}/top_level.txt +0 -0
|
@@ -10,10 +10,19 @@ import time
|
|
|
10
10
|
import json
|
|
11
11
|
from datetime import datetime
|
|
12
12
|
from pathlib import Path
|
|
13
|
-
from typing import Optional, Callable, List, Dict
|
|
13
|
+
from typing import Optional, Callable, List, Dict, Any
|
|
14
14
|
|
|
15
15
|
# Import AbstractVoice-compatible TTS manager (required dependency)
|
|
16
|
-
from ..core.
|
|
16
|
+
from ..core.agent_host import AgentHost
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
# Optional dependency (installed via `abstractassistant[full]`).
|
|
20
|
+
from ..core.tts_manager import VoiceManager # type: ignore
|
|
21
|
+
|
|
22
|
+
TTS_AVAILABLE = True
|
|
23
|
+
except Exception:
|
|
24
|
+
VoiceManager = None # type: ignore[assignment]
|
|
25
|
+
TTS_AVAILABLE = False
|
|
17
26
|
|
|
18
27
|
# Import our new manager classes (required dependencies)
|
|
19
28
|
from .provider_manager import ProviderManager
|
|
@@ -21,17 +30,17 @@ from .ui_styles import UIStyles
|
|
|
21
30
|
from .tts_state_manager import TTSStateManager, TTSState
|
|
22
31
|
from .history_dialog import iPhoneMessagesDialog
|
|
23
32
|
|
|
24
|
-
#
|
|
25
|
-
TTS_AVAILABLE = True
|
|
33
|
+
# Provider/model managers are package-local.
|
|
26
34
|
MANAGERS_AVAILABLE = True
|
|
27
35
|
|
|
28
36
|
try:
|
|
29
37
|
from PyQt5.QtWidgets import (
|
|
30
38
|
QApplication, QWidget, QVBoxLayout, QHBoxLayout,
|
|
31
39
|
QTextEdit, QPushButton, QComboBox, QLabel, QFrame,
|
|
32
|
-
QFileDialog, QMessageBox
|
|
40
|
+
QFileDialog, QMessageBox, QInputDialog, QCheckBox, QDialog, QMenu,
|
|
41
|
+
QLineEdit, QScrollArea, QSizePolicy, QButtonGroup
|
|
33
42
|
)
|
|
34
|
-
from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QThread, pyqtSlot, QRect, QMetaObject
|
|
43
|
+
from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QThread, pyqtSlot, QRect, QMetaObject, QEvent
|
|
35
44
|
from PyQt5.QtGui import QFont, QPalette, QColor, QPainter, QPen, QBrush
|
|
36
45
|
from PyQt5.QtCore import QPoint
|
|
37
46
|
QT_AVAILABLE = "PyQt5"
|
|
@@ -40,9 +49,10 @@ except ImportError:
|
|
|
40
49
|
from PySide2.QtWidgets import (
|
|
41
50
|
QApplication, QWidget, QVBoxLayout, QHBoxLayout,
|
|
42
51
|
QTextEdit, QPushButton, QComboBox, QLabel, QFrame,
|
|
43
|
-
QFileDialog, QMessageBox
|
|
52
|
+
QFileDialog, QMessageBox, QInputDialog, QCheckBox, QDialog, QMenu,
|
|
53
|
+
QLineEdit, QScrollArea, QSizePolicy, QButtonGroup
|
|
44
54
|
)
|
|
45
|
-
from PySide2.QtCore import Qt, QTimer, Signal as pyqtSignal, QThread, Slot as pyqtSlot, QMetaObject
|
|
55
|
+
from PySide2.QtCore import Qt, QTimer, Signal as pyqtSignal, QThread, Slot as pyqtSlot, QMetaObject, QEvent
|
|
46
56
|
from PySide2.QtGui import QFont, QPalette, QColor, QPainter, QPen, QBrush
|
|
47
57
|
from PySide2.QtCore import QPoint
|
|
48
58
|
QT_AVAILABLE = "PySide2"
|
|
@@ -51,9 +61,10 @@ except ImportError:
|
|
|
51
61
|
from PyQt6.QtWidgets import (
|
|
52
62
|
QApplication, QWidget, QVBoxLayout, QHBoxLayout,
|
|
53
63
|
QTextEdit, QPushButton, QComboBox, QLabel, QFrame,
|
|
54
|
-
QFileDialog, QMessageBox
|
|
64
|
+
QFileDialog, QMessageBox, QInputDialog, QCheckBox, QDialog, QMenu,
|
|
65
|
+
QLineEdit, QScrollArea, QSizePolicy, QButtonGroup
|
|
55
66
|
)
|
|
56
|
-
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QThread, pyqtSlot
|
|
67
|
+
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QThread, pyqtSlot, QEvent
|
|
57
68
|
from PyQt6.QtGui import QFont, QPalette, QColor, QPainter, QPen, QBrush
|
|
58
69
|
from PyQt6.QtCore import QPoint
|
|
59
70
|
QT_AVAILABLE = "PyQt6"
|
|
@@ -125,17 +136,27 @@ class TTSToggle(QPushButton):
|
|
|
125
136
|
|
|
126
137
|
def _update_appearance(self):
|
|
127
138
|
"""Update button appearance based on user's toggle state ONLY."""
|
|
139
|
+
palette = QApplication.instance().palette() if QApplication.instance() else self.palette()
|
|
140
|
+
is_dark = palette.window().color().lightness() < 128
|
|
141
|
+
accent = palette.highlight().color()
|
|
142
|
+
|
|
143
|
+
def rgba(color: QColor, alpha: float) -> str:
|
|
144
|
+
return f"rgba({color.red()}, {color.green()}, {color.blue()}, {alpha})"
|
|
145
|
+
|
|
146
|
+
overlay_bg = "rgba(255, 255, 255, 0.06)" if is_dark else "rgba(0, 0, 0, 0.06)"
|
|
147
|
+
overlay_hover = "rgba(255, 255, 255, 0.12)" if is_dark else "rgba(0, 0, 0, 0.10)"
|
|
148
|
+
overlay_pressed = "rgba(255, 255, 255, 0.06)" if is_dark else "rgba(0, 0, 0, 0.04)"
|
|
149
|
+
overlay_fg = "rgba(255, 255, 255, 0.7)" if is_dark else "rgba(0, 0, 0, 0.65)"
|
|
150
|
+
|
|
128
151
|
# SIMPLE USER CONTROL - only shows enabled/disabled state
|
|
129
152
|
if self._enabled:
|
|
130
|
-
# User has enabled TTS
|
|
131
153
|
icon = "🔉" # Speaker icon when enabled
|
|
132
|
-
bg_color =
|
|
154
|
+
bg_color = rgba(accent, 0.85)
|
|
133
155
|
text_color = "#ffffff"
|
|
134
156
|
else:
|
|
135
|
-
# User has disabled TTS
|
|
136
157
|
icon = "🔇" # Muted speaker when disabled
|
|
137
|
-
bg_color =
|
|
138
|
-
text_color =
|
|
158
|
+
bg_color = overlay_bg
|
|
159
|
+
text_color = overlay_fg
|
|
139
160
|
|
|
140
161
|
self.setText(icon)
|
|
141
162
|
self.setStyleSheet(f"""
|
|
@@ -149,10 +170,10 @@ class TTSToggle(QPushButton):
|
|
|
149
170
|
font-weight: 600;
|
|
150
171
|
}}
|
|
151
172
|
QPushButton:hover {{
|
|
152
|
-
background: rgba(
|
|
173
|
+
background: {rgba(accent, 0.9) if self._enabled else overlay_hover};
|
|
153
174
|
}}
|
|
154
175
|
QPushButton:pressed {{
|
|
155
|
-
background: rgba(
|
|
176
|
+
background: {rgba(accent, 0.75) if self._enabled else overlay_pressed};
|
|
156
177
|
}}
|
|
157
178
|
""")
|
|
158
179
|
|
|
@@ -192,17 +213,30 @@ class FullVoiceToggle(QPushButton):
|
|
|
192
213
|
|
|
193
214
|
def _update_appearance(self):
|
|
194
215
|
"""Update button appearance based on user's toggle state ONLY."""
|
|
216
|
+
palette = QApplication.instance().palette() if QApplication.instance() else self.palette()
|
|
217
|
+
is_dark = palette.window().color().lightness() < 128
|
|
218
|
+
accent = palette.highlight().color()
|
|
219
|
+
|
|
220
|
+
def rgba(color: QColor, alpha: float) -> str:
|
|
221
|
+
return f"rgba({color.red()}, {color.green()}, {color.blue()}, {alpha})"
|
|
222
|
+
|
|
223
|
+
overlay_bg = "rgba(255, 255, 255, 0.06)" if is_dark else "rgba(0, 0, 0, 0.06)"
|
|
224
|
+
overlay_hover = "rgba(255, 255, 255, 0.12)" if is_dark else "rgba(0, 0, 0, 0.10)"
|
|
225
|
+
overlay_pressed = "rgba(255, 255, 255, 0.06)" if is_dark else "rgba(0, 0, 0, 0.04)"
|
|
226
|
+
overlay_fg = "rgba(255, 255, 255, 0.7)" if is_dark else "rgba(0, 0, 0, 0.65)"
|
|
227
|
+
|
|
195
228
|
# SIMPLE USER CONTROL - only shows enabled/disabled state
|
|
196
229
|
if self._enabled:
|
|
197
|
-
# User has enabled Full Voice Mode
|
|
198
230
|
icon = "🎙️" # Microphone when enabled
|
|
199
|
-
bg_color =
|
|
231
|
+
bg_color = rgba(accent, 0.85)
|
|
200
232
|
text_color = "#ffffff"
|
|
201
233
|
else:
|
|
202
|
-
#
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
234
|
+
# Show an explicit "mic off" glyph by default (struck mic),
|
|
235
|
+
# because the app starts in non-listening mode until the user enables Full Voice Mode.
|
|
236
|
+
# Using a combining overlay is the most portable way to get a clear strike without custom painting.
|
|
237
|
+
icon = "🎙️\u20E0" # "no" overlay (combining enclosing circle backslash)
|
|
238
|
+
bg_color = overlay_bg
|
|
239
|
+
text_color = overlay_fg
|
|
206
240
|
|
|
207
241
|
self.setText(icon)
|
|
208
242
|
self.setStyleSheet(f"""
|
|
@@ -216,29 +250,461 @@ class FullVoiceToggle(QPushButton):
|
|
|
216
250
|
font-weight: 600;
|
|
217
251
|
}}
|
|
218
252
|
QPushButton:hover {{
|
|
219
|
-
background: {
|
|
253
|
+
background: {rgba(accent, 0.9) if self._enabled else overlay_hover};
|
|
220
254
|
}}
|
|
221
255
|
QPushButton:pressed {{
|
|
222
|
-
background: {
|
|
256
|
+
background: {rgba(accent, 0.75) if self._enabled else overlay_pressed};
|
|
223
257
|
}}
|
|
224
258
|
""")
|
|
225
259
|
|
|
226
260
|
|
|
227
261
|
|
|
228
262
|
|
|
263
|
+
class ToolSelectorDialog(QDialog):
|
|
264
|
+
"""Tool allowlist editor (All tools vs Custom allowlist)."""
|
|
265
|
+
|
|
266
|
+
def __init__(
|
|
267
|
+
self,
|
|
268
|
+
*,
|
|
269
|
+
parent: Optional[QWidget] = None,
|
|
270
|
+
tools: List[Dict[str, str]],
|
|
271
|
+
enabled: set[str],
|
|
272
|
+
safe_preset: set[str],
|
|
273
|
+
require_approval: set[str],
|
|
274
|
+
):
|
|
275
|
+
super().__init__(parent)
|
|
276
|
+
self.setWindowTitle("Tools")
|
|
277
|
+
self.setModal(True)
|
|
278
|
+
|
|
279
|
+
self._tools = [t for t in list(tools) if isinstance(t, dict)]
|
|
280
|
+
self._safe_preset = set(safe_preset)
|
|
281
|
+
self._require_approval = set(require_approval)
|
|
282
|
+
|
|
283
|
+
# Keep the tool order stable.
|
|
284
|
+
self._all_names = [
|
|
285
|
+
str(t.get("name") or "").strip()
|
|
286
|
+
for t in self._tools
|
|
287
|
+
if isinstance(t.get("name"), str) and str(t.get("name") or "").strip()
|
|
288
|
+
]
|
|
289
|
+
self._all_names_set = set(self._all_names)
|
|
290
|
+
|
|
291
|
+
self._mode: str = "all" if set(enabled) == self._all_names_set else "custom"
|
|
292
|
+
self._custom_selected: set[str] = set(enabled)
|
|
293
|
+
if self._mode == "all":
|
|
294
|
+
self._custom_selected = set(self._all_names_set)
|
|
295
|
+
|
|
296
|
+
palette = QApplication.instance().palette() if QApplication.instance() else self.palette()
|
|
297
|
+
is_dark = palette.window().color().lightness() < 128
|
|
298
|
+
window_bg = palette.window().color().name()
|
|
299
|
+
base_bg = palette.base().color().name()
|
|
300
|
+
mid = palette.mid().color()
|
|
301
|
+
mid_hex = mid.name()
|
|
302
|
+
text = palette.text().color()
|
|
303
|
+
accent = palette.highlight().color()
|
|
304
|
+
|
|
305
|
+
def rgba(color: QColor, alpha: float) -> str:
|
|
306
|
+
return f"rgba({color.red()}, {color.green()}, {color.blue()}, {alpha})"
|
|
307
|
+
|
|
308
|
+
overlay = "rgba(255, 255, 255, 0.08)" if is_dark else "rgba(0, 0, 0, 0.06)"
|
|
309
|
+
overlay_hover = "rgba(255, 255, 255, 0.12)" if is_dark else "rgba(0, 0, 0, 0.10)"
|
|
310
|
+
overlay_pressed = "rgba(255, 255, 255, 0.06)" if is_dark else "rgba(0, 0, 0, 0.04)"
|
|
311
|
+
text_primary = rgba(text, 0.92)
|
|
312
|
+
text_secondary = rgba(text, 0.70)
|
|
313
|
+
text_muted = rgba(text, 0.55 if is_dark else 0.50)
|
|
314
|
+
accent_hex = accent.name()
|
|
315
|
+
accent_hover = accent.lighter(115).name()
|
|
316
|
+
accent_pressed = accent.darker(115).name()
|
|
317
|
+
accent_border = rgba(accent, 0.28)
|
|
318
|
+
danger = QColor(255, 59, 48)
|
|
319
|
+
danger_border = rgba(danger, 0.45)
|
|
320
|
+
tool_name_color = "#22c55e" if is_dark else "#16a34a"
|
|
321
|
+
indicator_border = rgba(text, 0.38 if is_dark else 0.30)
|
|
322
|
+
|
|
323
|
+
self._suppress_checkbox_updates: bool = False
|
|
324
|
+
|
|
325
|
+
self.setStyleSheet(
|
|
326
|
+
f"""
|
|
327
|
+
QDialog {{
|
|
328
|
+
background: {window_bg};
|
|
329
|
+
color: {text_primary};
|
|
330
|
+
}}
|
|
331
|
+
"""
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
layout = QVBoxLayout(self)
|
|
335
|
+
layout.setContentsMargins(18, 18, 18, 18)
|
|
336
|
+
layout.setSpacing(12)
|
|
337
|
+
|
|
338
|
+
header = QLabel("TOOLS")
|
|
339
|
+
header.setStyleSheet(
|
|
340
|
+
f"""
|
|
341
|
+
QLabel {{
|
|
342
|
+
color: {accent_hex};
|
|
343
|
+
font-size: 11px;
|
|
344
|
+
font-weight: 700;
|
|
345
|
+
}}
|
|
346
|
+
"""
|
|
347
|
+
)
|
|
348
|
+
layout.addWidget(header)
|
|
349
|
+
|
|
350
|
+
subtitle = QLabel("Default is all tools. Switch to a custom allowlist only when needed.")
|
|
351
|
+
subtitle.setWordWrap(True)
|
|
352
|
+
subtitle.setStyleSheet(f"QLabel {{ font-size: 12px; color: {text_secondary}; }}")
|
|
353
|
+
layout.addWidget(subtitle)
|
|
354
|
+
|
|
355
|
+
controls_row = QHBoxLayout()
|
|
356
|
+
controls_row.setSpacing(10)
|
|
357
|
+
|
|
358
|
+
seg_frame = QFrame()
|
|
359
|
+
seg_frame.setStyleSheet(
|
|
360
|
+
f"""
|
|
361
|
+
QFrame {{
|
|
362
|
+
background: {overlay_pressed};
|
|
363
|
+
border: 1px solid {mid_hex};
|
|
364
|
+
border-radius: 14px;
|
|
365
|
+
}}
|
|
366
|
+
"""
|
|
367
|
+
)
|
|
368
|
+
seg_layout = QHBoxLayout(seg_frame)
|
|
369
|
+
seg_layout.setContentsMargins(2, 2, 2, 2)
|
|
370
|
+
seg_layout.setSpacing(2)
|
|
371
|
+
|
|
372
|
+
self.all_mode_btn = QPushButton("All tools")
|
|
373
|
+
self.custom_mode_btn = QPushButton("Custom allowlist")
|
|
374
|
+
for b in (self.all_mode_btn, self.custom_mode_btn):
|
|
375
|
+
b.setCheckable(True)
|
|
376
|
+
try:
|
|
377
|
+
b.setAutoExclusive(True)
|
|
378
|
+
except Exception:
|
|
379
|
+
pass
|
|
380
|
+
b.setFixedHeight(28)
|
|
381
|
+
b.setStyleSheet(
|
|
382
|
+
f"""
|
|
383
|
+
QPushButton {{
|
|
384
|
+
background: transparent;
|
|
385
|
+
border: none;
|
|
386
|
+
border-radius: 12px;
|
|
387
|
+
padding: 0 12px;
|
|
388
|
+
font-size: 12px;
|
|
389
|
+
font-weight: 600;
|
|
390
|
+
color: {text_secondary};
|
|
391
|
+
}}
|
|
392
|
+
QPushButton:hover {{ background: {overlay_hover}; color: {text_primary}; }}
|
|
393
|
+
QPushButton:checked {{ background: {base_bg}; color: {text_primary}; }}
|
|
394
|
+
"""
|
|
395
|
+
)
|
|
396
|
+
self.all_mode_btn.setChecked(self._mode == "all")
|
|
397
|
+
self.custom_mode_btn.setChecked(self._mode == "custom")
|
|
398
|
+
|
|
399
|
+
seg_layout.addWidget(self.all_mode_btn)
|
|
400
|
+
seg_layout.addWidget(self.custom_mode_btn)
|
|
401
|
+
controls_row.addWidget(seg_frame)
|
|
402
|
+
|
|
403
|
+
self.count_pill = QLabel("")
|
|
404
|
+
self.count_pill.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
405
|
+
self.count_pill.setFixedHeight(28)
|
|
406
|
+
self.count_pill.setMinimumWidth(160)
|
|
407
|
+
self.count_pill.setStyleSheet(
|
|
408
|
+
f"""
|
|
409
|
+
QLabel {{
|
|
410
|
+
background: {overlay};
|
|
411
|
+
border: 1px solid {mid_hex};
|
|
412
|
+
border-radius: 14px;
|
|
413
|
+
font-size: 11px;
|
|
414
|
+
font-weight: 600;
|
|
415
|
+
color: {text_secondary};
|
|
416
|
+
padding: 0 12px;
|
|
417
|
+
}}
|
|
418
|
+
"""
|
|
419
|
+
)
|
|
420
|
+
controls_row.addWidget(self.count_pill)
|
|
421
|
+
controls_row.addStretch()
|
|
422
|
+
layout.addLayout(controls_row)
|
|
423
|
+
|
|
424
|
+
self.filter_input = QLineEdit()
|
|
425
|
+
self.filter_input.setPlaceholderText("Filter tools…")
|
|
426
|
+
self.filter_input.setClearButtonEnabled(True)
|
|
427
|
+
self.filter_input.setFixedHeight(34)
|
|
428
|
+
self.filter_input.setStyleSheet(
|
|
429
|
+
f"""
|
|
430
|
+
QLineEdit {{
|
|
431
|
+
background: {overlay_pressed};
|
|
432
|
+
border: 1px solid {mid_hex};
|
|
433
|
+
border-radius: 10px;
|
|
434
|
+
padding: 0 12px;
|
|
435
|
+
font-size: 12px;
|
|
436
|
+
color: {text_primary};
|
|
437
|
+
}}
|
|
438
|
+
QLineEdit:focus {{
|
|
439
|
+
border: 1px solid {accent_hex};
|
|
440
|
+
background: {overlay_hover};
|
|
441
|
+
}}
|
|
442
|
+
"""
|
|
443
|
+
)
|
|
444
|
+
layout.addWidget(self.filter_input)
|
|
445
|
+
|
|
446
|
+
scroll = QScrollArea()
|
|
447
|
+
scroll.setWidgetResizable(True)
|
|
448
|
+
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
|
449
|
+
scroll.setStyleSheet("QScrollArea { background: transparent; }")
|
|
450
|
+
layout.addWidget(scroll, 1)
|
|
451
|
+
|
|
452
|
+
list_root = QWidget()
|
|
453
|
+
list_layout = QVBoxLayout(list_root)
|
|
454
|
+
list_layout.setContentsMargins(0, 0, 0, 0)
|
|
455
|
+
list_layout.setSpacing(10)
|
|
456
|
+
scroll.setWidget(list_root)
|
|
457
|
+
|
|
458
|
+
self._rows: Dict[str, Dict[str, Any]] = {}
|
|
459
|
+
|
|
460
|
+
def _badge(text_value: str, *, fg: str, bg: str, border: str) -> QLabel:
|
|
461
|
+
lab = QLabel(text_value)
|
|
462
|
+
lab.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
463
|
+
lab.setFixedHeight(18)
|
|
464
|
+
lab.setStyleSheet(
|
|
465
|
+
f"""
|
|
466
|
+
QLabel {{
|
|
467
|
+
color: {fg};
|
|
468
|
+
background: {bg};
|
|
469
|
+
border: 1px solid {border};
|
|
470
|
+
border-radius: 9px;
|
|
471
|
+
padding: 0 8px;
|
|
472
|
+
font-size: 10px;
|
|
473
|
+
font-weight: 700;
|
|
474
|
+
}}
|
|
475
|
+
"""
|
|
476
|
+
)
|
|
477
|
+
return lab
|
|
478
|
+
|
|
479
|
+
def _current_selected() -> set[str]:
|
|
480
|
+
return {n for n, info in self._rows.items() if info["checkbox"].isChecked()}
|
|
481
|
+
|
|
482
|
+
def _on_checkbox_changed(_: str) -> None:
|
|
483
|
+
if getattr(self, "_suppress_checkbox_updates", False):
|
|
484
|
+
return
|
|
485
|
+
|
|
486
|
+
selected = _current_selected()
|
|
487
|
+
|
|
488
|
+
# In "All tools" mode, any manual uncheck means the user is starting
|
|
489
|
+
# a custom allowlist. Flip to custom and keep current selection.
|
|
490
|
+
if self._mode == "all" and selected != self._all_names_set:
|
|
491
|
+
self._custom_selected = set(selected)
|
|
492
|
+
_set_mode("custom")
|
|
493
|
+
return
|
|
494
|
+
|
|
495
|
+
if self._mode == "custom":
|
|
496
|
+
self._custom_selected = set(selected)
|
|
497
|
+
|
|
498
|
+
self._update_counts()
|
|
499
|
+
|
|
500
|
+
for info in self._tools:
|
|
501
|
+
name = str(info.get("name") or "").strip()
|
|
502
|
+
if not name:
|
|
503
|
+
continue
|
|
504
|
+
desc = str(info.get("description") or "").strip()
|
|
505
|
+
|
|
506
|
+
row = QFrame()
|
|
507
|
+
row.setObjectName("toolRow")
|
|
508
|
+
row_layout = QHBoxLayout(row)
|
|
509
|
+
row_layout.setContentsMargins(12, 10, 12, 10)
|
|
510
|
+
row_layout.setSpacing(10)
|
|
511
|
+
|
|
512
|
+
cb = QCheckBox()
|
|
513
|
+
cb.setChecked(name in self._custom_selected)
|
|
514
|
+
cb.setStyleSheet(
|
|
515
|
+
f"""
|
|
516
|
+
QCheckBox {{ color: {text_primary}; }}
|
|
517
|
+
QCheckBox::indicator {{
|
|
518
|
+
width: 18px;
|
|
519
|
+
height: 18px;
|
|
520
|
+
border-radius: 4px;
|
|
521
|
+
border: 1px solid {indicator_border};
|
|
522
|
+
background: {overlay};
|
|
523
|
+
}}
|
|
524
|
+
QCheckBox::indicator:hover {{
|
|
525
|
+
background: {overlay_hover};
|
|
526
|
+
border: 1px solid {accent_hex};
|
|
527
|
+
}}
|
|
528
|
+
QCheckBox::indicator:checked {{
|
|
529
|
+
background: {accent_hex};
|
|
530
|
+
border: 1px solid {accent_hover};
|
|
531
|
+
}}
|
|
532
|
+
QCheckBox::indicator:checked:hover {{
|
|
533
|
+
background: {accent_hover};
|
|
534
|
+
border: 1px solid {accent_hover};
|
|
535
|
+
}}
|
|
536
|
+
"""
|
|
537
|
+
)
|
|
538
|
+
cb.stateChanged.connect(lambda _=0, n=name: _on_checkbox_changed(n))
|
|
539
|
+
row_layout.addWidget(cb, 0, Qt.AlignmentFlag.AlignTop)
|
|
540
|
+
|
|
541
|
+
text_col = QWidget()
|
|
542
|
+
text_col_layout = QVBoxLayout(text_col)
|
|
543
|
+
text_col_layout.setContentsMargins(0, 0, 0, 0)
|
|
544
|
+
text_col_layout.setSpacing(4)
|
|
545
|
+
|
|
546
|
+
meta_row = QHBoxLayout()
|
|
547
|
+
meta_row.setContentsMargins(0, 0, 0, 0)
|
|
548
|
+
meta_row.setSpacing(8)
|
|
549
|
+
|
|
550
|
+
name_label = QLabel(name.upper())
|
|
551
|
+
name_label.setStyleSheet(
|
|
552
|
+
f"""
|
|
553
|
+
QLabel {{
|
|
554
|
+
color: {tool_name_color};
|
|
555
|
+
font-size: 12px;
|
|
556
|
+
font-weight: 800;
|
|
557
|
+
font-family: "SF Mono", "Monaco", "Menlo", "Consolas", monospace;
|
|
558
|
+
}}
|
|
559
|
+
"""
|
|
560
|
+
)
|
|
561
|
+
meta_row.addWidget(name_label)
|
|
562
|
+
|
|
563
|
+
if name in self._require_approval:
|
|
564
|
+
meta_row.addWidget(
|
|
565
|
+
_badge("APPROVAL", fg="#ffffff", bg=danger_border, border=danger_border)
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
meta_row.addStretch()
|
|
569
|
+
text_col_layout.addLayout(meta_row)
|
|
570
|
+
|
|
571
|
+
if desc:
|
|
572
|
+
desc_label = QLabel(desc)
|
|
573
|
+
desc_label.setWordWrap(True)
|
|
574
|
+
desc_label.setStyleSheet(f"QLabel {{ color: {text_muted}; font-size: 11px; }}")
|
|
575
|
+
text_col_layout.addWidget(desc_label)
|
|
576
|
+
|
|
577
|
+
row_layout.addWidget(text_col, 1)
|
|
578
|
+
|
|
579
|
+
border_color = danger_border if name in self._require_approval else accent_border
|
|
580
|
+
row.setStyleSheet(
|
|
581
|
+
f"""
|
|
582
|
+
QFrame#toolRow {{
|
|
583
|
+
background: {overlay_pressed};
|
|
584
|
+
border: 1px solid {border_color};
|
|
585
|
+
border-radius: 12px;
|
|
586
|
+
}}
|
|
587
|
+
"""
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
list_layout.addWidget(row)
|
|
591
|
+
self._rows[name] = {"row": row, "checkbox": cb, "desc": desc}
|
|
592
|
+
|
|
593
|
+
list_layout.addStretch(1)
|
|
594
|
+
|
|
595
|
+
footer = QHBoxLayout()
|
|
596
|
+
footer.setSpacing(10)
|
|
597
|
+
footer.addStretch()
|
|
598
|
+
|
|
599
|
+
cancel_btn = QPushButton("Cancel")
|
|
600
|
+
save_btn = QPushButton("Save")
|
|
601
|
+
for b in (cancel_btn, save_btn):
|
|
602
|
+
b.setFixedHeight(34)
|
|
603
|
+
b.setStyleSheet(
|
|
604
|
+
f"""
|
|
605
|
+
QPushButton {{
|
|
606
|
+
background: {overlay};
|
|
607
|
+
border: 1px solid {mid_hex};
|
|
608
|
+
border-radius: 12px;
|
|
609
|
+
padding: 0 16px;
|
|
610
|
+
font-size: 12px;
|
|
611
|
+
font-weight: 700;
|
|
612
|
+
color: {text_primary};
|
|
613
|
+
}}
|
|
614
|
+
QPushButton:hover {{ background: {overlay_hover}; border: 1px solid {accent_hex}; }}
|
|
615
|
+
QPushButton:pressed {{ background: {overlay_pressed}; }}
|
|
616
|
+
"""
|
|
617
|
+
)
|
|
618
|
+
save_btn.setStyleSheet(
|
|
619
|
+
f"""
|
|
620
|
+
QPushButton {{
|
|
621
|
+
background: {accent_hex};
|
|
622
|
+
border: 1px solid {accent_hover};
|
|
623
|
+
border-radius: 12px;
|
|
624
|
+
padding: 0 16px;
|
|
625
|
+
font-size: 12px;
|
|
626
|
+
font-weight: 800;
|
|
627
|
+
color: #ffffff;
|
|
628
|
+
}}
|
|
629
|
+
QPushButton:hover {{ background: {accent_hover}; border: 1px solid {accent_hover}; }}
|
|
630
|
+
QPushButton:pressed {{ background: {accent_pressed}; }}
|
|
631
|
+
"""
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
cancel_btn.clicked.connect(self.reject)
|
|
635
|
+
save_btn.clicked.connect(self.accept)
|
|
636
|
+
footer.addWidget(cancel_btn)
|
|
637
|
+
footer.addWidget(save_btn)
|
|
638
|
+
layout.addLayout(footer)
|
|
639
|
+
|
|
640
|
+
def _set_mode(mode: str) -> None:
|
|
641
|
+
mode = "all" if mode == "all" else "custom"
|
|
642
|
+
self._mode = mode
|
|
643
|
+
self.all_mode_btn.setChecked(self._mode == "all")
|
|
644
|
+
self.custom_mode_btn.setChecked(self._mode == "custom")
|
|
645
|
+
self._suppress_checkbox_updates = True
|
|
646
|
+
try:
|
|
647
|
+
if self._mode == "all":
|
|
648
|
+
self._custom_selected = set(self._all_names_set)
|
|
649
|
+
for _, info in self._rows.items():
|
|
650
|
+
info["checkbox"].setEnabled(True)
|
|
651
|
+
info["checkbox"].setChecked(True)
|
|
652
|
+
else:
|
|
653
|
+
for n, info in self._rows.items():
|
|
654
|
+
info["checkbox"].setEnabled(True)
|
|
655
|
+
info["checkbox"].setChecked(n in self._custom_selected)
|
|
656
|
+
finally:
|
|
657
|
+
self._suppress_checkbox_updates = False
|
|
658
|
+
|
|
659
|
+
self._update_counts()
|
|
660
|
+
|
|
661
|
+
self.all_mode_btn.clicked.connect(lambda _=False: _set_mode("all"))
|
|
662
|
+
self.custom_mode_btn.clicked.connect(lambda _=False: _set_mode("custom"))
|
|
663
|
+
|
|
664
|
+
def _apply_filter() -> None:
|
|
665
|
+
q = (self.filter_input.text() or "").strip().lower()
|
|
666
|
+
for n, info in self._rows.items():
|
|
667
|
+
if not q:
|
|
668
|
+
info["row"].setVisible(True)
|
|
669
|
+
continue
|
|
670
|
+
hay = f"{n}\n{info.get('desc','')}".lower()
|
|
671
|
+
info["row"].setVisible(q in hay)
|
|
672
|
+
|
|
673
|
+
self.filter_input.textChanged.connect(lambda _=None: _apply_filter())
|
|
674
|
+
|
|
675
|
+
self.resize(720, 560)
|
|
676
|
+
_set_mode(self._mode)
|
|
677
|
+
_apply_filter()
|
|
678
|
+
self._update_counts()
|
|
679
|
+
|
|
680
|
+
def _update_counts(self) -> None:
|
|
681
|
+
total = len(self._all_names)
|
|
682
|
+
if self._mode == "all":
|
|
683
|
+
selected = total
|
|
684
|
+
else:
|
|
685
|
+
selected = len({n for n, info in self._rows.items() if info["checkbox"].isChecked()})
|
|
686
|
+
self.count_pill.setText(f"✓ {selected} of {total} selected")
|
|
687
|
+
|
|
688
|
+
def selected_tools(self) -> List[str]:
|
|
689
|
+
if self._mode == "all":
|
|
690
|
+
return list(self._all_names)
|
|
691
|
+
return sorted([n for n, info in self._rows.items() if info["checkbox"].isChecked()])
|
|
692
|
+
|
|
693
|
+
|
|
229
694
|
class LLMWorker(QThread):
|
|
230
695
|
"""Worker thread for LLM processing."""
|
|
231
696
|
|
|
232
697
|
response_ready = pyqtSignal(str)
|
|
233
698
|
error_occurred = pyqtSignal(str)
|
|
234
699
|
|
|
235
|
-
def __init__(self, llm_manager, message, provider, model, media=None):
|
|
700
|
+
def __init__(self, llm_manager, message, provider, model, media=None, debug: bool = False):
|
|
236
701
|
super().__init__()
|
|
237
702
|
self.llm_manager = llm_manager
|
|
238
703
|
self.message = message
|
|
239
704
|
self.provider = provider
|
|
240
705
|
self.model = model
|
|
241
706
|
self.media = media or []
|
|
707
|
+
self.debug = bool(debug)
|
|
242
708
|
|
|
243
709
|
def run(self):
|
|
244
710
|
"""Run LLM processing in background."""
|
|
@@ -264,6 +730,221 @@ class LLMWorker(QThread):
|
|
|
264
730
|
self.error_occurred.emit(str(e))
|
|
265
731
|
|
|
266
732
|
|
|
733
|
+
class AgentWorker(QThread):
|
|
734
|
+
"""Worker thread that drives an AgentHost turn (tick/resume loop)."""
|
|
735
|
+
|
|
736
|
+
event_emitted = pyqtSignal(object) # dict payloads
|
|
737
|
+
error_occurred = pyqtSignal(str)
|
|
738
|
+
|
|
739
|
+
def __init__(
|
|
740
|
+
self,
|
|
741
|
+
*,
|
|
742
|
+
agent_host: AgentHost,
|
|
743
|
+
user_text: str,
|
|
744
|
+
provider: str,
|
|
745
|
+
model: str,
|
|
746
|
+
attachments: Optional[List[str]] = None,
|
|
747
|
+
system_prompt_extra: Optional[str] = None,
|
|
748
|
+
allowed_tools: Optional[List[str]] = None,
|
|
749
|
+
debug: bool = False,
|
|
750
|
+
):
|
|
751
|
+
super().__init__()
|
|
752
|
+
self._agent_host = agent_host
|
|
753
|
+
self._user_text = str(user_text or "")
|
|
754
|
+
self._provider = str(provider or "")
|
|
755
|
+
self._model = str(model or "")
|
|
756
|
+
self._attachments = list(attachments or [])
|
|
757
|
+
self._system_prompt_extra = str(system_prompt_extra) if system_prompt_extra else None
|
|
758
|
+
self._allowed_tools = list(allowed_tools) if allowed_tools is not None else None
|
|
759
|
+
self._debug = bool(debug)
|
|
760
|
+
|
|
761
|
+
self._tool_approval_event = threading.Event()
|
|
762
|
+
self._tool_approval_decision: Optional[bool] = None
|
|
763
|
+
self._ask_user_event = threading.Event()
|
|
764
|
+
self._ask_user_response: Optional[str] = None
|
|
765
|
+
|
|
766
|
+
def provide_tool_approval(self, approved: bool) -> None:
|
|
767
|
+
self._tool_approval_decision = bool(approved)
|
|
768
|
+
self._tool_approval_event.set()
|
|
769
|
+
|
|
770
|
+
def provide_user_response(self, response: str) -> None:
|
|
771
|
+
self._ask_user_response = str(response or "")
|
|
772
|
+
self._ask_user_event.set()
|
|
773
|
+
|
|
774
|
+
def run(self) -> None:
|
|
775
|
+
try:
|
|
776
|
+
def _approve(_tool_calls):
|
|
777
|
+
return bool(self._tool_approval_decision)
|
|
778
|
+
|
|
779
|
+
def _ask_user(_wait):
|
|
780
|
+
return str(self._ask_user_response or "")
|
|
781
|
+
|
|
782
|
+
gen = self._agent_host.run_turn(
|
|
783
|
+
user_text=self._user_text,
|
|
784
|
+
attachments=self._attachments if self._attachments else None,
|
|
785
|
+
provider=self._provider,
|
|
786
|
+
model=self._model,
|
|
787
|
+
system_prompt_extra=self._system_prompt_extra,
|
|
788
|
+
allowed_tools=self._allowed_tools,
|
|
789
|
+
approve_tools=_approve,
|
|
790
|
+
ask_user=_ask_user,
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
while True:
|
|
794
|
+
try:
|
|
795
|
+
ev = next(gen)
|
|
796
|
+
except StopIteration:
|
|
797
|
+
return
|
|
798
|
+
|
|
799
|
+
self.event_emitted.emit(ev)
|
|
800
|
+
|
|
801
|
+
typ = ev.get("type") if isinstance(ev, dict) else None
|
|
802
|
+
if typ == "tool_request":
|
|
803
|
+
tool_calls = ev.get("tool_calls")
|
|
804
|
+
if isinstance(tool_calls, list) and not self._agent_host.tool_policy.requires_approval(tool_calls):
|
|
805
|
+
# Safe/read-only tool batch: auto-approve (no UI prompt required).
|
|
806
|
+
self._tool_approval_decision = True
|
|
807
|
+
continue
|
|
808
|
+
self._tool_approval_decision = None
|
|
809
|
+
self._tool_approval_event.clear()
|
|
810
|
+
self._tool_approval_event.wait()
|
|
811
|
+
if self._tool_approval_decision is None:
|
|
812
|
+
self._tool_approval_decision = False
|
|
813
|
+
continue
|
|
814
|
+
|
|
815
|
+
if typ == "ask_user":
|
|
816
|
+
self._ask_user_response = None
|
|
817
|
+
self._ask_user_event.clear()
|
|
818
|
+
self._ask_user_event.wait()
|
|
819
|
+
if self._ask_user_response is None:
|
|
820
|
+
self._ask_user_response = ""
|
|
821
|
+
continue
|
|
822
|
+
|
|
823
|
+
except Exception as e:
|
|
824
|
+
if self._debug:
|
|
825
|
+
import traceback
|
|
826
|
+
|
|
827
|
+
traceback.print_exc()
|
|
828
|
+
self.error_occurred.emit(str(e))
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
class _MessageInputRow(QWidget):
|
|
832
|
+
"""
|
|
833
|
+
Two-column message input row with a strict-square (1:1) 3-button action column.
|
|
834
|
+
|
|
835
|
+
Design goals (per UX requirements):
|
|
836
|
+
- The action buttons fill the *full* vertical space of the input row (up to the card border)
|
|
837
|
+
- Buttons remain perfectly square while scaling with available height
|
|
838
|
+
- Exactly 1px vertical spacing between the 3 buttons
|
|
839
|
+
- The text input ends exactly where the action column begins (two columns)
|
|
840
|
+
"""
|
|
841
|
+
|
|
842
|
+
def __init__(
|
|
843
|
+
self,
|
|
844
|
+
*,
|
|
845
|
+
parent: Optional[QWidget] = None,
|
|
846
|
+
h_spacing_px: int = 2,
|
|
847
|
+
v_spacing_px: int = 1,
|
|
848
|
+
min_button_px: int = 22,
|
|
849
|
+
min_text_width_px: int = 140,
|
|
850
|
+
) -> None:
|
|
851
|
+
super().__init__(parent)
|
|
852
|
+
self._h_spacing_px = max(0, int(h_spacing_px))
|
|
853
|
+
self._v_spacing_px = max(0, int(v_spacing_px))
|
|
854
|
+
self._min_button_px = max(12, int(min_button_px))
|
|
855
|
+
self._min_text_width_px = max(40, int(min_text_width_px))
|
|
856
|
+
|
|
857
|
+
self.input_text = QTextEdit(self)
|
|
858
|
+
self.attach_button = QPushButton("📎", self)
|
|
859
|
+
self.tools_button = QPushButton("🛠", self)
|
|
860
|
+
self.send_button = QPushButton("→", self)
|
|
861
|
+
self._voice_mode: bool = False
|
|
862
|
+
|
|
863
|
+
# Keep focus/navigation sane.
|
|
864
|
+
for b in (self.attach_button, self.tools_button, self.send_button):
|
|
865
|
+
try:
|
|
866
|
+
b.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
|
867
|
+
except Exception:
|
|
868
|
+
pass
|
|
869
|
+
|
|
870
|
+
def set_voice_mode(self, enabled: bool) -> None:
|
|
871
|
+
"""
|
|
872
|
+
In voice mode we keep the action column, but hide the Send button because
|
|
873
|
+
end-of-sentence detection effectively acts as "send".
|
|
874
|
+
"""
|
|
875
|
+
enabled = bool(enabled)
|
|
876
|
+
if self._voice_mode == enabled:
|
|
877
|
+
return
|
|
878
|
+
self._voice_mode = enabled
|
|
879
|
+
try:
|
|
880
|
+
self.send_button.setVisible(not enabled)
|
|
881
|
+
self.send_button.setEnabled(not enabled)
|
|
882
|
+
except Exception:
|
|
883
|
+
pass
|
|
884
|
+
self.updateGeometry()
|
|
885
|
+
self.update()
|
|
886
|
+
|
|
887
|
+
def _set_button_font_px(self, px: int) -> None:
|
|
888
|
+
px = max(10, int(px))
|
|
889
|
+
try:
|
|
890
|
+
for b in (self.attach_button, self.tools_button, self.send_button):
|
|
891
|
+
f = b.font() if hasattr(b, "font") else QFont()
|
|
892
|
+
try:
|
|
893
|
+
f.setPixelSize(px)
|
|
894
|
+
except Exception:
|
|
895
|
+
# Fallback: point size if pixel sizing isn't available.
|
|
896
|
+
f.setPointSize(max(9, px // 2))
|
|
897
|
+
b.setFont(f)
|
|
898
|
+
except Exception:
|
|
899
|
+
pass
|
|
900
|
+
|
|
901
|
+
def resizeEvent(self, event):
|
|
902
|
+
try:
|
|
903
|
+
super().resizeEvent(event)
|
|
904
|
+
except Exception:
|
|
905
|
+
pass
|
|
906
|
+
|
|
907
|
+
w = int(self.width())
|
|
908
|
+
h = int(self.height())
|
|
909
|
+
if w <= 0 or h <= 0:
|
|
910
|
+
return
|
|
911
|
+
|
|
912
|
+
# Compute a square button size that uses as much vertical space as possible.
|
|
913
|
+
# With N buttons and (N-1) gaps:
|
|
914
|
+
# used_h = N*btn + (N-1)*v_spacing <= h
|
|
915
|
+
action_buttons = (self.attach_button, self.tools_button) if self._voice_mode else (self.attach_button, self.tools_button, self.send_button)
|
|
916
|
+
n = max(1, len(action_buttons))
|
|
917
|
+
btn_from_h = (h - ((n - 1) * self._v_spacing_px)) // n
|
|
918
|
+
btn = max(self._min_button_px, btn_from_h)
|
|
919
|
+
|
|
920
|
+
# Ensure the column also fits horizontally (keep a minimum text width).
|
|
921
|
+
max_btn_from_w = w - self._min_text_width_px - self._h_spacing_px
|
|
922
|
+
if max_btn_from_w <= 0:
|
|
923
|
+
max_btn_from_w = self._min_button_px
|
|
924
|
+
btn = max(self._min_button_px, min(btn, max_btn_from_w))
|
|
925
|
+
|
|
926
|
+
input_w = max(10, w - btn - self._h_spacing_px)
|
|
927
|
+
col_x = input_w + self._h_spacing_px
|
|
928
|
+
|
|
929
|
+
# Place the text input to fill the available height.
|
|
930
|
+
self.input_text.setGeometry(0, 0, input_w, h)
|
|
931
|
+
|
|
932
|
+
# Place the square buttons stacked on the right with strict spacing.
|
|
933
|
+
# We anchor to the top to keep the tiny leftover (0-2px) at the bottom.
|
|
934
|
+
used_h = (n * btn) + ((n - 1) * self._v_spacing_px)
|
|
935
|
+
top = 0
|
|
936
|
+
if used_h < h:
|
|
937
|
+
# If there's slack (usually 0-2px), split it so borders look even.
|
|
938
|
+
top = (h - used_h) // 2
|
|
939
|
+
|
|
940
|
+
for i, b in enumerate(action_buttons):
|
|
941
|
+
y = top + i * (btn + self._v_spacing_px)
|
|
942
|
+
b.setGeometry(col_x, y, btn, btn)
|
|
943
|
+
|
|
944
|
+
# Scale icon glyphs with the square size.
|
|
945
|
+
self._set_button_font_px(int(btn * 0.55))
|
|
946
|
+
|
|
947
|
+
|
|
267
948
|
class QtChatBubble(QWidget):
|
|
268
949
|
"""Modern Qt-based chat bubble."""
|
|
269
950
|
|
|
@@ -273,6 +954,7 @@ class QtChatBubble(QWidget):
|
|
|
273
954
|
self.config = config
|
|
274
955
|
self.debug = debug
|
|
275
956
|
self.listening_mode = listening_mode
|
|
957
|
+
self._theme: Dict[str, Any] = {}
|
|
276
958
|
|
|
277
959
|
# State - default to LMStudio with qwen/qwen3-next-80b
|
|
278
960
|
self.current_provider = 'lmstudio' # Default to LMStudio
|
|
@@ -282,6 +964,11 @@ class QtChatBubble(QWidget):
|
|
|
282
964
|
|
|
283
965
|
# Message history for session management
|
|
284
966
|
self.message_history: List[Dict] = []
|
|
967
|
+
self._session_auto_approve_tools: set[str] = set()
|
|
968
|
+
self._voice_busy: bool = False
|
|
969
|
+
# Full Voice Mode lifecycle: treat "running" as separate from the toggle state so
|
|
970
|
+
# late callbacks cannot keep the UI in LISTENING after a user-initiated stop.
|
|
971
|
+
self._full_voice_running: bool = False
|
|
285
972
|
|
|
286
973
|
# History dialog instance for toggle behavior
|
|
287
974
|
self.history_dialog = None
|
|
@@ -291,6 +978,13 @@ class QtChatBubble(QWidget):
|
|
|
291
978
|
|
|
292
979
|
# Track file attachments per message for history display
|
|
293
980
|
self.message_file_attachments: Dict[int, List[str]] = {}
|
|
981
|
+
|
|
982
|
+
# Tool selection (external tools): controls the per-run allowlist passed to AbstractAgent.
|
|
983
|
+
self._available_external_tools: List[Dict[str, str]] = []
|
|
984
|
+
self._enabled_external_tools: set[str] = set()
|
|
985
|
+
self._safe_external_tools: set[str] = set()
|
|
986
|
+
self._require_approval_tools: set[str] = set()
|
|
987
|
+
self._refresh_tool_inventory()
|
|
294
988
|
|
|
295
989
|
# Initialize new manager classes
|
|
296
990
|
self.provider_manager = None
|
|
@@ -331,6 +1025,19 @@ class QtChatBubble(QWidget):
|
|
|
331
1025
|
self.setup_ui()
|
|
332
1026
|
self.setup_styling()
|
|
333
1027
|
self.load_providers()
|
|
1028
|
+
|
|
1029
|
+
# Bootstrap UI state from the durable active session (tokens/history).
|
|
1030
|
+
try:
|
|
1031
|
+
if self.llm_manager and hasattr(self.llm_manager, "refresh"):
|
|
1032
|
+
self.llm_manager.refresh()
|
|
1033
|
+
except Exception:
|
|
1034
|
+
pass
|
|
1035
|
+
try:
|
|
1036
|
+
self._update_message_history_from_session()
|
|
1037
|
+
self._update_token_count_from_session()
|
|
1038
|
+
self._reload_session_combo()
|
|
1039
|
+
except Exception:
|
|
1040
|
+
pass
|
|
334
1041
|
|
|
335
1042
|
if self.debug:
|
|
336
1043
|
print("✅ QtChatBubble initialized")
|
|
@@ -356,15 +1063,29 @@ class QtChatBubble(QWidget):
|
|
|
356
1063
|
def setup_ui(self):
|
|
357
1064
|
"""Set up the modern user interface with SOTA UX practices."""
|
|
358
1065
|
self.setWindowTitle("AbstractAssistant")
|
|
1066
|
+
self.setObjectName("AbstractAssistantBubble")
|
|
1067
|
+
try:
|
|
1068
|
+
attr = getattr(Qt, "WA_StyledBackground", None)
|
|
1069
|
+
if attr is None and hasattr(Qt, "WidgetAttribute"):
|
|
1070
|
+
attr = Qt.WidgetAttribute.WA_StyledBackground
|
|
1071
|
+
if attr is not None:
|
|
1072
|
+
self.setAttribute(attr, True)
|
|
1073
|
+
except Exception:
|
|
1074
|
+
pass
|
|
359
1075
|
self.setWindowFlags(
|
|
360
1076
|
Qt.WindowType.FramelessWindowHint |
|
|
361
1077
|
Qt.WindowType.WindowStaysOnTopHint |
|
|
362
1078
|
Qt.WindowType.Tool
|
|
363
1079
|
)
|
|
1080
|
+
try:
|
|
1081
|
+
self.setWindowOpacity(0.97)
|
|
1082
|
+
except Exception:
|
|
1083
|
+
pass
|
|
364
1084
|
|
|
365
|
-
# Set optimal size for modern chat interface
|
|
1085
|
+
# Set optimal size for modern chat interface.
|
|
1086
|
+
# Keep the default lightweight and compact: ~15% narrower than the previous 630px.
|
|
366
1087
|
# Initial size - will be adjusted dynamically based on file attachments
|
|
367
|
-
self.base_width =
|
|
1088
|
+
self.base_width = 536
|
|
368
1089
|
self.base_height = 196
|
|
369
1090
|
self.setFixedSize(self.base_width, self.base_height)
|
|
370
1091
|
self.position_near_tray()
|
|
@@ -399,40 +1120,102 @@ class QtChatBubble(QWidget):
|
|
|
399
1120
|
}
|
|
400
1121
|
""")
|
|
401
1122
|
header_layout.addWidget(self.close_button)
|
|
402
|
-
|
|
403
|
-
# Session
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
1123
|
+
|
|
1124
|
+
# Session selector + New session (replaces legacy "Clear" in the header).
|
|
1125
|
+
self.session_combo = QComboBox()
|
|
1126
|
+
self.session_combo.setFixedHeight(22)
|
|
1127
|
+
self.session_combo.setMinimumWidth(160)
|
|
1128
|
+
self.session_combo.setToolTip("Select a session")
|
|
1129
|
+
self.session_combo.setStyleSheet("""
|
|
1130
|
+
QComboBox {
|
|
1131
|
+
background: rgba(255, 255, 255, 0.06);
|
|
1132
|
+
border: none;
|
|
1133
|
+
border-radius: 6px;
|
|
1134
|
+
font-size: 10px;
|
|
1135
|
+
color: rgba(255, 255, 255, 0.8);
|
|
1136
|
+
font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
|
|
1137
|
+
padding: 0 10px;
|
|
1138
|
+
}
|
|
1139
|
+
QComboBox:hover {
|
|
1140
|
+
background: rgba(255, 255, 255, 0.12);
|
|
1141
|
+
color: rgba(255, 255, 255, 0.9);
|
|
1142
|
+
}
|
|
1143
|
+
QComboBox::drop-down {
|
|
1144
|
+
border: none;
|
|
1145
|
+
}
|
|
1146
|
+
""")
|
|
1147
|
+
try:
|
|
1148
|
+
self.session_combo.view().setMinimumWidth(420)
|
|
1149
|
+
except Exception:
|
|
1150
|
+
pass
|
|
1151
|
+
self.session_combo.currentIndexChanged.connect(self._on_session_combo_changed)
|
|
1152
|
+
header_layout.addWidget(self.session_combo)
|
|
1153
|
+
|
|
1154
|
+
self.new_session_button = QPushButton("New")
|
|
1155
|
+
self.new_session_button.setFixedHeight(22)
|
|
1156
|
+
self.new_session_button.setToolTip("Start a new session")
|
|
1157
|
+
self.new_session_button.clicked.connect(self._start_new_session)
|
|
1158
|
+
self.new_session_button.setStyleSheet("""
|
|
1159
|
+
QPushButton {
|
|
1160
|
+
background: rgba(255, 255, 255, 0.06);
|
|
1161
|
+
border: none;
|
|
1162
|
+
border-radius: 6px;
|
|
1163
|
+
font-size: 10px;
|
|
1164
|
+
color: rgba(255, 255, 255, 0.7);
|
|
1165
|
+
font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
|
|
1166
|
+
padding: 0 10px;
|
|
1167
|
+
}
|
|
1168
|
+
QPushButton:hover {
|
|
1169
|
+
background: rgba(255, 255, 255, 0.12);
|
|
1170
|
+
color: rgba(255, 255, 255, 0.9);
|
|
1171
|
+
}
|
|
1172
|
+
""")
|
|
1173
|
+
header_layout.addWidget(self.new_session_button)
|
|
1174
|
+
|
|
1175
|
+
# Overflow menu to reduce header clutter (Load/Save/Debug actions).
|
|
1176
|
+
self.more_button = QPushButton("⋯")
|
|
1177
|
+
self.more_button.setFixedSize(28, 22)
|
|
1178
|
+
self.more_button.setToolTip("More")
|
|
1179
|
+
self.more_button.clicked.connect(self._show_more_menu)
|
|
1180
|
+
self.more_button.setStyleSheet("""
|
|
1181
|
+
QPushButton {
|
|
1182
|
+
background: rgba(255, 255, 255, 0.06);
|
|
1183
|
+
border: none;
|
|
1184
|
+
border-radius: 6px;
|
|
1185
|
+
font-size: 14px;
|
|
1186
|
+
color: rgba(255, 255, 255, 0.7);
|
|
1187
|
+
font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
|
|
1188
|
+
padding: 0px;
|
|
1189
|
+
}
|
|
1190
|
+
QPushButton:hover {
|
|
1191
|
+
background: rgba(255, 255, 255, 0.12);
|
|
1192
|
+
color: rgba(255, 255, 255, 0.9);
|
|
1193
|
+
}
|
|
1194
|
+
""")
|
|
1195
|
+
header_layout.addWidget(self.more_button)
|
|
1196
|
+
|
|
1197
|
+
# Messages/history button (user-facing transcript).
|
|
1198
|
+
self.history_button = QPushButton("💬")
|
|
1199
|
+
self.history_button.setFixedHeight(22)
|
|
1200
|
+
self.history_button.setFixedWidth(28)
|
|
1201
|
+
self.history_button.setToolTip("Messages")
|
|
1202
|
+
self.history_button.clicked.connect(self.show_history)
|
|
1203
|
+
self.history_button.setStyleSheet("""
|
|
1204
|
+
QPushButton {
|
|
1205
|
+
background: rgba(255, 255, 255, 0.06);
|
|
1206
|
+
border: none;
|
|
1207
|
+
border-radius: 11px;
|
|
1208
|
+
font-size: 12px;
|
|
1209
|
+
color: rgba(255, 255, 255, 0.7);
|
|
1210
|
+
font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
|
|
1211
|
+
padding: 0px;
|
|
1212
|
+
}
|
|
1213
|
+
QPushButton:hover {
|
|
1214
|
+
background: rgba(255, 255, 255, 0.12);
|
|
1215
|
+
color: rgba(255, 255, 255, 0.9);
|
|
1216
|
+
}
|
|
1217
|
+
""")
|
|
1218
|
+
header_layout.addWidget(self.history_button)
|
|
436
1219
|
|
|
437
1220
|
# TTS toggle (if available)
|
|
438
1221
|
if self.voice_manager and self.voice_manager.is_available():
|
|
@@ -453,7 +1236,9 @@ class QtChatBubble(QWidget):
|
|
|
453
1236
|
|
|
454
1237
|
# Status (Cursor-style, enlarged to show full text including "Processing")
|
|
455
1238
|
self.status_label = QLabel("READY")
|
|
456
|
-
self.status_label.
|
|
1239
|
+
self.status_label.setFixedHeight(24)
|
|
1240
|
+
self.status_label.setMinimumWidth(92)
|
|
1241
|
+
self.status_label.setMaximumWidth(120)
|
|
457
1242
|
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
458
1243
|
self.status_label.setStyleSheet("""
|
|
459
1244
|
QLabel {
|
|
@@ -472,94 +1257,106 @@ class QtChatBubble(QWidget):
|
|
|
472
1257
|
|
|
473
1258
|
# Input section with modern card design
|
|
474
1259
|
self.input_container = QFrame()
|
|
1260
|
+
self.input_container.setObjectName("inputContainer")
|
|
475
1261
|
self.input_container.setStyleSheet("""
|
|
476
1262
|
QFrame {
|
|
477
1263
|
background: #2a2a2a;
|
|
478
1264
|
border: 1px solid #404040;
|
|
479
|
-
border-radius:
|
|
1265
|
+
border-radius: 6px;
|
|
480
1266
|
padding: 4px;
|
|
481
1267
|
}
|
|
482
1268
|
""")
|
|
483
1269
|
input_layout = QVBoxLayout(self.input_container)
|
|
484
|
-
|
|
485
|
-
input_layout.
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
1270
|
+
# Keep this tight so the right-side action column visually reaches the card border.
|
|
1271
|
+
input_layout.setContentsMargins(2, 2, 2, 2)
|
|
1272
|
+
input_layout.setSpacing(2)
|
|
1273
|
+
|
|
1274
|
+
# Deterministic manual-geometry input row (no layout race on first show).
|
|
1275
|
+
self._input_row = _MessageInputRow(parent=self.input_container, h_spacing_px=2, v_spacing_px=1)
|
|
1276
|
+
self._input_row.setMinimumHeight(86)
|
|
1277
|
+
input_layout.addWidget(self._input_row, 1)
|
|
1278
|
+
|
|
1279
|
+
# Expose the child widgets on the bubble for the rest of the codebase.
|
|
1280
|
+
self.input_text = self._input_row.input_text
|
|
1281
|
+
self.attach_button = self._input_row.attach_button
|
|
1282
|
+
self.tools_button = self._input_row.tools_button
|
|
1283
|
+
self.send_button = self._input_row.send_button
|
|
490
1284
|
|
|
491
|
-
|
|
492
|
-
|
|
1285
|
+
self.input_text.setPlaceholderText("Ask me anything... (Shift+Enter to send)")
|
|
1286
|
+
try:
|
|
1287
|
+
self.input_text.installEventFilter(self)
|
|
1288
|
+
except Exception:
|
|
1289
|
+
pass
|
|
1290
|
+
|
|
1291
|
+
# Wiring
|
|
493
1292
|
self.attach_button.clicked.connect(self.attach_files)
|
|
494
|
-
self.attach_button.setFixedSize(36, 36)
|
|
495
1293
|
self.attach_button.setToolTip("Attach files (images, PDFs, Office docs, etc.)")
|
|
496
|
-
self.
|
|
1294
|
+
self.tools_button.clicked.connect(self.open_tool_selector)
|
|
1295
|
+
self.tools_button.setToolTip("Tools")
|
|
1296
|
+
self.send_button.clicked.connect(self.send_message)
|
|
1297
|
+
|
|
1298
|
+
# Styling (theme methods will override these too; these are safe defaults for first paint)
|
|
1299
|
+
self.attach_button.setStyleSheet(
|
|
1300
|
+
"""
|
|
497
1301
|
QPushButton {
|
|
498
1302
|
background: rgba(255, 255, 255, 0.08);
|
|
499
1303
|
border: 1px solid #404040;
|
|
500
|
-
border-radius:
|
|
501
|
-
|
|
502
|
-
color: rgba(255, 255, 255, 0.7);
|
|
503
|
-
text-align: center;
|
|
1304
|
+
border-radius: 4px;
|
|
1305
|
+
color: rgba(255, 255, 255, 0.75);
|
|
504
1306
|
padding: 0px;
|
|
1307
|
+
margin: 0px;
|
|
505
1308
|
}
|
|
506
|
-
|
|
507
1309
|
QPushButton:hover {
|
|
508
1310
|
background: rgba(255, 255, 255, 0.12);
|
|
509
1311
|
border: 1px solid #0066cc;
|
|
510
|
-
color: rgba(255, 255, 255, 0.
|
|
1312
|
+
color: rgba(255, 255, 255, 0.95);
|
|
511
1313
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
input_row.addWidget(self.attach_button)
|
|
518
|
-
|
|
519
|
-
# Text input - larger, primary focus
|
|
520
|
-
self.input_text = QTextEdit()
|
|
521
|
-
self.input_text.setPlaceholderText("Ask me anything... (Shift+Enter to send)")
|
|
522
|
-
self.input_text.setMaximumHeight(100) # Increased to better use available space
|
|
523
|
-
self.input_text.setMinimumHeight(70) # Increased to better use available space
|
|
524
|
-
input_row.addWidget(self.input_text)
|
|
525
|
-
|
|
526
|
-
# Send button - primary action with special styling
|
|
527
|
-
self.send_button = QPushButton("→")
|
|
528
|
-
self.send_button.clicked.connect(self.send_message)
|
|
529
|
-
self.send_button.setFixedSize(40, 40)
|
|
530
|
-
self.send_button.setStyleSheet("""
|
|
1314
|
+
QPushButton:pressed { background: rgba(255, 255, 255, 0.06); }
|
|
1315
|
+
"""
|
|
1316
|
+
)
|
|
1317
|
+
self.tools_button.setStyleSheet(
|
|
1318
|
+
"""
|
|
531
1319
|
QPushButton {
|
|
532
|
-
background:
|
|
533
|
-
border: 1px solid #
|
|
534
|
-
border-radius:
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
padding: 0px;
|
|
540
|
-
}
|
|
541
|
-
|
|
1320
|
+
background: rgba(255, 255, 255, 0.08);
|
|
1321
|
+
border: 1px solid #404040;
|
|
1322
|
+
border-radius: 4px;
|
|
1323
|
+
color: rgba(255, 255, 255, 0.75);
|
|
1324
|
+
padding: 0px;
|
|
1325
|
+
margin: 0px;
|
|
1326
|
+
}
|
|
542
1327
|
QPushButton:hover {
|
|
543
|
-
background:
|
|
544
|
-
border: 1px solid #
|
|
1328
|
+
background: rgba(255, 255, 255, 0.12);
|
|
1329
|
+
border: 1px solid #7c3aed;
|
|
1330
|
+
color: rgba(255, 255, 255, 0.95);
|
|
545
1331
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
1332
|
+
QPushButton:pressed { background: rgba(255, 255, 255, 0.06); }
|
|
1333
|
+
"""
|
|
1334
|
+
)
|
|
1335
|
+
self.send_button.setStyleSheet(
|
|
1336
|
+
"""
|
|
1337
|
+
QPushButton {
|
|
1338
|
+
background: #0066cc;
|
|
1339
|
+
border: 1px solid #0080ff;
|
|
1340
|
+
border-radius: 4px;
|
|
1341
|
+
font-weight: 700;
|
|
1342
|
+
color: #ffffff;
|
|
1343
|
+
padding: 0px;
|
|
1344
|
+
margin: 0px;
|
|
549
1345
|
}
|
|
550
|
-
|
|
1346
|
+
QPushButton:hover { background: #0080ff; border: 1px solid #0099ff; }
|
|
1347
|
+
QPushButton:pressed { background: #0052a3; }
|
|
551
1348
|
QPushButton:disabled {
|
|
552
1349
|
background: #404040;
|
|
553
1350
|
color: #666666;
|
|
554
1351
|
border: 1px solid #333333;
|
|
555
1352
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
input_layout.addLayout(input_row)
|
|
1353
|
+
"""
|
|
1354
|
+
)
|
|
1355
|
+
self._update_tools_button_state()
|
|
560
1356
|
|
|
561
1357
|
# Attached files display area (initially hidden)
|
|
562
1358
|
self.attached_files_container = QFrame()
|
|
1359
|
+
self.attached_files_container.setObjectName("attachedFilesContainer")
|
|
563
1360
|
self.attached_files_container.setStyleSheet("""
|
|
564
1361
|
QFrame {
|
|
565
1362
|
background: rgba(255, 255, 255, 0.04);
|
|
@@ -615,6 +1412,7 @@ class QtChatBubble(QWidget):
|
|
|
615
1412
|
self.model_combo.currentTextChanged.connect(self.on_model_changed)
|
|
616
1413
|
self.model_combo.setFixedHeight(28)
|
|
617
1414
|
self.model_combo.setMinimumWidth(140)
|
|
1415
|
+
self.model_combo.view().setMinimumWidth(380) # Wider dropdown to show full model names
|
|
618
1416
|
self.model_combo.setStyleSheet("""
|
|
619
1417
|
QComboBox {
|
|
620
1418
|
background: rgba(255, 255, 255, 0.08);
|
|
@@ -674,210 +1472,364 @@ class QtChatBubble(QWidget):
|
|
|
674
1472
|
|
|
675
1473
|
# Enter key handling
|
|
676
1474
|
self.input_text.keyPressEvent = self.handle_key_press
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
1475
|
+
|
|
1476
|
+
# Populate session selector (durable multi-session).
|
|
1477
|
+
try:
|
|
1478
|
+
self._reload_session_combo(select_session_id=getattr(self.llm_manager, "active_session_id", None))
|
|
1479
|
+
except Exception:
|
|
1480
|
+
pass
|
|
1481
|
+
|
|
1482
|
+
def _compute_theme(self) -> Dict[str, Any]:
|
|
1483
|
+
palette = QApplication.instance().palette() if QApplication.instance() else self.palette()
|
|
1484
|
+
window = palette.window().color()
|
|
1485
|
+
base = palette.base().color()
|
|
1486
|
+
mid = palette.mid().color()
|
|
1487
|
+
text = palette.text().color()
|
|
1488
|
+
accent = palette.highlight().color()
|
|
1489
|
+
|
|
1490
|
+
is_dark = window.lightness() < 128
|
|
1491
|
+
|
|
1492
|
+
def rgba(color: QColor, alpha: float) -> str:
|
|
1493
|
+
return f"rgba({color.red()}, {color.green()}, {color.blue()}, {alpha})"
|
|
1494
|
+
|
|
1495
|
+
overlay = "rgba(255, 255, 255, 0.08)" if is_dark else "rgba(0, 0, 0, 0.06)"
|
|
1496
|
+
overlay_hover = "rgba(255, 255, 255, 0.12)" if is_dark else "rgba(0, 0, 0, 0.10)"
|
|
1497
|
+
overlay_pressed = "rgba(255, 255, 255, 0.06)" if is_dark else "rgba(0, 0, 0, 0.04)"
|
|
1498
|
+
|
|
1499
|
+
focus_bg = base.lighter(112) if is_dark else base.darker(102)
|
|
1500
|
+
accent_hover = accent.lighter(115)
|
|
1501
|
+
accent_pressed = accent.darker(115)
|
|
1502
|
+
|
|
1503
|
+
return {
|
|
1504
|
+
"is_dark": is_dark,
|
|
1505
|
+
"window_bg": window.name(),
|
|
1506
|
+
"surface_bg": base.name(),
|
|
1507
|
+
"surface_focus_bg": focus_bg.name(),
|
|
1508
|
+
"border": mid.name(),
|
|
1509
|
+
"text_primary": rgba(text, 0.9),
|
|
1510
|
+
"text_secondary": rgba(text, 0.72),
|
|
1511
|
+
"text_muted": rgba(text, 0.55 if is_dark else 0.5),
|
|
1512
|
+
"accent": accent.name(),
|
|
1513
|
+
"accent_hover": accent_hover.name(),
|
|
1514
|
+
"accent_pressed": accent_pressed.name(),
|
|
1515
|
+
"accent_rgba_12": rgba(accent, 0.12),
|
|
1516
|
+
"accent_rgba_20": rgba(accent, 0.20),
|
|
1517
|
+
"accent_rgba_35": rgba(accent, 0.35),
|
|
1518
|
+
"overlay": overlay,
|
|
1519
|
+
"overlay_hover": overlay_hover,
|
|
1520
|
+
"overlay_pressed": overlay_pressed,
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
def _apply_theme(self) -> None:
|
|
1524
|
+
t = self._theme or self._compute_theme()
|
|
1525
|
+
|
|
1526
|
+
input_focused = False
|
|
1527
|
+
try:
|
|
1528
|
+
input_focused = bool(getattr(self, "input_text", None) and self.input_text.hasFocus())
|
|
1529
|
+
except Exception:
|
|
1530
|
+
input_focused = False
|
|
1531
|
+
input_border = t["accent"] if input_focused else t["border"]
|
|
1532
|
+
|
|
1533
|
+
# Window
|
|
1534
|
+
self.setStyleSheet(
|
|
1535
|
+
f"""
|
|
1536
|
+
QWidget#AbstractAssistantBubble {{
|
|
1537
|
+
background: {t['window_bg']};
|
|
1538
|
+
border: 1px solid {t['border']};
|
|
685
1539
|
border-radius: 12px;
|
|
686
|
-
color:
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
|
|
699
|
-
selection-background-color: #0066cc;
|
|
700
|
-
line-height: 1.4;
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
QTextEdit:focus {
|
|
704
|
-
border: 1px solid #0066cc;
|
|
705
|
-
background: #333333;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
QTextEdit::placeholder {
|
|
709
|
-
color: rgba(255, 255, 255, 0.6);
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
/* Buttons - Grey Theme */
|
|
713
|
-
QPushButton {
|
|
714
|
-
background: #404040;
|
|
715
|
-
border: 1px solid #555555;
|
|
716
|
-
border-radius: 4px;
|
|
717
|
-
padding: 4px 8px;
|
|
718
|
-
font-size: 11px;
|
|
719
|
-
font-weight: 500;
|
|
720
|
-
color: #ffffff;
|
|
721
|
-
font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
QPushButton:hover {
|
|
725
|
-
background: #505050;
|
|
726
|
-
border: 1px solid #0066cc;
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
QPushButton:pressed {
|
|
730
|
-
background: #353535;
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
QPushButton:disabled {
|
|
734
|
-
background: #2a2a2a;
|
|
735
|
-
color: #666666;
|
|
736
|
-
border: 1px solid #333333;
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
/* Dropdown Menus - Grey Theme */
|
|
740
|
-
QComboBox {
|
|
741
|
-
background: #1e1e1e;
|
|
742
|
-
border: 1px solid #404040;
|
|
1540
|
+
color: {t['text_primary']};
|
|
1541
|
+
}}
|
|
1542
|
+
"""
|
|
1543
|
+
)
|
|
1544
|
+
|
|
1545
|
+
# Input container + text input
|
|
1546
|
+
if hasattr(self, "input_container"):
|
|
1547
|
+
self.input_container.setStyleSheet(
|
|
1548
|
+
f"""
|
|
1549
|
+
QFrame#inputContainer {{
|
|
1550
|
+
background: {t['surface_bg']};
|
|
1551
|
+
border: 1px solid {input_border};
|
|
743
1552
|
border-radius: 6px;
|
|
744
|
-
padding:
|
|
745
|
-
|
|
746
|
-
|
|
1553
|
+
padding: 4px;
|
|
1554
|
+
}}
|
|
1555
|
+
"""
|
|
1556
|
+
)
|
|
1557
|
+
if hasattr(self, "input_text"):
|
|
1558
|
+
self.input_text.setStyleSheet(
|
|
1559
|
+
f"""
|
|
1560
|
+
QTextEdit {{
|
|
1561
|
+
background: transparent;
|
|
1562
|
+
border: none;
|
|
1563
|
+
padding: 4px 8px;
|
|
1564
|
+
font-size: 14px;
|
|
747
1565
|
font-weight: 400;
|
|
748
|
-
color:
|
|
1566
|
+
color: {t['text_primary']};
|
|
749
1567
|
font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
1568
|
+
selection-background-color: {t['accent']};
|
|
1569
|
+
line-height: 1.4;
|
|
1570
|
+
}}
|
|
1571
|
+
|
|
1572
|
+
QTextEdit:focus {{
|
|
1573
|
+
background: transparent;
|
|
1574
|
+
}}
|
|
1575
|
+
|
|
1576
|
+
QTextEdit::placeholder {{
|
|
1577
|
+
color: {t['text_muted']};
|
|
1578
|
+
}}
|
|
1579
|
+
"""
|
|
1580
|
+
)
|
|
1581
|
+
|
|
1582
|
+
# Header controls
|
|
1583
|
+
pill_qss = f"""
|
|
1584
|
+
background: {t['overlay_pressed']};
|
|
1585
|
+
border: none;
|
|
1586
|
+
border-radius: 11px;
|
|
1587
|
+
font-size: 10px;
|
|
1588
|
+
color: {t['text_secondary']};
|
|
1589
|
+
font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
|
|
1590
|
+
padding: 0 10px;
|
|
1591
|
+
"""
|
|
1592
|
+
pill_hover = f"background: {t['overlay_hover']}; color: {t['text_primary']};"
|
|
1593
|
+
|
|
1594
|
+
if hasattr(self, "session_combo"):
|
|
1595
|
+
self.session_combo.setStyleSheet(
|
|
1596
|
+
f"""
|
|
1597
|
+
QComboBox {{ {pill_qss} }}
|
|
1598
|
+
QComboBox:hover {{ {pill_hover} }}
|
|
1599
|
+
QComboBox::drop-down {{ border: none; }}
|
|
1600
|
+
"""
|
|
1601
|
+
)
|
|
1602
|
+
|
|
1603
|
+
if hasattr(self, "new_session_button"):
|
|
1604
|
+
self.new_session_button.setStyleSheet(
|
|
1605
|
+
f"""
|
|
1606
|
+
QPushButton {{ {pill_qss} }}
|
|
1607
|
+
QPushButton:hover {{ {pill_hover} }}
|
|
1608
|
+
"""
|
|
1609
|
+
)
|
|
1610
|
+
|
|
1611
|
+
if hasattr(self, "more_button"):
|
|
1612
|
+
self.more_button.setStyleSheet(
|
|
1613
|
+
f"""
|
|
1614
|
+
QPushButton {{
|
|
1615
|
+
background: {t['overlay_pressed']};
|
|
785
1616
|
border: none;
|
|
1617
|
+
border-radius: 11px;
|
|
1618
|
+
font-size: 16px;
|
|
1619
|
+
color: {t['text_secondary']};
|
|
1620
|
+
font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
|
|
1621
|
+
padding: 0px;
|
|
1622
|
+
}}
|
|
1623
|
+
QPushButton:hover {{
|
|
1624
|
+
background: {t['overlay_hover']};
|
|
1625
|
+
color: {t['text_primary']};
|
|
1626
|
+
}}
|
|
1627
|
+
"""
|
|
1628
|
+
)
|
|
1629
|
+
|
|
1630
|
+
if hasattr(self, "history_button"):
|
|
1631
|
+
self.history_button.setStyleSheet(
|
|
1632
|
+
f"""
|
|
1633
|
+
QPushButton {{
|
|
1634
|
+
background: {t['overlay_pressed']};
|
|
1635
|
+
border: none;
|
|
1636
|
+
border-radius: 11px;
|
|
786
1637
|
font-size: 12px;
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1638
|
+
color: {t['text_secondary']};
|
|
1639
|
+
font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
|
|
1640
|
+
padding: 0px;
|
|
1641
|
+
}}
|
|
1642
|
+
QPushButton:hover {{
|
|
1643
|
+
background: {t['overlay_hover']};
|
|
1644
|
+
color: {t['text_primary']};
|
|
1645
|
+
}}
|
|
1646
|
+
"""
|
|
1647
|
+
)
|
|
1648
|
+
|
|
1649
|
+
if hasattr(self, "close_button"):
|
|
1650
|
+
self.close_button.setStyleSheet(
|
|
1651
|
+
f"""
|
|
1652
|
+
QPushButton {{
|
|
1653
|
+
background: {t['overlay_hover']};
|
|
1654
|
+
border: none;
|
|
1655
|
+
border-radius: 12px;
|
|
1656
|
+
font-size: 14px;
|
|
1657
|
+
font-weight: 600;
|
|
1658
|
+
color: {t['text_primary']};
|
|
1659
|
+
font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
|
|
1660
|
+
}}
|
|
1661
|
+
QPushButton:hover {{
|
|
1662
|
+
background: rgba(255, 60, 60, 0.85);
|
|
795
1663
|
color: #ffffff;
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
border: none;
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
1664
|
+
}}
|
|
1665
|
+
QPushButton:pressed {{
|
|
1666
|
+
background: rgba(255, 60, 60, 0.65);
|
|
1667
|
+
}}
|
|
1668
|
+
"""
|
|
1669
|
+
)
|
|
1670
|
+
|
|
1671
|
+
# Input action buttons (stretch to fill vertical space)
|
|
1672
|
+
icon_btn_qss = f"""
|
|
1673
|
+
QPushButton {{
|
|
1674
|
+
background: {t['overlay']};
|
|
1675
|
+
border: 1px solid {t['border']};
|
|
1676
|
+
border-radius: 4px;
|
|
1677
|
+
color: {t['text_secondary']};
|
|
1678
|
+
text-align: center;
|
|
1679
|
+
padding: 0px;
|
|
1680
|
+
margin: 0px;
|
|
1681
|
+
}}
|
|
1682
|
+
QPushButton:hover {{
|
|
1683
|
+
background: {t['overlay_hover']};
|
|
1684
|
+
border: 1px solid {t['accent']};
|
|
1685
|
+
color: {t['text_primary']};
|
|
1686
|
+
}}
|
|
1687
|
+
QPushButton:pressed {{
|
|
1688
|
+
background: {t['overlay_pressed']};
|
|
1689
|
+
}}
|
|
1690
|
+
"""
|
|
1691
|
+
if hasattr(self, "attach_button"):
|
|
1692
|
+
self.attach_button.setStyleSheet(icon_btn_qss)
|
|
1693
|
+
|
|
1694
|
+
# tools_button style is handled via _update_tools_button_state()
|
|
1695
|
+
|
|
1696
|
+
if hasattr(self, "send_button"):
|
|
1697
|
+
self.send_button.setStyleSheet(
|
|
1698
|
+
f"""
|
|
1699
|
+
QPushButton {{
|
|
1700
|
+
background: {t['accent']};
|
|
1701
|
+
border: 1px solid {t['accent_hover']};
|
|
1702
|
+
border-radius: 4px;
|
|
1703
|
+
font-weight: bold;
|
|
1704
|
+
color: #ffffff;
|
|
1705
|
+
text-align: center;
|
|
1706
|
+
padding: 0px;
|
|
1707
|
+
margin: 0px;
|
|
1708
|
+
}}
|
|
1709
|
+
QPushButton:hover {{
|
|
1710
|
+
background: {t['accent_hover']};
|
|
1711
|
+
border: 1px solid {t['accent_hover']};
|
|
1712
|
+
}}
|
|
1713
|
+
QPushButton:pressed {{
|
|
1714
|
+
background: {t['accent_pressed']};
|
|
1715
|
+
}}
|
|
1716
|
+
QPushButton:disabled {{
|
|
1717
|
+
background: {t['overlay_pressed']};
|
|
1718
|
+
color: {t['text_muted']};
|
|
1719
|
+
border: 1px solid {t['border']};
|
|
1720
|
+
}}
|
|
1721
|
+
"""
|
|
1722
|
+
)
|
|
1723
|
+
|
|
1724
|
+
# Bottom controls
|
|
1725
|
+
if hasattr(self, "provider_combo"):
|
|
1726
|
+
self.provider_combo.setStyleSheet(
|
|
1727
|
+
f"""
|
|
1728
|
+
QComboBox {{
|
|
1729
|
+
background: {t['overlay']};
|
|
1730
|
+
border: none;
|
|
1731
|
+
border-radius: 14px;
|
|
1732
|
+
padding: 0 8px;
|
|
1733
|
+
font-size: 11px;
|
|
1734
|
+
color: {t['text_primary']};
|
|
1735
|
+
font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
|
|
1736
|
+
}}
|
|
1737
|
+
QComboBox:hover {{ background: {t['overlay_hover']}; }}
|
|
1738
|
+
QComboBox::drop-down {{ border: none; width: 20px; }}
|
|
1739
|
+
QComboBox::down-arrow {{ image: none; border: none; width: 0px; }}
|
|
1740
|
+
"""
|
|
1741
|
+
)
|
|
1742
|
+
|
|
1743
|
+
if hasattr(self, "model_combo"):
|
|
1744
|
+
self.model_combo.setStyleSheet(
|
|
1745
|
+
f"""
|
|
1746
|
+
QComboBox {{
|
|
1747
|
+
background: {t['overlay']};
|
|
1748
|
+
border: none;
|
|
1749
|
+
border-radius: 14px;
|
|
1750
|
+
padding: 0 8px;
|
|
1751
|
+
font-size: 11px;
|
|
1752
|
+
color: {t['text_primary']};
|
|
1753
|
+
font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
|
|
1754
|
+
}}
|
|
1755
|
+
QComboBox:hover {{ background: {t['overlay_hover']}; }}
|
|
1756
|
+
QComboBox::drop-down {{ border: none; width: 20px; }}
|
|
1757
|
+
QComboBox::down-arrow {{ image: none; border: none; width: 0px; }}
|
|
1758
|
+
"""
|
|
1759
|
+
)
|
|
1760
|
+
|
|
1761
|
+
if hasattr(self, "token_label"):
|
|
1762
|
+
self.token_label.setStyleSheet(
|
|
1763
|
+
f"""
|
|
1764
|
+
QLabel {{
|
|
1765
|
+
background: {t['overlay_pressed']};
|
|
1766
|
+
border: none;
|
|
1767
|
+
border-radius: 14px;
|
|
1768
|
+
font-size: 12px;
|
|
1769
|
+
color: {t['text_muted']};
|
|
1770
|
+
font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
|
|
1771
|
+
}}
|
|
1772
|
+
"""
|
|
1773
|
+
)
|
|
1774
|
+
|
|
1775
|
+
# Attached files container
|
|
1776
|
+
if hasattr(self, "attached_files_container"):
|
|
1777
|
+
self.attached_files_container.setStyleSheet(
|
|
1778
|
+
f"""
|
|
1779
|
+
QFrame#attachedFilesContainer {{
|
|
1780
|
+
background: {t['overlay_pressed']};
|
|
1781
|
+
border: 1px solid {t['border']};
|
|
1782
|
+
border-radius: 6px;
|
|
1783
|
+
padding: 4px;
|
|
1784
|
+
}}
|
|
1785
|
+
"""
|
|
1786
|
+
)
|
|
1787
|
+
|
|
1788
|
+
def eventFilter(self, obj, event):
|
|
1789
|
+
try:
|
|
1790
|
+
if obj is getattr(self, "input_text", None):
|
|
1791
|
+
etype = event.type()
|
|
1792
|
+
focus_in = getattr(QEvent, "FocusIn", None)
|
|
1793
|
+
focus_out = getattr(QEvent, "FocusOut", None)
|
|
1794
|
+
if focus_in is None and hasattr(QEvent, "Type"):
|
|
1795
|
+
focus_in = QEvent.Type.FocusIn
|
|
1796
|
+
focus_out = QEvent.Type.FocusOut
|
|
1797
|
+
if etype in {focus_in, focus_out}:
|
|
1798
|
+
try:
|
|
1799
|
+
self._apply_theme()
|
|
1800
|
+
except Exception:
|
|
1801
|
+
pass
|
|
1802
|
+
except Exception:
|
|
1803
|
+
pass
|
|
1804
|
+
return super().eventFilter(obj, event)
|
|
1805
|
+
|
|
1806
|
+
def changeEvent(self, event):
|
|
1807
|
+
try:
|
|
1808
|
+
etype = event.type()
|
|
1809
|
+
palette_change = getattr(QEvent, "PaletteChange", None)
|
|
1810
|
+
app_palette_change = getattr(QEvent, "ApplicationPaletteChange", None)
|
|
1811
|
+
if palette_change is None and hasattr(QEvent, "Type"):
|
|
1812
|
+
palette_change = QEvent.Type.PaletteChange
|
|
1813
|
+
app_palette_change = QEvent.Type.ApplicationPaletteChange
|
|
1814
|
+
if etype in {palette_change, app_palette_change}:
|
|
1815
|
+
self._theme = self._compute_theme()
|
|
1816
|
+
self._apply_theme()
|
|
1817
|
+
try:
|
|
1818
|
+
self._update_tools_button_state()
|
|
1819
|
+
except Exception:
|
|
1820
|
+
pass
|
|
1821
|
+
except Exception:
|
|
1822
|
+
pass
|
|
1823
|
+
super().changeEvent(event)
|
|
1824
|
+
|
|
1825
|
+
def setup_styling(self):
|
|
1826
|
+
"""Apply a system-aware theme (follows OS light/dark)."""
|
|
1827
|
+
self._theme = self._compute_theme()
|
|
1828
|
+
self._apply_theme()
|
|
1829
|
+
try:
|
|
1830
|
+
self._update_tools_button_state()
|
|
1831
|
+
except Exception:
|
|
1832
|
+
pass
|
|
881
1833
|
|
|
882
1834
|
def position_near_tray(self):
|
|
883
1835
|
"""Position the bubble near the system tray."""
|
|
@@ -986,7 +1938,7 @@ class QtChatBubble(QWidget):
|
|
|
986
1938
|
|
|
987
1939
|
# Add models to dropdown with display names
|
|
988
1940
|
for model in models:
|
|
989
|
-
display_name = self.provider_manager.create_model_display_name(model, max_length=
|
|
1941
|
+
display_name = self.provider_manager.create_model_display_name(model, max_length=55)
|
|
990
1942
|
self.model_combo.addItem(display_name, model)
|
|
991
1943
|
|
|
992
1944
|
# Set preferred model
|
|
@@ -1014,9 +1966,10 @@ class QtChatBubble(QWidget):
|
|
|
1014
1966
|
models = get_available_models_for_provider(self.current_provider)
|
|
1015
1967
|
|
|
1016
1968
|
for model in models:
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1969
|
+
# Use full model name (preserving provider prefix)
|
|
1970
|
+
display_name = model
|
|
1971
|
+
if len(display_name) > 55:
|
|
1972
|
+
display_name = display_name[:52] + "..."
|
|
1020
1973
|
self.model_combo.addItem(display_name, model)
|
|
1021
1974
|
|
|
1022
1975
|
if self.model_combo.count() > 0:
|
|
@@ -1047,15 +2000,46 @@ class QtChatBubble(QWidget):
|
|
|
1047
2000
|
|
|
1048
2001
|
def update_token_limits(self):
|
|
1049
2002
|
"""Update token limits using AbstractCore's built-in detection."""
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
2003
|
+
max_tokens = None
|
|
2004
|
+
source = None
|
|
2005
|
+
|
|
2006
|
+
# Preferred: AbstractCore model capabilities (model_capabilities.json).
|
|
2007
|
+
try:
|
|
2008
|
+
from abstractcore.architectures.detection import get_model_capabilities
|
|
2009
|
+
|
|
2010
|
+
caps = get_model_capabilities(str(self.current_model or ""))
|
|
2011
|
+
mt = caps.get("max_tokens") if isinstance(caps, dict) else None
|
|
2012
|
+
if isinstance(mt, int) and mt > 0:
|
|
2013
|
+
max_tokens = int(mt)
|
|
2014
|
+
source = "abstractcore:model_capabilities"
|
|
2015
|
+
except Exception:
|
|
2016
|
+
max_tokens = None
|
|
2017
|
+
|
|
2018
|
+
# Fallback: provider instance (best-effort; may be lazy/unavailable).
|
|
2019
|
+
if max_tokens is None:
|
|
2020
|
+
try:
|
|
2021
|
+
llm = getattr(self.llm_manager, "llm", None)
|
|
2022
|
+
mt = getattr(llm, "max_tokens", None)
|
|
2023
|
+
if isinstance(mt, int) and mt > 0:
|
|
2024
|
+
max_tokens = int(mt)
|
|
2025
|
+
source = "provider"
|
|
2026
|
+
except Exception:
|
|
2027
|
+
max_tokens = None
|
|
2028
|
+
|
|
2029
|
+
# Final fallback: keep UI stable even for unknown models.
|
|
2030
|
+
if max_tokens is None:
|
|
2031
|
+
max_tokens = 128000
|
|
2032
|
+
source = "fallback"
|
|
2033
|
+
|
|
2034
|
+
self.max_tokens = int(max_tokens)
|
|
2035
|
+
|
|
2036
|
+
try:
|
|
2037
|
+
self.token_label.setToolTip(f"Max context: {self.max_tokens} tokens ({source})")
|
|
2038
|
+
except Exception:
|
|
2039
|
+
pass
|
|
2040
|
+
|
|
2041
|
+
if self.debug:
|
|
2042
|
+
print(f"📊 Token limit: {self.max_tokens} ({source})")
|
|
1059
2043
|
|
|
1060
2044
|
self.update_token_display()
|
|
1061
2045
|
|
|
@@ -1112,6 +2096,142 @@ class QtChatBubble(QWidget):
|
|
|
1112
2096
|
print(f"Model changed to: {self.current_model}")
|
|
1113
2097
|
|
|
1114
2098
|
|
|
2099
|
+
def _refresh_tool_inventory(self) -> None:
|
|
2100
|
+
"""Refresh the list of available tools and keep the enabled set consistent."""
|
|
2101
|
+
host = getattr(self.llm_manager, "agent_host", None)
|
|
2102
|
+
tool_infos: List[Dict[str, str]] = []
|
|
2103
|
+
safe: set[str] = set()
|
|
2104
|
+
require: set[str] = set()
|
|
2105
|
+
|
|
2106
|
+
if host is not None:
|
|
2107
|
+
try:
|
|
2108
|
+
policy = getattr(host, "tool_policy", None)
|
|
2109
|
+
safe = set(getattr(policy, "auto_approve_tools", set()) or set())
|
|
2110
|
+
require = set(getattr(policy, "require_approval_tools", set()) or set())
|
|
2111
|
+
except Exception:
|
|
2112
|
+
safe = set()
|
|
2113
|
+
require = set()
|
|
2114
|
+
|
|
2115
|
+
try:
|
|
2116
|
+
for t in getattr(host, "tools", []) or []:
|
|
2117
|
+
td = getattr(t, "_tool_definition", None)
|
|
2118
|
+
name = getattr(td, "name", None) or getattr(t, "__name__", None)
|
|
2119
|
+
if not isinstance(name, str) or not name.strip():
|
|
2120
|
+
continue
|
|
2121
|
+
desc = ""
|
|
2122
|
+
try:
|
|
2123
|
+
desc = str(getattr(td, "description", "") or "") if td is not None else ""
|
|
2124
|
+
except Exception:
|
|
2125
|
+
desc = ""
|
|
2126
|
+
tool_infos.append({"name": name.strip(), "description": desc.strip()})
|
|
2127
|
+
except Exception:
|
|
2128
|
+
tool_infos = []
|
|
2129
|
+
|
|
2130
|
+
available_names = {info.get("name", "") for info in tool_infos if isinstance(info, dict) and info.get("name")}
|
|
2131
|
+
available_names = {n for n in available_names if isinstance(n, str) and n.strip()}
|
|
2132
|
+
|
|
2133
|
+
safe = set(safe) & set(available_names)
|
|
2134
|
+
require = set(require) & set(available_names)
|
|
2135
|
+
|
|
2136
|
+
def _sort_key(info: Dict[str, str]) -> tuple[int, str]:
|
|
2137
|
+
name = str(info.get("name") or "")
|
|
2138
|
+
if name in safe:
|
|
2139
|
+
return (0, name)
|
|
2140
|
+
if name in require:
|
|
2141
|
+
return (1, name)
|
|
2142
|
+
return (2, name)
|
|
2143
|
+
|
|
2144
|
+
self._available_external_tools = sorted(tool_infos, key=_sort_key)
|
|
2145
|
+
self._safe_external_tools = set(safe)
|
|
2146
|
+
self._require_approval_tools = set(require)
|
|
2147
|
+
|
|
2148
|
+
if not self._enabled_external_tools:
|
|
2149
|
+
self._enabled_external_tools = set(available_names)
|
|
2150
|
+
else:
|
|
2151
|
+
self._enabled_external_tools &= set(available_names)
|
|
2152
|
+
|
|
2153
|
+
self._update_tools_button_state()
|
|
2154
|
+
|
|
2155
|
+
def _update_tools_button_state(self) -> None:
|
|
2156
|
+
"""Update the tools button tooltip/style based on current selection."""
|
|
2157
|
+
btn = getattr(self, "tools_button", None)
|
|
2158
|
+
if btn is None:
|
|
2159
|
+
return
|
|
2160
|
+
t = self._theme or self._compute_theme()
|
|
2161
|
+
|
|
2162
|
+
total = len(self._available_external_tools or [])
|
|
2163
|
+
enabled = len(self._enabled_external_tools or set())
|
|
2164
|
+
safe_enabled = len((self._enabled_external_tools or set()) & (self._safe_external_tools or set()))
|
|
2165
|
+
approval_enabled = len((self._enabled_external_tools or set()) & (self._require_approval_tools or set()))
|
|
2166
|
+
|
|
2167
|
+
if total <= 0:
|
|
2168
|
+
btn.setEnabled(False)
|
|
2169
|
+
btn.setToolTip("Tools: none available")
|
|
2170
|
+
return
|
|
2171
|
+
|
|
2172
|
+
btn.setEnabled(True)
|
|
2173
|
+
btn.setToolTip(f"Tools enabled: {enabled}/{total} (safe: {safe_enabled}, approval: {approval_enabled})")
|
|
2174
|
+
|
|
2175
|
+
if enabled == 0:
|
|
2176
|
+
border = t["border"]
|
|
2177
|
+
bg = t["overlay_pressed"]
|
|
2178
|
+
fg = t["text_muted"]
|
|
2179
|
+
elif enabled < total:
|
|
2180
|
+
border = t["accent"]
|
|
2181
|
+
bg = t["accent_rgba_12"]
|
|
2182
|
+
fg = t["text_primary"]
|
|
2183
|
+
else:
|
|
2184
|
+
border = t["border"]
|
|
2185
|
+
bg = t["overlay"]
|
|
2186
|
+
fg = t["text_secondary"]
|
|
2187
|
+
|
|
2188
|
+
btn.setStyleSheet(f"""
|
|
2189
|
+
QPushButton {{
|
|
2190
|
+
background: {bg};
|
|
2191
|
+
border: 1px solid {border};
|
|
2192
|
+
border-radius: 4px;
|
|
2193
|
+
color: {fg};
|
|
2194
|
+
text-align: center;
|
|
2195
|
+
padding: 0px;
|
|
2196
|
+
margin: 0px;
|
|
2197
|
+
}}
|
|
2198
|
+
QPushButton:hover {{
|
|
2199
|
+
background: {t['overlay_hover']};
|
|
2200
|
+
border: 1px solid {t['accent']};
|
|
2201
|
+
color: {t['text_primary']};
|
|
2202
|
+
}}
|
|
2203
|
+
QPushButton:pressed {{
|
|
2204
|
+
background: {t['overlay_pressed']};
|
|
2205
|
+
}}
|
|
2206
|
+
""")
|
|
2207
|
+
|
|
2208
|
+
def open_tool_selector(self) -> None:
|
|
2209
|
+
"""Open the tool selector dialog (controls per-run tool allowlist)."""
|
|
2210
|
+
self._refresh_tool_inventory()
|
|
2211
|
+
if not self._available_external_tools:
|
|
2212
|
+
QMessageBox.information(self, "Tools", "No tools are available in this configuration.")
|
|
2213
|
+
return
|
|
2214
|
+
|
|
2215
|
+
dlg = ToolSelectorDialog(
|
|
2216
|
+
parent=self,
|
|
2217
|
+
tools=list(self._available_external_tools),
|
|
2218
|
+
enabled=set(self._enabled_external_tools),
|
|
2219
|
+
safe_preset=set(self._safe_external_tools),
|
|
2220
|
+
require_approval=set(self._require_approval_tools),
|
|
2221
|
+
)
|
|
2222
|
+
result = dlg.exec()
|
|
2223
|
+
accepted_code = getattr(QDialog, "Accepted", 1)
|
|
2224
|
+
if result != accepted_code:
|
|
2225
|
+
return
|
|
2226
|
+
|
|
2227
|
+
self._enabled_external_tools = set(dlg.selected_tools())
|
|
2228
|
+
# Keep session auto-approve set consistent with enabled tool selection.
|
|
2229
|
+
try:
|
|
2230
|
+
self._session_auto_approve_tools &= set(self._enabled_external_tools)
|
|
2231
|
+
except Exception:
|
|
2232
|
+
pass
|
|
2233
|
+
self._update_tools_button_state()
|
|
2234
|
+
|
|
1115
2235
|
def attach_files(self):
|
|
1116
2236
|
"""Open file dialog to attach files (AbstractCore 2.4.5+ media handling)."""
|
|
1117
2237
|
file_dialog = QFileDialog(self)
|
|
@@ -1138,6 +2258,7 @@ class QtChatBubble(QWidget):
|
|
|
1138
2258
|
|
|
1139
2259
|
def update_attached_files_display(self):
|
|
1140
2260
|
"""Update the visual display of attached files."""
|
|
2261
|
+
t = self._theme or self._compute_theme()
|
|
1141
2262
|
# Clear existing file chips
|
|
1142
2263
|
while self.attached_files_layout.count():
|
|
1143
2264
|
child = self.attached_files_layout.takeAt(0)
|
|
@@ -1158,14 +2279,16 @@ class QtChatBubble(QWidget):
|
|
|
1158
2279
|
|
|
1159
2280
|
# Create file chip
|
|
1160
2281
|
file_chip = QFrame()
|
|
1161
|
-
file_chip.setStyleSheet(
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
2282
|
+
file_chip.setStyleSheet(
|
|
2283
|
+
f"""
|
|
2284
|
+
QFrame {{
|
|
2285
|
+
background: {t['accent_rgba_20']};
|
|
2286
|
+
border: 1px solid {t['accent_rgba_35']};
|
|
1165
2287
|
border-radius: 6px;
|
|
1166
2288
|
padding: 1px 4px;
|
|
1167
|
-
}
|
|
1168
|
-
|
|
2289
|
+
}}
|
|
2290
|
+
"""
|
|
2291
|
+
)
|
|
1169
2292
|
|
|
1170
2293
|
chip_layout = QHBoxLayout(file_chip)
|
|
1171
2294
|
chip_layout.setContentsMargins(2, 1, 2, 1)
|
|
@@ -1189,24 +2312,28 @@ class QtChatBubble(QWidget):
|
|
|
1189
2312
|
icon = "📎"
|
|
1190
2313
|
|
|
1191
2314
|
file_label = QLabel(f"{icon} {file_name[:20]}{'...' if len(file_name) > 20 else ''}")
|
|
1192
|
-
file_label.setStyleSheet(
|
|
2315
|
+
file_label.setStyleSheet(
|
|
2316
|
+
f"background: transparent; border: none; color: {t['text_primary']}; font-size: 8px;"
|
|
2317
|
+
)
|
|
1193
2318
|
chip_layout.addWidget(file_label)
|
|
1194
2319
|
|
|
1195
2320
|
# Remove button
|
|
1196
2321
|
remove_btn = QPushButton("✕")
|
|
1197
2322
|
remove_btn.setFixedSize(12, 12)
|
|
1198
|
-
remove_btn.setStyleSheet(
|
|
1199
|
-
|
|
2323
|
+
remove_btn.setStyleSheet(
|
|
2324
|
+
f"""
|
|
2325
|
+
QPushButton {{
|
|
1200
2326
|
background: transparent;
|
|
1201
2327
|
border: none;
|
|
1202
|
-
color:
|
|
2328
|
+
color: {t['text_muted']};
|
|
1203
2329
|
font-size: 8px;
|
|
1204
2330
|
padding: 0px;
|
|
1205
|
-
}
|
|
1206
|
-
QPushButton:hover {
|
|
2331
|
+
}}
|
|
2332
|
+
QPushButton:hover {{
|
|
1207
2333
|
color: rgba(255, 60, 60, 0.9);
|
|
1208
|
-
}
|
|
1209
|
-
|
|
2334
|
+
}}
|
|
2335
|
+
"""
|
|
2336
|
+
)
|
|
1210
2337
|
remove_btn.clicked.connect(lambda checked, fp=file_path: self.remove_attached_file(fp))
|
|
1211
2338
|
chip_layout.addWidget(remove_btn)
|
|
1212
2339
|
|
|
@@ -1277,6 +2404,7 @@ class QtChatBubble(QWidget):
|
|
|
1277
2404
|
# 4. Update UI for sending state
|
|
1278
2405
|
self.send_button.setEnabled(False)
|
|
1279
2406
|
self.send_button.setText("⏳")
|
|
2407
|
+
self._set_session_controls_enabled(False)
|
|
1280
2408
|
self.status_label.setText("generating")
|
|
1281
2409
|
self.status_label.setObjectName("status_generating")
|
|
1282
2410
|
self.status_label.setStyleSheet("""
|
|
@@ -1293,32 +2421,222 @@ class QtChatBubble(QWidget):
|
|
|
1293
2421
|
}
|
|
1294
2422
|
""")
|
|
1295
2423
|
|
|
1296
|
-
# Notify main app about status change (for icon animation)
|
|
1297
|
-
if self.status_callback:
|
|
1298
|
-
self.status_callback("generating")
|
|
2424
|
+
# Notify main app about status change (for icon animation)
|
|
2425
|
+
if self.status_callback:
|
|
2426
|
+
self.status_callback("generating")
|
|
2427
|
+
|
|
2428
|
+
if self.debug:
|
|
2429
|
+
print("🔄 QtChatBubble: UI updated, creating worker thread...")
|
|
2430
|
+
|
|
2431
|
+
# 5. Start worker thread to send request with optional media files
|
|
2432
|
+
system_prompt_extra = None
|
|
2433
|
+
if self._is_voice_mode_active():
|
|
2434
|
+
system_prompt_extra = (
|
|
2435
|
+
"You are in voice mode.\n"
|
|
2436
|
+
"- Keep responses concise and conversational.\n"
|
|
2437
|
+
"- Avoid markdown and heavy formatting.\n"
|
|
2438
|
+
)
|
|
2439
|
+
|
|
2440
|
+
host = getattr(self.llm_manager, "agent_host", None)
|
|
2441
|
+
if host is not None:
|
|
2442
|
+
self.worker = AgentWorker(
|
|
2443
|
+
agent_host=host,
|
|
2444
|
+
user_text=message,
|
|
2445
|
+
provider=self.current_provider,
|
|
2446
|
+
model=self.current_model,
|
|
2447
|
+
attachments=media_files if media_files else None,
|
|
2448
|
+
system_prompt_extra=system_prompt_extra,
|
|
2449
|
+
allowed_tools=sorted(self._enabled_external_tools),
|
|
2450
|
+
debug=bool(self.debug),
|
|
2451
|
+
)
|
|
2452
|
+
self.worker.event_emitted.connect(self.on_agent_event)
|
|
2453
|
+
self.worker.error_occurred.connect(self.on_error_occurred)
|
|
2454
|
+
else:
|
|
2455
|
+
self.worker = LLMWorker(
|
|
2456
|
+
self.llm_manager,
|
|
2457
|
+
message,
|
|
2458
|
+
self.current_provider,
|
|
2459
|
+
self.current_model,
|
|
2460
|
+
media=media_files if media_files else None,
|
|
2461
|
+
debug=bool(self.debug),
|
|
2462
|
+
)
|
|
2463
|
+
self.worker.response_ready.connect(self.on_response_ready)
|
|
2464
|
+
self.worker.error_occurred.connect(self.on_error_occurred)
|
|
2465
|
+
|
|
2466
|
+
if self.debug:
|
|
2467
|
+
print("🔄 QtChatBubble: Starting worker thread...")
|
|
2468
|
+
self.worker.start()
|
|
2469
|
+
|
|
2470
|
+
if self.debug:
|
|
2471
|
+
print("🔄 QtChatBubble: Worker thread started, hiding bubble...")
|
|
2472
|
+
# Hide bubble after sending (like the original design)
|
|
2473
|
+
QTimer.singleShot(500, self.hide)
|
|
2474
|
+
|
|
2475
|
+
@pyqtSlot(object)
|
|
2476
|
+
def on_agent_event(self, event):
|
|
2477
|
+
"""Handle AgentHost events emitted by AgentWorker."""
|
|
2478
|
+
if not isinstance(event, dict):
|
|
2479
|
+
return
|
|
2480
|
+
|
|
2481
|
+
typ = event.get("type")
|
|
2482
|
+
if typ == "status":
|
|
2483
|
+
status = str(event.get("status") or "")
|
|
2484
|
+
self._set_agent_status(status)
|
|
2485
|
+
return
|
|
2486
|
+
|
|
2487
|
+
if typ == "tool_request":
|
|
2488
|
+
self._handle_tool_request(event)
|
|
2489
|
+
return
|
|
2490
|
+
|
|
2491
|
+
if typ == "ask_user":
|
|
2492
|
+
self._handle_ask_user(event)
|
|
2493
|
+
return
|
|
2494
|
+
|
|
2495
|
+
if typ == "assistant":
|
|
2496
|
+
try:
|
|
2497
|
+
if hasattr(self.llm_manager, "refresh"):
|
|
2498
|
+
self.llm_manager.refresh()
|
|
2499
|
+
except Exception:
|
|
2500
|
+
pass
|
|
2501
|
+
self.on_response_ready(str(event.get("content") or ""))
|
|
2502
|
+
return
|
|
2503
|
+
|
|
2504
|
+
if typ == "error":
|
|
2505
|
+
self.on_error_occurred(str(event.get("error") or "error"))
|
|
2506
|
+
return
|
|
2507
|
+
|
|
2508
|
+
def _set_agent_status(self, status: str) -> None:
|
|
2509
|
+
st = str(status or "").strip().lower()
|
|
2510
|
+
if st in {"thinking", "running"}:
|
|
2511
|
+
self.status_label.setText("thinking")
|
|
2512
|
+
if self.status_callback:
|
|
2513
|
+
self.status_callback("thinking")
|
|
2514
|
+
return
|
|
2515
|
+
if st in {"executing_tools", "executing"}:
|
|
2516
|
+
self.status_label.setText("executing")
|
|
2517
|
+
if self.status_callback:
|
|
2518
|
+
self.status_callback("executing")
|
|
2519
|
+
return
|
|
2520
|
+
if st in {"ready", "completed"}:
|
|
2521
|
+
self.status_label.setText("ready")
|
|
2522
|
+
if self.status_callback:
|
|
2523
|
+
self.status_callback("ready")
|
|
2524
|
+
return
|
|
2525
|
+
|
|
2526
|
+
def _handle_tool_request(self, event: Dict) -> None:
|
|
2527
|
+
"""Prompt user for tool approval when required."""
|
|
2528
|
+
tool_calls = event.get("tool_calls")
|
|
2529
|
+
if not isinstance(tool_calls, list):
|
|
2530
|
+
tool_calls = []
|
|
2531
|
+
|
|
2532
|
+
host = getattr(self.llm_manager, "agent_host", None)
|
|
2533
|
+
requires = True
|
|
2534
|
+
try:
|
|
2535
|
+
if host is not None:
|
|
2536
|
+
requires = bool(host.tool_policy.requires_approval(tool_calls))
|
|
2537
|
+
except Exception:
|
|
2538
|
+
requires = True
|
|
2539
|
+
|
|
2540
|
+
# Session-level allowlist can bypass approval prompts (still bounded by policy).
|
|
2541
|
+
try:
|
|
2542
|
+
if all(
|
|
2543
|
+
str(tc.get("name") or "") in self._session_auto_approve_tools
|
|
2544
|
+
for tc in tool_calls
|
|
2545
|
+
if isinstance(tc, dict)
|
|
2546
|
+
):
|
|
2547
|
+
requires = False
|
|
2548
|
+
except Exception:
|
|
2549
|
+
pass
|
|
2550
|
+
|
|
2551
|
+
if not requires:
|
|
2552
|
+
if self.debug:
|
|
2553
|
+
print("✅ Auto-approving safe tool batch (no prompt).")
|
|
2554
|
+
if self.status_callback:
|
|
2555
|
+
self.status_callback("executing")
|
|
2556
|
+
if isinstance(self.worker, AgentWorker):
|
|
2557
|
+
self.worker.provide_tool_approval(True)
|
|
2558
|
+
return
|
|
2559
|
+
|
|
2560
|
+
# Bring UI forward for interactive approvals.
|
|
2561
|
+
try:
|
|
2562
|
+
self.show()
|
|
2563
|
+
self.raise_()
|
|
2564
|
+
self.activateWindow()
|
|
2565
|
+
except Exception:
|
|
2566
|
+
pass
|
|
1299
2567
|
|
|
1300
|
-
|
|
1301
|
-
|
|
2568
|
+
self.status_label.setText("approve")
|
|
2569
|
+
if self.status_callback:
|
|
2570
|
+
self.status_callback("thinking")
|
|
2571
|
+
|
|
2572
|
+
# Format tool calls for display.
|
|
2573
|
+
lines: List[str] = []
|
|
2574
|
+
for i, tc in enumerate(tool_calls):
|
|
2575
|
+
if not isinstance(tc, dict):
|
|
2576
|
+
continue
|
|
2577
|
+
name = str(tc.get("name") or f"tool_{i}")
|
|
2578
|
+
args = tc.get("arguments")
|
|
2579
|
+
try:
|
|
2580
|
+
args_txt = json.dumps(args, ensure_ascii=False, indent=2)
|
|
2581
|
+
except Exception:
|
|
2582
|
+
args_txt = str(args)
|
|
2583
|
+
lines.append(f"{name}({args_txt})")
|
|
2584
|
+
details = "\n\n".join(lines).strip()
|
|
2585
|
+
if len(details) > 8000:
|
|
2586
|
+
details = details[:8000] + "\n…(truncated)…"
|
|
2587
|
+
|
|
2588
|
+
box = QMessageBox(self)
|
|
2589
|
+
box.setWindowTitle("Tool approval required")
|
|
2590
|
+
box.setIcon(QMessageBox.Icon.Warning)
|
|
2591
|
+
box.setText("The assistant wants to run tools that may affect your system or workspace.")
|
|
2592
|
+
box.setInformativeText("Review the tool calls and approve or deny this batch.")
|
|
2593
|
+
box.setDetailedText(details)
|
|
2594
|
+
|
|
2595
|
+
allow_box = QCheckBox("Always allow these tools for this session (non-destructive tools only)")
|
|
2596
|
+
box.setCheckBox(allow_box)
|
|
2597
|
+
|
|
2598
|
+
approve_btn = box.addButton("Approve", QMessageBox.ButtonRole.AcceptRole)
|
|
2599
|
+
deny_btn = box.addButton("Deny", QMessageBox.ButtonRole.RejectRole)
|
|
2600
|
+
box.setDefaultButton(deny_btn)
|
|
2601
|
+
|
|
2602
|
+
box.exec()
|
|
2603
|
+
clicked = box.clickedButton()
|
|
2604
|
+
approved = clicked == approve_btn
|
|
2605
|
+
|
|
2606
|
+
if approved and allow_box.isChecked() and host is not None:
|
|
2607
|
+
try:
|
|
2608
|
+
policy = host.tool_policy
|
|
2609
|
+
for tc in tool_calls:
|
|
2610
|
+
if not isinstance(tc, dict):
|
|
2611
|
+
continue
|
|
2612
|
+
name = str(tc.get("name") or "").strip()
|
|
2613
|
+
if not name:
|
|
2614
|
+
continue
|
|
2615
|
+
# Only allow session auto-approvals for tools that are not in the explicit "requires approval" set.
|
|
2616
|
+
if name in getattr(policy, "require_approval_tools", set()):
|
|
2617
|
+
continue
|
|
2618
|
+
self._session_auto_approve_tools.add(name)
|
|
2619
|
+
except Exception:
|
|
2620
|
+
pass
|
|
1302
2621
|
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
self.llm_manager,
|
|
1306
|
-
message,
|
|
1307
|
-
self.current_provider,
|
|
1308
|
-
self.current_model,
|
|
1309
|
-
media=media_files if media_files else None
|
|
1310
|
-
)
|
|
1311
|
-
self.worker.response_ready.connect(self.on_response_ready)
|
|
1312
|
-
self.worker.error_occurred.connect(self.on_error_occurred)
|
|
2622
|
+
if isinstance(self.worker, AgentWorker):
|
|
2623
|
+
self.worker.provide_tool_approval(bool(approved))
|
|
1313
2624
|
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
2625
|
+
def _handle_ask_user(self, event: Dict) -> None:
|
|
2626
|
+
"""Prompt user for input required by the run."""
|
|
2627
|
+
prompt = str(event.get("prompt") or "Input required:")
|
|
1317
2628
|
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
2629
|
+
try:
|
|
2630
|
+
self.show()
|
|
2631
|
+
self.raise_()
|
|
2632
|
+
self.activateWindow()
|
|
2633
|
+
except Exception:
|
|
2634
|
+
pass
|
|
2635
|
+
|
|
2636
|
+
text, ok = QInputDialog.getText(self, "Assistant needs input", prompt)
|
|
2637
|
+
response = str(text) if ok else ""
|
|
2638
|
+
if isinstance(self.worker, AgentWorker):
|
|
2639
|
+
self.worker.provide_user_response(response)
|
|
1322
2640
|
|
|
1323
2641
|
@pyqtSlot(str)
|
|
1324
2642
|
def on_response_ready(self, response):
|
|
@@ -1328,6 +2646,7 @@ class QtChatBubble(QWidget):
|
|
|
1328
2646
|
|
|
1329
2647
|
self.send_button.setEnabled(True)
|
|
1330
2648
|
self.send_button.setText("→")
|
|
2649
|
+
self._set_session_controls_enabled(True)
|
|
1331
2650
|
self.status_label.setText("ready")
|
|
1332
2651
|
self.status_label.setObjectName("status_ready")
|
|
1333
2652
|
self.status_label.setStyleSheet("""
|
|
@@ -1353,6 +2672,25 @@ class QtChatBubble(QWidget):
|
|
|
1353
2672
|
|
|
1354
2673
|
# Update token count from AbstractCore
|
|
1355
2674
|
self._update_token_count_from_session()
|
|
2675
|
+
|
|
2676
|
+
# Refresh sessions list (recency ordering).
|
|
2677
|
+
try:
|
|
2678
|
+
self._reload_session_combo()
|
|
2679
|
+
except Exception:
|
|
2680
|
+
pass
|
|
2681
|
+
|
|
2682
|
+
# Best-effort: auto-title the active session for the dropdown.
|
|
2683
|
+
try:
|
|
2684
|
+
if self.llm_manager and hasattr(self.llm_manager, "update_active_session_title_async"):
|
|
2685
|
+
self.llm_manager.update_active_session_title_async(
|
|
2686
|
+
provider=self.current_provider,
|
|
2687
|
+
model=self.current_model,
|
|
2688
|
+
on_done=lambda _sid, _title: QMetaObject.invokeMethod(
|
|
2689
|
+
self, "_refresh_session_combo_ui", Qt.QueuedConnection
|
|
2690
|
+
),
|
|
2691
|
+
)
|
|
2692
|
+
except Exception:
|
|
2693
|
+
pass
|
|
1356
2694
|
|
|
1357
2695
|
# Handle TTS if enabled (AbstractVoice integration)
|
|
1358
2696
|
if self.tts_enabled and self.voice_manager and self.voice_manager.is_available():
|
|
@@ -1385,7 +2723,9 @@ class QtChatBubble(QWidget):
|
|
|
1385
2723
|
|
|
1386
2724
|
# Speak the cleaned response using AbstractVoice-compatible interface
|
|
1387
2725
|
# Note: We don't set "speaking" status here anymore - we wait for the callback
|
|
1388
|
-
self.voice_manager.speak(clean_response)
|
|
2726
|
+
started = bool(self.voice_manager.speak(clean_response))
|
|
2727
|
+
if not started:
|
|
2728
|
+
raise RuntimeError("TTS speak() returned False")
|
|
1389
2729
|
|
|
1390
2730
|
# Update toggle state to 'speaking'
|
|
1391
2731
|
self._update_tts_toggle_state()
|
|
@@ -1402,6 +2742,12 @@ class QtChatBubble(QWidget):
|
|
|
1402
2742
|
print(f"❌ TTS error: {e}")
|
|
1403
2743
|
# Show chat history as fallback - only if voice mode is OFF
|
|
1404
2744
|
QTimer.singleShot(100, self._show_history_if_voice_mode_off)
|
|
2745
|
+
if self._is_full_voice_running():
|
|
2746
|
+
self._voice_busy = False
|
|
2747
|
+
try:
|
|
2748
|
+
self.update_status("LISTENING")
|
|
2749
|
+
except Exception:
|
|
2750
|
+
pass
|
|
1405
2751
|
else:
|
|
1406
2752
|
# Show chat history instead of toast when TTS is disabled - only if voice mode is OFF
|
|
1407
2753
|
self._show_history_if_voice_mode_off()
|
|
@@ -1420,6 +2766,12 @@ class QtChatBubble(QWidget):
|
|
|
1420
2766
|
self.response_callback(response)
|
|
1421
2767
|
if self.status_callback:
|
|
1422
2768
|
self.status_callback("ready")
|
|
2769
|
+
if self._is_full_voice_running():
|
|
2770
|
+
self._voice_busy = False
|
|
2771
|
+
try:
|
|
2772
|
+
self.update_status("LISTENING")
|
|
2773
|
+
except Exception:
|
|
2774
|
+
pass
|
|
1423
2775
|
else:
|
|
1424
2776
|
# TTS path: Stay in thinking mode until audio actually starts
|
|
1425
2777
|
if self.debug:
|
|
@@ -1583,7 +2935,13 @@ class QtChatBubble(QWidget):
|
|
|
1583
2935
|
pass
|
|
1584
2936
|
|
|
1585
2937
|
def on_full_voice_toggled(self, enabled: bool):
|
|
1586
|
-
"""Handle Full Voice Mode toggle state change."""
|
|
2938
|
+
"""Handle Full Voice Mode toggle state change (always apply on Qt main thread)."""
|
|
2939
|
+
try:
|
|
2940
|
+
QTimer.singleShot(0, lambda e=bool(enabled): self._apply_full_voice_toggled(e))
|
|
2941
|
+
except Exception:
|
|
2942
|
+
self._apply_full_voice_toggled(bool(enabled))
|
|
2943
|
+
|
|
2944
|
+
def _apply_full_voice_toggled(self, enabled: bool) -> None:
|
|
1587
2945
|
if self.debug:
|
|
1588
2946
|
print(f"🎙️ Full Voice Mode {'enabled' if enabled else 'disabled'}")
|
|
1589
2947
|
|
|
@@ -1606,7 +2964,8 @@ class QtChatBubble(QWidget):
|
|
|
1606
2964
|
if self.debug:
|
|
1607
2965
|
print("🚀 Starting Full Voice Mode...")
|
|
1608
2966
|
|
|
1609
|
-
#
|
|
2967
|
+
# Keep the normal interface visible, but switch input actions to voice mode
|
|
2968
|
+
# (attach + tools remain usable; send is hidden).
|
|
1610
2969
|
self.hide_text_ui()
|
|
1611
2970
|
|
|
1612
2971
|
# Enable TTS automatically
|
|
@@ -1620,6 +2979,9 @@ class QtChatBubble(QWidget):
|
|
|
1620
2979
|
if self.llm_manager:
|
|
1621
2980
|
self.llm_manager.update_session_mode(tts_mode=True)
|
|
1622
2981
|
|
|
2982
|
+
# Mark running before starting the underlying loop so late UI updates can be gated.
|
|
2983
|
+
self._full_voice_running = True
|
|
2984
|
+
|
|
1623
2985
|
# Start listening
|
|
1624
2986
|
self.voice_manager.listen(
|
|
1625
2987
|
on_transcription=self.handle_voice_input,
|
|
@@ -1644,77 +3006,132 @@ class QtChatBubble(QWidget):
|
|
|
1644
3006
|
traceback.print_exc()
|
|
1645
3007
|
|
|
1646
3008
|
# Reset toggle state on error
|
|
3009
|
+
self._full_voice_running = False
|
|
1647
3010
|
self.full_voice_toggle.set_enabled(False)
|
|
1648
3011
|
self.show_text_ui()
|
|
1649
3012
|
|
|
3013
|
+
def _is_full_voice_running(self) -> bool:
|
|
3014
|
+
"""Centralized guard for any 'LISTENING' UI updates from async callbacks."""
|
|
3015
|
+
try:
|
|
3016
|
+
return bool(self._full_voice_running) and bool(self.full_voice_toggle.is_enabled())
|
|
3017
|
+
except Exception:
|
|
3018
|
+
return bool(getattr(self, "_full_voice_running", False))
|
|
3019
|
+
|
|
1650
3020
|
def stop_full_voice_mode(self):
|
|
1651
3021
|
"""Stop Full Voice Mode and return to normal text mode."""
|
|
1652
|
-
|
|
3022
|
+
# IMPORTANT: make this robust. Even if voice backend stop throws, the UI must restore.
|
|
3023
|
+
if self.debug:
|
|
1653
3024
|
if self.debug:
|
|
1654
|
-
|
|
1655
|
-
|
|
3025
|
+
print("🛑 Stopping Full Voice Mode...")
|
|
3026
|
+
|
|
3027
|
+
# Gate all future async 'LISTENING' updates immediately.
|
|
3028
|
+
self._full_voice_running = False
|
|
3029
|
+
self._voice_busy = False
|
|
1656
3030
|
|
|
1657
|
-
|
|
1658
|
-
|
|
3031
|
+
# 1) Stop listening/speaking (best-effort, never block UI restore)
|
|
3032
|
+
if self.voice_manager:
|
|
3033
|
+
try:
|
|
3034
|
+
# Detach callbacks to avoid late status flips after shutdown.
|
|
3035
|
+
try:
|
|
3036
|
+
self.voice_manager.on_speech_start = None
|
|
3037
|
+
self.voice_manager.on_speech_end = None
|
|
3038
|
+
except Exception:
|
|
3039
|
+
pass
|
|
1659
3040
|
self.voice_manager.stop_listening()
|
|
3041
|
+
except Exception as e:
|
|
3042
|
+
if self.debug:
|
|
3043
|
+
print(f"❌ Error stopping listening: {e}")
|
|
3044
|
+
try:
|
|
1660
3045
|
self.voice_manager.stop_speaking()
|
|
3046
|
+
except Exception as e:
|
|
3047
|
+
if self.debug:
|
|
3048
|
+
print(f"❌ Error stopping speaking: {e}")
|
|
3049
|
+
|
|
3050
|
+
# 2) Turn off the speaker toggle (TTS) when leaving voice mode (as requested)
|
|
3051
|
+
try:
|
|
3052
|
+
if hasattr(self, "tts_toggle") and self.tts_toggle:
|
|
3053
|
+
self.tts_toggle.set_enabled(False)
|
|
3054
|
+
self.tts_enabled = False
|
|
3055
|
+
except Exception:
|
|
3056
|
+
pass
|
|
1661
3057
|
|
|
1662
|
-
|
|
3058
|
+
# 3) Restore normal UI (Send visible again)
|
|
3059
|
+
try:
|
|
1663
3060
|
self.show_text_ui()
|
|
3061
|
+
except Exception:
|
|
3062
|
+
pass
|
|
1664
3063
|
|
|
1665
|
-
|
|
3064
|
+
# If no run is currently in progress, restore the send affordance immediately.
|
|
3065
|
+
try:
|
|
3066
|
+
if not self._is_run_in_progress():
|
|
3067
|
+
if getattr(self, "send_button", None) is not None:
|
|
3068
|
+
self.send_button.setEnabled(True)
|
|
3069
|
+
self.send_button.setText("→")
|
|
3070
|
+
self._set_session_controls_enabled(True)
|
|
3071
|
+
except Exception:
|
|
3072
|
+
pass
|
|
3073
|
+
|
|
3074
|
+
# 4) Status back to Ready (green) + tray icon ready
|
|
3075
|
+
try:
|
|
1666
3076
|
self.update_status("READY")
|
|
3077
|
+
except Exception:
|
|
3078
|
+
pass
|
|
3079
|
+
try:
|
|
3080
|
+
if self.status_callback:
|
|
3081
|
+
self.status_callback("ready")
|
|
3082
|
+
except Exception:
|
|
3083
|
+
pass
|
|
1667
3084
|
|
|
3085
|
+
if self.debug:
|
|
1668
3086
|
if self.debug:
|
|
1669
|
-
|
|
1670
|
-
print("✅ Full Voice Mode stopped")
|
|
1671
|
-
|
|
1672
|
-
except Exception as e:
|
|
1673
|
-
if self.debug:
|
|
1674
|
-
if self.debug:
|
|
1675
|
-
print(f"❌ Error stopping Full Voice Mode: {e}")
|
|
1676
|
-
import traceback
|
|
1677
|
-
traceback.print_exc()
|
|
3087
|
+
print("✅ Full Voice Mode stopped")
|
|
1678
3088
|
|
|
1679
3089
|
def handle_voice_input(self, transcribed_text: str):
|
|
1680
|
-
"""Handle speech-to-text input
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
print(f"👤 Voice input: {transcribed_text}")
|
|
3090
|
+
"""Handle speech-to-text input (thread-safe, routes through agentic pipeline)."""
|
|
3091
|
+
# Ignore any late STT callbacks after the user stopped voice mode.
|
|
3092
|
+
if not self._is_full_voice_running():
|
|
3093
|
+
return
|
|
1685
3094
|
|
|
1686
|
-
|
|
1687
|
-
|
|
3095
|
+
text = str(transcribed_text or "").strip()
|
|
3096
|
+
if not text:
|
|
3097
|
+
return
|
|
1688
3098
|
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
transcribed_text,
|
|
1692
|
-
self.current_provider,
|
|
1693
|
-
self.current_model
|
|
1694
|
-
)
|
|
3099
|
+
if self.debug:
|
|
3100
|
+
print(f"👤 Voice input: {text}")
|
|
1695
3101
|
|
|
1696
|
-
|
|
1697
|
-
|
|
3102
|
+
# Ensure we run UI + agent turn creation on the Qt main thread.
|
|
3103
|
+
QTimer.singleShot(0, lambda t=text: self._handle_voice_input_main_thread(t))
|
|
1698
3104
|
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
3105
|
+
def _handle_voice_input_main_thread(self, transcribed_text: str) -> None:
|
|
3106
|
+
"""Execute a voice input turn on the Qt main thread."""
|
|
3107
|
+
if not self._is_full_voice_running():
|
|
3108
|
+
self._voice_busy = False
|
|
3109
|
+
return
|
|
1702
3110
|
|
|
1703
|
-
|
|
1704
|
-
self.
|
|
3111
|
+
if self._voice_busy:
|
|
3112
|
+
if self.debug:
|
|
3113
|
+
print("🎙️ Ignoring transcription while busy")
|
|
3114
|
+
return
|
|
1705
3115
|
|
|
1706
|
-
|
|
1707
|
-
|
|
3116
|
+
self._voice_busy = True
|
|
3117
|
+
try:
|
|
3118
|
+
self.update_status("PROCESSING")
|
|
1708
3119
|
|
|
3120
|
+
# Route through the same agentic sending path as typed input.
|
|
3121
|
+
try:
|
|
3122
|
+
self.input_text.setPlainText(str(transcribed_text or ""))
|
|
3123
|
+
except Exception:
|
|
3124
|
+
pass
|
|
3125
|
+
self.send_message()
|
|
1709
3126
|
except Exception as e:
|
|
3127
|
+
self._voice_busy = False
|
|
1710
3128
|
if self.debug:
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
self.update_status("LISTENING")
|
|
3129
|
+
print(f"❌ Error handling voice input: {e}")
|
|
3130
|
+
try:
|
|
3131
|
+
if self._is_full_voice_running():
|
|
3132
|
+
self.update_status("LISTENING")
|
|
3133
|
+
except Exception:
|
|
3134
|
+
pass
|
|
1718
3135
|
|
|
1719
3136
|
def handle_voice_stop(self):
|
|
1720
3137
|
"""Handle when user says 'stop' to exit Full Voice Mode."""
|
|
@@ -1723,28 +3140,64 @@ class QtChatBubble(QWidget):
|
|
|
1723
3140
|
print("🛑 User said 'stop' - exiting Full Voice Mode")
|
|
1724
3141
|
|
|
1725
3142
|
# Disable Full Voice Mode
|
|
3143
|
+
self._full_voice_running = False
|
|
1726
3144
|
self.full_voice_toggle.set_enabled(False)
|
|
1727
3145
|
|
|
1728
3146
|
def hide_text_ui(self):
|
|
1729
|
-
"""
|
|
1730
|
-
|
|
1731
|
-
if hasattr(self, 'input_container'):
|
|
1732
|
-
self.input_container.hide()
|
|
1733
|
-
|
|
1734
|
-
# Update window size to be smaller but maintain wider width
|
|
1735
|
-
voice_base_height = 120
|
|
1736
|
-
attachment_height = 28 if (self.attached_files and self.attached_files_container.isVisible()) else 0
|
|
1737
|
-
voice_height = voice_base_height + attachment_height
|
|
1738
|
-
self.setFixedSize(self.base_width, voice_height) # Dynamic height for voice mode
|
|
3147
|
+
"""Enter Full Voice Mode UI (keep input visible; hide Send; keep attach/tools)."""
|
|
3148
|
+
self._set_voice_ui_mode(True)
|
|
1739
3149
|
|
|
1740
3150
|
def show_text_ui(self):
|
|
1741
|
-
"""
|
|
1742
|
-
|
|
1743
|
-
if hasattr(self, 'input_container'):
|
|
1744
|
-
self.input_container.show()
|
|
3151
|
+
"""Exit Full Voice Mode UI (restore Send and normal text interaction)."""
|
|
3152
|
+
self._set_voice_ui_mode(False)
|
|
1745
3153
|
|
|
1746
|
-
|
|
1747
|
-
|
|
3154
|
+
def _set_voice_ui_mode(self, enabled: bool) -> None:
|
|
3155
|
+
"""
|
|
3156
|
+
Centralized UI state switch for voice mode.
|
|
3157
|
+
|
|
3158
|
+
Requirements:
|
|
3159
|
+
- Even in voice mode, user can still change file attachments and tools.
|
|
3160
|
+
- Send is hidden/disabled in voice mode (end-of-sentence acts as "send").
|
|
3161
|
+
"""
|
|
3162
|
+
enabled = bool(enabled)
|
|
3163
|
+
try:
|
|
3164
|
+
if hasattr(self, "input_container") and self.input_container:
|
|
3165
|
+
self.input_container.show()
|
|
3166
|
+
except Exception:
|
|
3167
|
+
pass
|
|
3168
|
+
|
|
3169
|
+
# Toggle the action column behavior (2 buttons in voice mode, 3 otherwise).
|
|
3170
|
+
try:
|
|
3171
|
+
if hasattr(self, "_input_row") and self._input_row:
|
|
3172
|
+
self._input_row.set_voice_mode(enabled)
|
|
3173
|
+
except Exception:
|
|
3174
|
+
pass
|
|
3175
|
+
|
|
3176
|
+
# Ensure attach/tools remain available in both modes.
|
|
3177
|
+
for btn_attr in ("attach_button", "tools_button"):
|
|
3178
|
+
b = getattr(self, btn_attr, None)
|
|
3179
|
+
if b is None:
|
|
3180
|
+
continue
|
|
3181
|
+
try:
|
|
3182
|
+
b.setEnabled(True)
|
|
3183
|
+
b.setVisible(True)
|
|
3184
|
+
except Exception:
|
|
3185
|
+
pass
|
|
3186
|
+
|
|
3187
|
+
# Send button is only relevant in text mode.
|
|
3188
|
+
sb = getattr(self, "send_button", None)
|
|
3189
|
+
if sb is not None:
|
|
3190
|
+
try:
|
|
3191
|
+
sb.setVisible(not enabled)
|
|
3192
|
+
sb.setEnabled(not enabled)
|
|
3193
|
+
except Exception:
|
|
3194
|
+
pass
|
|
3195
|
+
|
|
3196
|
+
# Keep the window sizing consistent with attachments.
|
|
3197
|
+
try:
|
|
3198
|
+
self._adjust_window_size_for_attachments()
|
|
3199
|
+
except Exception:
|
|
3200
|
+
pass
|
|
1748
3201
|
|
|
1749
3202
|
def update_status(self, status_text: str):
|
|
1750
3203
|
"""Update the status label with the given text."""
|
|
@@ -1893,6 +3346,7 @@ class QtChatBubble(QWidget):
|
|
|
1893
3346
|
"""Handle LLM error."""
|
|
1894
3347
|
self.send_button.setEnabled(True)
|
|
1895
3348
|
self.send_button.setText("→")
|
|
3349
|
+
self._set_session_controls_enabled(True)
|
|
1896
3350
|
self.status_label.setText("error")
|
|
1897
3351
|
self.status_label.setObjectName("status_error")
|
|
1898
3352
|
self.status_label.setStyleSheet("""
|
|
@@ -1920,6 +3374,14 @@ class QtChatBubble(QWidget):
|
|
|
1920
3374
|
|
|
1921
3375
|
# Show history so user can see the error context - only if voice mode is OFF
|
|
1922
3376
|
QTimer.singleShot(100, self._show_history_if_voice_mode_off)
|
|
3377
|
+
|
|
3378
|
+
# If we're in full voice mode, unblock the STT loop.
|
|
3379
|
+
if self._is_full_voice_running():
|
|
3380
|
+
self._voice_busy = False
|
|
3381
|
+
try:
|
|
3382
|
+
self.update_status("LISTENING")
|
|
3383
|
+
except Exception:
|
|
3384
|
+
pass
|
|
1923
3385
|
|
|
1924
3386
|
# Call error callback
|
|
1925
3387
|
if self.error_callback:
|
|
@@ -1936,41 +3398,257 @@ class QtChatBubble(QWidget):
|
|
|
1936
3398
|
def set_status_callback(self, callback):
|
|
1937
3399
|
"""Set status callback function."""
|
|
1938
3400
|
self.status_callback = callback
|
|
3401
|
+
|
|
3402
|
+
def _set_session_controls_enabled(self, enabled: bool) -> None:
|
|
3403
|
+
for attr in ("session_combo", "new_session_button"):
|
|
3404
|
+
w = getattr(self, attr, None)
|
|
3405
|
+
if w is None:
|
|
3406
|
+
continue
|
|
3407
|
+
try:
|
|
3408
|
+
w.setEnabled(bool(enabled))
|
|
3409
|
+
except Exception:
|
|
3410
|
+
pass
|
|
3411
|
+
|
|
3412
|
+
def _selected_session_id(self) -> Optional[str]:
|
|
3413
|
+
combo = getattr(self, "session_combo", None)
|
|
3414
|
+
if combo is None:
|
|
3415
|
+
return None
|
|
3416
|
+
try:
|
|
3417
|
+
sid = combo.currentData()
|
|
3418
|
+
except Exception:
|
|
3419
|
+
sid = None
|
|
3420
|
+
sid = str(sid or "").strip()
|
|
3421
|
+
return sid or None
|
|
3422
|
+
|
|
3423
|
+
def _reload_session_combo(self, *, select_session_id: Optional[str] = None) -> None:
|
|
3424
|
+
combo = getattr(self, "session_combo", None)
|
|
3425
|
+
if combo is None:
|
|
3426
|
+
return
|
|
3427
|
+
if not self.llm_manager or not hasattr(self.llm_manager, "list_sessions"):
|
|
3428
|
+
try:
|
|
3429
|
+
combo.clear()
|
|
3430
|
+
combo.addItem("Session")
|
|
3431
|
+
combo.setEnabled(False)
|
|
3432
|
+
except Exception:
|
|
3433
|
+
pass
|
|
3434
|
+
return
|
|
3435
|
+
|
|
3436
|
+
try:
|
|
3437
|
+
sessions = list(self.llm_manager.list_sessions() or [])
|
|
3438
|
+
except Exception:
|
|
3439
|
+
sessions = []
|
|
3440
|
+
|
|
3441
|
+
active = select_session_id or self._selected_session_id()
|
|
3442
|
+
if not active:
|
|
3443
|
+
try:
|
|
3444
|
+
active = str(getattr(self.llm_manager, "active_session_id", "") or "").strip()
|
|
3445
|
+
except Exception:
|
|
3446
|
+
active = None
|
|
3447
|
+
|
|
3448
|
+
try:
|
|
3449
|
+
combo.blockSignals(True)
|
|
3450
|
+
combo.clear()
|
|
3451
|
+
|
|
3452
|
+
select_index = 0
|
|
3453
|
+
items = 0
|
|
3454
|
+
for rec in sessions:
|
|
3455
|
+
if not isinstance(rec, dict):
|
|
3456
|
+
continue
|
|
3457
|
+
sid = str(rec.get("session_id") or "").strip()
|
|
3458
|
+
if not sid:
|
|
3459
|
+
continue
|
|
3460
|
+
title = str(rec.get("title") or "New session").strip() or "New session"
|
|
3461
|
+
label = title
|
|
3462
|
+
if title.strip().lower() == "new session":
|
|
3463
|
+
stamp = str(rec.get("updated_at") or rec.get("created_at") or "").strip()
|
|
3464
|
+
human = None
|
|
3465
|
+
if stamp:
|
|
3466
|
+
try:
|
|
3467
|
+
dt = datetime.fromisoformat(stamp)
|
|
3468
|
+
human = dt.astimezone().strftime("%b %d %H:%M")
|
|
3469
|
+
except Exception:
|
|
3470
|
+
human = None
|
|
3471
|
+
label = f"{title} • {human}" if human else f"{title} • {sid[-6:]}"
|
|
3472
|
+
combo.addItem(label, sid)
|
|
3473
|
+
try:
|
|
3474
|
+
idx = combo.count() - 1
|
|
3475
|
+
role = getattr(Qt, "ToolTipRole", None) or getattr(getattr(Qt, "ItemDataRole", object), "ToolTipRole", None)
|
|
3476
|
+
if role is not None:
|
|
3477
|
+
tip_lines = [title, sid]
|
|
3478
|
+
updated = str(rec.get("updated_at") or "").strip()
|
|
3479
|
+
if updated:
|
|
3480
|
+
tip_lines.append(f"Updated: {updated}")
|
|
3481
|
+
combo.setItemData(idx, "\n".join(tip_lines), role)
|
|
3482
|
+
except Exception:
|
|
3483
|
+
pass
|
|
3484
|
+
if active and sid == active:
|
|
3485
|
+
select_index = items
|
|
3486
|
+
items += 1
|
|
3487
|
+
|
|
3488
|
+
if combo.count() <= 0:
|
|
3489
|
+
combo.addItem("New session")
|
|
3490
|
+
combo.setEnabled(False)
|
|
3491
|
+
else:
|
|
3492
|
+
combo.setEnabled(True)
|
|
3493
|
+
combo.setCurrentIndex(select_index)
|
|
3494
|
+
finally:
|
|
3495
|
+
try:
|
|
3496
|
+
combo.blockSignals(False)
|
|
3497
|
+
except Exception:
|
|
3498
|
+
pass
|
|
3499
|
+
|
|
3500
|
+
def _is_run_in_progress(self) -> bool:
|
|
3501
|
+
try:
|
|
3502
|
+
if self.worker is not None and hasattr(self.worker, "isRunning") and self.worker.isRunning():
|
|
3503
|
+
return True
|
|
3504
|
+
except Exception:
|
|
3505
|
+
pass
|
|
3506
|
+
try:
|
|
3507
|
+
return not bool(self.send_button.isEnabled())
|
|
3508
|
+
except Exception:
|
|
3509
|
+
return False
|
|
3510
|
+
|
|
3511
|
+
def _on_session_combo_changed(self, index: int) -> None:
|
|
3512
|
+
if not self.llm_manager or not hasattr(self.llm_manager, "switch_session"):
|
|
3513
|
+
return
|
|
3514
|
+
|
|
3515
|
+
combo = getattr(self, "session_combo", None)
|
|
3516
|
+
if combo is None:
|
|
3517
|
+
return
|
|
3518
|
+
|
|
3519
|
+
try:
|
|
3520
|
+
sid = combo.itemData(int(index))
|
|
3521
|
+
except Exception:
|
|
3522
|
+
sid = None
|
|
3523
|
+
sid = str(sid or "").strip()
|
|
3524
|
+
if not sid:
|
|
3525
|
+
return
|
|
3526
|
+
|
|
3527
|
+
try:
|
|
3528
|
+
current = str(getattr(self.llm_manager, "active_session_id", "") or "").strip()
|
|
3529
|
+
except Exception:
|
|
3530
|
+
current = ""
|
|
3531
|
+
if sid == current:
|
|
3532
|
+
return
|
|
3533
|
+
|
|
3534
|
+
if self._is_run_in_progress():
|
|
3535
|
+
QMessageBox.information(self, "Session switch", "Please wait for the current response to finish.")
|
|
3536
|
+
self._reload_session_combo(select_session_id=current or None)
|
|
3537
|
+
return
|
|
3538
|
+
|
|
3539
|
+
try:
|
|
3540
|
+
self.llm_manager.switch_session(sid)
|
|
3541
|
+
except Exception as e:
|
|
3542
|
+
QMessageBox.warning(self, "Session switch", f"Failed to switch session:\n{e}")
|
|
3543
|
+
self._reload_session_combo(select_session_id=current or None)
|
|
3544
|
+
return
|
|
3545
|
+
|
|
3546
|
+
try:
|
|
3547
|
+
if hasattr(self.llm_manager, "refresh"):
|
|
3548
|
+
self.llm_manager.refresh()
|
|
3549
|
+
except Exception:
|
|
3550
|
+
pass
|
|
3551
|
+
|
|
3552
|
+
self._reload_session_combo(select_session_id=sid)
|
|
3553
|
+
|
|
3554
|
+
# Reset per-session UI caches.
|
|
3555
|
+
self.attached_files.clear()
|
|
3556
|
+
self.message_file_attachments.clear()
|
|
3557
|
+
self.update_attached_files_display()
|
|
3558
|
+
|
|
3559
|
+
self._update_message_history_from_session()
|
|
3560
|
+
self._update_token_count_from_session()
|
|
3561
|
+
self._rebuild_chat_display()
|
|
3562
|
+
|
|
3563
|
+
# If the history window is open but the new session is empty, hide it.
|
|
3564
|
+
if self.history_dialog and self.history_dialog.isVisible() and not self.message_history:
|
|
3565
|
+
try:
|
|
3566
|
+
self.history_dialog.hide()
|
|
3567
|
+
self._update_history_button_appearance(False)
|
|
3568
|
+
except Exception:
|
|
3569
|
+
pass
|
|
3570
|
+
|
|
3571
|
+
def _start_new_session(self) -> None:
|
|
3572
|
+
if not self.llm_manager or not hasattr(self.llm_manager, "create_new_session"):
|
|
3573
|
+
return
|
|
3574
|
+
|
|
3575
|
+
if self._is_run_in_progress():
|
|
3576
|
+
QMessageBox.information(self, "New session", "Please wait for the current response to finish.")
|
|
3577
|
+
return
|
|
3578
|
+
|
|
3579
|
+
try:
|
|
3580
|
+
new_id = str(self.llm_manager.create_new_session() or "").strip()
|
|
3581
|
+
except Exception as e:
|
|
3582
|
+
QMessageBox.warning(self, "New session", f"Failed to create a new session:\n{e}")
|
|
3583
|
+
return
|
|
3584
|
+
|
|
3585
|
+
# Reset per-session UI caches.
|
|
3586
|
+
self.attached_files.clear()
|
|
3587
|
+
self.message_file_attachments.clear()
|
|
3588
|
+
self.update_attached_files_display()
|
|
3589
|
+
|
|
3590
|
+
self._update_message_history_from_session()
|
|
3591
|
+
self._update_token_count_from_session()
|
|
3592
|
+
self._rebuild_chat_display()
|
|
3593
|
+
|
|
3594
|
+
if self.history_dialog and self.history_dialog.isVisible():
|
|
3595
|
+
try:
|
|
3596
|
+
self.history_dialog.hide()
|
|
3597
|
+
self._update_history_button_appearance(False)
|
|
3598
|
+
except Exception:
|
|
3599
|
+
pass
|
|
3600
|
+
|
|
3601
|
+
self._reload_session_combo(select_session_id=new_id or None)
|
|
3602
|
+
|
|
3603
|
+
@pyqtSlot()
|
|
3604
|
+
def _refresh_session_combo_ui(self) -> None:
|
|
3605
|
+
try:
|
|
3606
|
+
self._reload_session_combo()
|
|
3607
|
+
except Exception:
|
|
3608
|
+
pass
|
|
3609
|
+
|
|
3610
|
+
def _show_more_menu(self) -> None:
|
|
3611
|
+
btn = getattr(self, "more_button", None)
|
|
3612
|
+
if btn is None:
|
|
3613
|
+
return
|
|
3614
|
+
menu = QMenu(self)
|
|
3615
|
+
menu.addAction("Load…", self.load_session)
|
|
3616
|
+
menu.addAction("Save…", self.save_session)
|
|
3617
|
+
if bool(getattr(self, "debug", False)):
|
|
3618
|
+
menu.addSeparator()
|
|
3619
|
+
menu.addAction("Run trace (debug)…", self.show_trace)
|
|
3620
|
+
|
|
3621
|
+
try:
|
|
3622
|
+
pos = btn.mapToGlobal(QPoint(0, btn.height()))
|
|
3623
|
+
except Exception:
|
|
3624
|
+
pos = None
|
|
3625
|
+
|
|
3626
|
+
try:
|
|
3627
|
+
if pos is None:
|
|
3628
|
+
if hasattr(menu, "exec_"):
|
|
3629
|
+
menu.exec_()
|
|
3630
|
+
else:
|
|
3631
|
+
menu.exec()
|
|
3632
|
+
else:
|
|
3633
|
+
if hasattr(menu, "exec_"):
|
|
3634
|
+
menu.exec_(pos)
|
|
3635
|
+
else:
|
|
3636
|
+
menu.exec(pos)
|
|
3637
|
+
except Exception:
|
|
3638
|
+
return
|
|
1939
3639
|
|
|
1940
3640
|
def clear_session(self):
|
|
1941
|
-
"""
|
|
3641
|
+
"""Start a new session (prior sessions remain available)."""
|
|
1942
3642
|
reply = QMessageBox.question(
|
|
1943
|
-
self,
|
|
1944
|
-
"
|
|
1945
|
-
"
|
|
3643
|
+
self,
|
|
3644
|
+
"New Session",
|
|
3645
|
+
"Start a new session?\nYour previous sessions will remain available in the session dropdown.",
|
|
1946
3646
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
1947
|
-
QMessageBox.StandardButton.No
|
|
3647
|
+
QMessageBox.StandardButton.No,
|
|
1948
3648
|
)
|
|
1949
|
-
|
|
1950
|
-
if reply == QMessageBox.StandardButton.Yes:
|
|
1951
|
-
if hasattr(self, 'chat_display'):
|
|
1952
|
-
self.chat_display.clear()
|
|
1953
|
-
self.chat_display.hide()
|
|
1954
|
-
|
|
1955
|
-
# Clear AbstractCore session and create a new one
|
|
1956
|
-
if self.llm_manager:
|
|
1957
|
-
self.llm_manager.create_new_session()
|
|
1958
|
-
if self.debug:
|
|
1959
|
-
if self.debug:
|
|
1960
|
-
print("🧹 AbstractCore session cleared and recreated")
|
|
1961
3649
|
|
|
1962
|
-
|
|
1963
|
-
self.
|
|
1964
|
-
self.update_token_display()
|
|
1965
|
-
|
|
1966
|
-
# Clear attached files as part of session clearing
|
|
1967
|
-
self.attached_files.clear()
|
|
1968
|
-
self.message_file_attachments.clear()
|
|
1969
|
-
self.update_attached_files_display()
|
|
1970
|
-
|
|
1971
|
-
if self.debug:
|
|
1972
|
-
if self.debug:
|
|
1973
|
-
print("🧹 Session cleared (including attached files and file tracking)")
|
|
3650
|
+
if reply == QMessageBox.StandardButton.Yes:
|
|
3651
|
+
self._start_new_session()
|
|
1974
3652
|
|
|
1975
3653
|
def compact_session(self):
|
|
1976
3654
|
"""Compact the current session using AbstractCore's summarizer functionality."""
|
|
@@ -2151,8 +3829,8 @@ Please provide the summary in a clear, structured format:"""
|
|
|
2151
3829
|
def _create_compacted_session(self, summary: str, recent_messages: list):
|
|
2152
3830
|
"""Create a new session with the summary and recent messages."""
|
|
2153
3831
|
try:
|
|
2154
|
-
# Create new session with summary
|
|
2155
|
-
|
|
3832
|
+
# Create new session with summary embedded in the system prompt.
|
|
3833
|
+
final_system_prompt = f"""You are a helpful AI assistant who has access to tools to help the user.
|
|
2156
3834
|
Always be a critical and creative thinker who leverage constructive skepticism to progress and evolve its reasoning and answers.
|
|
2157
3835
|
Always answer in nicely formatted markdown.
|
|
2158
3836
|
|
|
@@ -2165,7 +3843,7 @@ The following is a summary of our previous conversation:
|
|
|
2165
3843
|
|
|
2166
3844
|
Continue the conversation naturally, referring to the context above when relevant."""
|
|
2167
3845
|
|
|
2168
|
-
# Create new session with
|
|
3846
|
+
# Create new session with the composed system prompt
|
|
2169
3847
|
if self.llm_manager:
|
|
2170
3848
|
# Create new session with custom system prompt
|
|
2171
3849
|
from abstractcore import BasicSession
|
|
@@ -2187,7 +3865,7 @@ Continue the conversation naturally, referring to the context above when relevan
|
|
|
2187
3865
|
# Create new session with summary in system prompt
|
|
2188
3866
|
new_session = BasicSession(
|
|
2189
3867
|
self.llm_manager.llm,
|
|
2190
|
-
system_prompt=
|
|
3868
|
+
system_prompt=final_system_prompt,
|
|
2191
3869
|
tools=tools
|
|
2192
3870
|
)
|
|
2193
3871
|
|
|
@@ -2260,6 +3938,8 @@ Continue the conversation naturally, referring to the context above when relevan
|
|
|
2260
3938
|
|
|
2261
3939
|
# Update our local message history from AbstractCore
|
|
2262
3940
|
self._update_message_history_from_session()
|
|
3941
|
+
self._update_token_count_from_session()
|
|
3942
|
+
self._reload_session_combo()
|
|
2263
3943
|
self._rebuild_chat_display()
|
|
2264
3944
|
|
|
2265
3945
|
QMessageBox.information(
|
|
@@ -2357,37 +4037,78 @@ Continue the conversation naturally, referring to the context above when relevan
|
|
|
2357
4037
|
return not self._is_voice_mode_active()
|
|
2358
4038
|
|
|
2359
4039
|
def _update_message_history_from_session(self):
|
|
2360
|
-
"""Update local message history from
|
|
2361
|
-
|
|
4040
|
+
"""Update local message history from the durable agent snapshot (preferred).
|
|
4041
|
+
|
|
4042
|
+
Notes:
|
|
4043
|
+
- Runtime-backed agents include role="tool" observations in the transcript so the
|
|
4044
|
+
model can continue tool loops. Those are hidden from the user-visible history
|
|
4045
|
+
and replaced with a compact per-answer tool summary + resource links.
|
|
4046
|
+
"""
|
|
4047
|
+
if not self.llm_manager:
|
|
4048
|
+
return
|
|
4049
|
+
|
|
4050
|
+
from ..core.transcript_summary import build_display_messages
|
|
4051
|
+
|
|
4052
|
+
raw_messages = None
|
|
4053
|
+
try:
|
|
4054
|
+
host = getattr(self.llm_manager, "agent_host", None)
|
|
4055
|
+
snap = getattr(host, "snapshot", None) if host is not None else None
|
|
4056
|
+
raw_messages = getattr(snap, "messages", None) if snap is not None else None
|
|
4057
|
+
except Exception:
|
|
4058
|
+
raw_messages = None
|
|
4059
|
+
|
|
4060
|
+
messages: List[Dict] = []
|
|
4061
|
+
if isinstance(raw_messages, list):
|
|
4062
|
+
messages = [dict(m) for m in raw_messages if isinstance(m, dict)]
|
|
4063
|
+
elif getattr(self.llm_manager, "current_session", None) is not None:
|
|
2362
4064
|
try:
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
if hasattr(msg, 'role') and msg.role == 'system':
|
|
2371
|
-
continue
|
|
4065
|
+
session_messages = getattr(self.llm_manager.current_session, "messages", [])
|
|
4066
|
+
for msg in session_messages:
|
|
4067
|
+
role = getattr(msg, "role", "unknown")
|
|
4068
|
+
content = getattr(msg, "content", str(msg))
|
|
4069
|
+
messages.append({"role": str(role), "content": str(content)})
|
|
4070
|
+
except Exception:
|
|
4071
|
+
messages = []
|
|
2372
4072
|
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
4073
|
+
try:
|
|
4074
|
+
rendered = build_display_messages(messages)
|
|
4075
|
+
|
|
4076
|
+
self.message_history = []
|
|
4077
|
+
for m in rendered:
|
|
4078
|
+
role = str(m.get("role") or "unknown")
|
|
4079
|
+
content = str(m.get("content") or "")
|
|
4080
|
+
timestamp = m.get("timestamp")
|
|
4081
|
+
if not isinstance(timestamp, (str, int, float)) or (isinstance(timestamp, str) and not timestamp.strip()):
|
|
4082
|
+
timestamp = datetime.now().isoformat()
|
|
4083
|
+
|
|
4084
|
+
entry: Dict[str, Any] = {
|
|
4085
|
+
"timestamp": timestamp,
|
|
4086
|
+
"type": role,
|
|
4087
|
+
"content": content,
|
|
4088
|
+
"provider": self.current_provider,
|
|
4089
|
+
"model": self.current_model,
|
|
4090
|
+
"attached_files": self.message_file_attachments.get(len(self.message_history), []),
|
|
4091
|
+
}
|
|
2382
4092
|
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
4093
|
+
tool_summary = m.get("tool_summary")
|
|
4094
|
+
if isinstance(tool_summary, str) and tool_summary.strip():
|
|
4095
|
+
entry["tool_summary"] = tool_summary.strip()
|
|
4096
|
+
tool_links = m.get("tool_links")
|
|
4097
|
+
if isinstance(tool_links, list) and tool_links:
|
|
4098
|
+
entry["tool_links"] = [dict(x) for x in tool_links if isinstance(x, dict)]
|
|
2386
4099
|
|
|
2387
|
-
|
|
2388
|
-
if
|
|
2389
|
-
if
|
|
2390
|
-
|
|
4100
|
+
image_thumbnails = m.get("image_thumbnails")
|
|
4101
|
+
if isinstance(image_thumbnails, list) and image_thumbnails:
|
|
4102
|
+
entry["image_thumbnails"] = [dict(x) for x in image_thumbnails if isinstance(x, dict)]
|
|
4103
|
+
|
|
4104
|
+
self.message_history.append(entry)
|
|
4105
|
+
|
|
4106
|
+
if self.debug:
|
|
4107
|
+
print(f"📚 Updated message history: {len(self.message_history)} messages")
|
|
4108
|
+
|
|
4109
|
+
except Exception as e:
|
|
4110
|
+
if self.debug:
|
|
4111
|
+
print(f"❌ Error updating message history from snapshot/session: {e}")
|
|
2391
4112
|
|
|
2392
4113
|
def _rebuild_chat_display(self):
|
|
2393
4114
|
"""Rebuild chat display after session loading.
|
|
@@ -2426,6 +4147,75 @@ Continue the conversation naturally, referring to the context above when relevan
|
|
|
2426
4147
|
if self.debug:
|
|
2427
4148
|
print(f"❌ Error updating token count from session: {e}")
|
|
2428
4149
|
|
|
4150
|
+
def show_trace(self):
|
|
4151
|
+
"""Show a lightweight debug trace for the last run."""
|
|
4152
|
+
host = getattr(self.llm_manager, "agent_host", None)
|
|
4153
|
+
run_id = None
|
|
4154
|
+
try:
|
|
4155
|
+
snap = getattr(host, "snapshot", None)
|
|
4156
|
+
run_id = getattr(snap, "last_run_id", None) if snap else None
|
|
4157
|
+
except Exception:
|
|
4158
|
+
run_id = None
|
|
4159
|
+
|
|
4160
|
+
rid = str(run_id or "").strip()
|
|
4161
|
+
if not host or not rid:
|
|
4162
|
+
QMessageBox.information(self, "Run trace", "No run is available yet.")
|
|
4163
|
+
return
|
|
4164
|
+
|
|
4165
|
+
try:
|
|
4166
|
+
ensure = getattr(host, "_ensure_ready", None)
|
|
4167
|
+
if callable(ensure):
|
|
4168
|
+
ensure()
|
|
4169
|
+
except Exception:
|
|
4170
|
+
pass
|
|
4171
|
+
|
|
4172
|
+
rt = getattr(host, "_runtime", None)
|
|
4173
|
+
if rt is None:
|
|
4174
|
+
QMessageBox.information(self, "Run trace", "Runtime is not initialized yet.")
|
|
4175
|
+
return
|
|
4176
|
+
|
|
4177
|
+
try:
|
|
4178
|
+
state = rt.get_state(rid)
|
|
4179
|
+
payload = {
|
|
4180
|
+
"run_id": getattr(state, "run_id", rid),
|
|
4181
|
+
"status": str(getattr(state, "status", "")),
|
|
4182
|
+
"workflow_id": getattr(state, "workflow_id", None),
|
|
4183
|
+
"actor_id": getattr(state, "actor_id", None),
|
|
4184
|
+
"session_id": getattr(state, "session_id", None),
|
|
4185
|
+
"waiting": str(getattr(state, "waiting", None) or ""),
|
|
4186
|
+
"error": str(getattr(state, "error", None) or ""),
|
|
4187
|
+
"vars_keys": sorted(list(getattr(state, "vars", {}).keys())) if isinstance(getattr(state, "vars", None), dict) else [],
|
|
4188
|
+
"output_keys": sorted(list(getattr(state, "output", {}).keys())) if isinstance(getattr(state, "output", None), dict) else [],
|
|
4189
|
+
}
|
|
4190
|
+
|
|
4191
|
+
details_obj = {
|
|
4192
|
+
"summary": payload,
|
|
4193
|
+
"vars": getattr(state, "vars", None),
|
|
4194
|
+
"output": getattr(state, "output", None),
|
|
4195
|
+
}
|
|
4196
|
+
details_txt = json.dumps(details_obj, ensure_ascii=False, indent=2, default=str)
|
|
4197
|
+
if len(details_txt) > 20000:
|
|
4198
|
+
details_txt = details_txt[:20000] + "\n…(truncated)…"
|
|
4199
|
+
|
|
4200
|
+
box = QMessageBox(self)
|
|
4201
|
+
box.setWindowTitle("Run trace")
|
|
4202
|
+
box.setIcon(QMessageBox.Icon.Information)
|
|
4203
|
+
box.setText("Last run trace (summary).")
|
|
4204
|
+
box.setInformativeText(
|
|
4205
|
+
"\n".join(
|
|
4206
|
+
[
|
|
4207
|
+
f"Run ID: {payload['run_id']}",
|
|
4208
|
+
f"Status: {payload['status']}",
|
|
4209
|
+
f"Workflow: {payload['workflow_id']}",
|
|
4210
|
+
f"Actor: {payload['actor_id']}",
|
|
4211
|
+
]
|
|
4212
|
+
)
|
|
4213
|
+
)
|
|
4214
|
+
box.setDetailedText(details_txt)
|
|
4215
|
+
box.exec()
|
|
4216
|
+
except Exception as e:
|
|
4217
|
+
QMessageBox.warning(self, "Run trace", f"Failed to load trace:\n{e}")
|
|
4218
|
+
|
|
2429
4219
|
def _show_history_if_voice_mode_off(self):
|
|
2430
4220
|
"""Show chat history only if voice mode is OFF."""
|
|
2431
4221
|
if not self._should_show_chat_history():
|
|
@@ -2767,6 +4557,14 @@ Continue the conversation naturally, referring to the context above when relevan
|
|
|
2767
4557
|
if self.debug:
|
|
2768
4558
|
print("🔊 QtChatBubble: Speech ended, setting ready status")
|
|
2769
4559
|
self.status_callback("ready")
|
|
4560
|
+
|
|
4561
|
+
# Voice loop: allow next transcription after speaking ends.
|
|
4562
|
+
if self._is_full_voice_running():
|
|
4563
|
+
self._voice_busy = False
|
|
4564
|
+
try:
|
|
4565
|
+
self.update_status("LISTENING")
|
|
4566
|
+
except Exception:
|
|
4567
|
+
pass
|
|
2770
4568
|
|
|
2771
4569
|
@pyqtSlot()
|
|
2772
4570
|
def _execute_tts_completion_callbacks(self):
|
|
@@ -2786,6 +4584,8 @@ Continue the conversation naturally, referring to the context above when relevan
|
|
|
2786
4584
|
self._tts_completion_callback = None
|
|
2787
4585
|
|
|
2788
4586
|
|
|
4587
|
+
# NOTE: The message input row now owns sizing of the square action buttons via `_MessageInputRow`.
|
|
4588
|
+
|
|
2789
4589
|
def closeEvent(self, event):
|
|
2790
4590
|
"""Handle close event."""
|
|
2791
4591
|
if self.worker and self.worker.isRunning():
|