Files
agr-assistent/src/agr_assistent/ui/tray.py
T
mrleo1nidandClaude Opus 5 20a666e0d4 Этап 5: окно настроек и сборка exe
- Окно настроек: модель и провайдер (со списком моделей с сервера), озвучка,
  голосовой ввод, слово активации, автозапуск с Windows
- Сохранение в config.yaml через ruamel.yaml с комментариями и ссылками ${VAR},
  проверка значений до записи; переключатели трея тоже сохраняются
- Настройки LLM и переключатели применяются на лету, для остального — перезапуск
- Один экземпляр приложения, лог в файл, аргументы --config и --wait-pid, русские диалоги Qt
- Сборка PyInstaller (onedir) с библиотеками CUDA, иконка приложения
- Тесты сохранения настроек и окна настроек

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:10:31 +03:00

133 lines
5.8 KiB
Python

"""Значок в системном трее и его меню."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from PySide6.QtGui import QAction, QActionGroup
from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
from agr_assistent import APP_NAME
from agr_assistent.config import ConfigError
from agr_assistent.core.assistant import Assistant, AssistantState
from agr_assistent.core.settings import Settings
from agr_assistent.ui.chat_window import ChatWindow
from agr_assistent.ui.icons import STATE_TITLES, state_icon
_NOTIFICATION_PREVIEW_CHARS = 200
_NOTIFICATION_TIMEOUT_MS = 6000
class TrayIcon(QSystemTrayIcon):
def __init__(
self,
assistant: Assistant,
window: ChatWindow,
settings: Settings,
open_settings: Callable[[], None],
hotkey: str = "",
) -> None:
super().__init__(state_icon(assistant.state))
self._assistant = assistant
self._window = window
self._settings = settings
menu = QMenu()
menu.addAction("Открыть чат", window.show_and_raise)
if assistant.voice_available:
# Текст после табуляции QMenu выравнивает по правому краю, как подсказку клавиши
label = f"Говорить\t{hotkey.title()}" if hotkey else "Говорить"
menu.addAction(label, assistant.toggle_listening)
if assistant.wake_word_available:
phrases = ", ".join(f{phrase}»" for phrase in settings.config.wake_word.phrases)
wake_action = QAction(f"Слушать слово активации: {phrases}", self, checkable=True)
wake_action.setChecked(assistant.wake_word_enabled)
# triggered — только действие пользователя: автоотключение после ошибки не сохраняем
wake_action.triggered.connect(self._on_wake_word_triggered)
assistant.wake_word_enabled_changed.connect(wake_action.setChecked)
menu.addAction(wake_action)
menu.addSeparator()
provider_menu = menu.addMenu("Провайдер LLM")
provider_group = QActionGroup(self)
self._provider_actions: dict[str, QAction] = {}
for name in assistant.provider_names:
action = QAction(name, self, checkable=True)
action.setChecked(name == assistant.provider_name)
action.triggered.connect(lambda _checked=False, n=name: self._on_provider_triggered(n))
provider_group.addAction(action)
provider_menu.addAction(action)
self._provider_actions[name] = action
self._speech_action = QAction("Озвучивать ответы", self, checkable=True)
self._speech_action.setChecked(assistant.speech_enabled)
self._speech_action.triggered.connect(self._on_speech_triggered)
menu.addAction(self._speech_action)
menu.addAction("Новый диалог", assistant.clear_history)
menu.addAction("Настройки…", open_settings)
menu.addSeparator()
menu.addAction("Выход", QApplication.quit)
# Меню нужно держать в атрибуте, иначе его соберёт сборщик мусора
self._menu = menu
self.setContextMenu(menu)
self.activated.connect(self._on_activated)
self.messageClicked.connect(window.show_and_raise)
assistant.state_changed.connect(self._on_state_changed)
assistant.provider_changed.connect(self._on_provider_changed)
assistant.speech_enabled_changed.connect(self._speech_action.setChecked)
assistant.reply_finished.connect(self._on_reply_finished)
assistant.error_occurred.connect(self._on_error)
self._on_state_changed(assistant.state)
def _on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
if reason in (
QSystemTrayIcon.ActivationReason.Trigger,
QSystemTrayIcon.ActivationReason.DoubleClick,
):
self._window.toggle_visibility()
def _on_provider_triggered(self, name: str) -> None:
self._assistant.set_provider(name)
self._persist({"llm.provider": name})
def _on_speech_triggered(self, enabled: bool) -> None:
self._assistant.set_speech_enabled(enabled)
self._persist({"tts.enabled": enabled})
def _on_wake_word_triggered(self, enabled: bool) -> None:
self._assistant.set_wake_word_enabled(enabled)
self._persist({"wake_word.enabled": enabled})
def _persist(self, updates: dict[str, Any]) -> None:
try:
self._settings.save(updates)
except (ConfigError, OSError) as exc:
self._assistant.error_occurred.emit(f"Не удалось сохранить настройки: {exc}")
def _on_state_changed(self, state: AssistantState) -> None:
self.setIcon(state_icon(state))
self.setToolTip(f"{APP_NAME}{STATE_TITLES[state]}")
def _on_provider_changed(self, name: str) -> None:
if action := self._provider_actions.get(name):
action.setChecked(True)
def _on_reply_finished(self, text: str) -> None:
if text.strip() and not self._window.isVisible():
preview = text.strip()
if len(preview) > _NOTIFICATION_PREVIEW_CHARS:
preview = preview[:_NOTIFICATION_PREVIEW_CHARS].rstrip() + "…"
self.showMessage(
APP_NAME, preview, QSystemTrayIcon.MessageIcon.Information, _NOTIFICATION_TIMEOUT_MS
)
def _on_error(self, message: str) -> None:
if not self._window.isVisible():
self.showMessage(
APP_NAME, message, QSystemTrayIcon.MessageIcon.Warning, _NOTIFICATION_TIMEOUT_MS
)