diff --git a/.gitignore b/.gitignore
index 6d1aed1..cbebc85 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,7 +4,6 @@ __pycache__/
.pytest_cache/
build/
dist/
-*.spec
# Локальный конфиг может содержать API-ключи
config.yaml
diff --git a/README.md b/README.md
index 32202da..10e5963 100644
--- a/README.md
+++ b/README.md
@@ -60,8 +60,29 @@ Silero читает только кириллицу: числа переводя
не нагружает процессор. Слова, начинающиеся с ключевого («ассистентка»), могут давать ложные
срабатывания.
-Закрытие окна сворачивает приложение в трей. Клик по значку открывает чат, в меню значка —
-переключение провайдера, новый диалог и выход.
+### Настройки
+
+Окно настроек открывается из меню значка или кнопкой «Настройки» в чате: провайдер и модель
+(список моделей подгружается с сервера), голос, горячая клавиша, модель Whisper, слово активации,
+автозапуск с Windows. Изменения сохраняются в `config.yaml` с сохранением комментариев;
+ключи вида `${ПЕРЕМЕННАЯ}` остаются ссылками. Настройки модели и переключатели применяются сразу,
+для смены моделей, голоса и горячей клавиши приложение предложит перезапуститься.
+
+Закрытие окна сворачивает приложение в трей. Клик по значку открывает чат. Повторный запуск
+не создаёт второй экземпляр, а показывает окно уже запущенного.
+
+Лог пишется в `%LOCALAPPDATA%\agr-assistent\logs`. Путь к конфигу можно передать аргументом:
+`agr-assistent --config D:\path\config.yaml`.
+
+## Сборка exe
+
+```bash
+uv run pyinstaller agr-assistent.spec --noconfirm
+```
+
+Результат — папка `dist\agr-assistent` (~2.8 ГБ, из них ~2 ГБ — библиотеки CUDA) с
+`agr-assistent.exe`; её можно переносить целиком. `config.yaml` создаётся рядом с exe, модели
+скачиваются в `%LOCALAPPDATA%\agr-assistent\models` при первом запуске.
## Разработка
diff --git a/agr-assistent.spec b/agr-assistent.spec
new file mode 100644
index 0000000..c75b068
--- /dev/null
+++ b/agr-assistent.spec
@@ -0,0 +1,44 @@
+# -*- mode: python ; coding: utf-8 -*-
+# Сборка: uv run pyinstaller agr-assistent.spec --noconfirm
+# Результат: dist/agr-assistent/agr-assistent.exe (папка целиком переносима)
+
+from pathlib import Path
+
+import nvidia
+from PyInstaller.utils.hooks import collect_data_files, collect_dynamic_libs, collect_submodules
+
+datas = [("src/agr_assistent/default_config.yaml", "agr_assistent")]
+datas += collect_data_files("silero_vad")
+datas += collect_data_files("faster_whisper")
+
+binaries = collect_dynamic_libs("vosk") + collect_dynamic_libs("ctranslate2")
+# cuBLAS и cuDNN для распознавания на видеокарте; путь к ним добавляет stt/whisper.py
+for package_dir in nvidia.__path__:
+ for dll in Path(package_dir).glob("*/bin/*.dll"):
+ binaries.append((str(dll), f"nvidia/{dll.parent.parent.name}/bin"))
+
+# num2words подключает языковые модули динамически
+hiddenimports = collect_submodules("num2words")
+
+a = Analysis(
+ ["packaging/launcher.py"],
+ pathex=["src"],
+ binaries=binaries,
+ datas=datas,
+ hiddenimports=hiddenimports,
+ excludes=["tkinter", "matplotlib", "IPython", "pytest"],
+ noarchive=False,
+)
+pyz = PYZ(a.pure)
+
+exe = EXE(
+ pyz,
+ a.scripts,
+ [],
+ exclude_binaries=True,
+ name="agr-assistent",
+ icon="packaging/icon.ico",
+ console=False,
+ upx=False,
+)
+coll = COLLECT(exe, a.binaries, a.datas, name="agr-assistent", upx=False)
diff --git a/packaging/icon.ico b/packaging/icon.ico
new file mode 100644
index 0000000..01dc301
Binary files /dev/null and b/packaging/icon.ico differ
diff --git a/packaging/launcher.py b/packaging/launcher.py
new file mode 100644
index 0000000..dbdf7e1
--- /dev/null
+++ b/packaging/launcher.py
@@ -0,0 +1,10 @@
+"""Точка входа для PyInstaller."""
+
+import multiprocessing
+import sys
+
+from agr_assistent.app import main
+
+if __name__ == "__main__":
+ multiprocessing.freeze_support()
+ sys.exit(main())
diff --git a/pyproject.toml b/pyproject.toml
index 5e1351c..0e0efd7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,6 +13,7 @@ dependencies = [
"openai>=3.14.1",
"pyside6>=6.11.2",
"pyyaml>=6.0.3",
+ "ruamel-yaml>=0.19.1",
"silero-vad>=6.2.1",
"sounddevice>=0.5.6",
"torch>=2.14.0",
@@ -24,6 +25,7 @@ agr-assistent = "agr_assistent.app:main"
[dependency-groups]
dev = [
+ "pyinstaller>=6.22.3",
"pytest>=9.1.1",
]
diff --git a/src/agr_assistent/app.py b/src/agr_assistent/app.py
index 4ffc337..10d9250 100644
--- a/src/agr_assistent/app.py
+++ b/src/agr_assistent/app.py
@@ -2,45 +2,75 @@
from __future__ import annotations
+import argparse
import logging
+import os
import signal
import sys
+import threading
+from logging.handlers import RotatingFileHandler
+from pathlib import Path
+from PySide6.QtCore import QLibraryInfo, QLocale, QTranslator
from PySide6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon
-from agr_assistent import APP_NAME
+from agr_assistent import APP_NAME, __version__, system
from agr_assistent.audio.player import AudioPlayer
from agr_assistent.audio.recorder import SpeechRecorder
from agr_assistent.audio.wakeword import VoskWakeWord
-from agr_assistent.config import ConfigError, data_dir, load_config
+from agr_assistent.config import AppConfig, ConfigError, data_dir, load_config
from agr_assistent.core.assistant import Assistant
+from agr_assistent.core.settings import Settings
from agr_assistent.core.speech import Speaker
from agr_assistent.core.voice import VoiceInput
from agr_assistent.core.wake import WakeWordListener
from agr_assistent.hotkey import GlobalHotkey, HotkeyError
+from agr_assistent.single_instance import SingleInstance
from agr_assistent.stt.whisper import WhisperSTT
from agr_assistent.tts.silero import SileroTTS
from agr_assistent.ui.chat_window import ChatWindow
from agr_assistent.ui.icons import state_icon
+from agr_assistent.ui.settings_dialog import SettingsDialog
from agr_assistent.ui.tray import TrayIcon
log = logging.getLogger(__name__)
+_WAIT_PREVIOUS_INSTANCE_SECONDS = 15
+_LOG_FILE_BYTES = 1_000_000
+_LOG_FILE_COUNT = 3
-def main() -> int:
- logging.basicConfig(
- level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
- )
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(prog=APP_NAME, description="AI-ассистент в системном трее")
+ parser.add_argument("--config", type=Path, help="путь к config.yaml")
+ parser.add_argument("--wait-pid", type=int, help=argparse.SUPPRESS)
+ _ensure_standard_streams()
+ args, qt_args = parser.parse_known_args(sys.argv[1:] if argv is None else argv)
+
+ _setup_logging()
+ log.info("%s %s", APP_NAME, __version__)
# Ctrl+C в терминале завершает приложение
signal.signal(signal.SIGINT, signal.SIG_DFL)
_set_windows_app_id()
+ if args.wait_pid:
+ system.wait_for_process_exit(args.wait_pid, _WAIT_PREVIOUS_INSTANCE_SECONDS)
- app = QApplication(sys.argv)
+ app = QApplication([sys.argv[0], *qt_args])
app.setApplicationName(APP_NAME)
app.setQuitOnLastWindowClosed(False)
+ # Стандартные кнопки и диалоги Qt на языке системы
+ translator = QTranslator(app)
+ translations = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
+ if translator.load(QLocale.system(), "qtbase", "_", translations):
+ app.installTranslator(translator)
+
+ instance = SingleInstance(app)
+ if not instance.acquire():
+ log.info("Приложение уже запущено — показываю его окно")
+ return 0
try:
- config = load_config()
+ config = load_config(args.config.resolve() if args.config else None)
except ConfigError as exc:
QMessageBox.critical(None, APP_NAME, str(exc))
return 1
@@ -50,6 +80,7 @@ def main() -> int:
QMessageBox.critical(None, APP_NAME, "Системный трей недоступен")
return 1
+ settings = Settings(config, app)
models_dir = data_dir() / "models"
speaker = Speaker(
SileroTTS(config.tts, models_dir), AudioPlayer(), enabled=config.tts.enabled
@@ -75,6 +106,25 @@ def main() -> int:
assistant = Assistant(config.llm, speaker, voice, wake_word)
app.setWindowIcon(state_icon(assistant.state))
window = ChatWindow(assistant)
+ instance.activated.connect(window.show_and_raise)
+
+ dialog: SettingsDialog | None = None
+
+ def open_settings() -> None:
+ nonlocal dialog
+ if dialog is None or not dialog.isVisible():
+ dialog = SettingsDialog(settings, window)
+ dialog.show()
+ dialog.raise_()
+ dialog.activateWindow()
+
+ def apply_settings(new_config: AppConfig) -> None:
+ assistant.update_llm_config(new_config.llm)
+ assistant.set_speech_enabled(new_config.tts.enabled)
+ assistant.set_wake_word_enabled(new_config.wake_word.enabled)
+
+ settings.changed.connect(apply_settings)
+ window.settings_requested.connect(open_settings)
hotkey_spec = config.voice.hotkey if voice is not None else ""
if hotkey_spec:
@@ -89,7 +139,7 @@ def main() -> int:
app.aboutToQuit.connect(hotkey.stop)
hotkey.start()
- tray = TrayIcon(assistant, window, hotkey_spec, config.wake_word.phrases)
+ tray = TrayIcon(assistant, window, settings, open_settings, hotkey_spec)
tray.show()
if config.ui.start_minimized:
@@ -100,6 +150,43 @@ def main() -> int:
return app.exec()
+def _ensure_standard_streams() -> None:
+ """В сборке без консоли stdout/stderr равны None, а библиотеки (tqdm, argparse) пишут в них."""
+ if sys.stdout is None:
+ sys.stdout = open(os.devnull, "w", encoding="utf-8")
+ if sys.stderr is None:
+ sys.stderr = open(os.devnull, "w", encoding="utf-8")
+
+
+def _setup_logging() -> None:
+ log_dir = data_dir() / "logs"
+ log_dir.mkdir(parents=True, exist_ok=True)
+ handlers: list[logging.Handler] = [
+ RotatingFileHandler(
+ log_dir / f"{APP_NAME}.log",
+ maxBytes=_LOG_FILE_BYTES,
+ backupCount=_LOG_FILE_COUNT,
+ encoding="utf-8",
+ )
+ ]
+ if not getattr(sys, "frozen", False):
+ handlers.append(logging.StreamHandler())
+ logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
+ handlers=handlers,
+ )
+ logging.getLogger("httpx").setLevel(logging.WARNING)
+
+ def log_uncaught(exc_type, exc_value, exc_traceback) -> None: # type: ignore[no-untyped-def]
+ log.critical("Необработанное исключение", exc_info=(exc_type, exc_value, exc_traceback))
+
+ sys.excepthook = log_uncaught
+ threading.excepthook = lambda hook_args: log_uncaught(
+ hook_args.exc_type, hook_args.exc_value, hook_args.exc_traceback
+ )
+
+
def _set_windows_app_id() -> None:
"""Своя группа на панели задач и корректное имя в уведомлениях Windows."""
if sys.platform == "win32":
diff --git a/src/agr_assistent/config.py b/src/agr_assistent/config.py
index 51ea4b3..e14c817 100644
--- a/src/agr_assistent/config.py
+++ b/src/agr_assistent/config.py
@@ -125,6 +125,11 @@ def default_config_text() -> str:
def load_config(path: Path | None = None) -> AppConfig:
path = path or resolve_config_path()
+ return parse_config(load_config_data(path), path)
+
+
+def load_config_data(path: Path) -> dict[str, Any]:
+ """Настройки как есть (без подстановки ${VAR}), недостающие ключи — из умолчаний."""
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(default_config_text(), encoding="utf-8")
@@ -138,7 +143,60 @@ def load_config(path: Path | None = None) -> AppConfig:
raise ConfigError(f"{path}: ожидался словарь настроек верхнего уровня")
defaults = yaml.safe_load(default_config_text())
- return parse_config(_deep_merge(defaults, user_data), path)
+ return _deep_merge(defaults, user_data)
+
+
+def get_value(data: dict[str, Any], key: str) -> Any:
+ """get_value(data, "llm.provider")"""
+ node: Any = data
+ for part in key.split("."):
+ if not isinstance(node, dict) or part not in node:
+ return None
+ node = node[part]
+ return node
+
+
+def save_config_updates(path: Path, updates: dict[str, Any]) -> AppConfig:
+ """Записывает изменения вида {"llm.provider": "openrouter"}, сохраняя комментарии.
+
+ Итоговый конфиг проверяется до записи: ошибочные значения не попадут в файл.
+ """
+ merged = load_config_data(path)
+ for key, value in updates.items():
+ _set_value(merged, key, value)
+ config = parse_config(merged, path)
+ if not updates:
+ return config
+
+ from ruamel.yaml import YAML
+ from ruamel.yaml.comments import CommentedMap
+
+ round_trip = YAML()
+ round_trip.preserve_quotes = True
+ round_trip.width = 4096
+ round_trip.indent(mapping=2, sequence=4, offset=2)
+ document = round_trip.load(path.read_text(encoding="utf-8")) or CommentedMap()
+ for key, value in updates.items():
+ _set_value(document, key, value, CommentedMap)
+
+ temp_path = path.with_suffix(".tmp")
+ with temp_path.open("w", encoding="utf-8") as file:
+ round_trip.dump(document, file)
+ temp_path.replace(path)
+ log.info("Настройки сохранены: %s", ", ".join(updates))
+ return config
+
+
+def _set_value(
+ data: dict[str, Any], key: str, value: Any, mapping_type: type[dict] = dict
+) -> None:
+ *parents, last = key.split(".")
+ node = data
+ for part in parents:
+ if not isinstance(node.get(part), dict):
+ node[part] = mapping_type()
+ node = node[part]
+ node[last] = value
def parse_config(data: dict[str, Any], path: Path) -> AppConfig:
@@ -148,7 +206,7 @@ def parse_config(data: dict[str, Any], path: Path) -> AppConfig:
str(name): ProviderConfig(
name=str(name),
base_url=str(item["base_url"]).rstrip("/"),
- api_key=_expand_env(str(item.get("api_key") or "")),
+ api_key=expand_env(str(item.get("api_key") or "")),
model=str(item["model"]),
)
for name, item in (llm_data.get("providers") or {}).items()
@@ -214,7 +272,7 @@ def parse_config(data: dict[str, Any], path: Path) -> AppConfig:
)
-def _expand_env(value: str) -> str:
+def expand_env(value: str) -> str:
"""Подставляет ${VAR} из окружения; незаданная переменная даёт пустую строку."""
return _ENV_REFERENCE.sub(lambda match: os.environ.get(match.group(1), ""), value)
diff --git a/src/agr_assistent/core/assistant.py b/src/agr_assistent/core/assistant.py
index 6faf53a..78c0417 100644
--- a/src/agr_assistent/core/assistant.py
+++ b/src/agr_assistent/core/assistant.py
@@ -167,6 +167,16 @@ class Assistant(QObject):
log.info("Провайдер LLM: %s (%s)", name, self.model_name)
self.provider_changed.emit(name)
+ def update_llm_config(self, config: LLMConfig) -> None:
+ """Применяет новые настройки модели; текущий ответ прерывается, только если они изменились."""
+ changed = config != self._config
+ self._config = config
+ if changed:
+ self.cancel()
+ self._client = None
+ log.info("Настройки LLM обновлены: %s (%s)", config.provider, self.model_name)
+ self.provider_changed.emit(config.provider)
+
def set_speech_enabled(self, enabled: bool) -> None:
if enabled == self._speaker.enabled:
return
diff --git a/src/agr_assistent/core/settings.py b/src/agr_assistent/core/settings.py
new file mode 100644
index 0000000..1a552f3
--- /dev/null
+++ b/src/agr_assistent/core/settings.py
@@ -0,0 +1,45 @@
+"""Текущие настройки приложения и их сохранение."""
+
+from __future__ import annotations
+
+from dataclasses import replace
+from typing import Any
+
+from PySide6.QtCore import QObject, Signal
+
+from agr_assistent.config import AppConfig, load_config_data, save_config_updates
+
+
+def needs_restart(old: AppConfig, new: AppConfig) -> bool:
+ """На лету применяются настройки LLM, интерфейса и включение озвучки и слова активации;
+ смена моделей, голоса, горячей клавиши и параметров записи требует перезапуска."""
+ return (
+ replace(old.tts, enabled=False) != replace(new.tts, enabled=False)
+ or old.stt != new.stt
+ or old.voice != new.voice
+ or replace(old.wake_word, enabled=False) != replace(new.wake_word, enabled=False)
+ )
+
+
+class Settings(QObject):
+ changed = Signal(object) # AppConfig
+
+ def __init__(self, config: AppConfig, parent: QObject | None = None) -> None:
+ super().__init__(parent)
+ self._config = config
+
+ @property
+ def config(self) -> AppConfig:
+ return self._config
+
+ def raw(self) -> dict[str, Any]:
+ """Значения как в файле: ${VAR} не подставлены, чтобы не записать секрет вместо ссылки."""
+ return load_config_data(self._config.path)
+
+ def save(self, updates: dict[str, Any]) -> AppConfig:
+ """Бросает ConfigError, если значения некорректны; файл при этом не меняется."""
+ if not updates:
+ return self._config
+ self._config = save_config_updates(self._config.path, updates)
+ self.changed.emit(self._config)
+ return self._config
diff --git a/src/agr_assistent/single_instance.py b/src/agr_assistent/single_instance.py
new file mode 100644
index 0000000..e25b39b
--- /dev/null
+++ b/src/agr_assistent/single_instance.py
@@ -0,0 +1,48 @@
+"""Не даёт запустить второй экземпляр: вместо этого показывает окно уже запущенного."""
+
+from __future__ import annotations
+
+import getpass
+import logging
+
+from PySide6.QtCore import QObject, Signal
+from PySide6.QtNetwork import QLocalServer, QLocalSocket
+
+from agr_assistent import APP_NAME
+
+log = logging.getLogger(__name__)
+
+_CONNECT_TIMEOUT_MS = 500
+
+
+class SingleInstance(QObject):
+ activated = Signal() # кто-то попытался запустить приложение повторно
+
+ def __init__(self, parent: QObject | None = None) -> None:
+ super().__init__(parent)
+ self._name = f"{APP_NAME}-{getpass.getuser()}"
+ self._server: QLocalServer | None = None
+
+ def acquire(self) -> bool:
+ """True — мы единственный экземпляр; False — уже запущенный разбужен."""
+ socket = QLocalSocket()
+ socket.connectToServer(self._name)
+ if socket.waitForConnected(_CONNECT_TIMEOUT_MS):
+ socket.write(b"show")
+ socket.waitForBytesWritten(_CONNECT_TIMEOUT_MS)
+ socket.disconnectFromServer()
+ return False
+
+ # Сокет мог остаться после аварийного завершения
+ QLocalServer.removeServer(self._name)
+ self._server = QLocalServer(self)
+ self._server.newConnection.connect(self._on_new_connection)
+ if not self._server.listen(self._name):
+ log.warning("Не удалось занять имя экземпляра: %s", self._server.errorString())
+ return True
+
+ def _on_new_connection(self) -> None:
+ assert self._server is not None
+ while connection := self._server.nextPendingConnection():
+ connection.disconnected.connect(connection.deleteLater)
+ self.activated.emit()
diff --git a/src/agr_assistent/stt/whisper.py b/src/agr_assistent/stt/whisper.py
index 2b05bf3..cf18ec7 100644
--- a/src/agr_assistent/stt/whisper.py
+++ b/src/agr_assistent/stt/whisper.py
@@ -96,11 +96,17 @@ def _add_cuda_dll_directories() -> None:
"""cuBLAS и cuDNN из pip-пакетов nvidia-* не лежат в PATH — добавляем их сами."""
if sys.platform != "win32":
return
+ package_dirs: list[str] = []
try:
import nvidia
+
+ package_dirs.extend(nvidia.__path__)
except ImportError:
- return
- for package_dir in nvidia.__path__:
+ pass
+ # В сборке PyInstaller библиотеки лежат рядом с программой
+ if bundle_dir := getattr(sys, "_MEIPASS", None):
+ package_dirs.append(os.path.join(bundle_dir, "nvidia"))
+ for package_dir in package_dirs:
for bin_dir in Path(package_dir).glob("*/bin"):
if str(bin_dir) not in os.environ["PATH"]:
os.add_dll_directory(str(bin_dir))
diff --git a/src/agr_assistent/system.py b/src/agr_assistent/system.py
new file mode 100644
index 0000000..7b1c3c3
--- /dev/null
+++ b/src/agr_assistent/system.py
@@ -0,0 +1,76 @@
+"""Интеграция с Windows: автозапуск, перезапуск, ожидание завершения процесса."""
+
+from __future__ import annotations
+
+import ctypes
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+from agr_assistent import APP_NAME
+
+_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
+_SYNCHRONIZE = 0x00100000
+_DETACHED_PROCESS = 0x00000008
+
+
+def launch_command(config_path: Path) -> list[str]:
+ """Команда запуска приложения без консольного окна."""
+ if getattr(sys, "frozen", False):
+ command = [sys.executable]
+ else:
+ pythonw = Path(sys.executable).with_name("pythonw.exe")
+ interpreter = pythonw if pythonw.exists() else Path(sys.executable)
+ command = [str(interpreter), "-m", "agr_assistent"]
+ return [*command, "--config", str(config_path)]
+
+
+def autostart_supported() -> bool:
+ return sys.platform == "win32"
+
+
+def is_autostart_enabled() -> bool:
+ if not autostart_supported():
+ return False
+ import winreg
+
+ try:
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _RUN_KEY) as key:
+ winreg.QueryValueEx(key, APP_NAME)
+ return True
+ except FileNotFoundError:
+ return False
+
+
+def set_autostart(enabled: bool, config_path: Path) -> None:
+ import winreg
+
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _RUN_KEY, 0, winreg.KEY_SET_VALUE) as key:
+ if enabled:
+ command = subprocess.list2cmdline(launch_command(config_path))
+ winreg.SetValueEx(key, APP_NAME, 0, winreg.REG_SZ, command)
+ else:
+ try:
+ winreg.DeleteValue(key, APP_NAME)
+ except FileNotFoundError:
+ pass
+
+
+def restart(config_path: Path) -> None:
+ """Запускает новый экземпляр, который дождётся завершения текущего."""
+ command = [*launch_command(config_path), "--wait-pid", str(os.getpid())]
+ flags = _DETACHED_PROCESS if sys.platform == "win32" else 0
+ subprocess.Popen(command, creationflags=flags, close_fds=True)
+
+
+def wait_for_process_exit(pid: int, timeout_seconds: float) -> None:
+ if sys.platform != "win32":
+ return
+ kernel32 = ctypes.windll.kernel32
+ handle = kernel32.OpenProcess(_SYNCHRONIZE, False, pid)
+ if handle:
+ try:
+ kernel32.WaitForSingleObject(handle, int(timeout_seconds * 1000))
+ finally:
+ kernel32.CloseHandle(handle)
diff --git a/src/agr_assistent/ui/chat_window.py b/src/agr_assistent/ui/chat_window.py
index f895756..51514ae 100644
--- a/src/agr_assistent/ui/chat_window.py
+++ b/src/agr_assistent/ui/chat_window.py
@@ -63,6 +63,8 @@ class _MessageInput(QPlainTextEdit):
class ChatWindow(QWidget):
+ settings_requested = Signal()
+
def __init__(self, assistant: Assistant) -> None:
super().__init__()
self._assistant = assistant
@@ -95,9 +97,13 @@ class ChatWindow(QWidget):
self._voice_button.clicked.connect(assistant.toggle_listening)
self._voice_button.setVisible(assistant.voice_available)
+ settings_button = QPushButton("Настройки")
+ settings_button.clicked.connect(self.settings_requested)
+
header = QHBoxLayout()
header.addWidget(self._provider_label, 1)
header.addWidget(new_chat_button)
+ header.addWidget(settings_button)
buttons = QVBoxLayout()
buttons.addWidget(self._send_button)
diff --git a/src/agr_assistent/ui/icons.py b/src/agr_assistent/ui/icons.py
index 4646d83..6c4d7cf 100644
--- a/src/agr_assistent/ui/icons.py
+++ b/src/agr_assistent/ui/icons.py
@@ -30,21 +30,26 @@ _ICON_SIZE = 64
@cache
def state_icon(state: AssistantState) -> QIcon:
- pixmap = QPixmap(_ICON_SIZE, _ICON_SIZE)
+ return QIcon(state_pixmap(state, _ICON_SIZE))
+
+
+def state_pixmap(state: AssistantState, size: int) -> QPixmap:
+ pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
+ margin = max(1, size // 32)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(_STATE_COLORS[state]))
- painter.drawEllipse(2, 2, _ICON_SIZE - 4, _ICON_SIZE - 4)
+ painter.drawEllipse(margin, margin, size - 2 * margin, size - 2 * margin)
font = painter.font()
- font.setPixelSize(38)
+ font.setPixelSize(round(size * 0.6))
font.setBold(True)
painter.setFont(font)
painter.setPen(QColor("white"))
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "A")
painter.end()
- return QIcon(pixmap)
+ return pixmap
diff --git a/src/agr_assistent/ui/settings_dialog.py b/src/agr_assistent/ui/settings_dialog.py
new file mode 100644
index 0000000..d0f8172
--- /dev/null
+++ b/src/agr_assistent/ui/settings_dialog.py
@@ -0,0 +1,359 @@
+"""Окно настроек."""
+
+from __future__ import annotations
+
+import logging
+import threading
+from pathlib import Path
+from typing import Any
+
+from PySide6.QtCore import QObject, Qt, QUrl, Signal
+from PySide6.QtGui import QDesktopServices
+from PySide6.QtWidgets import (
+ QCheckBox,
+ QComboBox,
+ QDialog,
+ QDialogButtonBox,
+ QDoubleSpinBox,
+ QFormLayout,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QMessageBox,
+ QPlainTextEdit,
+ QPushButton,
+ QSpinBox,
+ QTabWidget,
+ QToolButton,
+ QVBoxLayout,
+ QWidget,
+)
+
+from agr_assistent import APP_NAME, system
+from agr_assistent.config import ConfigError, data_dir, expand_env, get_value
+from agr_assistent.core.settings import Settings, needs_restart
+
+log = logging.getLogger(__name__)
+
+_TTS_SPEAKERS = ["xenia", "baya", "kseniya", "aidar", "eugene"]
+_STT_MODELS = ["large-v3-turbo", "large-v3", "medium", "small", "base", "tiny"]
+_STT_DEVICES = ["auto", "cuda", "cpu"]
+
+
+class _ModelListLoader(QObject):
+ """Загружает список моделей провайдера в фоне."""
+
+ loaded = Signal(list)
+ failed = Signal(str)
+
+ def load(self, base_url: str, api_key: str) -> None:
+ threading.Thread(
+ target=self._run, args=(base_url, api_key), name="llm-models", daemon=True
+ ).start()
+
+ def _run(self, base_url: str, api_key: str) -> None:
+ try:
+ from openai import OpenAI
+
+ client = OpenAI(base_url=base_url, api_key=api_key or "none", timeout=15, max_retries=0)
+ self.loaded.emit(sorted(model.id for model in client.models.list()))
+ except Exception as exc:
+ self.failed.emit(str(exc))
+
+
+class SettingsDialog(QDialog):
+ def __init__(self, settings: Settings, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._settings = settings
+ self._raw = settings.raw()
+ self._provider_edits: dict[str, dict[str, str]] = {
+ name: {field: str(values.get(field) or "") for field in ("base_url", "api_key", "model")}
+ for name, values in (get_value(self._raw, "llm.providers") or {}).items()
+ }
+ self._current_provider = ""
+
+ self.setWindowTitle(f"{APP_NAME} — настройки")
+ self.setMinimumWidth(560)
+
+ tabs = QTabWidget()
+ tabs.addTab(self._build_llm_tab(), "Модель")
+ tabs.addTab(self._build_speech_tab(), "Озвучка")
+ tabs.addTab(self._build_voice_tab(), "Голосовой ввод")
+ tabs.addTab(self._build_general_tab(), "Общие")
+
+ buttons = QDialogButtonBox(
+ QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel
+ )
+ buttons.accepted.connect(self._save)
+ buttons.rejected.connect(self.reject)
+
+ layout = QVBoxLayout(self)
+ layout.addWidget(tabs)
+ layout.addWidget(buttons)
+
+ # --- вкладки
+
+ def _build_llm_tab(self) -> QWidget:
+ self._provider = QComboBox()
+ self._provider.addItems(list(self._provider_edits))
+ self._base_url = QLineEdit()
+ self._api_key = QLineEdit()
+ self._api_key.setEchoMode(QLineEdit.EchoMode.Password)
+ self._api_key.setPlaceholderText("ключ или ${ПЕРЕМЕННАЯ_ОКРУЖЕНИЯ}")
+
+ self._model = QComboBox()
+ self._model.setEditable(True)
+ self._model.setSizePolicy(self._base_url.sizePolicy())
+ refresh_models = QToolButton()
+ refresh_models.setText("↻")
+ refresh_models.setToolTip("Загрузить список моделей с сервера")
+ refresh_models.clicked.connect(self._load_models)
+ model_row = QHBoxLayout()
+ model_row.addWidget(self._model, 1)
+ model_row.addWidget(refresh_models)
+ self._models_status = QLabel()
+ self._models_status.setWordWrap(True)
+
+ self._model_loader = _ModelListLoader(self)
+ self._model_loader.loaded.connect(self._on_models_loaded)
+ self._model_loader.failed.connect(
+ lambda message: self._models_status.setText(f"Не удалось загрузить: {message}")
+ )
+
+ self._system_prompt = QPlainTextEdit(str(get_value(self._raw, "llm.system_prompt") or ""))
+ self._system_prompt.setMaximumHeight(90)
+ self._temperature = QDoubleSpinBox()
+ self._temperature.setRange(0, 2)
+ self._temperature.setSingleStep(0.1)
+ self._temperature.setValue(float(get_value(self._raw, "llm.temperature")))
+ self._max_history = QSpinBox()
+ self._max_history.setRange(0, 1000)
+ self._max_history.setSpecialValueText("вся история")
+ self._max_history.setValue(int(get_value(self._raw, "llm.max_history_messages")))
+
+ self._provider.currentTextChanged.connect(self._switch_provider)
+ self._provider.setCurrentText(str(get_value(self._raw, "llm.provider")))
+ self._switch_provider(self._provider.currentText())
+
+ form = QFormLayout()
+ form.addRow("Провайдер", self._provider)
+ form.addRow("Адрес API", self._base_url)
+ form.addRow("API-ключ", self._api_key)
+ form.addRow("Модель", model_row)
+ form.addRow("", self._models_status)
+ form.addRow("Системный промпт", self._system_prompt)
+ form.addRow("Температура", self._temperature)
+ form.addRow("Сообщений истории", self._max_history)
+ return _page(form)
+
+ def _build_speech_tab(self) -> QWidget:
+ self._tts_enabled = QCheckBox("Озвучивать ответы")
+ self._tts_enabled.setChecked(bool(get_value(self._raw, "tts.enabled")))
+ self._tts_speaker = _combo(_TTS_SPEAKERS, str(get_value(self._raw, "tts.speaker")))
+
+ form = QFormLayout()
+ form.addRow(self._tts_enabled)
+ form.addRow("Голос", self._tts_speaker)
+ return _page(form)
+
+ def _build_voice_tab(self) -> QWidget:
+ self._voice_enabled = QCheckBox("Голосовой ввод")
+ self._voice_enabled.setChecked(bool(get_value(self._raw, "voice.enabled")))
+ self._hotkey = QLineEdit(str(get_value(self._raw, "voice.hotkey") or ""))
+ self._hotkey.setPlaceholderText("например, win+alt+space; пусто — без клавиши")
+ self._sound_cues = QCheckBox("Звуковые сигналы начала и конца записи")
+ self._sound_cues.setChecked(bool(get_value(self._raw, "voice.sound_cues")))
+ self._silence = QDoubleSpinBox()
+ self._silence.setRange(0.3, 5)
+ self._silence.setSingleStep(0.1)
+ self._silence.setSuffix(" с")
+ self._silence.setValue(float(get_value(self._raw, "voice.silence_seconds")))
+ self._vad_threshold = QDoubleSpinBox()
+ self._vad_threshold.setRange(0.1, 0.95)
+ self._vad_threshold.setSingleStep(0.05)
+ self._vad_threshold.setValue(float(get_value(self._raw, "voice.vad_threshold")))
+
+ self._stt_model = _combo(_STT_MODELS, str(get_value(self._raw, "stt.model")))
+ self._stt_device = _combo(_STT_DEVICES, str(get_value(self._raw, "stt.device")))
+ self._stt_language = QLineEdit(str(get_value(self._raw, "stt.language") or ""))
+ self._stt_language.setPlaceholderText("ru, en…; пусто — определять автоматически")
+
+ self._wake_enabled = QCheckBox("Слушать слово активации")
+ self._wake_enabled.setChecked(bool(get_value(self._raw, "wake_word.enabled")))
+ phrases = get_value(self._raw, "wake_word.phrases") or []
+ if isinstance(phrases, str):
+ phrases = [phrases]
+ self._wake_phrases = QLineEdit(", ".join(str(phrase) for phrase in phrases))
+ self._wake_phrases.setPlaceholderText("через запятую, например: ассистент, эй компьютер")
+
+ form = QFormLayout()
+ form.addRow(self._voice_enabled)
+ form.addRow("Горячая клавиша", self._hotkey)
+ form.addRow(self._sound_cues)
+ form.addRow("Пауза в конце фразы", self._silence)
+ form.addRow("Порог детектора речи", self._vad_threshold)
+ form.addRow(_section("Распознавание"))
+ form.addRow("Модель Whisper", self._stt_model)
+ form.addRow("Устройство", self._stt_device)
+ form.addRow("Язык", self._stt_language)
+ form.addRow(_section("Слово активации"))
+ form.addRow(self._wake_enabled)
+ form.addRow("Фразы", self._wake_phrases)
+ return _page(form)
+
+ def _build_general_tab(self) -> QWidget:
+ self._start_minimized = QCheckBox("Запускаться свёрнутым в трей")
+ self._start_minimized.setChecked(bool(get_value(self._raw, "ui.start_minimized")))
+
+ self._autostart = QCheckBox("Запускать вместе с Windows")
+ self._autostart.setVisible(system.autostart_supported())
+ self._autostart.setChecked(system.is_autostart_enabled())
+
+ config_path = self._settings.config.path
+ open_config = QPushButton("Открыть config.yaml")
+ open_config.clicked.connect(lambda: _open_path(config_path))
+ open_data = QPushButton("Открыть папку с моделями и логами")
+ open_data.clicked.connect(lambda: _open_path(data_dir()))
+
+ note = QLabel(
+ "Редко используемые параметры (провайдеры, таймауты, модели Silero и Vosk) "
+ f"настраиваются в файле:
{config_path}"
+ )
+ note.setWordWrap(True)
+ note.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+
+ form = QFormLayout()
+ form.addRow(self._start_minimized)
+ form.addRow(self._autostart)
+ form.addRow(note)
+ form.addRow(open_config)
+ form.addRow(open_data)
+ return _page(form)
+
+ # --- провайдеры и модели
+
+ def _switch_provider(self, name: str) -> None:
+ if self._current_provider in self._provider_edits:
+ self._provider_edits[self._current_provider] = self._current_provider_fields()
+ self._current_provider = name
+ fields = self._provider_edits.get(name, {})
+ self._base_url.setText(fields.get("base_url", ""))
+ self._api_key.setText(fields.get("api_key", ""))
+ self._model.clear()
+ self._model.setEditText(fields.get("model", ""))
+ self._models_status.clear()
+
+ def _current_provider_fields(self) -> dict[str, str]:
+ return {
+ "base_url": self._base_url.text().strip(),
+ "api_key": self._api_key.text().strip(),
+ "model": self._model.currentText().strip(),
+ }
+
+ def _load_models(self) -> None:
+ self._models_status.setText("Загружаю список моделей…")
+ self._model_loader.load(
+ self._base_url.text().strip(), expand_env(self._api_key.text().strip())
+ )
+
+ def _on_models_loaded(self, models: list[str]) -> None:
+ current = self._model.currentText()
+ self._model.clear()
+ self._model.addItems(models)
+ self._model.setEditText(current)
+ self._models_status.setText(f"Доступно моделей: {len(models)}")
+
+ # --- сохранение
+
+ def _collect(self) -> dict[str, Any]:
+ self._provider_edits[self._current_provider] = self._current_provider_fields()
+ phrases = [phrase.strip() for phrase in self._wake_phrases.text().split(",") if phrase.strip()]
+ values: dict[str, Any] = {
+ "llm.provider": self._provider.currentText(),
+ "llm.system_prompt": self._system_prompt.toPlainText().strip(),
+ "llm.temperature": round(self._temperature.value(), 2),
+ "llm.max_history_messages": self._max_history.value(),
+ "tts.enabled": self._tts_enabled.isChecked(),
+ "tts.speaker": self._tts_speaker.currentText(),
+ "voice.enabled": self._voice_enabled.isChecked(),
+ "voice.hotkey": self._hotkey.text().strip().lower(),
+ "voice.sound_cues": self._sound_cues.isChecked(),
+ "voice.silence_seconds": round(self._silence.value(), 2),
+ "voice.vad_threshold": round(self._vad_threshold.value(), 2),
+ "stt.model": self._stt_model.currentText().strip(),
+ "stt.device": self._stt_device.currentText(),
+ "stt.language": self._stt_language.text().strip(),
+ "wake_word.enabled": self._wake_enabled.isChecked(),
+ "wake_word.phrases": phrases,
+ "ui.start_minimized": self._start_minimized.isChecked(),
+ }
+ for name, fields in self._provider_edits.items():
+ for field, value in fields.items():
+ values[f"llm.providers.{name}.{field}"] = value
+ # Записываем только изменённое, чтобы не раздувать пользовательский конфиг
+ return {
+ key: value for key, value in values.items() if _differs(get_value(self._raw, key), value)
+ }
+
+ def _save(self) -> None:
+ old_config = self._settings.config
+ try:
+ new_config = self._settings.save(self._collect())
+ except ConfigError as exc:
+ QMessageBox.warning(self, APP_NAME, f"Настройки не сохранены:\n{exc}")
+ return
+
+ if self._autostart.isVisible() and self._autostart.isChecked() != system.is_autostart_enabled():
+ try:
+ system.set_autostart(self._autostart.isChecked(), new_config.path)
+ except OSError as exc:
+ QMessageBox.warning(self, APP_NAME, f"Не удалось изменить автозапуск: {exc}")
+
+ self.accept()
+ if needs_restart(old_config, new_config):
+ answer = QMessageBox.question(
+ self.parentWidget(),
+ APP_NAME,
+ "Часть изменений вступит в силу после перезапуска. Перезапустить сейчас?",
+ )
+ if answer == QMessageBox.StandardButton.Yes:
+ system.restart(new_config.path)
+ from PySide6.QtWidgets import QApplication
+
+ QApplication.quit()
+
+
+def _differs(old: Any, new: Any) -> bool:
+ if old is None:
+ return new not in ("", [], None)
+ if isinstance(old, (int, float)) and not isinstance(old, bool) and isinstance(new, (int, float)):
+ return abs(float(old) - float(new)) > 1e-9
+ if isinstance(new, str):
+ return str(old) != new
+ return old != new
+
+
+def _combo(items: list[str], current: str) -> QComboBox:
+ combo = QComboBox()
+ combo.setEditable(True)
+ combo.addItems(items)
+ combo.setCurrentText(current)
+ return combo
+
+
+def _section(title: str) -> QLabel:
+ label = QLabel(f"{title}")
+ label.setContentsMargins(0, 8, 0, 0)
+ return label
+
+
+def _page(form: QFormLayout) -> QWidget:
+ page = QWidget()
+ layout = QVBoxLayout(page)
+ layout.addLayout(form)
+ layout.addStretch()
+ return page
+
+
+def _open_path(path: Path) -> None:
+ QDesktopServices.openUrl(QUrl.fromLocalFile(str(path)))
diff --git a/src/agr_assistent/ui/tray.py b/src/agr_assistent/ui/tray.py
index c437072..a63562b 100644
--- a/src/agr_assistent/ui/tray.py
+++ b/src/agr_assistent/ui/tray.py
@@ -2,11 +2,16 @@
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
@@ -19,13 +24,14 @@ class TrayIcon(QSystemTrayIcon):
self,
assistant: Assistant,
window: ChatWindow,
+ settings: Settings,
+ open_settings: Callable[[], None],
hotkey: str = "",
- wake_phrases: list[str] | None = None,
) -> None:
- wake_phrases = wake_phrases or []
super().__init__(state_icon(assistant.state))
self._assistant = assistant
self._window = window
+ self._settings = settings
menu = QMenu()
menu.addAction("Открыть чат", window.show_and_raise)
@@ -34,10 +40,11 @@ class TrayIcon(QSystemTrayIcon):
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 wake_phrases)
+ 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)
- wake_action.toggled.connect(assistant.set_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()
@@ -48,17 +55,18 @@ class TrayIcon(QSystemTrayIcon):
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: assistant.set_provider(n))
+ 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.toggled.connect(assistant.set_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)
# Меню нужно держать в атрибуте, иначе его соберёт сборщик мусора
@@ -82,6 +90,24 @@ class TrayIcon(QSystemTrayIcon):
):
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]}")
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..1973fff
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,13 @@
+import os
+
+import pytest
+
+# Окна в тестах не показываем
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
+from PySide6.QtWidgets import QApplication # noqa: E402
+
+
+@pytest.fixture(scope="session")
+def qapp() -> QApplication:
+ return QApplication.instance() or QApplication([])
diff --git a/tests/test_settings.py b/tests/test_settings.py
new file mode 100644
index 0000000..d165a9d
--- /dev/null
+++ b/tests/test_settings.py
@@ -0,0 +1,85 @@
+from dataclasses import replace
+from pathlib import Path
+
+import pytest
+from PySide6.QtWidgets import QApplication
+
+from agr_assistent.config import ConfigError, load_config, save_config_updates
+from agr_assistent.core.settings import Settings, needs_restart
+from agr_assistent.ui.settings_dialog import SettingsDialog
+
+_USER_CONFIG = """\
+# мой конфиг
+llm:
+ provider: ollama # локально
+ providers:
+ openrouter:
+ api_key: ${TEST_SETTINGS_KEY}
+"""
+
+
+@pytest.fixture
+def config_path(tmp_path: Path) -> Path:
+ path = tmp_path / "config.yaml"
+ path.write_text(_USER_CONFIG, encoding="utf-8")
+ return path
+
+
+def test_save_keeps_comments_and_env_references(config_path: Path) -> None:
+ config = save_config_updates(
+ config_path, {"llm.provider": "openrouter", "wake_word.phrases": ["эй", "компьютер"]}
+ )
+
+ text = config_path.read_text(encoding="utf-8")
+ assert "# мой конфиг" in text
+ assert "provider: openrouter # локально" in text
+ assert "api_key: ${TEST_SETTINGS_KEY}" in text
+ assert config.llm.provider == "openrouter"
+ assert load_config(config_path).wake_word.phrases == ["эй", "компьютер"]
+
+
+def test_invalid_update_does_not_touch_file(config_path: Path) -> None:
+ with pytest.raises(ConfigError):
+ save_config_updates(config_path, {"llm.provider": "nope"})
+
+ assert config_path.read_text(encoding="utf-8") == _USER_CONFIG
+
+
+def test_restart_is_needed_only_for_heavy_settings(config_path: Path) -> None:
+ config = load_config(config_path)
+
+ live = replace(config, tts=replace(config.tts, enabled=False))
+ live.llm = replace(config.llm, temperature=0.1)
+ assert not needs_restart(config, live)
+ assert needs_restart(config, replace(config, voice=replace(config.voice, hotkey="f9")))
+ assert needs_restart(config, replace(config, tts=replace(config.tts, speaker="aidar")))
+
+
+def test_dialog_saves_only_changed_values(
+ qapp: QApplication, config_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setenv("TEST_SETTINGS_KEY", "secret")
+ settings = Settings(load_config(config_path))
+ changes: list[object] = []
+ settings.changed.connect(changes.append)
+ dialog = SettingsDialog(settings)
+
+ # Переключаемся на openrouter: ключ показан ссылкой на переменную, а не секретом
+ dialog._provider.setCurrentText("openrouter")
+ assert dialog._api_key.text() == "${TEST_SETTINGS_KEY}"
+ dialog._model.setEditText("anthropic/claude-sonnet-5")
+ dialog._tts_enabled.setChecked(False)
+
+ assert dialog._collect() == {
+ "llm.provider": "openrouter",
+ "llm.providers.openrouter.model": "anthropic/claude-sonnet-5",
+ "tts.enabled": False,
+ }
+
+ dialog._save()
+
+ text = config_path.read_text(encoding="utf-8")
+ assert "secret" not in text
+ assert settings.config.llm.active_provider.model == "anthropic/claude-sonnet-5"
+ assert settings.config.llm.active_provider.api_key == "secret"
+ assert len(changes) == 1
diff --git a/tests/test_speaker.py b/tests/test_speaker.py
index 0f39a1e..31e96c1 100644
--- a/tests/test_speaker.py
+++ b/tests/test_speaker.py
@@ -3,17 +3,11 @@ import time
from collections.abc import Callable
import numpy as np
-import pytest
from PySide6.QtCore import QCoreApplication
from agr_assistent.core.speech import Speaker
-@pytest.fixture(scope="module")
-def qapp() -> QCoreApplication:
- return QCoreApplication.instance() or QCoreApplication([])
-
-
class FakeEngine:
sample_rate = 1000
diff --git a/tests/test_voice.py b/tests/test_voice.py
index cab62e4..3616582 100644
--- a/tests/test_voice.py
+++ b/tests/test_voice.py
@@ -3,17 +3,11 @@ import time
from collections.abc import Callable
import numpy as np
-import pytest
from PySide6.QtCore import QCoreApplication
from agr_assistent.core.voice import VoiceInput
-@pytest.fixture(scope="module")
-def qapp() -> QCoreApplication:
- return QCoreApplication.instance() or QCoreApplication([])
-
-
class FakeRecorder:
def __init__(self, *, wait_for_stop: bool = False, speech: bool = True) -> None:
self.wait_for_stop = wait_for_stop
diff --git a/tests/test_wake_listener.py b/tests/test_wake_listener.py
index 46dff36..48beb24 100644
--- a/tests/test_wake_listener.py
+++ b/tests/test_wake_listener.py
@@ -2,17 +2,11 @@ import threading
import time
from collections.abc import Callable
-import pytest
from PySide6.QtCore import QCoreApplication
from agr_assistent.core.wake import WakeWordListener
-@pytest.fixture(scope="module")
-def qapp() -> QCoreApplication:
- return QCoreApplication.instance() or QCoreApplication([])
-
-
class FakeStream:
def __init__(self, owner: "FakeMicrophone") -> None:
self.owner = owner
diff --git a/uv.lock b/uv.lock
index e0c8cdc..1a48858 100644
--- a/uv.lock
+++ b/uv.lock
@@ -21,6 +21,7 @@ dependencies = [
{ name = "openai" },
{ name = "pyside6" },
{ name = "pyyaml" },
+ { name = "ruamel-yaml" },
{ name = "silero-vad" },
{ name = "sounddevice" },
{ name = "torch" },
@@ -29,6 +30,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
+ { name = "pyinstaller" },
{ name = "pytest" },
]
@@ -42,6 +44,7 @@ requires-dist = [
{ name = "openai", specifier = ">=3.14.1" },
{ name = "pyside6", specifier = ">=6.11.2" },
{ name = "pyyaml", specifier = ">=6.0.3" },
+ { name = "ruamel-yaml", specifier = ">=0.19.1" },
{ name = "silero-vad", specifier = ">=6.2.1" },
{ name = "sounddevice", specifier = ">=0.5.6" },
{ name = "torch", specifier = ">=2.14.0" },
@@ -49,7 +52,19 @@ requires-dist = [
]
[package.metadata.requires-dev]
-dev = [{ name = "pytest", specifier = ">=9.1.1" }]
+dev = [
+ { name = "pyinstaller", specifier = ">=6.22.3" },
+ { name = "pytest", specifier = ">=9.1.1" },
+]
+
+[[package]]
+name = "altgraph"
+version = "0.17.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" },
+]
[[package]]
name = "annotated-types"
@@ -796,6 +811,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/8b/d76219ebdbcf3d4209d9d21a0810db4c8d0a6f88e3ee87d30bdea4e90d30/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c0bf24c72fd0491405dce5d40194f2070e9021ce648c1a1d46234b93d848ff", size = 307147, upload-time = "2026-09-12T15:14:12.897Z" },
]
+[[package]]
+name = "macholib"
+version = "1.16.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "altgraph", marker = "python_full_version < '3.12' or sys_platform != 'emscripten'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" },
+]
+
[[package]]
name = "markupsafe"
version = "3.0.3"
@@ -1311,6 +1338,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
+[[package]]
+name = "pefile"
+version = "2024.8.26"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
+]
+
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -1470,6 +1506,46 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
+[[package]]
+name = "pyinstaller"
+version = "6.22.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "altgraph" },
+ { name = "macholib", marker = "sys_platform == 'darwin'" },
+ { name = "packaging" },
+ { name = "pefile", marker = "sys_platform == 'win32'" },
+ { name = "pyinstaller-hooks-contrib" },
+ { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
+ { name = "setuptools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/63/41/f90302845945abd4ed647933ff5ee7c6ac93983187be67f897b6cb613331/pyinstaller-6.22.3.tar.gz", hash = "sha256:05eb2f5615503e72939a7224d68b4aff572c6b0438ee4a17d0a4b481f399362d", size = 4437180, upload-time = "2026-09-12T21:54:09.669Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/af/898f70947e27f8e65d55183c780e77112e023c0b4a6fba6255d4d563a27d/pyinstaller-6.22.3-py3-none-macosx_10_13_universal2.whl", hash = "sha256:052f4a1cd4f81092ccb7a18fe8ebc9ecf832a5917317e39fb3775af4e959b253", size = 1071847, upload-time = "2026-09-12T21:53:01.711Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/96/99f5184985141c2b024bfa61335e9142ae444555224f303186e59b7bb438/pyinstaller-6.22.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:312da84b4b31ab9750066a823734c11b7c68757fdd67c9f038bf19c330a954e4", size = 758172, upload-time = "2026-09-12T21:53:06.171Z" },
+ { url = "https://files.pythonhosted.org/packages/69/9f/9ccb12cc1e6617042cb028fe79f0a756f5f03305bd14b584a4e043fe3844/pyinstaller-6.22.3-py3-none-manylinux2014_i686.whl", hash = "sha256:020d4fe0627f5c73dfbcfad1a6578ef354d5fe4bdccd9d8e5a615528f5a45d2b", size = 772135, upload-time = "2026-09-12T21:53:10.291Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/43/4b1d5193a88628c052db89f064433681076959a9f41d0d9f660001b49079/pyinstaller-6.22.3-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:9e4f7bd64b2103f14b4a6240fb9155e9fe7bc61391b4043bbddb7d3ebc8ab0fa", size = 770461, upload-time = "2026-09-12T21:53:14.592Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/83/f173f8757c618d83e116fa2b8e57c5cd796e5ad09792e664eabd6ac5edae/pyinstaller-6.22.3-py3-none-manylinux2014_s390x.whl", hash = "sha256:f82d0fb89f21c3ed6f482ee16f341bc59e8a31dbabb9bae9ef04e53ade4dc0d9", size = 764997, upload-time = "2026-09-12T21:53:19.079Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/b6/dd21d61fc5e25377be24eabf13e99e4e46548a5abe2bf7b15d8b3d9cc74a/pyinstaller-6.22.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:451a4ae14b719365bf1a2f0a99dae7b3463060061c3a394c70d5264cfb439528", size = 764974, upload-time = "2026-09-12T21:53:23.176Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/fd/d9590942972b1fc205519f7ccffb0d5cd8eb1d6fa61edad319c71d25baed/pyinstaller-6.22.3-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:ea240b5dfa831ff673c97505eee0ec24670bccf71396c83109440348944c83d0", size = 764450, upload-time = "2026-09-12T21:53:27.419Z" },
+ { url = "https://files.pythonhosted.org/packages/72/d6/f22c55d1768433b65c460b19a3136dd9769b6c74b786149b829344654c7d/pyinstaller-6.22.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a2b6fc26601e8c2596c3d54981566934a3382e85d05d0fe91b55b6a8dcc6b282", size = 763863, upload-time = "2026-09-12T21:53:31.609Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/87/cf28cfd42ceca889171e6dd5e7f1553d4372db83d047049ffb7cf95fcbd4/pyinstaller-6.22.3-py3-none-win32.whl", hash = "sha256:46a3e30a118341a1ec20667a5a95aa7e0aa8c4fa129893c0151b3eb3f801a722", size = 1390905, upload-time = "2026-09-12T21:53:38.133Z" },
+ { url = "https://files.pythonhosted.org/packages/24/83/c5422204772a27aef643280fdc887b7ef55679989096b21dceb1b9625fc4/pyinstaller-6.22.3-py3-none-win_amd64.whl", hash = "sha256:500bd58c7bf7e584a8435adccbd763a0b918d5c12b08d74ff50fd79b2915458b", size = 1454907, upload-time = "2026-09-12T21:53:44.812Z" },
+ { url = "https://files.pythonhosted.org/packages/67/2e/ebc93968bf63f81b122e205da116b16036b653b7ce2cc8ad86e3ddd19586/pyinstaller-6.22.3-py3-none-win_arm64.whl", hash = "sha256:31df3cc4261e804a53d358b7897c293ec8b88b167a7cca06c78018085c855f97", size = 1402956, upload-time = "2026-09-12T21:53:51.369Z" },
+]
+
+[[package]]
+name = "pyinstaller-hooks-contrib"
+version = "2026.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/26/60/d881fa1ba8c160c18d8e6f782bb16ec4640c08bc08fc50f704c368ad4f9e/pyinstaller_hooks_contrib-2026.7.tar.gz", hash = "sha256:5fbcaacb22c4f4aac869a127dce283f67a4b4cfcc37d496f2446603e6d68aefa", size = 175992, upload-time = "2026-08-24T21:26:58.713Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0a/67/350377af7b50416344ab8792756d414eef7629c618a73e9a0b13bb1552d9/pyinstaller_hooks_contrib-2026.7-py3-none-any.whl", hash = "sha256:24257a04c7a5a7a034cf28e39dcee20fbeeb9f043076729480f2e1b69904408a", size = 459445, upload-time = "2026-08-24T21:26:57.274Z" },
+]
+
[[package]]
name = "pyside6"
version = "6.11.2"
@@ -1534,6 +1610,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
+[[package]]
+name = "pywin32-ctypes"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
+]
+
[[package]]
name = "pyyaml"
version = "6.0.3"
@@ -1604,6 +1689,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
+[[package]]
+name = "ruamel-yaml"
+version = "0.19.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" },
+]
+
[[package]]
name = "setuptools"
version = "84.0.0"