Compare commits

..

13 commits
v0.3.0 ... main

Author SHA1 Message Date
Bossiara13
53678fbdfa Release v0.4.1 — browser choice for url-action 2026-04-27 20:06:58 +03:00
Bossiara13
b8fc7b4eb7 chore: bump VA_VER to 0.4.1 2026-04-27 20:06:52 +03:00
Bossiara13
d0eac79275 Merge feature/browser-choice-in-url into dev 2026-04-27 20:06:51 +03:00
Bossiara13
a4171ec6b1 feat(url): optional browser field (yandex/chrome/firefox/edge)
Adds optional 'browser' field to url-action; runtime resolves it via
config.BROWSER_PATHS (subprocess.Popen with the chosen exe). When
omitted, falls back to webbrowser.open (system default). Constructor
GUI gains a browser dropdown on URL action and on multi-step URL.
Existing open_browser/youtube/google updated to use yandex by default
since user prefers Yandex Browser.
2026-04-27 20:06:26 +03:00
Bossiara13
47927fcf41 Release v0.4.0 — semantic intent (ONNX MiniLM) + optional denoise 2026-04-23 11:09:54 +03:00
Bossiara13
d22a35c9c7 chore: bump VA_VER to 0.4.0 2026-04-23 11:09:48 +03:00
Bossiara13
59aece53da Merge feature/onnx-intent-and-denoise into dev
Replaces fuzzywuzzy intent matching with ONNX MiniLM-L6-v2 sentence
embeddings (cosine similarity, threshold 0.45). Now triggers on
semantically equivalent phrases not literally in commands.yaml.
Adds optional noisereduce preprocessing before Vosk (off by default,
DENOISE_ENABLED config flag). Inference latency: 2.5ms median CPU.
2026-04-23 11:09:46 +03:00
Bossiara13
8655b4fc69 docs: README — semantic intent + denoise 2026-04-23 10:55:07 +03:00
Bossiara13
cc3fa60c28 chore: pin onnxruntime / tokenizers / noisereduce in requirements 2026-04-23 10:54:38 +03:00
Bossiara13
13370da5b7 feat: optional noisereduce preprocessing before vosk 2026-04-23 10:54:20 +03:00
Bossiara13
6a710c55b8 feat: onnx intent classifier replacing fuzzy match 2026-04-23 10:53:18 +03:00
Bossiara13
9cb55f4730 chore: vendor MiniLM-L6-v2 onnx model from Qdrant 2026-04-23 10:51:53 +03:00
Bossiara13
31a2dd1a6c chore: enable git lfs for .onnx files 2026-04-23 10:51:29 +03:00
16 changed files with 61597 additions and 25 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
*.onnx filter=lfs diff=lfs merge=lfs -text

View file

