diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-19 10:44:48 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-19 10:44:48 +0200 |
| commit | c10c750c83a3cde13f7ca574dac673e2358df522 (patch) | |
| tree | 11d3694da330091155e9c066399ce39c790000fc /llamachat/ui.py | |
| parent | 309c0dd8b0fc294f67af8a82b78830a7610b700f (diff) | |
| download | llamachat-master.tar.gz llamachat-master.zip | |
Make the chat toolbar icon-only (tooltips retained) and add an icon_size config key, default 28, so the drawn glyphs can be scaled. Qt was drawing the 24px pixmaps at its ~16px default; the explicit icon size fixes that.
Fix the deferred audio minors: AudioRecorder.start() now halts a prior source and checks the source error, so a failed capture no longer claims to be recording. Send now validates every pending modality in one gate, so a mixed image+audio clip cannot reach a model without vision. Sub-second clips no longer name voice-note-0s.wav, and non-WAV audio containers classify as unknown, guarded by a test.
Diffstat (limited to 'llamachat/ui.py')
| -rw-r--r-- | llamachat/ui.py | 147 |
1 files changed, 104 insertions, 43 deletions
diff --git a/llamachat/ui.py b/llamachat/ui.py index 91321e4..b45daad 100644 --- a/llamachat/ui.py +++ b/llamachat/ui.py @@ -22,7 +22,8 @@ import re from pathlib import Path from PySide6.QtCore import ( - QObject, QPointF, QRect, QRectF, QSettings, Qt, QThread, QTimer, Signal, Slot, + QObject, QPointF, QRect, QRectF, QSettings, QSize, Qt, QThread, QTimer, + Signal, Slot, ) from PySide6.QtGui import ( QAction, QColor, QDesktopServices, QIcon, QKeySequence, QPainter, @@ -58,12 +59,17 @@ COPY_SCHEME = "x-llamachat-copy:" # The buttons get their glyphs drawn at runtime, so there is no icon file # to install. All the marks share the brand blue, which also makes each # button an unambiguous control rather than a bare text label. -def _button_icon(kind: str) -> QIcon: - """A small monochrome glyph for one of the chat buttons.""" - pixmap = QPixmap(24, 24) +def _button_icon(kind: str, size: int = 24) -> QIcon: + """A small monochrome glyph for one of the chat buttons. + + The glyph coordinates below are authored on a 24x24 grid and the painter + is scaled, so one call renders crisply at any configured icon size. + """ + pixmap = QPixmap(size, size) pixmap.fill(QColor(0, 0, 0, 0)) painter = QPainter(pixmap) painter.setRenderHint(QPainter.Antialiasing) + painter.scale(size / 24.0, size / 24.0) colour = QColor("#3F5EFB") pen = QPen(colour, 2.2) pen.setCapStyle(Qt.RoundCap) @@ -124,6 +130,16 @@ def _button_icon(kind: str) -> QIcon: return QIcon(pixmap) +def _set_button_icon(button, kind: str, size: int) -> None: + """Give a toolbar button its glyph and match the icon box to it. + + Without the explicit icon size, Qt draws the pixmap at the style's small + default (about 16px) regardless of how large it was rendered. + """ + button.setIcon(_button_icon(kind, size)) + button.setIconSize(QSize(size, size)) + + class ContextMeter(QWidget): """A bar showing how much of the model's context the next request uses. @@ -694,10 +710,10 @@ class ChatWindow(QMainWindow): compose.addWidget(self.input, 1) # A square send control beside the entry box, like a chat app. + size = self.cfg.icon_size self.send_button = QToolButton() - self.send_button.setIcon(_button_icon("send")) - self.send_button.setText("Send") - self.send_button.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) + _set_button_icon(self.send_button, "send", size) + self.send_button.setToolButtonStyle(Qt.ToolButtonIconOnly) self.send_button.setFixedSize(44, 44) self.send_button.setToolTip("Send (Ctrl+Enter)") self.send_button.clicked.connect(self.send) @@ -750,33 +766,36 @@ class ChatWindow(QMainWindow): buttons.addWidget(self.prompt_button) buttons.addStretch() - self.clear_attach_button = QPushButton("Clear files") - self.clear_attach_button.setIcon(_button_icon("clear")) + self.clear_attach_button = QPushButton() + _set_button_icon(self.clear_attach_button, "clear", size) + self.clear_attach_button.setToolTip("Clear attached files") self.clear_attach_button.clicked.connect(self.clear_attachments) self.clear_attach_button.hide() buttons.addWidget(self.clear_attach_button) - self.stop_button = QPushButton("Stop") - self.stop_button.setIcon(_button_icon("stop")) + self.stop_button = QPushButton() + _set_button_icon(self.stop_button, "stop", size) + self.stop_button.setToolTip("Stop generating") self.stop_button.clicked.connect(self.stop_stream) self.stop_button.hide() buttons.addWidget(self.stop_button) - self.new_button = QPushButton("New") - self.new_button.setIcon(_button_icon("new")) + self.new_button = QPushButton() + _set_button_icon(self.new_button, "new", size) self.new_button.setToolTip("Start a fresh conversation (Ctrl+N)") self.new_button.clicked.connect(self.new_session) buttons.addWidget(self.new_button) - self.record_button = QPushButton("Record") - self.record_button.setIcon(_button_icon("record")) + self.record_button = QPushButton() + _set_button_icon(self.record_button, "record", size) self.record_button.setToolTip("Record audio for an audio-capable model") self.record_button.clicked.connect(self._toggle_record) self.record_button.hide() buttons.addWidget(self.record_button) - self.attach_button = QPushButton("Attach…") - self.attach_button.setIcon(_button_icon("attach")) + self.attach_button = QPushButton() + _set_button_icon(self.attach_button, "attach", size) + self.attach_button.setToolTip("Attach files") self.attach_button.clicked.connect(self._on_attach_clicked) buttons.addWidget(self.attach_button) @@ -994,39 +1013,73 @@ class ChatWindow(QMainWindow): names.append(name) return names - def audio_models(self) -> list[str]: + def _modality_models(self, audio: bool, vision: bool) -> list[str]: + """Model ids that satisfy every required modality. + + Audio is strict, vision lenient: only a model that reports audio can + receive a recording, while an unknown vision status gets the benefit + of the doubt. Same asymmetry as the attach-time gate. + """ names = [] for i in range(self.model_box.count()): name = self.model_box.itemData(i) - if self.model_info(name).audio is True: - names.append(name) + info = self.model_info(name) + if audio and info.audio is not True: + continue + if vision and info.vision is False: + continue + names.append(name) return names - def _ensure_audio_model(self) -> bool: - """Offer to switch to an audio-capable model. True when one is active. + def _ensure_modalities(self, *, audio: bool, vision: bool) -> bool: + """Offer a single switch to a model that accepts everything pending. - Unlike vision, an unknown model is not given the benefit of the - doubt: audio input is rare and explicitly reported, so only a model - known to accept it is allowed to receive a recording. + Returns True when the current model can receive the attachments. One + combined switch, rather than a vision switch then an audio switch, + stops a mixed clip ping-ponging between a vision-only and an + audio-only model. """ - if self.current_info().audio is True: + info = self.current_info() + if (not audio or info.audio is True) and ( + not vision or info.vision is not False + ): return True - candidates = self.audio_models() + candidates = self._modality_models(audio, vision) if not candidates: - QMessageBox.warning( - self, - "No audio model", - "No model on the router reports audio input, so a recording " - "cannot be sent.", - ) + if audio and vision: + title = "No compatible model" + body = ( + "No model accepts both audio and images, so this clip " + "cannot be sent." + ) + elif audio: + title = "No audio model" + body = ( + "No model on the router reports audio input, so a " + "recording cannot be sent." + ) + else: + title = "No vision model" + body = ( + "No model on the router has an mmproj file configured, so " + "images cannot be sent." + ) + QMessageBox.warning(self, title, body) return False + if audio and vision: + need = "Audio and images need a model that accepts both." + elif audio: + need = "Audio needs a model that accepts voice input." + else: + need = "Images need a vision-capable model." + target = candidates[0] answer = QMessageBox.question( self, "Switch model?", - f"Audio needs a model that accepts voice input.\n\n" + f"{need}\n\n" f"Switch to {target}?\n\n" "The router unloads the current model to do this, so the next " "reply will take several seconds to start.", @@ -1267,9 +1320,15 @@ class ChatWindow(QMainWindow): self.recorder is not None and self.current_info().audio is True ) self.record_button.setVisible(can) - self.record_button.setText("Stop" if self.recording else "Record") - self.record_button.setIcon( - _button_icon("stop" if self.recording else "record") + _set_button_icon( + self.record_button, + "stop" if self.recording else "record", + self.cfg.icon_size, + ) + self.record_button.setToolTip( + "Stop recording" + if self.recording + else "Record audio for an audio-capable model" ) def _toggle_record(self) -> None: @@ -1280,7 +1339,7 @@ class ChatWindow(QMainWindow): if self.recorder is None or self.current_info().audio is not True: return if not self.recorder.start(): - self.show_status("No microphone available.", error=True) + self.show_status("Could not start recording.", error=True) return self.recording = True self.show_status("Recording. Click Stop when done.") @@ -1306,7 +1365,7 @@ class ChatWindow(QMainWindow): else: self.hide_status() self.attachments.append( - backend.audio_attachment(audio.to_wav(pcm), round(seconds)) + backend.audio_attachment(audio.to_wav(pcm), max(1, round(seconds))) ) self.update_attach_label() @@ -1346,12 +1405,14 @@ class ChatWindow(QMainWindow): if not text and not self.attachments: return - if any(a.kind == "audio" for a in self.attachments): + needs_audio = any(a.kind == "audio" for a in self.attachments) + needs_vision = any(a.kind == "image" for a in self.attachments) + if needs_audio or needs_vision: # A switch here changes the model, so this must run before the - # model is read below. - if not self._ensure_audio_model(): + # model is read below. One combined gate covers a mixed clip. + if not self._ensure_modalities(audio=needs_audio, vision=needs_vision): return - if not text: + if needs_audio and not text: text = backend.AUDIO_PROMPT model = self.current_model() |
