Этап 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>
This commit is contained in:
mrleo1nid
2026-09-17 04:10:31 +03:00
co-authored by Claude Opus 5
parent a719980d74
commit 20a666e0d4
23 changed files with 1022 additions and 46 deletions
-1
View File
@@ -4,7 +4,6 @@ __pycache__/
.pytest_cache/ .pytest_cache/
build/ build/
dist/ dist/
*.spec
# Локальный конфиг может содержать API-ключи # Локальный конфиг может содержать API-ключи
config.yaml config.yaml
+23 -2
View File
@@ -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` при первом запуске.
## Разработка ## Разработка
+44
View File
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

+10
View File
@@ -0,0 +1,10 @@
"""Точка входа для PyInstaller."""
import multiprocessing
import sys
from agr_assistent.app import main
if __name__ == "__main__":
multiprocessing.freeze_support()
sys.exit(main())
+2
View File
@@ -13,6 +13,7 @@ dependencies = [
"openai>=3.14.1", "openai>=3.14.1",
"pyside6>=6.11.2", "pyside6>=6.11.2",
"pyyaml>=6.0.3", "pyyaml>=6.0.3",
"ruamel-yaml>=0.19.1",
"silero-vad>=6.2.1", "silero-vad>=6.2.1",
"sounddevice>=0.5.6", "sounddevice>=0.5.6",
"torch>=2.14.0", "torch>=2.14.0",
@@ -24,6 +25,7 @@ agr-assistent = "agr_assistent.app:main"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pyinstaller>=6.22.3",
"pytest>=9.1.1", "pytest>=9.1.1",
] ]
+96 -9
View File
@@ -2,45 +2,75 @@
from __future__ import annotations from __future__ import annotations
import argparse
import logging import logging
import os
import signal import signal
import sys 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 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.player import AudioPlayer
from agr_assistent.audio.recorder import SpeechRecorder from agr_assistent.audio.recorder import SpeechRecorder
from agr_assistent.audio.wakeword import VoskWakeWord 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.assistant import Assistant
from agr_assistent.core.settings import Settings
from agr_assistent.core.speech import Speaker from agr_assistent.core.speech import Speaker
from agr_assistent.core.voice import VoiceInput from agr_assistent.core.voice import VoiceInput
from agr_assistent.core.wake import WakeWordListener from agr_assistent.core.wake import WakeWordListener
from agr_assistent.hotkey import GlobalHotkey, HotkeyError from agr_assistent.hotkey import GlobalHotkey, HotkeyError
from agr_assistent.single_instance import SingleInstance
from agr_assistent.stt.whisper import WhisperSTT from agr_assistent.stt.whisper import WhisperSTT
from agr_assistent.tts.silero import SileroTTS from agr_assistent.tts.silero import SileroTTS
from agr_assistent.ui.chat_window import ChatWindow from agr_assistent.ui.chat_window import ChatWindow
from agr_assistent.ui.icons import state_icon from agr_assistent.ui.icons import state_icon
from agr_assistent.ui.settings_dialog import SettingsDialog
from agr_assistent.ui.tray import TrayIcon from agr_assistent.ui.tray import TrayIcon
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_WAIT_PREVIOUS_INSTANCE_SECONDS = 15
_LOG_FILE_BYTES = 1_000_000
_LOG_FILE_COUNT = 3
def main() -> int:
logging.basicConfig( def main(argv: list[str] | None = None) -> int:
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s" 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 в терминале завершает приложение # Ctrl+C в терминале завершает приложение
signal.signal(signal.SIGINT, signal.SIG_DFL) signal.signal(signal.SIGINT, signal.SIG_DFL)
_set_windows_app_id() _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.setApplicationName(APP_NAME)
app.setQuitOnLastWindowClosed(False) 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: try:
config = load_config() config = load_config(args.config.resolve() if args.config else None)
except ConfigError as exc: except ConfigError as exc:
QMessageBox.critical(None, APP_NAME, str(exc)) QMessageBox.critical(None, APP_NAME, str(exc))
return 1 return 1
@@ -50,6 +80,7 @@ def main() -> int:
QMessageBox.critical(None, APP_NAME, "Системный трей недоступен") QMessageBox.critical(None, APP_NAME, "Системный трей недоступен")
return 1 return 1
settings = Settings(config, app)
models_dir = data_dir() / "models" models_dir = data_dir() / "models"
speaker = Speaker( speaker = Speaker(
SileroTTS(config.tts, models_dir), AudioPlayer(), enabled=config.tts.enabled 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) assistant = Assistant(config.llm, speaker, voice, wake_word)
app.setWindowIcon(state_icon(assistant.state)) app.setWindowIcon(state_icon(assistant.state))
window = ChatWindow(assistant) 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 "" hotkey_spec = config.voice.hotkey if voice is not None else ""
if hotkey_spec: if hotkey_spec:
@@ -89,7 +139,7 @@ def main() -> int:
app.aboutToQuit.connect(hotkey.stop) app.aboutToQuit.connect(hotkey.stop)
hotkey.start() hotkey.start()
tray = TrayIcon(assistant, window, hotkey_spec, config.wake_word.phrases) tray = TrayIcon(assistant, window, settings, open_settings, hotkey_spec)
tray.show() tray.show()
if config.ui.start_minimized: if config.ui.start_minimized:
@@ -100,6 +150,43 @@ def main() -> int:
return app.exec() 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: def _set_windows_app_id() -> None:
"""Своя группа на панели задач и корректное имя в уведомлениях Windows.""" """Своя группа на панели задач и корректное имя в уведомлениях Windows."""
if sys.platform == "win32": if sys.platform == "win32":
+61 -3
View File
@@ -125,6 +125,11 @@ def default_config_text() -> str:
def load_config(path: Path | None = None) -> AppConfig: def load_config(path: Path | None = None) -> AppConfig:
path = path or resolve_config_path() 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(): if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(default_config_text(), encoding="utf-8") 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}: ожидался словарь настроек верхнего уровня") raise ConfigError(f"{path}: ожидался словарь настроек верхнего уровня")
defaults = yaml.safe_load(default_config_text()) 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: 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( str(name): ProviderConfig(
name=str(name), name=str(name),
base_url=str(item["base_url"]).rstrip("/"), 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"]), model=str(item["model"]),
) )
for name, item in (llm_data.get("providers") or {}).items() 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} из окружения; незаданная переменная даёт пустую строку.""" """Подставляет ${VAR} из окружения; незаданная переменная даёт пустую строку."""
return _ENV_REFERENCE.sub(lambda match: os.environ.get(match.group(1), ""), value) return _ENV_REFERENCE.sub(lambda match: os.environ.get(match.group(1), ""), value)
+10
View File
@@ -167,6 +167,16 @@ class Assistant(QObject):
log.info("Провайдер LLM: %s (%s)", name, self.model_name) log.info("Провайдер LLM: %s (%s)", name, self.model_name)
self.provider_changed.emit(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: def set_speech_enabled(self, enabled: bool) -> None:
if enabled == self._speaker.enabled: if enabled == self._speaker.enabled:
return return
+45
View File
@@ -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
+48
View File
@@ -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()
+8 -2
View File
@@ -96,11 +96,17 @@ def _add_cuda_dll_directories() -> None:
"""cuBLAS и cuDNN из pip-пакетов nvidia-* не лежат в PATH — добавляем их сами.""" """cuBLAS и cuDNN из pip-пакетов nvidia-* не лежат в PATH — добавляем их сами."""
if sys.platform != "win32": if sys.platform != "win32":
return return
package_dirs: list[str] = []
try: try:
import nvidia import nvidia
package_dirs.extend(nvidia.__path__)
except ImportError: except ImportError:
return pass
for package_dir in nvidia.__path__: # В сборке 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"): for bin_dir in Path(package_dir).glob("*/bin"):
if str(bin_dir) not in os.environ["PATH"]: if str(bin_dir) not in os.environ["PATH"]:
os.add_dll_directory(str(bin_dir)) os.add_dll_directory(str(bin_dir))
+76
View File
@@ -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)
+6
View File
@@ -63,6 +63,8 @@ class _MessageInput(QPlainTextEdit):
class ChatWindow(QWidget): class ChatWindow(QWidget):
settings_requested = Signal()
def __init__(self, assistant: Assistant) -> None: def __init__(self, assistant: Assistant) -> None:
super().__init__() super().__init__()
self._assistant = assistant self._assistant = assistant
@@ -95,9 +97,13 @@ class ChatWindow(QWidget):
self._voice_button.clicked.connect(assistant.toggle_listening) self._voice_button.clicked.connect(assistant.toggle_listening)
self._voice_button.setVisible(assistant.voice_available) self._voice_button.setVisible(assistant.voice_available)
settings_button = QPushButton("Настройки")
settings_button.clicked.connect(self.settings_requested)
header = QHBoxLayout() header = QHBoxLayout()
header.addWidget(self._provider_label, 1) header.addWidget(self._provider_label, 1)
header.addWidget(new_chat_button) header.addWidget(new_chat_button)
header.addWidget(settings_button)
buttons = QVBoxLayout() buttons = QVBoxLayout()
buttons.addWidget(self._send_button) buttons.addWidget(self._send_button)
+9 -4
View File
@@ -30,21 +30,26 @@ _ICON_SIZE = 64
@cache @cache
def state_icon(state: AssistantState) -> QIcon: 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) pixmap.fill(Qt.GlobalColor.transparent)
margin = max(1, size // 32)
painter = QPainter(pixmap) painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen) painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(_STATE_COLORS[state])) 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 = painter.font()
font.setPixelSize(38) font.setPixelSize(round(size * 0.6))
font.setBold(True) font.setBold(True)
painter.setFont(font) painter.setFont(font)
painter.setPen(QColor("white")) painter.setPen(QColor("white"))
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "A") painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "A")
painter.end() painter.end()
return QIcon(pixmap) return pixmap
+359
View File
@@ -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"настраиваются в файле:<br><code>{config_path}</code>"
)
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"<b>{title}</b>")
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)))
+32 -6
View File
@@ -2,11 +2,16 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from typing import Any
from PySide6.QtGui import QAction, QActionGroup from PySide6.QtGui import QAction, QActionGroup
from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
from agr_assistent import APP_NAME from agr_assistent import APP_NAME
from agr_assistent.config import ConfigError
from agr_assistent.core.assistant import Assistant, AssistantState 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.chat_window import ChatWindow
from agr_assistent.ui.icons import STATE_TITLES, state_icon from agr_assistent.ui.icons import STATE_TITLES, state_icon
@@ -19,13 +24,14 @@ class TrayIcon(QSystemTrayIcon):
self, self,
assistant: Assistant, assistant: Assistant,
window: ChatWindow, window: ChatWindow,
settings: Settings,
open_settings: Callable[[], None],
hotkey: str = "", hotkey: str = "",
wake_phrases: list[str] | None = None,
) -> None: ) -> None:
wake_phrases = wake_phrases or []
super().__init__(state_icon(assistant.state)) super().__init__(state_icon(assistant.state))
self._assistant = assistant self._assistant = assistant
self._window = window self._window = window
self._settings = settings
menu = QMenu() menu = QMenu()
menu.addAction("Открыть чат", window.show_and_raise) menu.addAction("Открыть чат", window.show_and_raise)
@@ -34,10 +40,11 @@ class TrayIcon(QSystemTrayIcon):
label = f"Говорить\t{hotkey.title()}" if hotkey else "Говорить" label = f"Говорить\t{hotkey.title()}" if hotkey else "Говорить"
menu.addAction(label, assistant.toggle_listening) menu.addAction(label, assistant.toggle_listening)
if assistant.wake_word_available: 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 = QAction(f"Слушать слово активации: {phrases}", self, checkable=True)
wake_action.setChecked(assistant.wake_word_enabled) 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) assistant.wake_word_enabled_changed.connect(wake_action.setChecked)
menu.addAction(wake_action) menu.addAction(wake_action)
menu.addSeparator() menu.addSeparator()
@@ -48,17 +55,18 @@ class TrayIcon(QSystemTrayIcon):
for name in assistant.provider_names: for name in assistant.provider_names:
action = QAction(name, self, checkable=True) action = QAction(name, self, checkable=True)
action.setChecked(name == assistant.provider_name) 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_group.addAction(action)
provider_menu.addAction(action) provider_menu.addAction(action)
self._provider_actions[name] = action self._provider_actions[name] = action
self._speech_action = QAction("Озвучивать ответы", self, checkable=True) self._speech_action = QAction("Озвучивать ответы", self, checkable=True)
self._speech_action.setChecked(assistant.speech_enabled) 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(self._speech_action)
menu.addAction("Новый диалог", assistant.clear_history) menu.addAction("Новый диалог", assistant.clear_history)
menu.addAction("Настройки…", open_settings)
menu.addSeparator() menu.addSeparator()
menu.addAction("Выход", QApplication.quit) menu.addAction("Выход", QApplication.quit)
# Меню нужно держать в атрибуте, иначе его соберёт сборщик мусора # Меню нужно держать в атрибуте, иначе его соберёт сборщик мусора
@@ -82,6 +90,24 @@ class TrayIcon(QSystemTrayIcon):
): ):
self._window.toggle_visibility() 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: def _on_state_changed(self, state: AssistantState) -> None:
self.setIcon(state_icon(state)) self.setIcon(state_icon(state))
self.setToolTip(f"{APP_NAME}{STATE_TITLES[state]}") self.setToolTip(f"{APP_NAME}{STATE_TITLES[state]}")
+13
View File
@@ -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([])
+85
View File
@@ -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
-6
View File
@@ -3,17 +3,11 @@ import time
from collections.abc import Callable from collections.abc import Callable
import numpy as np import numpy as np
import pytest
from PySide6.QtCore import QCoreApplication from PySide6.QtCore import QCoreApplication
from agr_assistent.core.speech import Speaker from agr_assistent.core.speech import Speaker
@pytest.fixture(scope="module")
def qapp() -> QCoreApplication:
return QCoreApplication.instance() or QCoreApplication([])
class FakeEngine: class FakeEngine:
sample_rate = 1000 sample_rate = 1000
-6
View File
@@ -3,17 +3,11 @@ import time
from collections.abc import Callable from collections.abc import Callable
import numpy as np import numpy as np
import pytest
from PySide6.QtCore import QCoreApplication from PySide6.QtCore import QCoreApplication
from agr_assistent.core.voice import VoiceInput from agr_assistent.core.voice import VoiceInput
@pytest.fixture(scope="module")
def qapp() -> QCoreApplication:
return QCoreApplication.instance() or QCoreApplication([])
class FakeRecorder: class FakeRecorder:
def __init__(self, *, wait_for_stop: bool = False, speech: bool = True) -> None: def __init__(self, *, wait_for_stop: bool = False, speech: bool = True) -> None:
self.wait_for_stop = wait_for_stop self.wait_for_stop = wait_for_stop
-6
View File
@@ -2,17 +2,11 @@ import threading
import time import time
from collections.abc import Callable from collections.abc import Callable
import pytest
from PySide6.QtCore import QCoreApplication from PySide6.QtCore import QCoreApplication
from agr_assistent.core.wake import WakeWordListener from agr_assistent.core.wake import WakeWordListener
@pytest.fixture(scope="module")
def qapp() -> QCoreApplication:
return QCoreApplication.instance() or QCoreApplication([])
class FakeStream: class FakeStream:
def __init__(self, owner: "FakeMicrophone") -> None: def __init__(self, owner: "FakeMicrophone") -> None:
self.owner = owner self.owner = owner
Generated
+95 -1
View File
@@ -21,6 +21,7 @@ dependencies = [
{ name = "openai" }, { name = "openai" },
{ name = "pyside6" }, { name = "pyside6" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "ruamel-yaml" },
{ name = "silero-vad" }, { name = "silero-vad" },
{ name = "sounddevice" }, { name = "sounddevice" },
{ name = "torch" }, { name = "torch" },
@@ -29,6 +30,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "pyinstaller" },
{ name = "pytest" }, { name = "pytest" },
] ]
@@ -42,6 +44,7 @@ requires-dist = [
{ name = "openai", specifier = ">=3.14.1" }, { name = "openai", specifier = ">=3.14.1" },
{ name = "pyside6", specifier = ">=6.11.2" }, { name = "pyside6", specifier = ">=6.11.2" },
{ name = "pyyaml", specifier = ">=6.0.3" }, { name = "pyyaml", specifier = ">=6.0.3" },
{ name = "ruamel-yaml", specifier = ">=0.19.1" },
{ name = "silero-vad", specifier = ">=6.2.1" }, { name = "silero-vad", specifier = ">=6.2.1" },
{ name = "sounddevice", specifier = ">=0.5.6" }, { name = "sounddevice", specifier = ">=0.5.6" },
{ name = "torch", specifier = ">=2.14.0" }, { name = "torch", specifier = ">=2.14.0" },
@@ -49,7 +52,19 @@ requires-dist = [
] ]
[package.metadata.requires-dev] [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]] [[package]]
name = "annotated-types" 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" }, { 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]] [[package]]
name = "markupsafe" name = "markupsafe"
version = "3.0.3" 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" }, { 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]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" 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" }, { 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]] [[package]]
name = "pyside6" name = "pyside6"
version = "6.11.2" 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" }, { 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]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" 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" }, { 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]] [[package]]
name = "setuptools" name = "setuptools"
version = "84.0.0" version = "84.0.0"