@ -43,7 +43,9 @@ python main.py
- `MICROPHONE_INDEX = -1` — индекс микрофона (`-1` означает устройство по умолчанию). Если микрофонов несколько и берётся не тот, поменяйте число. - `MICROPHONE_INDEX = -1` — индекс микрофона (`-1` означает устройство по умолчанию). Если микрофонов несколько и берётся не тот, поменяйте число.
- `WAKE_WORDS = ('jarvis', 'джарвис')` — варианты транскрипции wake-word, которые принимает Vosk. Если ваш голос/акцент стабильно расшифровывается как, например, `жарвис` — добавьте этот вариант сюда (всё в нижнем регистре). - `WAKE_WORDS = ('jarvis', 'джарвис')` — варианты транскрипции wake-word, которые принимает Vosk. Если ваш голос/акцент стабильно расшифровывается как, например, `жарвис` — добавьте этот вариант сюда (всё в нижнем регистре).
- `GROQ_MODEL` — имя модели Groq (по умолчанию `llama-3.3-70b-versatile`). - `GROQ_MODEL` — имя модели Groq (по умолчанию `llama-3.3-70b-versatile`).
- `commands.yaml` — набор голосовых команд и их вариантов для fuzzy-матчинга. - `INTENT_SIMILARITY_THRESHOLD = 0.45` — минимальная косинусная близость для срабатывания команды (см. ниже).
- `DENOISE_ENABLED = False` — шумоподавление перед Vosk (см. ниже).
- `commands.yaml` — набор голосовых команд и их вариантов; матчится семантически по эмбеддингам.
- `custom-commands/` — скомпилированные AHK-скрипты под конкретный сетап автора оригинала (переключение мониторов, управление Яндекс.Музыкой и т.п.). На вашей машине большая часть из них работать не будет, но ассистент запустится в любом случае. - `custom-commands/` — скомпилированные AHK-скрипты под конкретный сетап автора оригинала (переключение мониторов, управление Яндекс.Музыкой и т.п.). На вашей машине большая часть из них работать не будет, но ассистент запустится в любом случае.
## Использование ## Использование
@ -63,6 +65,26 @@ python main.py
- `COMMAND_MIN_LISTEN_MS` — минимальное время записи, чтобы не закрыться мгновенно (1000 мс). - `COMMAND_MIN_LISTEN_MS` — минимальное время записи, чтобы не закрыться мгновенно (1000 мс).
- `COMMAND_MAX_LISTEN_MS` — жёсткий потолок (15000 мс). - `COMMAND_MAX_LISTEN_MS` — жёсткий потолок (15000 мс).
## Семантический матчинг команд
Раньше распознанная фраза сравнивалась с фразами из `commands.yaml` через `fuzzywuzzy.fuzz.ratio` — это срабатывало только на формальное совпадение букв. Теперь используется sentence-transformer `all-MiniLM-L6-v2` (ONNX, CPU) — фраза кодируется в 384-мерный вектор, считается косинусная близость с фразами всех команд, побеждает самая близкая.
Это означает, что фраза `«запусти браузер»` сматчится с командой `open_browser`, даже если в YAML записано только `«открой браузер»`у них близкий смысл.
- Модель лежит в `models/all-MiniLM-L6-v2/` (87 МБ, хранится через Git LFS — `git lfs pull` после клонирования).
- Порог `INTENT_SIMILARITY_THRESHOLD = 0.45` (косинус, диапазон [-1; 1], для близких смыслов обычно 0.7+). Ниже порога — «команда не распознана», сработает `not_found.wav` (или ветка `«скажи …»` для LLM).
- Все фразы из `commands.yaml` эмбеддятся один раз при старте; на каждой команде эмбеддится только распознанная пользователем фраза (~1-5 мс на CPU).
## Шумоподавление перед STT
Опциональный пре-процессинг аудио командного буфера через `noisereduce` (спектральное гейтирование). Помогает в шумных помещениях (кулер, кондиционер, фоновая музыка). К wake-word фазе не применяется — там важна отзывчивость.
В `config.py`:
- `DENOISE_ENABLED = False` — главный тумблер.
- `DENOISE_PROP = 0.85` — доля подавления (0..1).
- `DENOISE_STATIONARY = False``False` для меняющегося шума (рекомендуется), `True` для стабильного фона.
## Эффекты под AI-ассистента ## Эффекты под AI-ассистента
Silero звучит естественно, но иногда хочется лёгкой «AI-окраски» в духе J.A.R.V.I.S. — узкую полосу, еле заметную реверберацию, при желании небольшой сдвиг высоты. Это тонкий постпроцессинг, голос и sample rate не меняются. Silero звучит естественно, но иногда хочется лёгкой «AI-окраски» в духе J.A.R.V.I.S. — узкую полосу, еле заметную реверберацию, при желании небольшой сдвиг высоты. Это тонкий постпроцессинг, голос и sample rate не меняются.

View file

