Этап 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:
co-authored by
Claude Opus 5
parent
a719980d74
commit
20a666e0d4
@@ -4,7 +4,6 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
build/
|
||||
dist/
|
||||
*.spec
|
||||
|
||||
# Локальный конфиг может содержать API-ключи
|
||||
config.yaml
|
||||
|
||||
@@ -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` при первом запуске.
|
||||
|
||||
## Разработка
|
||||
|
||||
|
||||
@@ -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 |
@@ -0,0 +1,10 @@
|
||||
"""Точка входа для PyInstaller."""
|
||||
|
||||
import multiprocessing
|
||||
import sys
|
||||
|
||||
from agr_assistent.app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
multiprocessing.freeze_support()
|
||||
sys.exit(main())
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
@@ -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]}")
|
||||
|
||||
@@ -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([])
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user