diff --git a/README.md b/README.md index fa19a5d..76e2904 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,27 @@ sound_off: - {type: system, op: volume_mute} ``` +## Конструктор команд + +GUI для добавления команд в `commands.yaml` без ручной правки YAML. + +Запуск: + +``` +python -m tools.command_builder +``` + +Или из корня репо `run_builder.bat` (под Windows). Открывается окно pywebview с пошаговым мастером: фразы → тип действия → параметры → предпросмотр YAML и сохранение. На шаге «Действие» доступна вкладка «Подсказать (LLM)» — модель Groq предложит готовый блок по описанию на русском. + +После сохранения команды Jarvis нужно перезапустить, чтобы она подхватилась. + +Требования: + +- Windows 10/11 с установленным WebView2 Runtime (обычно уже стоит вместе с Edge — иначе [скачать у Microsoft](https://developer.microsoft.com/microsoft-edge/webview2/)). +- Зависимости из `requirements.txt` (включая `pywebview`, `ruamel.yaml`, `pyautogui`). + +Подробности для разработчиков — [tools/command_builder/README.md](tools/command_builder/README.md). TODO: добавить скриншот окна. + ## Лицензия CC BY-NC-SA 4.0 — см. [LICENSE.txt](LICENSE.txt). Авторство оригинала — Abraham Tugalov / Priler. Изменения в этом форке — Bossiara13 (Dmitry Bykov), 2026. diff --git a/config.py b/config.py index 4ba0240..9cc8aef 100644 --- a/config.py +++ b/config.py @@ -6,7 +6,7 @@ load_dotenv("dev.env") # Конфигурация VA_NAME = 'Jarvis' -VA_VER = "0.1.0" +VA_VER = "0.3.0" VA_ALIAS = ('джарвис',) VA_TBR = ('скажи', 'покажи', 'ответь', 'произнеси', 'расскажи', 'сколько', 'слушай') diff --git a/main.py b/main.py index 7ecc082..2193db8 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ import struct import subprocess import sys import time +import webbrowser from ctypes import POINTER, cast import openai @@ -226,6 +227,20 @@ def run_action(action): _shutdown() elif t == 'sleep': time.sleep(action['ms'] / 1000) + elif t == 'shell': + if action.get('wait'): + subprocess.check_call(action['cmd'], shell=True) + else: + subprocess.Popen(action['cmd'], shell=True) + elif t == 'url': + webbrowser.open(action['href']) + elif t == 'keys': + import pyautogui + if action.get('sequence'): + keys = [k.strip() for k in action['sequence'].split('+') if k.strip()] + pyautogui.hotkey(*keys) + elif action.get('text'): + pyautogui.typewrite(action['text'], interval=0.02) elif t == 'multi': for step in action['steps']: run_action(step) diff --git a/requirements.txt b/requirements.txt index 22e2d95..a0ba992 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,3 +26,8 @@ pycaw sounddevice PyYAML python-dotenv + +# GUI command constructor (tools/command_builder) +ruamel.yaml +pywebview +pyautogui diff --git a/run_builder.bat b/run_builder.bat new file mode 100644 index 0000000..03cd254 --- /dev/null +++ b/run_builder.bat @@ -0,0 +1,6 @@ +@echo off +if exist .venv\Scripts\python.exe ( + .venv\Scripts\python.exe -m tools.command_builder +) else ( + python -m tools.command_builder +) diff --git a/tools/command_builder/README.md b/tools/command_builder/README.md new file mode 100644 index 0000000..a50c70d --- /dev/null +++ b/tools/command_builder/README.md @@ -0,0 +1,68 @@ +# Конструктор команд (developer notes) + +GUI-надстройка для редактирования `commands.yaml` без ручной правки YAML. Запускается отдельным процессом, не зависит от рантайма Jarvis. + +## Запуск + +``` +python -m tools.command_builder +``` + +или из корня репо `run_builder.bat`. + +## Стек + +- `pywebview` (бэкенд EdgeWebView2 на Windows) — оборачивает локальный HTML. +- Frontend — vanilla JS + CSS, без сборки. Vendored `js-yaml` только для предпросмотра YAML на стороне UI. +- `ruamel.yaml` — чтение/запись `commands.yaml` с сохранением комментариев и форматирования. +- `pyautogui` — рантайм для action-типа `keys`. + +## Структура + +``` +tools/command_builder/ + __main__.py точка входа python -m + app.py pywebview bootstrap + Python bridge (js_api) + schema.py dataclass-схема action-блоков и валидация + yaml_writer.py atomic write + .bak бэкап перед каждой записью + llm_suggest.py Groq/OpenAI SDK 1.x, response_format=json_object + web/ frontend + tests/ pytest для schema + writer +``` + +## Bridge API (методы класса `Bridge` в `app.py`) + +Все методы возвращают сериализуемые структуры. Ошибки идут как `{"ok": false, "error": "..."}`. + +| метод | назначение | +|-------|-----------| +| `list_existing_commands()` | список ключей верхнего уровня в `commands.yaml` | +| `list_exes()` | basename'ы `*.exe` из `custom-commands/` | +| `list_sound_names()` | разрешённые имена для `play_sound` (из `schema.VALID_SOUND_NAMES`) | +| `preview_sound(name)` | проигрывает звук в фоновом потоке через `simpleaudio` | +| `slugify(phrase)` | транслит для генерации `cmd_id` | +| `validate_draft(json_str)` | прогон через `command_from_dict` | +| `save_command(json_str)` | append/upsert + бэкап (`overwrite: true` чтобы перезаписать) | +| `llm_suggest(prompt)` | Groq → JSON → провалидированный action-блок | + +## Расширение схемы (новый тип action) + +1. В `schema.py`: добавить dataclass с полем `type` и методом `to_dict()`. Включить в Union `Action`. Расширить `action_from_dict()`. +2. В `tests/test_schema.py`: добавить roundtrip + negative-case тесты. +3. В `web/app.js`: + - карточка в `#action-grid` (`index.html`); + - ветка в `renderActionForm()`; + - ветка в `updateAction()`; + - ветка в `renderInlineEditor()` для использования внутри multi.steps. +4. В `main.py` → `run_action()`: обработчик нового `type`. +5. В `llm_suggest.py` → `_ACTION_SCHEMA_DOC`: описание для LLM. + +## Бэкапы + +`yaml_writer.append_command` / `upsert_command` перед записью копируют `commands.yaml` в `commands.yaml.bak` (через `shutil.copy2`). При запуске `app.py` вызывается `_check_yaml_health()` — если основной файл не парсится, в stderr печатается путь к `.bak` для ручного восстановления. + +## Тесты + +``` +.venv\Scripts\python.exe -m pytest tools/command_builder/tests/ +``` diff --git a/tools/command_builder/__init__.py b/tools/command_builder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/command_builder/__main__.py b/tools/command_builder/__main__.py new file mode 100644 index 0000000..356fd8a --- /dev/null +++ b/tools/command_builder/__main__.py @@ -0,0 +1,4 @@ +from tools.command_builder.app import main + +if __name__ == "__main__": + main() diff --git a/tools/command_builder/app.py b/tools/command_builder/app.py new file mode 100644 index 0000000..a4a53db --- /dev/null +++ b/tools/command_builder/app.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import json +import os +import sys +import threading +from pathlib import Path + +import webview + +REPO_ROOT = Path(__file__).resolve().parents[2] +WEB_DIR = Path(__file__).resolve().parent / "web" +COMMANDS_YAML = REPO_ROOT / "commands.yaml" +CUSTOM_COMMANDS_DIR = REPO_ROOT / "custom-commands" +SOUND_DIR = REPO_ROOT / "sound" + +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from tools.command_builder.schema import ( + CommandEntry, + SchemaError, + VALID_SOUND_NAMES, + action_from_dict, + command_from_dict, + slugify, +) +from tools.command_builder import yaml_writer + + +class Bridge: + def list_existing_commands(self): + try: + return yaml_writer.list_command_ids(COMMANDS_YAML) + except Exception as ex: + return {"error": str(ex)} + + def list_exes(self): + if not CUSTOM_COMMANDS_DIR.exists(): + return [] + names = [] + for p in sorted(CUSTOM_COMMANDS_DIR.iterdir()): + if p.suffix.lower() == ".exe": + names.append(p.stem) + return names + + def list_sound_names(self): + return list(VALID_SOUND_NAMES) + + def preview_sound(self, name): + if name not in VALID_SOUND_NAMES: + return {"ok": False, "error": "unknown sound"} + candidates = [] + if name in ("ok", "greet"): + for i in (1, 2, 3): + candidates.append(SOUND_DIR / f"{name}{i}.wav") + candidates.append(SOUND_DIR / f"{name}.wav") + target = next((c for c in candidates if c.exists()), None) + if target is None: + return {"ok": False, "error": f"file not found for {name}"} + + def _play(): + try: + import simpleaudio as sa + wave_obj = sa.WaveObject.from_wave_file(str(target)) + play_obj = wave_obj.play() + play_obj.wait_done() + except Exception: + pass + + threading.Thread(target=_play, daemon=True).start() + return {"ok": True} + + def slugify(self, phrase): + return slugify(phrase or "") + + def validate_draft(self, draft_json): + try: + data = json.loads(draft_json) if isinstance(draft_json, str) else draft_json + cmd_id = data.get("cmd_id") or "" + if not cmd_id.strip(): + return {"ok": False, "error": "Не указан идентификатор команды"} + entry = command_from_dict(cmd_id, { + "phrases": data.get("phrases") or [], + "action": data.get("action") or {}, + **({"confirm_sound": data["confirm_sound"]} if data.get("confirm_sound") else {}), + }) + return {"ok": True, "id": entry.cmd_id} + except SchemaError as ex: + return {"ok": False, "error": str(ex)} + except Exception as ex: + return {"ok": False, "error": f"Внутренняя ошибка: {ex}"} + + def save_command(self, draft_json): + try: + data = json.loads(draft_json) if isinstance(draft_json, str) else draft_json + cmd_id = (data.get("cmd_id") or "").strip() + if not cmd_id: + return {"ok": False, "error": "Не указан идентификатор команды"} + entry = command_from_dict(cmd_id, { + "phrases": data.get("phrases") or [], + "action": data.get("action") or {}, + **({"confirm_sound": data["confirm_sound"]} if data.get("confirm_sound") else {}), + }) + existing = set(yaml_writer.list_command_ids(COMMANDS_YAML)) + if entry.cmd_id in existing and not data.get("overwrite"): + return {"ok": False, "error": f"Команда {entry.cmd_id} уже существует"} + if data.get("overwrite"): + yaml_writer.upsert_command(entry, path=COMMANDS_YAML) + else: + yaml_writer.append_command(entry, path=COMMANDS_YAML) + return {"ok": True, "id": entry.cmd_id} + except SchemaError as ex: + return {"ok": False, "error": str(ex)} + except Exception as ex: + return {"ok": False, "error": f"Не удалось сохранить: {ex}"} + + def llm_suggest(self, prompt): + try: + from tools.command_builder import llm_suggest as ls + return ls.suggest(prompt or "") + except Exception as ex: + return {"ok": False, "error": f"LLM недоступен: {ex}"} + + +def _check_yaml_health(): + if not COMMANDS_YAML.exists(): + return + try: + yaml_writer.load_yaml(COMMANDS_YAML) + except Exception as ex: + bak = COMMANDS_YAML.with_suffix(COMMANDS_YAML.suffix + ".bak") + msg = f"[command_builder] Не удалось разобрать {COMMANDS_YAML.name}: {ex}" + if bak.exists(): + msg += f"\n[command_builder] Резервная копия доступна: {bak} — можно восстановить вручную." + print(msg, file=sys.stderr) + + +def main(): + _check_yaml_health() + bridge = Bridge() + index_path = WEB_DIR / "index.html" + if not index_path.exists(): + raise SystemExit(f"web/index.html not found at {index_path}") + webview.create_window( + title="J.A.R.V.I.S — конструктор команд", + url=str(index_path), + js_api=bridge, + width=900, + height=720, + min_size=(720, 560), + background_color="#0a1214", + ) + webview.start() + + +if __name__ == "__main__": + main() diff --git a/tools/command_builder/llm_suggest.py b/tools/command_builder/llm_suggest.py new file mode 100644 index 0000000..b5d69ec --- /dev/null +++ b/tools/command_builder/llm_suggest.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +import config +from tools.command_builder.schema import ( + SchemaError, + VALID_SOUND_NAMES, + VALID_SYSTEM_OPS, + action_from_dict, + slugify, +) + + +_ACTION_SCHEMA_DOC = """ +Допустимые типы действий (action.type): + +- exe — запуск .exe из custom-commands/ + {"type":"exe","file":"","wait":,"delay_ms":} +- play_sound — встроенный звук + {"type":"play_sound","name":""} +- system — системная операция + {"type":"system","op":""} +- sleep — пауза в миллисекундах (только внутри multi) + {"type":"sleep","ms":} +- shell — PowerShell/cmd строка + {"type":"shell","cmd":"","wait":} +- url — открыть URL в системном браузере + {"type":"url","href":""} +- keys — нажатие клавиш ИЛИ ввод текста (только одно из двух) + {"type":"keys","sequence":"ctrl+shift+m"} + {"type":"keys","text":"печатаемая строка"} +- multi — последовательность шагов + {"type":"multi","steps":[, ...]} +""".strip() + + +def _build_system_prompt(exe_names: list[str]) -> str: + schema = ( + _ACTION_SCHEMA_DOC + .replace("%SOUNDS%", ", ".join(VALID_SOUND_NAMES)) + .replace("%SYS_OPS%", ", ".join(VALID_SYSTEM_OPS)) + ) + if exe_names: + exe_lines = "\n".join(f" - {n}.exe" for n in exe_names) + else: + exe_lines = " (папка custom-commands/ пуста)" + return ( + "Ты помогаешь собрать команду для голосового ассистента Jarvis. " + "Пользователь описывает желаемое поведение по-русски. " + "Верни ОДИН JSON-объект ровно с такими полями:\n" + "{\n" + ' "cmd_id": "<латиница, snake_case, опционально — иначе будет сгенерирован>",\n' + ' "phrases": ["<голосовая фраза 1>", "..."],\n' + ' "action": <один action из схемы ниже>,\n' + ' "confirm_sound": "<опционально, имя звука для подтверждения>"\n' + "}\n\n" + f"{schema}\n\n" + "Доступные .exe файлы (используй ровно эти имена в action.file):\n" + f"{exe_lines}\n\n" + "Правила:\n" + "- Только валидный JSON, без пояснений и markdown.\n" + "- phrases — 1-3 коротких русских фразы, без слова-активатора «джарвис».\n" + "- Если нужно несколько шагов — используй multi.\n" + "- sleep валиден только внутри multi.steps." + ) + + +def _get_client(): + from openai import OpenAI + if not config.GROQ_TOKEN: + raise RuntimeError("GROQ_TOKEN не задан в dev.env") + return OpenAI(api_key=config.GROQ_TOKEN, base_url=config.GROQ_BASE_URL) + + +def _list_exes() -> list[str]: + custom = REPO_ROOT / "custom-commands" + if not custom.exists(): + return [] + return sorted(p.stem for p in custom.iterdir() if p.suffix.lower() == ".exe") + + +def suggest(prompt: str) -> dict: + user_prompt = (prompt or "").strip() + if not user_prompt: + return {"ok": False, "error": "Пустой запрос"} + + try: + client = _get_client() + except Exception as ex: + return {"ok": False, "error": str(ex)} + + system_prompt = _build_system_prompt(_list_exes()) + + try: + response = client.chat.completions.create( + model=config.GROQ_MODEL, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + response_format={"type": "json_object"}, + temperature=0.2, + max_tokens=800, + ) + except Exception as ex: + return {"ok": False, "error": f"Ошибка запроса к Groq: {ex}"} + + raw = response.choices[0].message.content or "" + try: + parsed = json.loads(raw) + except json.JSONDecodeError as ex: + return {"ok": False, "error": f"Модель вернула не-JSON: {ex}", "raw": raw} + + phrases = parsed.get("phrases") + if not isinstance(phrases, list) or not phrases: + return {"ok": False, "error": "В ответе нет phrases", "raw": parsed} + phrases = [p for p in phrases if isinstance(p, str) and p.strip()] + if not phrases: + return {"ok": False, "error": "phrases пуст после фильтрации", "raw": parsed} + + action_raw = parsed.get("action") + if not isinstance(action_raw, dict): + return {"ok": False, "error": "В ответе нет action", "raw": parsed} + + try: + action_obj = action_from_dict(action_raw) + except SchemaError as ex: + return {"ok": False, "error": f"Action не прошёл валидацию: {ex}", "raw": parsed} + + cmd_id = parsed.get("cmd_id") + if not isinstance(cmd_id, str) or not cmd_id.strip(): + cmd_id = slugify(phrases[0]) + + confirm = parsed.get("confirm_sound") + if confirm is not None and confirm not in VALID_SOUND_NAMES: + confirm = None + + suggestion = { + "cmd_id": cmd_id, + "phrases": phrases, + "action": action_obj.to_dict(), + } + if confirm: + suggestion["confirm_sound"] = confirm + + return {"ok": True, "suggestion": suggestion} diff --git a/tools/command_builder/schema.py b/tools/command_builder/schema.py new file mode 100644 index 0000000..60c0e5f --- /dev/null +++ b/tools/command_builder/schema.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, List, Optional, Union + + +class SchemaError(ValueError): + pass + + +@dataclass +class ExeAction: + file: str + wait: Optional[bool] = None + delay_ms: Optional[int] = None + + type: str = "exe" + + def to_dict(self) -> dict: + out: dict = {"type": "exe", "file": self.file} + if self.wait is not None: + out["wait"] = bool(self.wait) + if self.delay_ms is not None: + out["delay_ms"] = int(self.delay_ms) + return out + + +@dataclass +class PlaySoundAction: + name: str + type: str = "play_sound" + + def to_dict(self) -> dict: + return {"type": "play_sound", "name": self.name} + + +@dataclass +class SystemAction: + op: str + type: str = "system" + + def to_dict(self) -> dict: + return {"type": "system", "op": self.op} + + +@dataclass +class SleepAction: + ms: int + type: str = "sleep" + + def to_dict(self) -> dict: + return {"type": "sleep", "ms": int(self.ms)} + + +@dataclass +class ShellAction: + cmd: str + wait: Optional[bool] = None + type: str = "shell" + + def to_dict(self) -> dict: + out: dict = {"type": "shell", "cmd": self.cmd} + if self.wait is not None: + out["wait"] = bool(self.wait) + return out + + +@dataclass +class UrlAction: + href: str + type: str = "url" + + def to_dict(self) -> dict: + return {"type": "url", "href": self.href} + + +@dataclass +class KeysAction: + sequence: Optional[str] = None + text: Optional[str] = None + type: str = "keys" + + def to_dict(self) -> dict: + out: dict = {"type": "keys"} + if self.sequence is not None: + out["sequence"] = self.sequence + if self.text is not None: + out["text"] = self.text + return out + + +Action = Union[ + "ExeAction", + "PlaySoundAction", + "SystemAction", + "SleepAction", + "ShellAction", + "UrlAction", + "KeysAction", + "MultiAction", +] + + +@dataclass +class MultiAction: + steps: List[Action] = field(default_factory=list) + type: str = "multi" + + def to_dict(self) -> dict: + return {"type": "multi", "steps": [s.to_dict() for s in self.steps]} + + +VALID_SYSTEM_OPS = ("volume_mute", "volume_unmute", "exit") +VALID_SOUND_NAMES = ( + "ok", "ready", "thanks", "not_found", "greet", "run", "stupid", "off", +) + + +def action_from_dict(data: Any) -> Action: + if not isinstance(data, dict): + raise SchemaError("action must be a mapping") + t = data.get("type") + if not t: + raise SchemaError("action is missing 'type'") + + if t == "exe": + if not data.get("file"): + raise SchemaError("exe action requires 'file'") + return ExeAction( + file=data["file"], + wait=data.get("wait"), + delay_ms=data.get("delay_ms"), + ) + if t == "play_sound": + name = data.get("name") + if not name: + raise SchemaError("play_sound action requires 'name'") + if name not in VALID_SOUND_NAMES: + raise SchemaError(f"unknown sound name: {name}") + return PlaySoundAction(name=name) + if t == "system": + op = data.get("op") + if op not in VALID_SYSTEM_OPS: + raise SchemaError(f"unknown system op: {op}") + return SystemAction(op=op) + if t == "sleep": + ms = data.get("ms") + if not isinstance(ms, int) or ms < 0: + raise SchemaError("sleep action requires non-negative integer 'ms'") + return SleepAction(ms=ms) + if t == "shell": + cmd = data.get("cmd") + if not cmd or not isinstance(cmd, str): + raise SchemaError("shell action requires non-empty 'cmd' string") + return ShellAction(cmd=cmd, wait=data.get("wait")) + if t == "url": + href = data.get("href") + if not href or not isinstance(href, str): + raise SchemaError("url action requires non-empty 'href' string") + return UrlAction(href=href) + if t == "keys": + sequence = data.get("sequence") + text = data.get("text") + if not sequence and not text: + raise SchemaError("keys action requires 'sequence' or 'text'") + if sequence and text: + raise SchemaError("keys action cannot have both 'sequence' and 'text'") + return KeysAction(sequence=sequence, text=text) + if t == "multi": + steps_raw = data.get("steps") + if not isinstance(steps_raw, list) or not steps_raw: + raise SchemaError("multi action requires non-empty 'steps' list") + steps = [action_from_dict(s) for s in steps_raw] + return MultiAction(steps=steps) + + raise SchemaError(f"unknown action type: {t}") + + +@dataclass +class CommandEntry: + cmd_id: str + phrases: List[str] + action: Action + confirm_sound: Optional[str] = None + + def to_dict(self) -> dict: + body: dict = { + "phrases": list(self.phrases), + "action": self.action.to_dict(), + } + if self.confirm_sound is not None: + body["confirm_sound"] = self.confirm_sound + return body + + +def command_from_dict(cmd_id: str, data: Any) -> CommandEntry: + if not isinstance(data, dict): + raise SchemaError(f"command {cmd_id!r} body must be a mapping") + phrases = data.get("phrases") + if not isinstance(phrases, list) or not phrases: + raise SchemaError(f"command {cmd_id!r} requires non-empty 'phrases' list") + if not all(isinstance(p, str) and p.strip() for p in phrases): + raise SchemaError(f"command {cmd_id!r} phrases must be non-empty strings") + action_raw = data.get("action") + if action_raw is None: + raise SchemaError(f"command {cmd_id!r} requires 'action'") + action = action_from_dict(action_raw) + confirm = data.get("confirm_sound") + if confirm is not None and confirm not in VALID_SOUND_NAMES: + raise SchemaError(f"command {cmd_id!r} has unknown confirm_sound: {confirm}") + return CommandEntry(cmd_id=cmd_id, phrases=phrases, action=action, confirm_sound=confirm) + + +_SLUG_TRANSLIT = { + "а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", + "ж": "zh", "з": "z", "и": "i", "й": "y", "к": "k", "л": "l", "м": "m", + "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t", "у": "u", + "ф": "f", "х": "h", "ц": "ts", "ч": "ch", "ш": "sh", "щ": "sch", + "ъ": "", "ы": "y", "ь": "", "э": "e", "ю": "yu", "я": "ya", +} + + +def slugify(phrase: str) -> str: + lowered = phrase.lower().strip() + out_chars: list = [] + for ch in lowered: + if ch in _SLUG_TRANSLIT: + out_chars.append(_SLUG_TRANSLIT[ch]) + elif ch.isalnum() and ord(ch) < 128: + out_chars.append(ch) + elif ch in (" ", "_", "-"): + out_chars.append("_") + slug = "".join(out_chars) + while "__" in slug: + slug = slug.replace("__", "_") + slug = slug.strip("_") + return slug or "cmd" diff --git a/tools/command_builder/tests/__init__.py b/tools/command_builder/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/command_builder/tests/test_schema.py b/tools/command_builder/tests/test_schema.py new file mode 100644 index 0000000..0182560 --- /dev/null +++ b/tools/command_builder/tests/test_schema.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import io +from pathlib import Path + +import pytest +from ruamel.yaml import YAML + +from tools.command_builder.schema import ( + CommandEntry, + ExeAction, + KeysAction, + MultiAction, + PlaySoundAction, + SchemaError, + ShellAction, + SleepAction, + SystemAction, + UrlAction, + action_from_dict, + command_from_dict, + slugify, +) +from tools.command_builder.yaml_writer import ( + append_command, + load_yaml, + upsert_command, +) + + +def _roundtrip(action_obj): + d = action_obj.to_dict() + y = YAML() + buf = io.StringIO() + y.dump(d, buf) + buf.seek(0) + loaded = y.load(buf) + return action_from_dict(dict(loaded)) + + +def test_exe_roundtrip(): + a = ExeAction(file="Run browser.exe", wait=True, delay_ms=200) + b = _roundtrip(a) + assert b.file == a.file and b.wait is True and b.delay_ms == 200 + + +def test_exe_minimal(): + a = ExeAction(file="x.exe") + b = _roundtrip(a) + assert b.file == "x.exe" and b.wait is None and b.delay_ms is None + + +def test_play_sound_roundtrip(): + a = PlaySoundAction(name="ok") + b = _roundtrip(a) + assert b.name == "ok" + + +def test_play_sound_rejects_unknown(): + with pytest.raises(SchemaError): + action_from_dict({"type": "play_sound", "name": "nope"}) + + +def test_system_roundtrip(): + for op in ("volume_mute", "volume_unmute", "exit"): + a = SystemAction(op=op) + b = _roundtrip(a) + assert b.op == op + + +def test_system_rejects_unknown_op(): + with pytest.raises(SchemaError): + action_from_dict({"type": "system", "op": "reboot"}) + + +def test_sleep_roundtrip(): + a = SleepAction(ms=1500) + b = _roundtrip(a) + assert b.ms == 1500 + + +def test_sleep_rejects_negative(): + with pytest.raises(SchemaError): + action_from_dict({"type": "sleep", "ms": -1}) + + +def test_shell_roundtrip(): + a = ShellAction(cmd="echo hello", wait=True) + b = _roundtrip(a) + assert b.cmd == "echo hello" and b.wait is True + + +def test_shell_minimal(): + a = ShellAction(cmd="echo x") + b = _roundtrip(a) + assert b.wait is None + + +def test_shell_requires_cmd(): + with pytest.raises(SchemaError): + action_from_dict({"type": "shell"}) + + +def test_url_roundtrip(): + a = UrlAction(href="https://example.com") + b = _roundtrip(a) + assert b.href == "https://example.com" + + +def test_url_requires_href(): + with pytest.raises(SchemaError): + action_from_dict({"type": "url"}) + + +def test_keys_sequence_roundtrip(): + a = KeysAction(sequence="ctrl+shift+m") + b = _roundtrip(a) + assert b.sequence == "ctrl+shift+m" and b.text is None + + +def test_keys_text_roundtrip(): + a = KeysAction(text="hello world") + b = _roundtrip(a) + assert b.text == "hello world" and b.sequence is None + + +def test_keys_requires_one_of(): + with pytest.raises(SchemaError): + action_from_dict({"type": "keys"}) + + +def test_keys_rejects_both(): + with pytest.raises(SchemaError): + action_from_dict({"type": "keys", "sequence": "ctrl+c", "text": "x"}) + + +def test_multi_roundtrip(): + a = MultiAction(steps=[ + PlaySoundAction(name="ok"), + ExeAction(file="x.exe", wait=True), + SleepAction(ms=500), + UrlAction(href="https://example.com"), + KeysAction(sequence="ctrl+s"), + ShellAction(cmd="dir"), + SystemAction(op="volume_mute"), + ]) + b = _roundtrip(a) + assert isinstance(b, MultiAction) + assert len(b.steps) == 7 + assert isinstance(b.steps[0], PlaySoundAction) + assert isinstance(b.steps[4], KeysAction) + assert b.steps[4].sequence == "ctrl+s" + + +def test_unknown_type_rejected(): + with pytest.raises(SchemaError): + action_from_dict({"type": "wat"}) + + +def test_command_entry_roundtrip(): + entry = CommandEntry( + cmd_id="open_site", + phrases=["открой сайт", "открой пример"], + action=UrlAction(href="https://example.com"), + ) + d = entry.to_dict() + restored = command_from_dict("open_site", d) + assert restored.cmd_id == "open_site" + assert restored.phrases == ["открой сайт", "открой пример"] + assert isinstance(restored.action, UrlAction) + assert restored.action.href == "https://example.com" + + +def test_command_requires_phrases(): + with pytest.raises(SchemaError): + command_from_dict("x", {"phrases": [], "action": {"type": "system", "op": "exit"}}) + + +def test_slugify_russian(): + assert slugify("Открой Браузер") == "otkroy_brauzer" + assert slugify("hello world") == "hello_world" + assert slugify(" ") == "cmd" + + +def _write_fixture(path: Path) -> None: + path.write_text( + "existing_cmd:\n" + " phrases:\n" + " - пример\n" + " action: {type: play_sound, name: ok}\n" + "\n" + "another:\n" + " phrases:\n" + " - другой\n" + " action: {type: system, op: exit}\n", + encoding="utf-8", + ) + + +def test_append_writes_and_creates_backup(tmp_path): + yaml_path = tmp_path / "commands.yaml" + _write_fixture(yaml_path) + original = yaml_path.read_text(encoding="utf-8") + entry = CommandEntry( + cmd_id="open_docs", + phrases=["открой доки", "документация"], + action=UrlAction(href="https://docs.example.com"), + ) + append_command(entry, path=yaml_path) + bak = yaml_path.with_suffix(yaml_path.suffix + ".bak") + assert bak.exists() + assert bak.read_text(encoding="utf-8") == original + data = load_yaml(yaml_path) + assert "open_docs" in data + assert "existing_cmd" in data + assert data["open_docs"]["action"]["href"] == "https://docs.example.com" + + +def test_append_rejects_duplicate_id(tmp_path): + yaml_path = tmp_path / "commands.yaml" + _write_fixture(yaml_path) + entry = CommandEntry( + cmd_id="existing_cmd", + phrases=["дубликат"], + action=SystemAction(op="exit"), + ) + with pytest.raises(SchemaError): + append_command(entry, path=yaml_path) + + +def test_upsert_overwrites(tmp_path): + yaml_path = tmp_path / "commands.yaml" + _write_fixture(yaml_path) + entry = CommandEntry( + cmd_id="existing_cmd", + phrases=["обновлено"], + action=SystemAction(op="volume_mute"), + ) + upsert_command(entry, path=yaml_path) + data = load_yaml(yaml_path) + assert data["existing_cmd"]["action"]["op"] == "volume_mute" + + +def test_ruamel_preserves_inline_comments(tmp_path): + yaml_path = tmp_path / "commands.yaml" + yaml_path.write_text( + "# header comment about commands\n" + "open_browser:\n" + " phrases:\n" + " - открой браузер\n" + " action: {type: exe, file: \"Run browser.exe\"} # inline note\n", + encoding="utf-8", + ) + entry = CommandEntry( + cmd_id="open_new", + phrases=["новое"], + action=UrlAction(href="https://new.example.com"), + ) + append_command(entry, path=yaml_path) + text = yaml_path.read_text(encoding="utf-8") + assert "# header comment about commands" in text + assert "# inline note" in text + assert "open_new" in text diff --git a/tools/command_builder/web/app.js b/tools/command_builder/web/app.js new file mode 100644 index 0000000..ce35e8e --- /dev/null +++ b/tools/command_builder/web/app.js @@ -0,0 +1,833 @@ +(function () { + const state = { + step: 1, + phrases: [""], + cmdId: "", + cmdIdAuto: true, + existingIds: [], + actionType: null, + action: null, + confirmSound: null, + }; + + const els = { + steps: document.querySelectorAll(".step"), + panels: document.querySelectorAll(".panel"), + phrase0: document.getElementById("phrase-0"), + extra: document.getElementById("extra-phrases"), + addPhrase: document.getElementById("add-phrase"), + cmdId: document.getElementById("cmd-id"), + cmdIdHint: document.getElementById("cmd-id-hint"), + actionGrid: document.getElementById("action-grid"), + paramsForm: document.getElementById("params-form"), + yamlPreview: document.getElementById("yaml-preview"), + saveBtn: document.getElementById("save-btn"), + cancelBtn: document.getElementById("cancel-btn"), + saveStatus: document.getElementById("save-status"), + toast: document.getElementById("toast"), + toStep3: document.getElementById("to-step-3"), + tabs: document.querySelectorAll(".tab"), + tabContents: document.querySelectorAll("[data-tab-content]"), + llmPrompt: document.getElementById("llm-prompt"), + llmGenerate: document.getElementById("llm-generate"), + llmStatus: document.getElementById("llm-status"), + llmSuggestions: document.getElementById("llm-suggestions"), + }; + + function api() { + return (window.pywebview && window.pywebview.api) || null; + } + + async function callApi(method, ...args) { + const a = api(); + if (!a || typeof a[method] !== "function") { + return { ok: false, error: "bridge not ready: " + method }; + } + try { + return await a[method](...args); + } catch (ex) { + return { ok: false, error: String(ex) }; + } + } + + function setStep(n) { + state.step = n; + els.panels.forEach((p) => { + p.hidden = parseInt(p.dataset.panel, 10) !== n; + }); + els.steps.forEach((s) => { + s.classList.toggle("active", parseInt(s.dataset.step, 10) === n); + }); + if (n === 4) { + renderPreview(); + } + } + + function showToast(msg, isError) { + els.toast.textContent = msg; + els.toast.classList.toggle("error", !!isError); + els.toast.hidden = false; + clearTimeout(showToast._t); + showToast._t = setTimeout(() => { els.toast.hidden = true; }, 2800); + } + + function refreshExtraPhrases() { + els.extra.innerHTML = ""; + for (let i = 1; i < state.phrases.length; i++) { + const row = document.createElement("div"); + row.className = "extra-row"; + const inp = document.createElement("input"); + inp.type = "text"; + inp.value = state.phrases[i]; + inp.placeholder = "синоним"; + inp.addEventListener("input", () => { + state.phrases[i] = inp.value; + }); + const rm = document.createElement("button"); + rm.type = "button"; + rm.textContent = "удалить"; + rm.addEventListener("click", () => { + state.phrases.splice(i, 1); + refreshExtraPhrases(); + }); + row.appendChild(inp); + row.appendChild(rm); + els.extra.appendChild(row); + } + } + + function transliterateSlug(s) { + const map = { + "а":"a","б":"b","в":"v","г":"g","д":"d","е":"e","ё":"e","ж":"zh", + "з":"z","и":"i","й":"y","к":"k","л":"l","м":"m","н":"n","о":"o", + "п":"p","р":"r","с":"s","т":"t","у":"u","ф":"f","х":"h","ц":"ts", + "ч":"ch","ш":"sh","щ":"sch","ъ":"","ы":"y","ь":"","э":"e","ю":"yu","я":"ya", + }; + let out = ""; + const lower = (s || "").toLowerCase().trim(); + for (const ch of lower) { + if (map[ch] !== undefined) out += map[ch]; + else if (/[a-z0-9]/i.test(ch)) out += ch; + else if (ch === " " || ch === "_" || ch === "-") out += "_"; + } + out = out.replace(/_+/g, "_").replace(/^_|_$/g, ""); + return out || "cmd"; + } + + function checkCmdIdCollision() { + if (!state.cmdId) { + els.cmdIdHint.className = "row-hint"; + els.cmdIdHint.textContent = ""; + return; + } + if (state.existingIds.includes(state.cmdId)) { + els.cmdIdHint.className = "row-hint error"; + els.cmdIdHint.textContent = `Идентификатор «${state.cmdId}» уже используется`; + } else { + els.cmdIdHint.className = "row-hint ok"; + els.cmdIdHint.textContent = "Свободен"; + } + } + + function syncCmdId() { + if (state.cmdIdAuto) { + state.cmdId = transliterateSlug(state.phrases[0] || ""); + els.cmdId.value = state.cmdId; + } + checkCmdIdCollision(); + } + + function renderActionForm() { + const t = state.actionType; + const f = els.paramsForm; + f.innerHTML = ""; + if (!t) { + f.innerHTML = `