@ -2,23 +2,23 @@ open_browser:
phrases: phrases:
- открой браузер - открой браузер
- запусти браузер - запусти браузер
- открой гугл хром - открой яндекс
- гугл хром - яндекс браузер
action: {type: exe, file: "Run browser.exe"} action: {type: url, href: "about:blank", browser: "yandex"}
open_youtube: open_youtube:
phrases: phrases:
- открой ютуб - открой ютуб
- ютуб - ютуб
- запусти ютуб - запусти ютуб
action: {type: exe, file: "Run youtube.exe"} action: {type: url, href: "https://www.youtube.com", browser: "yandex"}
open_google: open_google:
phrases: phrases:
- открой гугл - открой гугл
- гугл - гугл
- запусти гугл - запусти гугл
action: {type: exe, file: "Run google.exe"} action: {type: url, href: "https://www.google.com", browser: "yandex"}
music: music:
phrases: phrases:

View file

@ -6,7 +6,7 @@ load_dotenv("dev.env")
# Конфигурация # Конфигурация
VA_NAME = 'Jarvis' VA_NAME = 'Jarvis'
VA_VER = "0.3.0" VA_VER = "0.4.1"
VA_ALIAS = ('джарвис',) VA_ALIAS = ('джарвис',)
VA_TBR = ('скажи', 'покажи', 'ответь', 'произнеси', 'расскажи', 'сколько', 'слушай') VA_TBR = ('скажи', 'покажи', 'ответь', 'произнеси', 'расскажи', 'сколько', 'слушай')
@ -19,8 +19,12 @@ WAKE_WORDS = ('jarvis', 'джарвис')
# -1 это стандартное записывающее устройство # -1 это стандартное записывающее устройство
MICROPHONE_INDEX = -1 MICROPHONE_INDEX = -1
# Путь к браузеру Google Chrome BROWSER_PATHS = {
CHROME_PATH = 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe %s' 'yandex': 'C:/Program Files/Yandex/YandexBrowser/Application/browser.exe',
'chrome': 'C:/Program Files/Google/Chrome/Application/chrome.exe',
'firefox': 'C:/Program Files/Mozilla Firefox/firefox.exe',
'edge': 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
}
# Токен Groq # Токен Groq
GROQ_TOKEN = os.getenv('GROQ_TOKEN') GROQ_TOKEN = os.getenv('GROQ_TOKEN')
@ -41,3 +45,16 @@ TTS_BANDPASS_HIGH_HZ = 7000
TTS_REVERB_WET = 0.20 TTS_REVERB_WET = 0.20
TTS_REVERB_DECAY_MS = 100 TTS_REVERB_DECAY_MS = 100
TTS_PITCH_SEMITONES = 0 TTS_PITCH_SEMITONES = 0
# Семантический матчинг команд через эмбеддинги MiniLM-L6-v2 (ONNX, CPU).
# Порог — косинусная близость [0..1]. На вход поступает уже отфильтрованная
# фраза (без алиасов и vа_tbr); 0.45 эмпирически отделяет валидные команды
# от шума, оставляя запас на разговорные перефразировки.
INTENT_SIMILARITY_THRESHOLD = 0.45
# Шумоподавление аудио перед Vosk на этапе захвата команды (после wake-word).
# Спектральное гейтирование через noisereduce; даёт небольшую латентность,
# поэтому выключено по умолчанию и применяется только к буферу команды.
DENOISE_ENABLED = False
DENOISE_PROP = 0.85
DENOISE_STATIONARY = False

79
intent.py Normal file
View file

