abstractassistant 0.3.5__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/qt_bubble.py +2276 -477
- abstractassistant-0.4.0.dist-info/METADATA +168 -0
- {abstractassistant-0.3.5.dist-info → abstractassistant-0.4.0.dist-info}/RECORD +16 -11
- {abstractassistant-0.3.5.dist-info → abstractassistant-0.4.0.dist-info}/WHEEL +1 -1
- {abstractassistant-0.3.5.dist-info → abstractassistant-0.4.0.dist-info}/entry_points.txt +1 -0
- abstractassistant-0.3.5.dist-info/METADATA +0 -297
- {abstractassistant-0.3.5.dist-info → abstractassistant-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {abstractassistant-0.3.5.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);
|
|
@@ -675,209 +1472,364 @@ class QtChatBubble(QWidget):
|
|
|
675
1472
|
|
|
676
1473
|
# Enter key handling
|
|
677
1474
|
self.input_text.keyPressEvent = self.handle_key_press
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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']};
|
|
686
1539
|
border-radius: 12px;
|
|
687
|
-
color:
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
|
|
700
|
-
selection-background-color: #0066cc;
|
|
701
|
-
line-height: 1.4;
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
QTextEdit:focus {
|
|
705
|
-
border: 1px solid #0066cc;
|
|
706
|
-
background: #333333;
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
QTextEdit::placeholder {
|
|
710
|
-
color: rgba(255, 255, 255, 0.6);
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
/* Buttons - Grey Theme */
|
|
714
|
-
QPushButton {
|
|
715
|
-
background: #404040;
|
|
716
|
-
border: 1px solid #555555;
|
|
717
|
-
border-radius: 4px;
|
|
718
|
-
padding: 4px 8px;
|
|
719
|
-
font-size: 11px;
|
|
720
|
-
font-weight: 500;
|
|
721
|
-
color: #ffffff;
|
|
722
|
-
font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
QPushButton:hover {
|
|
726
|
-
background: #505050;
|
|
727
|
-
border: 1px solid #0066cc;
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
QPushButton:pressed {
|
|
731
|
-
background: #353535;
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
QPushButton:disabled {
|
|
735
|
-
background: #2a2a2a;
|
|
736
|
-
color: #666666;
|
|
737
|
-
border: 1px solid #333333;
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
/* Dropdown Menus - Grey Theme */
|
|
741
|
-
QComboBox {
|
|
742
|
-
background: #1e1e1e;
|
|
743
|
-
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};
|
|
744
1552
|
border-radius: 6px;
|
|
745
|
-
padding:
|
|
746
|
-
|
|
747
|
-
|
|
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;
|
|
748
1565
|
font-weight: 400;
|
|
749
|
-
color:
|
|
1566
|
+
color: {t['text_primary']};
|
|
750
1567
|
font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
|
|
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
|
-
|
|
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']};
|
|
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;
|
|
1637
|
+
font-size: 12px;
|
|
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);
|
|
778
1663
|
color: #ffffff;
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
1664
|
+
}}
|
|
1665
|
+
QPushButton:pressed {{
|
|
1666
|
+
background: rgba(255, 60, 60, 0.65);
|
|
1667
|
+
}}
|
|
1668
|
+
"""
|
|
1669
|
+
)
|
|
782
1670
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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']};
|
|
786
1730
|
border: none;
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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']};
|
|
790
1782
|
border-radius: 6px;
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
1783
|
+
padding: 4px;
|
|
1784
|
+
}}
|
|
1785
|
+
"""
|
|
1786
|
+
)
|
|
794
1787
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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)
|
|
801
1805
|
|
|
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
|
-
QLabel#status_generating {
|
|
830
|
-
background: rgba(250, 179, 135, 0.15);
|
|
831
|
-
border: 1px solid rgba(250, 179, 135, 0.3);
|
|
832
|
-
border-radius: 12px;
|
|
833
|
-
padding: 6px 12px;
|
|
834
|
-
font-size: 10px;
|
|
835
|
-
font-weight: 600;
|
|
836
|
-
text-transform: uppercase;
|
|
837
|
-
letter-spacing: 0.5px;
|
|
838
|
-
color: #fab387;
|
|
839
|
-
font-family: "Helvetica Neue", "Helvetica", "Segoe UI", Arial, sans-serif;
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
QLabel#status_error {
|
|
843
|
-
background: rgba(243, 139, 168, 0.15);
|
|
844
|
-
border: 1px solid rgba(243, 139, 168, 0.3);
|
|
845
|
-
border-radius: 12px;
|
|
846
|
-
padding: 6px 12px;
|
|
847
|
-
font-size: 10px;
|
|
848
|
-
font-weight: 600;
|
|
849
|
-
text-transform: uppercase;
|
|
850
|
-
letter-spacing: 0.5px;
|
|
851
|
-
color: #f38ba8;
|
|
852
|
-
font-family: "Helvetica Neue", "Helvetica", "Segoe UI", Arial, sans-serif;
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
QLabel#token_label {
|
|
856
|
-
background: #2d3748;
|
|
857
|
-
border: 1px solid #4a5568;
|
|
858
|
-
border-radius: 8px;
|
|
859
|
-
padding: 10px 12px;
|
|
860
|
-
font-family: "Helvetica Neue", "Helvetica", "Segoe UI", Arial, sans-serif;
|
|
861
|
-
font-size: 11px;
|
|
862
|
-
font-weight: 500;
|
|
863
|
-
color: #cbd5e0;
|
|
864
|
-
text-align: center;
|
|
865
|
-
letter-spacing: 0.025em;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
/* Frames - Invisible Containers */
|
|
869
|
-
QFrame {
|
|
870
|
-
border: none;
|
|
871
|
-
background: transparent;
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
/* Separator Lines */
|
|
875
|
-
QLabel#separator {
|
|
876
|
-
color: rgba(255, 255, 255, 0.3);
|
|
877
|
-
font-size: 14px;
|
|
878
|
-
font-weight: 300;
|
|
879
|
-
}
|
|
880
|
-
""")
|
|
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."""
|
|
@@ -1048,15 +2000,46 @@ class QtChatBubble(QWidget):
|
|
|
1048
2000
|
|
|
1049
2001
|
def update_token_limits(self):
|
|
1050
2002
|
"""Update token limits using AbstractCore's built-in detection."""
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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})")
|
|
1060
2043
|
|
|
1061
2044
|
self.update_token_display()
|
|
1062
2045
|
|
|
@@ -1113,6 +2096,142 @@ class QtChatBubble(QWidget):
|
|
|
1113
2096
|
print(f"Model changed to: {self.current_model}")
|
|
1114
2097
|
|
|
1115
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
|
+
|
|
1116
2235
|
def attach_files(self):
|
|
1117
2236
|
"""Open file dialog to attach files (AbstractCore 2.4.5+ media handling)."""
|
|
1118
2237
|
file_dialog = QFileDialog(self)
|
|
@@ -1139,6 +2258,7 @@ class QtChatBubble(QWidget):
|
|
|
1139
2258
|
|
|
1140
2259
|
def update_attached_files_display(self):
|
|
1141
2260
|
"""Update the visual display of attached files."""
|
|
2261
|
+
t = self._theme or self._compute_theme()
|
|
1142
2262
|
# Clear existing file chips
|
|
1143
2263
|
while self.attached_files_layout.count():
|
|
1144
2264
|
child = self.attached_files_layout.takeAt(0)
|
|
@@ -1159,14 +2279,16 @@ class QtChatBubble(QWidget):
|
|
|
1159
2279
|
|
|
1160
2280
|
# Create file chip
|
|
1161
2281
|
file_chip = QFrame()
|
|
1162
|
-
file_chip.setStyleSheet(
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
2282
|
+
file_chip.setStyleSheet(
|
|
2283
|
+
f"""
|
|
2284
|
+
QFrame {{
|
|
2285
|
+
background: {t['accent_rgba_20']};
|
|
2286
|
+
border: 1px solid {t['accent_rgba_35']};
|
|
1166
2287
|
border-radius: 6px;
|
|
1167
2288
|
padding: 1px 4px;
|
|
1168
|
-
}
|
|
1169
|
-
|
|
2289
|
+
}}
|
|
2290
|
+
"""
|
|
2291
|
+
)
|
|
1170
2292
|
|
|
1171
2293
|
chip_layout = QHBoxLayout(file_chip)
|
|
1172
2294
|
chip_layout.setContentsMargins(2, 1, 2, 1)
|
|
@@ -1190,24 +2312,28 @@ class QtChatBubble(QWidget):
|
|
|
1190
2312
|
icon = "📎"
|
|
1191
2313
|
|
|
1192
2314
|
file_label = QLabel(f"{icon} {file_name[:20]}{'...' if len(file_name) > 20 else ''}")
|
|
1193
|
-
file_label.setStyleSheet(
|
|
2315
|
+
file_label.setStyleSheet(
|
|
2316
|
+
f"background: transparent; border: none; color: {t['text_primary']}; font-size: 8px;"
|
|
2317
|
+
)
|
|
1194
2318
|
chip_layout.addWidget(file_label)
|
|
1195
2319
|
|
|
1196
2320
|
# Remove button
|
|
1197
2321
|
remove_btn = QPushButton("✕")
|
|
1198
2322
|
remove_btn.setFixedSize(12, 12)
|
|
1199
|
-
remove_btn.setStyleSheet(
|
|
1200
|
-
|
|
2323
|
+
remove_btn.setStyleSheet(
|
|
2324
|
+
f"""
|
|
2325
|
+
QPushButton {{
|
|
1201
2326
|
background: transparent;
|
|
1202
2327
|
border: none;
|
|
1203
|
-
color:
|
|
2328
|
+
color: {t['text_muted']};
|
|
1204
2329
|
font-size: 8px;
|
|
1205
2330
|
padding: 0px;
|
|
1206
|
-
}
|
|
1207
|
-
QPushButton:hover {
|
|
2331
|
+
}}
|
|
2332
|
+
QPushButton:hover {{
|
|
1208
2333
|
color: rgba(255, 60, 60, 0.9);
|
|
1209
|
-
}
|
|
1210
|
-
|
|
2334
|
+
}}
|
|
2335
|
+
"""
|
|
2336
|
+
)
|
|
1211
2337
|
remove_btn.clicked.connect(lambda checked, fp=file_path: self.remove_attached_file(fp))
|
|
1212
2338
|
chip_layout.addWidget(remove_btn)
|
|
1213
2339
|
|
|
@@ -1278,6 +2404,7 @@ class QtChatBubble(QWidget):
|
|
|
1278
2404
|
# 4. Update UI for sending state
|
|
1279
2405
|
self.send_button.setEnabled(False)
|
|
1280
2406
|
self.send_button.setText("⏳")
|
|
2407
|
+
self._set_session_controls_enabled(False)
|
|
1281
2408
|
self.status_label.setText("generating")
|
|
1282
2409
|
self.status_label.setObjectName("status_generating")
|
|
1283
2410
|
self.status_label.setStyleSheet("""
|
|
@@ -1294,32 +2421,222 @@ class QtChatBubble(QWidget):
|
|
|
1294
2421
|
}
|
|
1295
2422
|
""")
|
|
1296
2423
|
|
|
1297
|
-
# Notify main app about status change (for icon animation)
|
|
1298
|
-
if self.status_callback:
|
|
1299
|
-
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
|
|
1300
2567
|
|
|
1301
|
-
|
|
1302
|
-
|
|
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
|
|
1303
2621
|
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
self.llm_manager,
|
|
1307
|
-
message,
|
|
1308
|
-
self.current_provider,
|
|
1309
|
-
self.current_model,
|
|
1310
|
-
media=media_files if media_files else None
|
|
1311
|
-
)
|
|
1312
|
-
self.worker.response_ready.connect(self.on_response_ready)
|
|
1313
|
-
self.worker.error_occurred.connect(self.on_error_occurred)
|
|
2622
|
+
if isinstance(self.worker, AgentWorker):
|
|
2623
|
+
self.worker.provide_tool_approval(bool(approved))
|
|
1314
2624
|
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
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:")
|
|
1318
2628
|
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
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)
|
|
1323
2640
|
|
|
1324
2641
|
@pyqtSlot(str)
|
|
1325
2642
|
def on_response_ready(self, response):
|
|
@@ -1329,6 +2646,7 @@ class QtChatBubble(QWidget):
|
|
|
1329
2646
|
|
|
1330
2647
|
self.send_button.setEnabled(True)
|
|
1331
2648
|
self.send_button.setText("→")
|
|
2649
|
+
self._set_session_controls_enabled(True)
|
|
1332
2650
|
self.status_label.setText("ready")
|
|
1333
2651
|
self.status_label.setObjectName("status_ready")
|
|
1334
2652
|
self.status_label.setStyleSheet("""
|
|
@@ -1354,6 +2672,25 @@ class QtChatBubble(QWidget):
|
|
|
1354
2672
|
|
|
1355
2673
|
# Update token count from AbstractCore
|
|
1356
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
|
|
1357
2694
|
|
|
1358
2695
|
# Handle TTS if enabled (AbstractVoice integration)
|
|
1359
2696
|
if self.tts_enabled and self.voice_manager and self.voice_manager.is_available():
|
|
@@ -1386,7 +2723,9 @@ class QtChatBubble(QWidget):
|
|
|
1386
2723
|
|
|
1387
2724
|
# Speak the cleaned response using AbstractVoice-compatible interface
|
|
1388
2725
|
# Note: We don't set "speaking" status here anymore - we wait for the callback
|
|
1389
|
-
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")
|
|
1390
2729
|
|
|
1391
2730
|
# Update toggle state to 'speaking'
|
|
1392
2731
|
self._update_tts_toggle_state()
|
|
@@ -1403,6 +2742,12 @@ class QtChatBubble(QWidget):
|
|
|
1403
2742
|
print(f"❌ TTS error: {e}")
|
|
1404
2743
|
# Show chat history as fallback - only if voice mode is OFF
|
|
1405
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
|
|
1406
2751
|
else:
|
|
1407
2752
|
# Show chat history instead of toast when TTS is disabled - only if voice mode is OFF
|
|
1408
2753
|
self._show_history_if_voice_mode_off()
|
|
@@ -1421,6 +2766,12 @@ class QtChatBubble(QWidget):
|
|
|
1421
2766
|
self.response_callback(response)
|
|
1422
2767
|
if self.status_callback:
|
|
1423
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
|
|
1424
2775
|
else:
|
|
1425
2776
|
# TTS path: Stay in thinking mode until audio actually starts
|
|
1426
2777
|
if self.debug:
|
|
@@ -1584,7 +2935,13 @@ class QtChatBubble(QWidget):
|
|
|
1584
2935
|
pass
|
|
1585
2936
|
|
|
1586
2937
|
def on_full_voice_toggled(self, enabled: bool):
|
|
1587
|
-
"""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:
|
|
1588
2945
|
if self.debug:
|
|
1589
2946
|
print(f"🎙️ Full Voice Mode {'enabled' if enabled else 'disabled'}")
|
|
1590
2947
|
|
|
@@ -1607,7 +2964,8 @@ class QtChatBubble(QWidget):
|
|
|
1607
2964
|
if self.debug:
|
|
1608
2965
|
print("🚀 Starting Full Voice Mode...")
|
|
1609
2966
|
|
|
1610
|
-
#
|
|
2967
|
+
# Keep the normal interface visible, but switch input actions to voice mode
|
|
2968
|
+
# (attach + tools remain usable; send is hidden).
|
|
1611
2969
|
self.hide_text_ui()
|
|
1612
2970
|
|
|
1613
2971
|
# Enable TTS automatically
|
|
@@ -1621,6 +2979,9 @@ class QtChatBubble(QWidget):
|
|
|
1621
2979
|
if self.llm_manager:
|
|
1622
2980
|
self.llm_manager.update_session_mode(tts_mode=True)
|
|
1623
2981
|
|
|
2982
|
+
# Mark running before starting the underlying loop so late UI updates can be gated.
|
|
2983
|
+
self._full_voice_running = True
|
|
2984
|
+
|
|
1624
2985
|
# Start listening
|
|
1625
2986
|
self.voice_manager.listen(
|
|
1626
2987
|
on_transcription=self.handle_voice_input,
|
|
@@ -1645,77 +3006,132 @@ class QtChatBubble(QWidget):
|
|
|
1645
3006
|
traceback.print_exc()
|
|
1646
3007
|
|
|
1647
3008
|
# Reset toggle state on error
|
|
3009
|
+
self._full_voice_running = False
|
|
1648
3010
|
self.full_voice_toggle.set_enabled(False)
|
|
1649
3011
|
self.show_text_ui()
|
|
1650
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
|
+
|
|
1651
3020
|
def stop_full_voice_mode(self):
|
|
1652
3021
|
"""Stop Full Voice Mode and return to normal text mode."""
|
|
1653
|
-
|
|
3022
|
+
# IMPORTANT: make this robust. Even if voice backend stop throws, the UI must restore.
|
|
3023
|
+
if self.debug:
|
|
1654
3024
|
if self.debug:
|
|
1655
|
-
|
|
1656
|
-
|
|
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
|
|
1657
3030
|
|
|
1658
|
-
|
|
1659
|
-
|
|
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
|
|
1660
3040
|
self.voice_manager.stop_listening()
|
|
3041
|
+
except Exception as e:
|
|
3042
|
+
if self.debug:
|
|
3043
|
+
print(f"❌ Error stopping listening: {e}")
|
|
3044
|
+
try:
|
|
1661
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
|
|
1662
3057
|
|
|
1663
|
-
|
|
3058
|
+
# 3) Restore normal UI (Send visible again)
|
|
3059
|
+
try:
|
|
1664
3060
|
self.show_text_ui()
|
|
3061
|
+
except Exception:
|
|
3062
|
+
pass
|
|
1665
3063
|
|
|
1666
|
-
|
|
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:
|
|
1667
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
|
|
1668
3084
|
|
|
3085
|
+
if self.debug:
|
|
1669
3086
|
if self.debug:
|
|
1670
|
-
|
|
1671
|
-
print("✅ Full Voice Mode stopped")
|
|
1672
|
-
|
|
1673
|
-
except Exception as e:
|
|
1674
|
-
if self.debug:
|
|
1675
|
-
if self.debug:
|
|
1676
|
-
print(f"❌ Error stopping Full Voice Mode: {e}")
|
|
1677
|
-
import traceback
|
|
1678
|
-
traceback.print_exc()
|
|
3087
|
+
print("✅ Full Voice Mode stopped")
|
|
1679
3088
|
|
|
1680
3089
|
def handle_voice_input(self, transcribed_text: str):
|
|
1681
|
-
"""Handle speech-to-text input
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
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
|
|
1686
3094
|
|
|
1687
|
-
|
|
1688
|
-
|
|
3095
|
+
text = str(transcribed_text or "").strip()
|
|
3096
|
+
if not text:
|
|
3097
|
+
return
|
|
1689
3098
|
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
transcribed_text,
|
|
1693
|
-
self.current_provider,
|
|
1694
|
-
self.current_model
|
|
1695
|
-
)
|
|
3099
|
+
if self.debug:
|
|
3100
|
+
print(f"👤 Voice input: {text}")
|
|
1696
3101
|
|
|
1697
|
-
|
|
1698
|
-
|
|
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))
|
|
1699
3104
|
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
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
|
|
1703
3110
|
|
|
1704
|
-
|
|
1705
|
-
self.
|
|
3111
|
+
if self._voice_busy:
|
|
3112
|
+
if self.debug:
|
|
3113
|
+
print("🎙️ Ignoring transcription while busy")
|
|
3114
|
+
return
|
|
1706
3115
|
|
|
1707
|
-
|
|
1708
|
-
|
|
3116
|
+
self._voice_busy = True
|
|
3117
|
+
try:
|
|
3118
|
+
self.update_status("PROCESSING")
|
|
1709
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()
|
|
1710
3126
|
except Exception as e:
|
|
3127
|
+
self._voice_busy = False
|
|
1711
3128
|
if self.debug:
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
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
|
|
1719
3135
|
|
|
1720
3136
|
def handle_voice_stop(self):
|
|
1721
3137
|
"""Handle when user says 'stop' to exit Full Voice Mode."""
|
|
@@ -1724,28 +3140,64 @@ class QtChatBubble(QWidget):
|
|
|
1724
3140
|
print("🛑 User said 'stop' - exiting Full Voice Mode")
|
|
1725
3141
|
|
|
1726
3142
|
# Disable Full Voice Mode
|
|
3143
|
+
self._full_voice_running = False
|
|
1727
3144
|
self.full_voice_toggle.set_enabled(False)
|
|
1728
3145
|
|
|
1729
3146
|
def hide_text_ui(self):
|
|
1730
|
-
"""
|
|
1731
|
-
|
|
1732
|
-
if hasattr(self, 'input_container'):
|
|
1733
|
-
self.input_container.hide()
|
|
1734
|
-
|
|
1735
|
-
# Update window size to be smaller but maintain wider width
|
|
1736
|
-
voice_base_height = 120
|
|
1737
|
-
attachment_height = 28 if (self.attached_files and self.attached_files_container.isVisible()) else 0
|
|
1738
|
-
voice_height = voice_base_height + attachment_height
|
|
1739
|
-
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)
|
|
1740
3149
|
|
|
1741
3150
|
def show_text_ui(self):
|
|
1742
|
-
"""
|
|
1743
|
-
|
|
1744
|
-
if hasattr(self, 'input_container'):
|
|
1745
|
-
self.input_container.show()
|
|
3151
|
+
"""Exit Full Voice Mode UI (restore Send and normal text interaction)."""
|
|
3152
|
+
self._set_voice_ui_mode(False)
|
|
1746
3153
|
|
|
1747
|
-
|
|
1748
|
-
|
|
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
|
|
1749
3201
|
|
|
1750
3202
|
def update_status(self, status_text: str):
|
|
1751
3203
|
"""Update the status label with the given text."""
|
|
@@ -1894,6 +3346,7 @@ class QtChatBubble(QWidget):
|
|
|
1894
3346
|
"""Handle LLM error."""
|
|
1895
3347
|
self.send_button.setEnabled(True)
|
|
1896
3348
|
self.send_button.setText("→")
|
|
3349
|
+
self._set_session_controls_enabled(True)
|
|
1897
3350
|
self.status_label.setText("error")
|
|
1898
3351
|
self.status_label.setObjectName("status_error")
|
|
1899
3352
|
self.status_label.setStyleSheet("""
|
|
@@ -1921,6 +3374,14 @@ class QtChatBubble(QWidget):
|
|
|
1921
3374
|
|
|
1922
3375
|
# Show history so user can see the error context - only if voice mode is OFF
|
|
1923
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
|
|
1924
3385
|
|
|
1925
3386
|
# Call error callback
|
|
1926
3387
|
if self.error_callback:
|
|
@@ -1937,41 +3398,257 @@ class QtChatBubble(QWidget):
|
|
|
1937
3398
|
def set_status_callback(self, callback):
|
|
1938
3399
|
"""Set status callback function."""
|
|
1939
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
|
|
1940
3639
|
|
|
1941
3640
|
def clear_session(self):
|
|
1942
|
-
"""
|
|
3641
|
+
"""Start a new session (prior sessions remain available)."""
|
|
1943
3642
|
reply = QMessageBox.question(
|
|
1944
|
-
self,
|
|
1945
|
-
"
|
|
1946
|
-
"
|
|
3643
|
+
self,
|
|
3644
|
+
"New Session",
|
|
3645
|
+
"Start a new session?\nYour previous sessions will remain available in the session dropdown.",
|
|
1947
3646
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
1948
|
-
QMessageBox.StandardButton.No
|
|
3647
|
+
QMessageBox.StandardButton.No,
|
|
1949
3648
|
)
|
|
1950
|
-
|
|
1951
|
-
if reply == QMessageBox.StandardButton.Yes:
|
|
1952
|
-
if hasattr(self, 'chat_display'):
|
|
1953
|
-
self.chat_display.clear()
|
|
1954
|
-
self.chat_display.hide()
|
|
1955
|
-
|
|
1956
|
-
# Clear AbstractCore session and create a new one
|
|
1957
|
-
if self.llm_manager:
|
|
1958
|
-
self.llm_manager.create_new_session()
|
|
1959
|
-
if self.debug:
|
|
1960
|
-
if self.debug:
|
|
1961
|
-
print("🧹 AbstractCore session cleared and recreated")
|
|
1962
3649
|
|
|
1963
|
-
|
|
1964
|
-
self.
|
|
1965
|
-
self.update_token_display()
|
|
1966
|
-
|
|
1967
|
-
# Clear attached files as part of session clearing
|
|
1968
|
-
self.attached_files.clear()
|
|
1969
|
-
self.message_file_attachments.clear()
|
|
1970
|
-
self.update_attached_files_display()
|
|
1971
|
-
|
|
1972
|
-
if self.debug:
|
|
1973
|
-
if self.debug:
|
|
1974
|
-
print("🧹 Session cleared (including attached files and file tracking)")
|
|
3650
|
+
if reply == QMessageBox.StandardButton.Yes:
|
|
3651
|
+
self._start_new_session()
|
|
1975
3652
|
|
|
1976
3653
|
def compact_session(self):
|
|
1977
3654
|
"""Compact the current session using AbstractCore's summarizer functionality."""
|
|
@@ -2261,6 +3938,8 @@ Continue the conversation naturally, referring to the context above when relevan
|
|
|
2261
3938
|
|
|
2262
3939
|
# Update our local message history from AbstractCore
|
|
2263
3940
|
self._update_message_history_from_session()
|
|
3941
|
+
self._update_token_count_from_session()
|
|
3942
|
+
self._reload_session_combo()
|
|
2264
3943
|
self._rebuild_chat_display()
|
|
2265
3944
|
|
|
2266
3945
|
QMessageBox.information(
|
|
@@ -2358,37 +4037,78 @@ Continue the conversation naturally, referring to the context above when relevan
|
|
|
2358
4037
|
return not self._is_voice_mode_active()
|
|
2359
4038
|
|
|
2360
4039
|
def _update_message_history_from_session(self):
|
|
2361
|
-
"""Update local message history from
|
|
2362
|
-
|
|
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:
|
|
2363
4064
|
try:
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
if hasattr(msg, 'role') and msg.role == 'system':
|
|
2372
|
-
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 = []
|
|
2373
4072
|
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
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
|
+
}
|
|
2383
4092
|
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
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)]
|
|
2387
4099
|
|
|
2388
|
-
|
|
2389
|
-
if
|
|
2390
|
-
if
|
|
2391
|
-
|
|
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}")
|
|
2392
4112
|
|
|
2393
4113
|
def _rebuild_chat_display(self):
|
|
2394
4114
|
"""Rebuild chat display after session loading.
|
|
@@ -2427,6 +4147,75 @@ Continue the conversation naturally, referring to the context above when relevan
|
|
|
2427
4147
|
if self.debug:
|
|
2428
4148
|
print(f"❌ Error updating token count from session: {e}")
|
|
2429
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
|
+
|
|
2430
4219
|
def _show_history_if_voice_mode_off(self):
|
|
2431
4220
|
"""Show chat history only if voice mode is OFF."""
|
|
2432
4221
|
if not self._should_show_chat_history():
|
|
@@ -2768,6 +4557,14 @@ Continue the conversation naturally, referring to the context above when relevan
|
|
|
2768
4557
|
if self.debug:
|
|
2769
4558
|
print("🔊 QtChatBubble: Speech ended, setting ready status")
|
|
2770
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
|
|
2771
4568
|
|
|
2772
4569
|
@pyqtSlot()
|
|
2773
4570
|
def _execute_tts_completion_callbacks(self):
|
|
@@ -2787,6 +4584,8 @@ Continue the conversation naturally, referring to the context above when relevan
|
|
|
2787
4584
|
self._tts_completion_callback = None
|
|
2788
4585
|
|
|
2789
4586
|
|
|
4587
|
+
# NOTE: The message input row now owns sizing of the square action buttons via `_MessageInputRow`.
|
|
4588
|
+
|
|
2790
4589
|
def closeEvent(self, event):
|
|
2791
4590
|
"""Handle close event."""
|
|
2792
4591
|
if self.worker and self.worker.isRunning():
|