Сначала выберите тип на шаге 2.

`; + return; + } + + if (t === "exe") { + const wrap = document.createElement("div"); + wrap.innerHTML = ` +
+ + +
+
+
+ + +
+
+ +
+ `; + f.appendChild(wrap); + const sel = wrap.querySelector("#exe-file"); + callApi("list_exes").then((list) => { + if (Array.isArray(list)) { + for (const name of list) { + const o = document.createElement("option"); + o.value = `${name}.exe`; + o.textContent = name; + sel.appendChild(o); + } + if (state.action && state.action.file) sel.value = state.action.file; + } + }); + sel.addEventListener("change", () => updateAction()); + wrap.querySelector("#exe-delay").addEventListener("input", () => updateAction()); + wrap.querySelector("#exe-wait").addEventListener("change", () => updateAction()); + if (state.action) { + wrap.querySelector("#exe-delay").value = state.action.delay_ms ?? ""; + wrap.querySelector("#exe-wait").checked = !!state.action.wait; + } + return; + } + + if (t === "url") { + f.innerHTML = ` +
+ + +
+ `; + const inp = f.querySelector("#url-href"); + if (state.action) inp.value = state.action.href || ""; + inp.addEventListener("input", () => updateAction()); + return; + } + + if (t === "keys") { + f.innerHTML = ` +
+ + +
+
+ + +
Разделяйте плюсом. Пример: ctrl+alt+delete
+
+ + `; + const mode = f.querySelector("#keys-mode"); + const seqField = f.querySelector("#keys-seq-field"); + const textField = f.querySelector("#keys-text-field"); + const seq = f.querySelector("#keys-sequence"); + const text = f.querySelector("#keys-text"); + if (state.action && state.action.text) { + mode.value = "text"; + text.value = state.action.text; + seqField.hidden = true; + textField.hidden = false; + } else if (state.action && state.action.sequence) { + seq.value = state.action.sequence; + } + const sync = () => { + if (mode.value === "sequence") { + seqField.hidden = false; + textField.hidden = true; + } else { + seqField.hidden = true; + textField.hidden = false; + } + updateAction(); + }; + mode.addEventListener("change", sync); + seq.addEventListener("input", () => updateAction()); + text.addEventListener("input", () => updateAction()); + return; + } + + if (t === "play_sound") { + f.innerHTML = ` +
+
+ + +
+ +
+ `; + const sel = f.querySelector("#snd-name"); + callApi("list_sound_names").then((list) => { + if (Array.isArray(list)) { + for (const name of list) { + const o = document.createElement("option"); + o.value = name; + o.textContent = name; + sel.appendChild(o); + } + if (state.action && state.action.name) sel.value = state.action.name; + updateAction(); + } + }); + sel.addEventListener("change", () => updateAction()); + f.querySelector("#snd-preview").addEventListener("click", () => { + callApi("preview_sound", sel.value); + }); + return; + } + + if (t === "system") { + f.innerHTML = ` +
+ + +
+ `; + const sel = f.querySelector("#sys-op"); + if (state.action && state.action.op) sel.value = state.action.op; + sel.addEventListener("change", () => updateAction()); + return; + } + + if (t === "shell") { + f.innerHTML = ` +
+ + +
+ + `; + const cmd = f.querySelector("#shell-cmd"); + const wait = f.querySelector("#shell-wait"); + if (state.action) { + cmd.value = state.action.cmd || ""; + wait.checked = !!state.action.wait; + } + cmd.addEventListener("input", () => updateAction()); + wait.addEventListener("change", () => updateAction()); + return; + } + + if (t === "sleep") { + f.innerHTML = ` +
+ + +
+
Обычно используется внутри последовательности.
+ `; + const inp = f.querySelector("#sleep-ms"); + if (state.action && state.action.ms != null) inp.value = state.action.ms; + inp.addEventListener("input", () => updateAction()); + return; + } + + if (t === "multi") { + renderMultiForm(); + return; + } + } + + function renderMultiForm() { + const f = els.paramsForm; + f.innerHTML = ` +