@ -0,0 +1,79 @@
import os
import numpy as np
import onnxruntime as ort
from tokenizers import Tokenizer
import config
MODEL_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models", "all-MiniLM-L6-v2")
MAX_TOKENS = 128
def _mean_pool(last_hidden: np.ndarray, attention_mask: np.ndarray) -> np.ndarray:
mask = attention_mask.astype(np.float32)[..., None]
summed = (last_hidden * mask).sum(axis=1)
counts = np.clip(mask.sum(axis=1), a_min=1e-9, a_max=None)
return summed / counts
def _l2_normalize(x: np.ndarray) -> np.ndarray:
norm = np.linalg.norm(x, axis=-1, keepdims=True)
return x / np.clip(norm, a_min=1e-12, a_max=None)
class IntentClassifier:
def __init__(self, model_dir: str = MODEL_DIR):
tok_path = os.path.join(model_dir, "tokenizer.json")
onnx_path = os.path.join(model_dir, "model.onnx")
self._tokenizer = Tokenizer.from_file(tok_path)
self._tokenizer.enable_truncation(max_length=MAX_TOKENS)
self._tokenizer.enable_padding(pad_id=0, pad_token="[PAD]")
so = ort.SessionOptions()
so.intra_op_num_threads = max(1, (os.cpu_count() or 2) // 2)
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
self._session = ort.InferenceSession(onnx_path, sess_options=so, providers=["CPUExecutionProvider"])
self._input_names = {inp.name for inp in self._session.get_inputs()}
self._cache: dict[str, np.ndarray] = {}
def embed(self, texts: list[str]) -> np.ndarray:
if not texts:
return np.zeros((0, 384), dtype=np.float32)
encs = self._tokenizer.encode_batch(texts)
ids = np.array([e.ids for e in encs], dtype=np.int64)
mask = np.array([e.attention_mask for e in encs], dtype=np.int64)
feeds = {"input_ids": ids, "attention_mask": mask}
if "token_type_ids" in self._input_names:
feeds["token_type_ids"] = np.zeros_like(ids)
outputs = self._session.run(None, feeds)
last_hidden = outputs[0]
pooled = _mean_pool(last_hidden, mask)
return _l2_normalize(pooled).astype(np.float32)
def prime(self, candidates: dict[str, list[str]]) -> None:
self._cache.clear()
for cmd_id, phrases in candidates.items():
if not phrases:
continue
self._cache[cmd_id] = self.embed(list(phrases))
def match(self, utterance: str, candidates: dict[str, list[str]]) -> dict:
utterance = (utterance or "").strip()
if not utterance:
return {"cmd": "", "score": 0.0}
for cmd_id, phrases in candidates.items():
if cmd_id not in self._cache and phrases:
self._cache[cmd_id] = self.embed(list(phrases))
query = self.embed([utterance])[0]
best_cmd = ""
best_score = -1.0
for cmd_id, mat in self._cache.items():
sims = mat @ query
top = float(sims.max()) if sims.size else -1.0
if top > best_score:
best_score = top
best_cmd = cmd_id
threshold = getattr(config, "INTENT_SIMILARITY_THRESHOLD", 0.45)
if best_score < threshold:
return {"cmd": "", "score": max(0.0, best_score)}
return {"cmd": best_cmd, "score": max(0.0, best_score)}

51
main.py
View file

@ -10,6 +10,7 @@ import time
import webbrowser import webbrowser
from ctypes import POINTER, cast from ctypes import POINTER, cast
import numpy as np
import openai import openai
from openai import OpenAI from openai import OpenAI
import simpleaudio as sa import simpleaudio as sa
@ -27,6 +28,7 @@ from rich import print
import config import config
import tts import tts
from intent import IntentClassifier
# some consts # some consts
CDIR = os.getcwd() CDIR = os.getcwd()
@ -60,6 +62,28 @@ VAD_FRAME_MS = 30
VAD_FRAME_BYTES = int(samplerate * VAD_FRAME_MS / 1000) * 2 VAD_FRAME_BYTES = int(samplerate * VAD_FRAME_MS / 1000) * 2
vad = webrtcvad.Vad(config.VAD_AGGRESSIVENESS) vad = webrtcvad.Vad(config.VAD_AGGRESSIVENESS)
intent_classifier = IntentClassifier()
intent_classifier.prime({c: v['phrases'] for c, v in VA_CMD_LIST.items()})
if config.DENOISE_ENABLED:
import noisereduce as nr
else:
nr = None
def denoise_pcm(raw: bytes) -> bytes:
if not raw or nr is None:
return raw
samples = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
reduced = nr.reduce_noise(
y=samples,
sr=samplerate,
prop_decrease=config.DENOISE_PROP,
stationary=config.DENOISE_STATIONARY,
)
clipped = np.clip(reduced * 32768.0, -32768, 32767).astype(np.int16)
return clipped.tobytes()
def gpt_answer(): def gpt_answer():
global message_log global message_log
@ -140,9 +164,10 @@ def va_respond(voice: str):
print(cmd) print(cmd)
min_percent = int(round(config.INTENT_SIMILARITY_THRESHOLD * 100))
if len(cmd['cmd'].strip()) <= 0: if len(cmd['cmd'].strip()) <= 0:
return False return False
elif cmd['percent'] < 70 or cmd['cmd'] not in VA_CMD_LIST.keys(): elif cmd['percent'] < min_percent or cmd['cmd'] not in VA_CMD_LIST.keys():
# play("not_found") # play("not_found")
# tts.va_speak("Что?") # tts.va_speak("Что?")
if fuzz.ratio(voice.join(voice.split()[:1]).strip(), "скажи") > 75: if fuzz.ratio(voice.join(voice.split()[:1]).strip(), "скажи") > 75:
@ -179,15 +204,9 @@ def filter_cmd(raw_voice: str):
def recognize_cmd(cmd: str): def recognize_cmd(cmd: str):
rc = {'cmd': '', 'percent': 0} candidates = {c: v['phrases'] for c, v in VA_CMD_LIST.items()}
for c, v in VA_CMD_LIST.items(): res = intent_classifier.match(cmd, candidates)
for x in v['phrases']: return {'cmd': res['cmd'], 'percent': int(round(res['score'] * 100))}
vrt = fuzz.ratio(cmd, x)
if vrt > rc['percent']:
rc['cmd'] = c
rc['percent'] = vrt
return rc
def _set_mute(state: int): def _set_mute(state: int):
@ -233,6 +252,12 @@ def run_action(action):
else: else:
subprocess.Popen(action['cmd'], shell=True) subprocess.Popen(action['cmd'], shell=True)
elif t == 'url': elif t == 'url':
browser = action.get('browser')
if browser:
exe = config.BROWSER_PATHS.get(browser)
if exe and os.path.isfile(exe):
subprocess.Popen([exe, action['href']])
return
webbrowser.open(action['href']) webbrowser.open(action['href'])
elif t == 'keys': elif t == 'keys':
import pyautogui import pyautogui
@ -295,6 +320,7 @@ while True:
silence_ms = 0 silence_ms = 0
vad_buf = b"" vad_buf = b""
finalize = False finalize = False
cmd_buf = b"" if config.DENOISE_ENABLED else None
while True: while True:
elapsed_ms = (time.time() - listen_started) * 1000 elapsed_ms = (time.time() - listen_started) * 1000
@ -303,7 +329,10 @@ while True:
pcm = recorder.read() pcm = recorder.read()
sp = struct.pack("h" * len(pcm), *pcm) sp = struct.pack("h" * len(pcm), *pcm)
if cmd_buf is None:
kaldi_rec.AcceptWaveform(sp) kaldi_rec.AcceptWaveform(sp)
else:
cmd_buf += sp
vad_buf += sp vad_buf += sp
while len(vad_buf) >= VAD_FRAME_BYTES: while len(vad_buf) >= VAD_FRAME_BYTES:
@ -322,6 +351,8 @@ while True:
break break
if finalize or speech_ms > 0: if finalize or speech_ms > 0:
if cmd_buf is not None:
kaldi_rec.AcceptWaveform(denoise_pcm(cmd_buf))
text = json.loads(kaldi_rec.FinalResult()).get("text", "") text = json.loads(kaldi_rec.FinalResult()).get("text", "")
if text: if text:
va_respond(text) va_respond(text)

View file

@ -0,0 +1,35 @@
---
license: apache-2.0
pipeline_tag: sentence-similarity
---
ONNX port of [sentence-transformers/all-MiniLM-L6-v2](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2) for text classification and similarity searches.
### Usage
Here's an example of performing inference using the model with [FastEmbed](https://github.com/qdrant/fastembed).
```py
from fastembed import TextEmbedding
documents = [
"You should stay, study and sprint.",
"History can only prepare us to be surprised yet again.",
]
model = TextEmbedding(model_name="sentence-transformers/all-MiniLM-L6-v2")
embeddings = list(model.embed(documents))
# [
# array([
# 0.00611658, 0.00068912, -0.0203846, ..., -0.01751488, -0.01174267,
# 0.01463472
# ],
# dtype=float32),
# array([
# 0.00173448, -0.00329958, 0.01557874, ..., -0.01473586, 0.0281806,
# -0.00448205
# ],
# dtype=float32)
# ]
```

View file

@ -0,0 +1,25 @@
{
"_name_or_path": "sentence-transformers/all-MiniLM-L6-v2",
"architectures": [
"BertModel"
],
"attention_probs_dropout_prob": 0.1,
"classifier_dropout": null,
"gradient_checkpointing": false,
"hidden_act": "gelu",
"hidden_dropout_prob": 0.1,
"hidden_size": 384,
"initializer_range": 0.02,
"intermediate_size": 1536,
"layer_norm_eps": 1e-12,
"max_position_embeddings": 512,
"model_type": "bert",
"num_attention_heads": 12,
"num_hidden_layers": 6,
"pad_token_id": 0,
"position_embedding_type": "absolute",
"transformers_version": "4.36.2",
"type_vocab_size": 2,
"use_cache": true,
"vocab_size": 30522
}

BIN
models/all-MiniLM-L6-v2/model.onnx (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -0,0 +1,37 @@
{
"cls_token": {
"content": "[CLS]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
"mask_token": {
"content": "[MASK]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
"pad_token": {
"content": "[PAD]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
"sep_token": {
"content": "[SEP]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
"unk_token": {
"content": "[UNK]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
{
"added_tokens_decoder": {
"0": {
"content": "[PAD]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"100": {
"content": "[UNK]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"101": {
"content": "[CLS]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"102": {
"content": "[SEP]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"103": {
"content": "[MASK]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
}
},
"clean_up_tokenization_spaces": true,
"cls_token": "[CLS]",
"do_basic_tokenize": true,
"do_lower_case": true,
"mask_token": "[MASK]",
"max_length": 128,
"model_max_length": 512,
"never_split": null,
"pad_to_multiple_of": null,
"pad_token": "[PAD]",
"pad_token_type_id": 0,
"padding_side": "right",
"sep_token": "[SEP]",
"stride": 0,
"strip_accents": null,
"tokenize_chinese_chars": true,
"tokenizer_class": "BertTokenizer",
"truncation_side": "right",
"truncation_strategy": "longest_first",
"unk_token": "[UNK]"
}

File diff suppressed because it is too large Load diff

View file

@ -27,6 +27,13 @@ sounddevice
PyYAML PyYAML
python-dotenv python-dotenv
# Semantic intent classifier (MiniLM-L6-v2 ONNX)
onnxruntime>=1.17
tokenizers>=0.15
# Noise suppression before STT (optional, off by default)
noisereduce>=3.0
# GUI command constructor (tools/command_builder) # GUI command constructor (tools/command_builder)
ruamel.yaml ruamel.yaml
pywebview pywebview

View file

@ -65,13 +65,20 @@ class ShellAction:
return out return out
KNOWN_BROWSERS = ("yandex", "chrome", "firefox", "edge")
@dataclass @dataclass
class UrlAction: class UrlAction:
href: str href: str
browser: Optional[str] = None
type: str = "url" type: str = "url"
def to_dict(self) -> dict: def to_dict(self) -> dict:
return {"type": "url", "href": self.href} out: dict = {"type": "url", "href": self.href}
if self.browser:
out["browser"] = self.browser
return out
@dataclass @dataclass
@ -157,7 +164,10 @@ def action_from_dict(data: Any) -> Action:
href = data.get("href") href = data.get("href")
if not href or not isinstance(href, str): if not href or not isinstance(href, str):
raise SchemaError("url action requires non-empty 'href' string") raise SchemaError("url action requires non-empty 'href' string")
return UrlAction(href=href) browser = data.get("browser")
if browser is not None and browser not in KNOWN_BROWSERS:
raise SchemaError(f"url.browser must be one of {KNOWN_BROWSERS} or omitted")
return UrlAction(href=href, browser=browser)
if t == "keys": if t == "keys":
sequence = data.get("sequence") sequence = data.get("sequence")
text = data.get("text") text = data.get("text")

View file

@ -191,10 +191,25 @@
<label>URL</label> <label>URL</label>
<input type="text" id="url-href" placeholder="https://example.com"> <input type="text" id="url-href" placeholder="https://example.com">
</div> </div>
<div class="field">
<label>Браузер</label>
<select id="url-browser">
<option value="">по умолчанию (системный)</option>
<option value="yandex">Yandex Browser</option>
<option value="chrome">Google Chrome</option>
<option value="firefox">Mozilla Firefox</option>
<option value="edge">Microsoft Edge</option>
</select>
</div>
`; `;
const inp = f.querySelector("#url-href"); const inp = f.querySelector("#url-href");
if (state.action) inp.value = state.action.href || ""; const sel = f.querySelector("#url-browser");
if (state.action) {
inp.value = state.action.href || "";
sel.value = state.action.browser || "";
}
inp.addEventListener("input", () => updateAction()); inp.addEventListener("input", () => updateAction());
sel.addEventListener("change", () => updateAction());
return; return;
} }
@ -481,8 +496,23 @@
inp.type = "text"; inp.type = "text";
inp.value = step.href || ""; inp.value = step.href || "";
inp.placeholder = "https://..."; inp.placeholder = "https://...";
inp.addEventListener("input", () => onChange({ type: "url", href: inp.value })); const sel = document.createElement("select");
sel.innerHTML = `
<option value="">по умолчанию (системный)</option>
<option value="yandex">Yandex Browser</option>
<option value="chrome">Google Chrome</option>
<option value="firefox">Mozilla Firefox</option>
<option value="edge">Microsoft Edge</option>`;
sel.value = step.browser || "";
const apply = () => {
const next = { type: "url", href: inp.value };
if (sel.value) next.browser = sel.value;
onChange(next);
};
inp.addEventListener("input", apply);
sel.addEventListener("change", apply);
body.appendChild(wrapField("URL", inp)); body.appendChild(wrapField("URL", inp));
body.appendChild(wrapField("Браузер", sel));
} else if (t === "keys") { } else if (t === "keys") {
const inp = document.createElement("input"); const inp = document.createElement("input");
inp.type = "text"; inp.type = "text";
@ -569,7 +599,10 @@
if (delay && delay.value) a.delay_ms = parseInt(delay.value, 10); if (delay && delay.value) a.delay_ms = parseInt(delay.value, 10);
state.action = a; state.action = a;
} else if (t === "url") { } else if (t === "url") {
state.action = { type: "url", href: (document.getElementById("url-href")||{}).value || "" }; const a = { type: "url", href: (document.getElementById("url-href")||{}).value || "" };
const b = (document.getElementById("url-browser")||{}).value;
if (b) a.browser = b;
state.action = a;
} else if (t === "keys") { } else if (t === "keys") {
const mode = (document.getElementById("keys-mode")||{}).value; const mode = (document.getElementById("keys-mode")||{}).value;
if (mode === "text") { if (mode === "text") {