Files
agr-assistent/src/agr_assistent/commands/catalog.py
T
mrleo1nidandClaude Opus 5 04029f3750 Этап 8: настраиваемые команды
- commands.yaml с рабочими примерами: медиа, громкость, поиск, папки, блокировка;
  перечитывается автоматически, ошибки не ломают остальные команды
- Действия run / open / http / keys; вывод и ответы возвращаются модели
- Вызов через модель (tool calling) и мгновенно по точным фразам, в том числе с параметрами
- Подтверждение «да/нет» для опасных команд, после голосового вопроса микрофон включается сам
- Безопасность: запуск без оболочки, защита аргументов cmd/PowerShell/.bat,
  переменные окружения раскрываются только в шаблоне
- Вкладка «Команды» в настройках
- Тесты разбора, действий, фраз и полных сценариев через Assistant

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

94 lines
3.3 KiB
Python

"""Загруженные команды: файл commands.yaml, перезагрузка при изменении, инструменты для модели."""
from __future__ import annotations
import logging
from importlib import resources
from pathlib import Path
from typing import Any
from PySide6.QtCore import QFileSystemWatcher, QObject, QTimer, Signal
from agr_assistent.commands import actions
from agr_assistent.commands.model import Command, load_commands
from agr_assistent.llm.tools import Tool, ToolResult
log = logging.getLogger(__name__)
COMMANDS_FILE_NAME = "commands.yaml"
# Редакторы сохраняют файл в несколько приёмов — перечитываем после паузы
_RELOAD_DELAY_MS = 300
def default_commands_text() -> str:
return (
resources.files("agr_assistent").joinpath("default_commands.yaml").read_text(encoding="utf-8")
)
def command_tool(command: Command) -> Tool:
def handler(arguments: dict[str, Any]) -> ToolResult:
outcome = actions.execute(command, arguments)
return ToolResult(outcome.ok, outcome.content, outcome.display)
return Tool(
name=command.name,
description=command.description,
parameters=command.schema(),
handler=handler,
confirm=command.confirm,
describe=command.summary,
)
class CommandCatalog(QObject):
changed = Signal()
def __init__(self, path: Path, parent: QObject | None = None) -> None:
super().__init__(parent)
self._path = path
self._commands: list[Command] = []
self._errors: list[str] = []
self._reload_timer = QTimer(self)
self._reload_timer.setSingleShot(True)
self._reload_timer.setInterval(_RELOAD_DELAY_MS)
self._reload_timer.timeout.connect(self.reload)
self._watcher = QFileSystemWatcher(self)
self._watcher.fileChanged.connect(lambda _path: self._reload_timer.start())
self.reload()
@property
def path(self) -> Path:
return self._path
@property
def commands(self) -> list[Command]:
return self._commands
@property
def errors(self) -> list[str]:
return self._errors
def ensure_file(self) -> None:
"""Создаёт commands.yaml с примерами, если его ещё нет."""
if not self._path.exists():
self._path.parent.mkdir(parents=True, exist_ok=True)
self._path.write_text(default_commands_text(), encoding="utf-8")
log.info("Создан файл команд: %s", self._path)
self.reload()
def tools(self) -> list[Tool]:
return [command_tool(command) for command in self._commands]
def reload(self) -> None:
self._commands, self._errors = load_commands(self._path)
# После сохранения «заменой файла» наблюдение слетает — ставим заново
if self._path.exists() and str(self._path) not in self._watcher.files():
self._watcher.addPath(str(self._path))
log.info("Команды: %d загружено, %d ошибок", len(self._commands), len(self._errors))
for error in self._errors:
log.warning(error)
self.changed.emit()