Шаги выполняются по порядку.

+
+ + `; + const list = f.querySelector("#steps-list"); + if (!state.action || state.action.type !== "multi") { + state.action = { type: "multi", steps: [] }; + } + const renderList = () => { + list.innerHTML = ""; + state.action.steps.forEach((step, idx) => { + const card = document.createElement("div"); + card.className = "step-card"; + card.innerHTML = ` +
+ Шаг ${idx + 1} +
+ + + +
+
+ `; + const body = document.createElement("div"); + renderInlineEditor(body, step, (next) => { + state.action.steps[idx] = next; + updateAction(true); + }); + card.appendChild(body); + card.querySelector("[data-up]").addEventListener("click", () => { + if (idx > 0) { + const tmp = state.action.steps[idx - 1]; + state.action.steps[idx - 1] = state.action.steps[idx]; + state.action.steps[idx] = tmp; + renderList(); + updateAction(true); + } + }); + card.querySelector("[data-down]").addEventListener("click", () => { + if (idx < state.action.steps.length - 1) { + const tmp = state.action.steps[idx + 1]; + state.action.steps[idx + 1] = state.action.steps[idx]; + state.action.steps[idx] = tmp; + renderList(); + updateAction(true); + } + }); + card.querySelector("[data-rm]").addEventListener("click", () => { + state.action.steps.splice(idx, 1); + renderList(); + updateAction(true); + }); + list.appendChild(card); + }); + }; + renderList(); + f.querySelector("#add-step").addEventListener("click", () => { + state.action.steps.push({ type: "play_sound", name: "ok" }); + renderList(); + updateAction(true); + }); + updateAction(true); + } + + function renderInlineEditor(host, step, onChange) { + const typeSel = document.createElement("select"); + const types = [ + ["play_sound", "Звук"], + ["exe", ".exe"], + ["url", "URL"], + ["keys", "Клавиши"], + ["shell", "Shell"], + ["system", "Система"], + ["sleep", "Пауза"], + ]; + for (const [v, label] of types) { + const o = document.createElement("option"); + o.value = v; + o.textContent = label; + if (v === step.type) o.selected = true; + typeSel.appendChild(o); + } + host.appendChild(wrapField("Тип", typeSel)); + + const body = document.createElement("div"); + host.appendChild(body); + + const renderBody = () => { + body.innerHTML = ""; + const t = typeSel.value; + if (t === "play_sound") { + const sel = document.createElement("select"); + callApi("list_sound_names").then((list) => { + if (Array.isArray(list)) { + for (const name of list) { + const o = document.createElement("option"); + o.value = name; + o.textContent = name; + if (step.name === name) o.selected = true; + sel.appendChild(o); + } + } + }); + sel.addEventListener("change", () => onChange({ type: "play_sound", name: sel.value })); + body.appendChild(wrapField("Звук", sel)); + } else if (t === "exe") { + const sel = document.createElement("select"); + callApi("list_exes").then((list) => { + if (Array.isArray(list)) { + for (const name of list) { + const o = document.createElement("option"); + o.value = `${name}.exe`; + o.textContent = name; + if (step.file === `${name}.exe`) o.selected = true; + sel.appendChild(o); + } + } + }); + const wait = document.createElement("input"); + wait.type = "checkbox"; + wait.checked = !!step.wait; + const waitLabel = document.createElement("label"); + waitLabel.className = "checkbox"; + waitLabel.appendChild(wait); + waitLabel.appendChild(document.createTextNode("ждать")); + const delay = document.createElement("input"); + delay.type = "number"; + delay.value = step.delay_ms ?? ""; + delay.placeholder = "delay_ms"; + const apply = () => { + const next = { type: "exe", file: sel.value }; + if (wait.checked) next.wait = true; + if (delay.value) next.delay_ms = parseInt(delay.value, 10); + onChange(next); + }; + sel.addEventListener("change", apply); + wait.addEventListener("change", apply); + delay.addEventListener("input", apply); + body.appendChild(wrapField("Файл", sel)); + body.appendChild(wrapField("Задержка (мс)", delay)); + body.appendChild(waitLabel); + } else if (t === "url") { + const inp = document.createElement("input"); + inp.type = "text"; + inp.value = step.href || ""; + inp.placeholder = "https://..."; + inp.addEventListener("input", () => onChange({ type: "url", href: inp.value })); + body.appendChild(wrapField("URL", inp)); + } else if (t === "keys") { + const inp = document.createElement("input"); + inp.type = "text"; + inp.value = step.sequence || ""; + inp.placeholder = "ctrl+shift+m"; + inp.addEventListener("input", () => onChange({ type: "keys", sequence: inp.value })); + body.appendChild(wrapField("Сочетание", inp)); + } else if (t === "shell") { + const inp = document.createElement("input"); + inp.type = "text"; + inp.value = step.cmd || ""; + const wait = document.createElement("input"); + wait.type = "checkbox"; + wait.checked = !!step.wait; + const waitLabel = document.createElement("label"); + waitLabel.className = "checkbox"; + waitLabel.appendChild(wait); + waitLabel.appendChild(document.createTextNode("ждать")); + const apply = () => { + const next = { type: "shell", cmd: inp.value }; + if (wait.checked) next.wait = true; + onChange(next); + }; + inp.addEventListener("input", apply); + wait.addEventListener("change", apply); + body.appendChild(wrapField("Команда", inp)); + body.appendChild(waitLabel); + } else if (t === "system") { + const sel = document.createElement("select"); + for (const op of ["volume_mute", "volume_unmute", "exit"]) { + const o = document.createElement("option"); + o.value = op; + o.textContent = op; + if (step.op === op) o.selected = true; + sel.appendChild(o); + } + sel.addEventListener("change", () => onChange({ type: "system", op: sel.value })); + body.appendChild(wrapField("Операция", sel)); + } else if (t === "sleep") { + const inp = document.createElement("input"); + inp.type = "number"; + inp.value = step.ms ?? 500; + inp.addEventListener("input", () => onChange({ type: "sleep", ms: parseInt(inp.value, 10) || 0 })); + body.appendChild(wrapField("Миллисекунды", inp)); + } + }; + typeSel.addEventListener("change", () => { + const t = typeSel.value; + const fresh = ({ + play_sound: { type: "play_sound", name: "ok" }, + exe: { type: "exe", file: "" }, + url: { type: "url", href: "" }, + keys: { type: "keys", sequence: "" }, + shell: { type: "shell", cmd: "" }, + system: { type: "system", op: "volume_mute" }, + sleep: { type: "sleep", ms: 500 }, + })[t]; + onChange(fresh); + step = fresh; + renderBody(); + }); + renderBody(); + } + + function wrapField(label, control) { + const w = document.createElement("div"); + w.className = "field"; + const l = document.createElement("label"); + l.textContent = label; + w.appendChild(l); + w.appendChild(control); + return w; + } + + function updateAction(skipRender) { + const t = state.actionType; + if (!t) return; + if (t === "exe") { + const file = document.getElementById("exe-file"); + const delay = document.getElementById("exe-delay"); + const wait = document.getElementById("exe-wait"); + const a = { type: "exe", file: file ? file.value : "" }; + if (wait && wait.checked) a.wait = true; + if (delay && delay.value) a.delay_ms = parseInt(delay.value, 10); + state.action = a; + } else if (t === "url") { + state.action = { type: "url", href: (document.getElementById("url-href")||{}).value || "" }; + } else if (t === "keys") { + const mode = (document.getElementById("keys-mode")||{}).value; + if (mode === "text") { + state.action = { type: "keys", text: (document.getElementById("keys-text")||{}).value || "" }; + } else { + state.action = { type: "keys", sequence: (document.getElementById("keys-sequence")||{}).value || "" }; + } + } else if (t === "play_sound") { + state.action = { type: "play_sound", name: (document.getElementById("snd-name")||{}).value || "ok" }; + } else if (t === "system") { + state.action = { type: "system", op: (document.getElementById("sys-op")||{}).value || "volume_mute" }; + } else if (t === "shell") { + const a = { type: "shell", cmd: (document.getElementById("shell-cmd")||{}).value || "" }; + if ((document.getElementById("shell-wait")||{}).checked) a.wait = true; + state.action = a; + } else if (t === "sleep") { + state.action = { type: "sleep", ms: parseInt((document.getElementById("sleep-ms")||{}).value || "0", 10) }; + } + } + + function buildDraft() { + return { + cmd_id: state.cmdId, + phrases: state.phrases.map((p) => p.trim()).filter(Boolean), + action: state.action, + confirm_sound: state.confirmSound || undefined, + }; + } + + function renderPreview() { + const draft = buildDraft(); + if (!draft.cmd_id || !draft.phrases.length || !draft.action) { + els.yamlPreview.textContent = "(черновик неполный)"; + return; + } + const body = { phrases: draft.phrases, action: draft.action }; + if (draft.confirm_sound) body.confirm_sound = draft.confirm_sound; + const obj = {}; + obj[draft.cmd_id] = body; + try { + els.yamlPreview.textContent = jsyaml.dump(obj, { lineWidth: 4096, noRefs: true }); + } catch (ex) { + els.yamlPreview.textContent = "Ошибка YAML: " + ex.message; + } + } + + async function refreshExistingIds() { + const r = await callApi("list_existing_commands"); + if (Array.isArray(r)) { + state.existingIds = r; + checkCmdIdCollision(); + } + } + + function bind() { + els.phrase0.addEventListener("input", () => { + state.phrases[0] = els.phrase0.value; + syncCmdId(); + }); + els.addPhrase.addEventListener("click", () => { + state.phrases.push(""); + refreshExtraPhrases(); + }); + els.cmdId.addEventListener("input", () => { + state.cmdIdAuto = false; + state.cmdId = els.cmdId.value.trim(); + checkCmdIdCollision(); + }); + + els.actionGrid.addEventListener("click", (ev) => { + const card = ev.target.closest(".card"); + if (!card) return; + const t = card.dataset.action; + els.actionGrid.querySelectorAll(".card").forEach((c) => c.classList.toggle("selected", c === card)); + if (state.actionType !== t) { + state.actionType = t; + state.action = null; + } + }); + + els.toStep3.addEventListener("click", () => { + if (!state.actionType) { + showToast("Выберите тип действия", true); + return; + } + renderActionForm(); + setStep(3); + }); + + document.querySelectorAll("[data-go]").forEach((b) => { + if (b.id === "to-step-3") return; + b.addEventListener("click", () => setStep(parseInt(b.dataset.go, 10))); + }); + + els.steps.forEach((s) => { + s.addEventListener("click", () => { + const target = parseInt(s.dataset.step, 10); + if (target === 3) renderActionForm(); + setStep(target); + }); + }); + + els.tabs.forEach((t) => { + t.addEventListener("click", () => { + els.tabs.forEach((x) => x.classList.toggle("active", x === t)); + els.tabContents.forEach((c) => { + c.hidden = c.dataset.tabContent !== t.dataset.tab; + }); + }); + }); + + els.llmGenerate.addEventListener("click", async () => { + els.llmStatus.className = "row-hint"; + els.llmStatus.textContent = "Запрос к модели..."; + els.llmSuggestions.innerHTML = ""; + const r = await callApi("llm_suggest", els.llmPrompt.value || ""); + if (!r || !r.ok) { + els.llmStatus.className = "row-hint error"; + els.llmStatus.textContent = (r && r.error) || "Не удалось получить ответ"; + return; + } + els.llmStatus.className = "row-hint ok"; + els.llmStatus.textContent = "Готово. Примите блок или отредактируйте вручную."; + renderLlmSuggestion(r.suggestion); + }); + + els.saveBtn.addEventListener("click", async () => { + const draft = buildDraft(); + const v = await callApi("validate_draft", JSON.stringify(draft)); + if (!v.ok) { + els.saveStatus.className = "row-hint error"; + els.saveStatus.textContent = v.error; + return; + } + const overwrite = state.existingIds.includes(draft.cmd_id); + if (overwrite) { + if (!confirm(`Команда «${draft.cmd_id}» уже существует. Перезаписать?`)) return; + draft.overwrite = true; + } + const r = await callApi("save_command", JSON.stringify(draft)); + if (!r.ok) { + els.saveStatus.className = "row-hint error"; + els.saveStatus.textContent = r.error; + return; + } + els.saveStatus.className = "row-hint ok"; + els.saveStatus.textContent = `Сохранено: ${r.id}`; + showToast(`Команда «${r.id}» сохранена`); + refreshExistingIds(); + }); + + els.cancelBtn.addEventListener("click", () => { + if (!confirm("Сбросить черновик?")) return; + resetDraft(); + }); + + document.addEventListener("keydown", (ev) => { + if (ev.key === "Escape") { + if (state.step > 1) setStep(state.step - 1); + return; + } + if (ev.key === "Enter" && !ev.shiftKey && !ev.ctrlKey && !ev.altKey) { + const tag = (ev.target && ev.target.tagName) || ""; + if (tag === "TEXTAREA") return; + if (tag === "BUTTON") return; + if (state.step < 4) { + if (state.step === 2 && state.actionType) renderActionForm(); + setStep(state.step + 1); + ev.preventDefault(); + } + } + }); + } + + function renderLlmSuggestion(suggestion) { + const host = els.llmSuggestions; + host.innerHTML = ""; + if (!suggestion) return; + const block = document.createElement("div"); + block.className = "suggested-block"; + const pre = document.createElement("pre"); + try { + pre.textContent = jsyaml.dump(suggestion, { lineWidth: 4096, noRefs: true }); + } catch (ex) { + pre.textContent = JSON.stringify(suggestion, null, 2); + } + block.appendChild(pre); + const accept = document.createElement("button"); + accept.type = "button"; + accept.className = "primary"; + accept.textContent = "Принять"; + accept.addEventListener("click", () => { + if (suggestion.phrases) state.phrases = suggestion.phrases.slice(); + if (suggestion.cmd_id) { + state.cmdId = suggestion.cmd_id; + state.cmdIdAuto = false; + els.cmdId.value = state.cmdId; + } else { + syncCmdId(); + } + els.phrase0.value = state.phrases[0] || ""; + refreshExtraPhrases(); + if (suggestion.action) { + state.actionType = suggestion.action.type; + state.action = suggestion.action; + els.actionGrid.querySelectorAll(".card").forEach((c) => { + c.classList.toggle("selected", c.dataset.action === state.actionType); + }); + els.tabs.forEach((x) => x.classList.toggle("active", x.dataset.tab === "manual")); + els.tabContents.forEach((c) => { c.hidden = c.dataset.tabContent !== "manual"; }); + renderActionForm(); + } + setStep(3); + }); + const discard = document.createElement("button"); + discard.type = "button"; + discard.className = "ghost"; + discard.textContent = "Отклонить"; + discard.addEventListener("click", () => { host.innerHTML = ""; }); + const actions = document.createElement("div"); + actions.className = "actions"; + actions.appendChild(discard); + actions.appendChild(accept); + block.appendChild(actions); + host.appendChild(block); + } + + function resetDraft() { + state.phrases = [""]; + state.cmdId = ""; + state.cmdIdAuto = true; + state.actionType = null; + state.action = null; + state.confirmSound = null; + els.phrase0.value = ""; + els.cmdId.value = ""; + els.cmdIdHint.textContent = ""; + refreshExtraPhrases(); + els.actionGrid.querySelectorAll(".card").forEach((c) => c.classList.remove("selected")); + els.paramsForm.innerHTML = ""; + els.yamlPreview.textContent = ""; + els.saveStatus.textContent = ""; + setStep(1); + } + + function init() { + bind(); + refreshExtraPhrases(); + setStep(1); + const tryReady = () => { + if (api()) { + refreshExistingIds(); + } else { + setTimeout(tryReady, 80); + } + }; + tryReady(); + } + + document.addEventListener("DOMContentLoaded", init); +})(); diff --git a/tools/command_builder/web/index.html b/tools/command_builder/web/index.html new file mode 100644 index 0000000..b1da05b --- /dev/null +++ b/tools/command_builder/web/index.html @@ -0,0 +1,106 @@ + + + + + J.A.R.V.I.S — конструктор команд + + + +
+
+

Конструктор команд

+
+ + + + +
+
+ +
+
+

Голосовые фразы

+

Минимум одна фраза. Идентификатор подставится автоматически из первой фразы.

+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ + + + + + +
+ + +
+ + + + + diff --git a/tools/command_builder/web/styles.css b/tools/command_builder/web/styles.css new file mode 100644 index 0000000..b1f6b57 --- /dev/null +++ b/tools/command_builder/web/styles.css @@ -0,0 +1,392 @@ +:root { + --bg: #0a1214; + --bg-elev: #0f1a1d; + --bg-elev-2: #142327; + --border: #1d3036; + --border-strong: #294349; + --text: #d6e4e6; + --text-dim: #8aa1a5; + --accent: #52fefe; + --accent-dim: #2cb8b8; + --danger: #ff6b6b; + --ok: #6bf2a4; + --radius: 6px; + --radius-lg: 8px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: "Inter", "Segoe UI", system-ui, sans-serif; + font-size: 14px; + line-height: 1.45; +} + +.app { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.topbar { + padding: 18px 24px; + border-bottom: 1px solid var(--border); + background: var(--bg-elev); +} + +.topbar h1 { + margin: 0 0 14px 0; + font-size: 17px; + font-weight: 600; + letter-spacing: 0.3px; + color: var(--accent); +} + +.steps { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.step { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: var(--radius); + background: var(--bg-elev-2); + border: 1px solid var(--border); + color: var(--text-dim); + font-size: 12px; + cursor: pointer; + font-family: inherit; +} + +.step span { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--border); + color: var(--text); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; +} + +.step.active { + border-color: var(--accent-dim); + color: var(--text); +} + +.step.active span { + background: var(--accent); + color: #062023; +} + +.main { + flex: 1; + padding: 22px 24px 32px; + max-width: 880px; + width: 100%; + margin: 0 auto; +} + +.panel h2 { + margin: 0 0 6px 0; + font-size: 15px; + font-weight: 600; +} + +.hint { + margin: 0 0 18px 0; + color: var(--text-dim); + font-size: 12.5px; +} + +.field { + margin-bottom: 14px; +} + +.field label { + display: block; + margin-bottom: 6px; + color: var(--text-dim); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +input[type="text"], +input[type="number"], +textarea, +select { + width: 100%; + padding: 9px 11px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: inherit; + font-size: 13.5px; + outline: none; +} + +input[type="text"]:focus, +input[type="number"]:focus, +textarea:focus, +select:focus { + border-color: var(--accent-dim); +} + +textarea { + resize: vertical; +} + +.row-hint { + margin-top: 6px; + font-size: 11.5px; + color: var(--text-dim); + min-height: 14px; +} + +.row-hint.error { + color: var(--danger); +} + +.row-hint.ok { + color: var(--ok); +} + +.actions { + margin-top: 20px; + display: flex; + gap: 10px; + justify-content: flex-end; + flex-wrap: wrap; +} + +button { + cursor: pointer; + border: none; + border-radius: var(--radius); + padding: 9px 16px; + font-family: inherit; + font-size: 13px; + font-weight: 500; + transition: background 80ms ease, border-color 80ms ease; +} + +.primary { + background: var(--accent); + color: #062023; +} + +.primary:hover { + background: #7affff; +} + +.primary:disabled { + background: var(--border); + color: var(--text-dim); + cursor: not-allowed; +} + +.ghost { + background: transparent; + border: 1px solid var(--border-strong); + color: var(--text); +} + +.ghost:hover { + border-color: var(--accent-dim); +} + +#extra-phrases .extra-row { + display: flex; + gap: 6px; + margin-bottom: 8px; +} + +#extra-phrases .extra-row input { + flex: 1; +} + +#extra-phrases .extra-row button { + background: transparent; + border: 1px solid var(--border-strong); + color: var(--text-dim); + padding: 6px 10px; + font-size: 12px; +} + +.tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--border); + margin-bottom: 18px; +} + +.tab { + background: transparent; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + padding: 8px 14px; + color: var(--text-dim); + font-size: 13px; +} + +.tab.active { + color: var(--text); + border-bottom-color: var(--accent); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 10px; +} + +.card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 14px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + color: var(--text); + text-align: left; + min-height: 70px; +} + +.card:hover { + border-color: var(--accent-dim); +} + +.card.selected { + border-color: var(--accent); + background: var(--bg-elev-2); +} + +.card-title { + font-weight: 600; + font-size: 13.5px; +} + +.card-sub { + font-size: 11.5px; + color: var(--text-dim); +} + +pre { + background: #06101294; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 14px; + overflow-x: auto; + font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace; + font-size: 12.5px; + color: #c4ecec; + white-space: pre-wrap; +} + +.toast { + position: fixed; + right: 22px; + bottom: 22px; + padding: 10px 16px; + background: var(--bg-elev-2); + border: 1px solid var(--accent-dim); + border-radius: var(--radius); + color: var(--accent); + font-size: 13px; + box-shadow: 0 4px 14px rgba(0,0,0,0.45); +} + +.toast.error { + border-color: var(--danger); + color: var(--danger); +} + +.params-row { + display: flex; + gap: 10px; + align-items: flex-end; + flex-wrap: wrap; +} + +.params-row .field { + flex: 1; + min-width: 200px; + margin-bottom: 0; +} + +.checkbox { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--text-dim); + font-size: 12.5px; + margin-top: 6px; + cursor: pointer; +} + +.checkbox input { + accent-color: var(--accent); +} + +.steps-list { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 14px; +} + +.step-card { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; +} + +.step-card .step-head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.step-card .step-head .step-label { + font-size: 12px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.step-card .step-head .icon-btn { + background: transparent; + border: 1px solid var(--border-strong); + color: var(--text-dim); + padding: 3px 8px; + font-size: 11px; +} + +.suggested-block { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; + margin-bottom: 10px; +} + +.suggested-block pre { + margin: 6px 0; + font-size: 12px; +} diff --git a/tools/command_builder/web/vendor/js-yaml.min.js b/tools/command_builder/web/vendor/js-yaml.min.js new file mode 100644 index 0000000..bdd8eef --- /dev/null +++ b/tools/command_builder/web/vendor/js-yaml.min.js @@ -0,0 +1,2 @@ +/*! js-yaml 4.1.0 https://github.com/nodeca/js-yaml @license MIT */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).jsyaml={})}(this,(function(e){"use strict";function t(e){return null==e}var n={isNothing:t,isObject:function(e){return"object"==typeof e&&null!==e},toArray:function(e){return Array.isArray(e)?e:t(e)?[]:[e]},repeat:function(e,t){var n,i="";for(n=0;nl&&(t=i-l+(o=" ... ").length),n-i>l&&(n=i+l-(a=" ...").length),{str:o+e.slice(t,n).replace(/\t/g,"→")+a,pos:i-t+o.length}}function l(e,t){return n.repeat(" ",t-e.length)+e}var c=function(e,t){if(t=Object.create(t||null),!e.buffer)return null;t.maxLength||(t.maxLength=79),"number"!=typeof t.indent&&(t.indent=1),"number"!=typeof t.linesBefore&&(t.linesBefore=3),"number"!=typeof t.linesAfter&&(t.linesAfter=2);for(var i,r=/\r?\n|\r|\0/g,o=[0],c=[],s=-1;i=r.exec(e.buffer);)c.push(i.index),o.push(i.index+i[0].length),e.position<=i.index&&s<0&&(s=o.length-2);s<0&&(s=o.length-1);var u,p,f="",d=Math.min(e.line+t.linesAfter,c.length).toString().length,h=t.maxLength-(t.indent+d+3);for(u=1;u<=t.linesBefore&&!(s-u<0);u++)p=a(e.buffer,o[s-u],c[s-u],e.position-(o[s]-o[s-u]),h),f=n.repeat(" ",t.indent)+l((e.line-u+1).toString(),d)+" | "+p.str+"\n"+f;for(p=a(e.buffer,o[s],c[s],e.position,h),f+=n.repeat(" ",t.indent)+l((e.line+1).toString(),d)+" | "+p.str+"\n",f+=n.repeat("-",t.indent+d+3+p.pos)+"^\n",u=1;u<=t.linesAfter&&!(s+u>=c.length);u++)p=a(e.buffer,o[s+u],c[s+u],e.position-(o[s]-o[s+u]),h),f+=n.repeat(" ",t.indent)+l((e.line+u+1).toString(),d)+" | "+p.str+"\n";return f.replace(/\n$/,"")},s=["kind","multi","resolve","construct","instanceOf","predicate","represent","representName","defaultStyle","styleAliases"],u=["scalar","sequence","mapping"];var p=function(e,t){if(t=t||{},Object.keys(t).forEach((function(t){if(-1===s.indexOf(t))throw new o('Unknown option "'+t+'" is met in definition of "'+e+'" YAML type.')})),this.options=t,this.tag=e,this.kind=t.kind||null,this.resolve=t.resolve||function(){return!0},this.construct=t.construct||function(e){return e},this.instanceOf=t.instanceOf||null,this.predicate=t.predicate||null,this.represent=t.represent||null,this.representName=t.representName||null,this.defaultStyle=t.defaultStyle||null,this.multi=t.multi||!1,this.styleAliases=function(e){var t={};return null!==e&&Object.keys(e).forEach((function(n){e[n].forEach((function(e){t[String(e)]=n}))})),t}(t.styleAliases||null),-1===u.indexOf(this.kind))throw new o('Unknown kind "'+this.kind+'" is specified for "'+e+'" YAML type.')};function f(e,t){var n=[];return e[t].forEach((function(e){var t=n.length;n.forEach((function(n,i){n.tag===e.tag&&n.kind===e.kind&&n.multi===e.multi&&(t=i)})),n[t]=e})),n}function d(e){return this.extend(e)}d.prototype.extend=function(e){var t=[],n=[];if(e instanceof p)n.push(e);else if(Array.isArray(e))n=n.concat(e);else{if(!e||!Array.isArray(e.implicit)&&!Array.isArray(e.explicit))throw new o("Schema.extend argument should be a Type, [ Type ], or a schema definition ({ implicit: [...], explicit: [...] })");e.implicit&&(t=t.concat(e.implicit)),e.explicit&&(n=n.concat(e.explicit))}t.forEach((function(e){if(!(e instanceof p))throw new o("Specified list of YAML types (or a single Type object) contains a non-Type object.");if(e.loadKind&&"scalar"!==e.loadKind)throw new o("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported.");if(e.multi)throw new o("There is a multi type in the implicit list of a schema. Multi tags can only be listed as explicit.")})),n.forEach((function(e){if(!(e instanceof p))throw new o("Specified list of YAML types (or a single Type object) contains a non-Type object.")}));var i=Object.create(d.prototype);return i.implicit=(this.implicit||[]).concat(t),i.explicit=(this.explicit||[]).concat(n),i.compiledImplicit=f(i,"implicit"),i.compiledExplicit=f(i,"explicit"),i.compiledTypeMap=function(){var e,t,n={scalar:{},sequence:{},mapping:{},fallback:{},multi:{scalar:[],sequence:[],mapping:[],fallback:[]}};function i(e){e.multi?(n.multi[e.kind].push(e),n.multi.fallback.push(e)):n[e.kind][e.tag]=n.fallback[e.tag]=e}for(e=0,t=arguments.length;e=0?"0b"+e.toString(2):"-0b"+e.toString(2).slice(1)},octal:function(e){return e>=0?"0o"+e.toString(8):"-0o"+e.toString(8).slice(1)},decimal:function(e){return e.toString(10)},hexadecimal:function(e){return e>=0?"0x"+e.toString(16).toUpperCase():"-0x"+e.toString(16).toUpperCase().slice(1)}},defaultStyle:"decimal",styleAliases:{binary:[2,"bin"],octal:[8,"oct"],decimal:[10,"dec"],hexadecimal:[16,"hex"]}}),x=new RegExp("^(?:[-+]?(?:[0-9][0-9_]*)(?:\\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$");var I=/^[-+]?[0-9]+e/;var S=new p("tag:yaml.org,2002:float",{kind:"scalar",resolve:function(e){return null!==e&&!(!x.test(e)||"_"===e[e.length-1])},construct:function(e){var t,n;return n="-"===(t=e.replace(/_/g,"").toLowerCase())[0]?-1:1,"+-".indexOf(t[0])>=0&&(t=t.slice(1)),".inf"===t?1===n?Number.POSITIVE_INFINITY:Number.NEGATIVE_INFINITY:".nan"===t?NaN:n*parseFloat(t,10)},predicate:function(e){return"[object Number]"===Object.prototype.toString.call(e)&&(e%1!=0||n.isNegativeZero(e))},represent:function(e,t){var i;if(isNaN(e))switch(t){case"lowercase":return".nan";case"uppercase":return".NAN";case"camelcase":return".NaN"}else if(Number.POSITIVE_INFINITY===e)switch(t){case"lowercase":return".inf";case"uppercase":return".INF";case"camelcase":return".Inf"}else if(Number.NEGATIVE_INFINITY===e)switch(t){case"lowercase":return"-.inf";case"uppercase":return"-.INF";case"camelcase":return"-.Inf"}else if(n.isNegativeZero(e))return"-0.0";return i=e.toString(10),I.test(i)?i.replace("e",".e"):i},defaultStyle:"lowercase"}),O=b.extend({implicit:[A,v,C,S]}),j=O,T=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"),N=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$");var F=new p("tag:yaml.org,2002:timestamp",{kind:"scalar",resolve:function(e){return null!==e&&(null!==T.exec(e)||null!==N.exec(e))},construct:function(e){var t,n,i,r,o,a,l,c,s=0,u=null;if(null===(t=T.exec(e))&&(t=N.exec(e)),null===t)throw new Error("Date resolve error");if(n=+t[1],i=+t[2]-1,r=+t[3],!t[4])return new Date(Date.UTC(n,i,r));if(o=+t[4],a=+t[5],l=+t[6],t[7]){for(s=t[7].slice(0,3);s.length<3;)s+="0";s=+s}return t[9]&&(u=6e4*(60*+t[10]+ +(t[11]||0)),"-"===t[9]&&(u=-u)),c=new Date(Date.UTC(n,i,r,o,a,l,s)),u&&c.setTime(c.getTime()-u),c},instanceOf:Date,represent:function(e){return e.toISOString()}});var E=new p("tag:yaml.org,2002:merge",{kind:"scalar",resolve:function(e){return"<<"===e||null===e}}),M="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\n\r";var L=new p("tag:yaml.org,2002:binary",{kind:"scalar",resolve:function(e){if(null===e)return!1;var t,n,i=0,r=e.length,o=M;for(n=0;n64)){if(t<0)return!1;i+=6}return i%8==0},construct:function(e){var t,n,i=e.replace(/[\r\n=]/g,""),r=i.length,o=M,a=0,l=[];for(t=0;t>16&255),l.push(a>>8&255),l.push(255&a)),a=a<<6|o.indexOf(i.charAt(t));return 0===(n=r%4*6)?(l.push(a>>16&255),l.push(a>>8&255),l.push(255&a)):18===n?(l.push(a>>10&255),l.push(a>>2&255)):12===n&&l.push(a>>4&255),new Uint8Array(l)},predicate:function(e){return"[object Uint8Array]"===Object.prototype.toString.call(e)},represent:function(e){var t,n,i="",r=0,o=e.length,a=M;for(t=0;t>18&63],i+=a[r>>12&63],i+=a[r>>6&63],i+=a[63&r]),r=(r<<8)+e[t];return 0===(n=o%3)?(i+=a[r>>18&63],i+=a[r>>12&63],i+=a[r>>6&63],i+=a[63&r]):2===n?(i+=a[r>>10&63],i+=a[r>>4&63],i+=a[r<<2&63],i+=a[64]):1===n&&(i+=a[r>>2&63],i+=a[r<<4&63],i+=a[64],i+=a[64]),i}}),_=Object.prototype.hasOwnProperty,D=Object.prototype.toString;var U=new p("tag:yaml.org,2002:omap",{kind:"sequence",resolve:function(e){if(null===e)return!0;var t,n,i,r,o,a=[],l=e;for(t=0,n=l.length;t>10),56320+(e-65536&1023))}for(var ie=new Array(256),re=new Array(256),oe=0;oe<256;oe++)ie[oe]=te(oe)?1:0,re[oe]=te(oe);function ae(e,t){this.input=e,this.filename=t.filename||null,this.schema=t.schema||K,this.onWarning=t.onWarning||null,this.legacy=t.legacy||!1,this.json=t.json||!1,this.listener=t.listener||null,this.implicitTypes=this.schema.compiledImplicit,this.typeMap=this.schema.compiledTypeMap,this.length=e.length,this.position=0,this.line=0,this.lineStart=0,this.lineIndent=0,this.firstTabInLine=-1,this.documents=[]}function le(e,t){var n={name:e.filename,buffer:e.input.slice(0,-1),position:e.position,line:e.line,column:e.position-e.lineStart};return n.snippet=c(n),new o(t,n)}function ce(e,t){throw le(e,t)}function se(e,t){e.onWarning&&e.onWarning.call(null,le(e,t))}var ue={YAML:function(e,t,n){var i,r,o;null!==e.version&&ce(e,"duplication of %YAML directive"),1!==n.length&&ce(e,"YAML directive accepts exactly one argument"),null===(i=/^([0-9]+)\.([0-9]+)$/.exec(n[0]))&&ce(e,"ill-formed argument of the YAML directive"),r=parseInt(i[1],10),o=parseInt(i[2],10),1!==r&&ce(e,"unacceptable YAML version of the document"),e.version=n[0],e.checkLineBreaks=o<2,1!==o&&2!==o&&se(e,"unsupported YAML version of the document")},TAG:function(e,t,n){var i,r;2!==n.length&&ce(e,"TAG directive accepts exactly two arguments"),i=n[0],r=n[1],G.test(i)||ce(e,"ill-formed tag handle (first argument) of the TAG directive"),P.call(e.tagMap,i)&&ce(e,'there is a previously declared suffix for "'+i+'" tag handle'),V.test(r)||ce(e,"ill-formed tag prefix (second argument) of the TAG directive");try{r=decodeURIComponent(r)}catch(t){ce(e,"tag prefix is malformed: "+r)}e.tagMap[i]=r}};function pe(e,t,n,i){var r,o,a,l;if(t1&&(e.result+=n.repeat("\n",t-1))}function be(e,t){var n,i,r=e.tag,o=e.anchor,a=[],l=!1;if(-1!==e.firstTabInLine)return!1;for(null!==e.anchor&&(e.anchorMap[e.anchor]=a),i=e.input.charCodeAt(e.position);0!==i&&(-1!==e.firstTabInLine&&(e.position=e.firstTabInLine,ce(e,"tab characters must not be used in indentation")),45===i)&&z(e.input.charCodeAt(e.position+1));)if(l=!0,e.position++,ge(e,!0,-1)&&e.lineIndent<=t)a.push(null),i=e.input.charCodeAt(e.position);else if(n=e.line,we(e,t,3,!1,!0),a.push(e.result),ge(e,!0,-1),i=e.input.charCodeAt(e.position),(e.line===n||e.lineIndent>t)&&0!==i)ce(e,"bad indentation of a sequence entry");else if(e.lineIndentt?g=1:e.lineIndent===t?g=0:e.lineIndentt?g=1:e.lineIndent===t?g=0:e.lineIndentt)&&(y&&(a=e.line,l=e.lineStart,c=e.position),we(e,t,4,!0,r)&&(y?g=e.result:m=e.result),y||(de(e,f,d,h,g,m,a,l,c),h=g=m=null),ge(e,!0,-1),s=e.input.charCodeAt(e.position)),(e.line===o||e.lineIndent>t)&&0!==s)ce(e,"bad indentation of a mapping entry");else if(e.lineIndent=0))break;0===o?ce(e,"bad explicit indentation width of a block scalar; it cannot be less than one"):u?ce(e,"repeat of an indentation width identifier"):(p=t+o-1,u=!0)}if(Q(a)){do{a=e.input.charCodeAt(++e.position)}while(Q(a));if(35===a)do{a=e.input.charCodeAt(++e.position)}while(!J(a)&&0!==a)}for(;0!==a;){for(he(e),e.lineIndent=0,a=e.input.charCodeAt(e.position);(!u||e.lineIndentp&&(p=e.lineIndent),J(a))f++;else{if(e.lineIndent0){for(r=a,o=0;r>0;r--)(a=ee(l=e.input.charCodeAt(++e.position)))>=0?o=(o<<4)+a:ce(e,"expected hexadecimal character");e.result+=ne(o),e.position++}else ce(e,"unknown escape sequence");n=i=e.position}else J(l)?(pe(e,n,i,!0),ye(e,ge(e,!1,t)),n=i=e.position):e.position===e.lineStart&&me(e)?ce(e,"unexpected end of the document within a double quoted scalar"):(e.position++,i=e.position)}ce(e,"unexpected end of the stream within a double quoted scalar")}(e,d)?y=!0:!function(e){var t,n,i;if(42!==(i=e.input.charCodeAt(e.position)))return!1;for(i=e.input.charCodeAt(++e.position),t=e.position;0!==i&&!z(i)&&!X(i);)i=e.input.charCodeAt(++e.position);return e.position===t&&ce(e,"name of an alias node must contain at least one character"),n=e.input.slice(t,e.position),P.call(e.anchorMap,n)||ce(e,'unidentified alias "'+n+'"'),e.result=e.anchorMap[n],ge(e,!0,-1),!0}(e)?function(e,t,n){var i,r,o,a,l,c,s,u,p=e.kind,f=e.result;if(z(u=e.input.charCodeAt(e.position))||X(u)||35===u||38===u||42===u||33===u||124===u||62===u||39===u||34===u||37===u||64===u||96===u)return!1;if((63===u||45===u)&&(z(i=e.input.charCodeAt(e.position+1))||n&&X(i)))return!1;for(e.kind="scalar",e.result="",r=o=e.position,a=!1;0!==u;){if(58===u){if(z(i=e.input.charCodeAt(e.position+1))||n&&X(i))break}else if(35===u){if(z(e.input.charCodeAt(e.position-1)))break}else{if(e.position===e.lineStart&&me(e)||n&&X(u))break;if(J(u)){if(l=e.line,c=e.lineStart,s=e.lineIndent,ge(e,!1,-1),e.lineIndent>=t){a=!0,u=e.input.charCodeAt(e.position);continue}e.position=o,e.line=l,e.lineStart=c,e.lineIndent=s;break}}a&&(pe(e,r,o,!1),ye(e,e.line-l),r=o=e.position,a=!1),Q(u)||(o=e.position+1),u=e.input.charCodeAt(++e.position)}return pe(e,r,o,!1),!!e.result||(e.kind=p,e.result=f,!1)}(e,d,1===i)&&(y=!0,null===e.tag&&(e.tag="?")):(y=!0,null===e.tag&&null===e.anchor||ce(e,"alias node should not have any properties")),null!==e.anchor&&(e.anchorMap[e.anchor]=e.result)):0===g&&(y=c&&be(e,h))),null===e.tag)null!==e.anchor&&(e.anchorMap[e.anchor]=e.result);else if("?"===e.tag){for(null!==e.result&&"scalar"!==e.kind&&ce(e,'unacceptable node kind for ! tag; it should be "scalar", not "'+e.kind+'"'),s=0,u=e.implicitTypes.length;s"),null!==e.result&&f.kind!==e.kind&&ce(e,"unacceptable node kind for !<"+e.tag+'> tag; it should be "'+f.kind+'", not "'+e.kind+'"'),f.resolve(e.result,e.tag)?(e.result=f.construct(e.result,e.tag),null!==e.anchor&&(e.anchorMap[e.anchor]=e.result)):ce(e,"cannot resolve a node with !<"+e.tag+"> explicit tag")}return null!==e.listener&&e.listener("close",e),null!==e.tag||null!==e.anchor||y}function ke(e){var t,n,i,r,o=e.position,a=!1;for(e.version=null,e.checkLineBreaks=e.legacy,e.tagMap=Object.create(null),e.anchorMap=Object.create(null);0!==(r=e.input.charCodeAt(e.position))&&(ge(e,!0,-1),r=e.input.charCodeAt(e.position),!(e.lineIndent>0||37!==r));){for(a=!0,r=e.input.charCodeAt(++e.position),t=e.position;0!==r&&!z(r);)r=e.input.charCodeAt(++e.position);for(i=[],(n=e.input.slice(t,e.position)).length<1&&ce(e,"directive name must not be less than one character in length");0!==r;){for(;Q(r);)r=e.input.charCodeAt(++e.position);if(35===r){do{r=e.input.charCodeAt(++e.position)}while(0!==r&&!J(r));break}if(J(r))break;for(t=e.position;0!==r&&!z(r);)r=e.input.charCodeAt(++e.position);i.push(e.input.slice(t,e.position))}0!==r&&he(e),P.call(ue,n)?ue[n](e,n,i):se(e,'unknown document directive "'+n+'"')}ge(e,!0,-1),0===e.lineIndent&&45===e.input.charCodeAt(e.position)&&45===e.input.charCodeAt(e.position+1)&&45===e.input.charCodeAt(e.position+2)?(e.position+=3,ge(e,!0,-1)):a&&ce(e,"directives end mark is expected"),we(e,e.lineIndent-1,4,!1,!0),ge(e,!0,-1),e.checkLineBreaks&&H.test(e.input.slice(o,e.position))&&se(e,"non-ASCII line breaks are interpreted as content"),e.documents.push(e.result),e.position===e.lineStart&&me(e)?46===e.input.charCodeAt(e.position)&&(e.position+=3,ge(e,!0,-1)):e.position=55296&&i<=56319&&t+1=56320&&n<=57343?1024*(i-55296)+n-56320+65536:i}function Re(e){return/^\n* /.test(e)}function Be(e,t,n,i,r,o,a,l){var c,s,u=0,p=null,f=!1,d=!1,h=-1!==i,g=-1,m=De(s=Ye(e,0))&&s!==Oe&&!_e(s)&&45!==s&&63!==s&&58!==s&&44!==s&&91!==s&&93!==s&&123!==s&&125!==s&&35!==s&&38!==s&&42!==s&&33!==s&&124!==s&&61!==s&&62!==s&&39!==s&&34!==s&&37!==s&&64!==s&&96!==s&&function(e){return!_e(e)&&58!==e}(Ye(e,e.length-1));if(t||a)for(c=0;c=65536?c+=2:c++){if(!De(u=Ye(e,c)))return 5;m=m&&qe(u,p,l),p=u}else{for(c=0;c=65536?c+=2:c++){if(10===(u=Ye(e,c)))f=!0,h&&(d=d||c-g-1>i&&" "!==e[g+1],g=c);else if(!De(u))return 5;m=m&&qe(u,p,l),p=u}d=d||h&&c-g-1>i&&" "!==e[g+1]}return f||d?n>9&&Re(e)?5:a?2===o?5:2:d?4:3:!m||a||r(e)?2===o?5:2:1}function Ke(e,t,n,i,r){e.dump=function(){if(0===t.length)return 2===e.quotingType?'""':"''";if(!e.noCompatMode&&(-1!==Te.indexOf(t)||Ne.test(t)))return 2===e.quotingType?'"'+t+'"':"'"+t+"'";var a=e.indent*Math.max(1,n),l=-1===e.lineWidth?-1:Math.max(Math.min(e.lineWidth,40),e.lineWidth-a),c=i||e.flowLevel>-1&&n>=e.flowLevel;switch(Be(t,c,e.indent,l,(function(t){return function(e,t){var n,i;for(n=0,i=e.implicitTypes.length;n"+Pe(t,e.indent)+We(Me(function(e,t){var n,i,r=/(\n+)([^\n]*)/g,o=(l=e.indexOf("\n"),l=-1!==l?l:e.length,r.lastIndex=l,He(e.slice(0,l),t)),a="\n"===e[0]||" "===e[0];var l;for(;i=r.exec(e);){var c=i[1],s=i[2];n=" "===s[0],o+=c+(a||n||""===s?"":"\n")+He(s,t),a=n}return o}(t,l),a));case 5:return'"'+function(e){for(var t,n="",i=0,r=0;r=65536?r+=2:r++)i=Ye(e,r),!(t=je[i])&&De(i)?(n+=e[r],i>=65536&&(n+=e[r+1])):n+=t||Fe(i);return n}(t)+'"';default:throw new o("impossible error: invalid scalar style")}}()}function Pe(e,t){var n=Re(e)?String(t):"",i="\n"===e[e.length-1];return n+(i&&("\n"===e[e.length-2]||"\n"===e)?"+":i?"":"-")+"\n"}function We(e){return"\n"===e[e.length-1]?e.slice(0,-1):e}function He(e,t){if(""===e||" "===e[0])return e;for(var n,i,r=/ [^ ]/g,o=0,a=0,l=0,c="";n=r.exec(e);)(l=n.index)-o>t&&(i=a>o?a:l,c+="\n"+e.slice(o,i),o=i+1),a=l;return c+="\n",e.length-o>t&&a>o?c+=e.slice(o,a)+"\n"+e.slice(a+1):c+=e.slice(o),c.slice(1)}function $e(e,t,n,i){var r,o,a,l="",c=e.tag;for(r=0,o=n.length;r tag resolver accepts not "'+s+'" style');i=c.represent[s](t,s)}e.dump=i}return!0}return!1}function Ve(e,t,n,i,r,a,l){e.tag=null,e.dump=n,Ge(e,n,!1)||Ge(e,n,!0);var c,s=Ie.call(e.dump),u=i;i&&(i=e.flowLevel<0||e.flowLevel>t);var p,f,d="[object Object]"===s||"[object Array]"===s;if(d&&(f=-1!==(p=e.duplicates.indexOf(n))),(null!==e.tag&&"?"!==e.tag||f||2!==e.indent&&t>0)&&(r=!1),f&&e.usedDuplicates[p])e.dump="*ref_"+p;else{if(d&&f&&!e.usedDuplicates[p]&&(e.usedDuplicates[p]=!0),"[object Object]"===s)i&&0!==Object.keys(e.dump).length?(!function(e,t,n,i){var r,a,l,c,s,u,p="",f=e.tag,d=Object.keys(n);if(!0===e.sortKeys)d.sort();else if("function"==typeof e.sortKeys)d.sort(e.sortKeys);else if(e.sortKeys)throw new o("sortKeys must be a boolean or a function");for(r=0,a=d.length;r1024)&&(e.dump&&10===e.dump.charCodeAt(0)?u+="?":u+="? "),u+=e.dump,s&&(u+=Le(e,t)),Ve(e,t+1,c,!0,s)&&(e.dump&&10===e.dump.charCodeAt(0)?u+=":":u+=": ",p+=u+=e.dump));e.tag=f,e.dump=p||"{}"}(e,t,e.dump,r),f&&(e.dump="&ref_"+p+e.dump)):(!function(e,t,n){var i,r,o,a,l,c="",s=e.tag,u=Object.keys(n);for(i=0,r=u.length;i1024&&(l+="? "),l+=e.dump+(e.condenseFlow?'"':"")+":"+(e.condenseFlow?"":" "),Ve(e,t,a,!1,!1)&&(c+=l+=e.dump));e.tag=s,e.dump="{"+c+"}"}(e,t,e.dump),f&&(e.dump="&ref_"+p+" "+e.dump));else if("[object Array]"===s)i&&0!==e.dump.length?(e.noArrayIndent&&!l&&t>0?$e(e,t-1,e.dump,r):$e(e,t,e.dump,r),f&&(e.dump="&ref_"+p+e.dump)):(!function(e,t,n){var i,r,o,a="",l=e.tag;for(i=0,r=n.length;i",e.dump=c+" "+e.dump)}return!0}function Ze(e,t){var n,i,r=[],o=[];for(Je(e,r,o),n=0,i=o.length;n YAML: + y = YAML() + y.preserve_quotes = True + y.indent(mapping=2, sequence=4, offset=2) + y.width = 4096 + return y + + +def load_yaml(path: Path = DEFAULT_YAML_PATH): + y = _make_yaml() + with open(path, "rt", encoding="utf-8") as f: + return y.load(f) or {} + + +def list_command_ids(path: Path = DEFAULT_YAML_PATH) -> list: + try: + data = load_yaml(path) + except FileNotFoundError: + return [] + return list(data.keys()) + + +def _dump_to_string(data) -> str: + y = _make_yaml() + buf = io.StringIO() + y.dump(data, buf) + return buf.getvalue() + + +def _atomic_write(path: Path, text: str) -> None: + tmp = path.with_suffix(path.suffix + ".tmp") + with open(tmp, "wt", encoding="utf-8", newline="\n") as f: + f.write(text) + os.replace(tmp, path) + + +def _backup(path: Path) -> Optional[Path]: + if not path.exists(): + return None + bak = path.with_suffix(path.suffix + ".bak") + shutil.copy2(path, bak) + return bak + + +def append_command(entry: CommandEntry, path: Path = DEFAULT_YAML_PATH) -> None: + try: + data = load_yaml(path) + except FileNotFoundError: + data = {} + if entry.cmd_id in data: + raise SchemaError(f"command id already exists: {entry.cmd_id}") + data[entry.cmd_id] = entry.to_dict() + _backup(path) + text = _dump_to_string(data) + _atomic_write(path, text) + + +def upsert_command(entry: CommandEntry, path: Path = DEFAULT_YAML_PATH) -> None: + try: + data = load_yaml(path) + except FileNotFoundError: + data = {} + data[entry.cmd_id] = entry.to_dict() + _backup(path) + text = _dump_to_string(data) + _atomic_write(path, text) + + +def validate_file(path: Path = DEFAULT_YAML_PATH) -> list: + data = load_yaml(path) + out = [] + for cmd_id, body in data.items(): + plain_body = body + out.append(command_from_dict(cmd_id, dict(plain_body))) + return out