From 80a685230cc799ba3d6e8a095a6c972168cb506a Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Fri, 15 May 2026 01:48:34 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20parity=20with=20rust=20fork=20=E2=80=94?= =?UTF-8?q?=20LLM=20auto-fallback=20+=206=20new=20action=20types=20+=2020+?= =?UTF-8?q?=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the python fork in line with the new feature/pc-tools work that landed in rust. Same UX, same trigger phrases, same env vars. config.py: + LLM_AUTO_FALLBACK = True + LLM_AUTO_FALLBACK_MIN_CHARS = 4 Mirrors the rust config knobs. The old "скажи X" trigger still works (VA_TBR strips it before intent matching, so what remains hits this fallback anyway) — but now ANY unrecognized query goes to Groq, not just "скажи" ones. main.py: va_respond: replaced the fuzz-on-"скажи" gate with a unified auto- fallback that fires whenever no command matches, GROQ_TOKEN is set, and the utterance is at least LLM_AUTO_FALLBACK_MIN_CHARS long. + _speak(text): recorder.stop() → tts.va_speak → start, used by the new action handlers so the mic doesn't pick up the assistant. + _set_clipboard(text): subprocess to PowerShell Set-Clipboard, no new python deps. + _strip_trigger(voice, triggers): shared helper for codegen/file_search where we need the text AFTER the trigger phrase. New action handlers (registered in run_action dispatch): + groq_codegen — extracts query, calls Groq with strict "code only" system prompt at temp 0.2, strips ```lang ... ``` fences, puts the result in the clipboard, speaks "Код в буфере, сэр. Строк: N". + ocr_screen — PowerShell System.Drawing screenshot to a temp PNG, shells to tesseract.exe (-l rus+eng), speaks a 120-char preview, clipboard. Looks for tesseract on PATH first then standard install dirs; gives a clear "Tesseract не установлен" if missing. + sysinfo — CIM-based battery/cpu/ram/disk via PS one-liners, time via python datetime (gets us localized weekday for free). Topics: time / battery / cpu / ram / disk / all. + file_search — strips trigger, recursive Get-ChildItem in Desktop/ Documents/Downloads (depth 3, top 5), opens explorer /select on the first hit, speaks count and basename. + audio_devices_panel — opens mmsys.cpl. Proper COM-based device switching (like the rust IPolicyConfig version) is deferred — TODO in backlog. commands.yaml: Total 33 commands (was 19). New: + open_notepad / open_calculator / open_explorer / open_terminal / open_settings / open_task_manager / lock_screen / screenshot — all via shell action (no AHK exes needed). + volume_up / volume_down — five chained keys=volumeup/volumedown presses each, like rust's _volume_helper.ps1 with Times=5. + media_stop — keys=stop. + sysinfo_time / sysinfo_battery / sysinfo_cpu / sysinfo_ram / sysinfo_disk / sysinfo_all — sysinfo action. + file_search, codegen, read_screen, switch_audio_device. Replaced (were AHK exes that Priler ships and we don't have): ~ music → keys playpause ~ music_off → keys playpause (toggle) ~ music_next → keys nexttrack ~ music_prev → keys prevtrack Removed (Priler hardware/setup specific, can't sensibly port): - gaming_mode_on / gaming_mode_off (display config switch) - music_save (depends on the specific player AHK was driving) - switch_to_headphones / switch_to_dynamics (replaced with the generic switch_audio_device that opens mmsys.cpl) Kept as-is (worked already, no AHK dependency): open_browser / open_youtube / open_google (url action) sound_off / sound_on (pycaw via system.volume_mute/unmute) thanks / stupid / off Smoke-tested: yaml.safe_load → 33 commands, ast.parse main.py → OK. Portability: USERPROFILE for screenshot path, $env:SystemDrive for disk, PATH lookup for tesseract before falling back to standard install dirs. No hardcoded user-specific paths. --- commands.yaml | 370 ++++++++++++++++++++++++++++++-------------------- config.py | 6 + main.py | 238 +++++++++++++++++++++++++++++++- 3 files changed, 463 insertions(+), 151 deletions(-) diff --git a/commands.yaml b/commands.yaml index a80d0e3..e7c91fc 100644 --- a/commands.yaml +++ b/commands.yaml @@ -20,82 +20,99 @@ open_google: - запусти гугл action: {type: url, href: "https://www.google.com", browser: "yandex"} -music: +open_notepad: phrases: - - давай музыку - - музыка - - яндекс музыка - - включи музыку - - открой музыку - - послушаем музыку - - хочу музыку - - врубай музло - - включи что-то послушать - - давай что-то послушаем - action: {type: exe, file: "Run music.exe"} + - открой блокнот + - запусти блокнот + - открой нотепад + action: {type: shell, cmd: 'start "" notepad.exe'} -music_off: +open_calculator: phrases: - - выключи музыку - - отруби музыку - - убери музыку - - отключи музыку - - закрой музыку - - останови музыку - - хватит музыки - action: {type: exe, file: "Stop music.exe", delay_ms: 200} + - открой калькулятор + - запусти калькулятор + - нужен калькулятор + action: {type: shell, cmd: 'start "" calc.exe'} -music_save: +open_explorer: phrases: - - сохрани трек - - мне нравится трек - - сохрани песню - - мне нравится песня - - добавь трек в избранное - - добавь песню в избранное - - запомни мелодию - - запомни трек - - запомни песню - - добавь мелодию в избранное - - сохрани то что сейчас играет - - добавь то что сейчас играет в избранное - - лайкни трек - - лайкни мелодию - - лайкни песню - action: {type: exe, file: "Save music.exe", delay_ms: 200} + - открой проводник + - открой папку + - запусти проводник + - открой файлы + action: {type: shell, cmd: 'start "" explorer.exe'} -music_next: +open_terminal: phrases: - - следующий трек - - следующая мелодия - - следующая песня - - переключи трек - - переключи музыку - - переключи мелодию - - переключи песню - - следующая музыка - action: {type: exe, file: "Next music.exe", delay_ms: 200} + - открой терминал + - запусти терминал + - открой консоль + - открой повершелл + action: {type: shell, cmd: 'start "" wt.exe || start "" powershell.exe'} -music_prev: +open_settings: phrases: - - предыдущая музыка - - предыдущий трек - - предыдущая мелодия - - предыдущая песня - - верни трек - - верни мелодию - - верни песню - - верни прошлый трек - - верни прошлую мелодию - - верни прошлую песню - - верни прошлую музыку - - верни то что играло - - давай предыдущий трек - - переключи музыку обратно - - переключи на прошлый трек - - переключи на прошлую мелодию - - переключи на прошлую песню - action: {type: exe, file: "Prev music.exe", delay_ms: 200} + - открой настройки + - открой параметры + - открой настройки виндоус + action: {type: shell, cmd: 'start "" ms-settings:'} + +open_task_manager: + phrases: + - открой диспетчер задач + - запусти диспетчер задач + - диспетчер задач + action: {type: shell, cmd: 'start "" taskmgr.exe'} + +lock_screen: + phrases: + - заблокируй компьютер + - заблокируй экран + - блокировка экрана + - залочь + action: {type: shell, cmd: 'rundll32.exe user32.dll,LockWorkStation'} + +screenshot: + phrases: + - сделай скриншот + - скриншот + - сними экран + action: + type: shell + cmd: >- + powershell -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bmp=New-Object Drawing.Bitmap $b.Width,$b.Height; $g=[Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen($b.Location,[Drawing.Point]::Empty,$b.Size); $p=Join-Path $env:USERPROFILE ('Pictures\jarvis_'+(Get-Date -Format 'yyyyMMdd_HHmmss')+'.png'); $bmp.Save($p); $g.Dispose(); $bmp.Dispose()" + +volume_up: + phrases: + - громче + - сделай громче + - прибавь звук + - увеличь громкость + - погромче + action: + type: multi + steps: + - {type: keys, sequence: 'volumeup'} + - {type: keys, sequence: 'volumeup'} + - {type: keys, sequence: 'volumeup'} + - {type: keys, sequence: 'volumeup'} + - {type: keys, sequence: 'volumeup'} + +volume_down: + phrases: + - тише + - сделай тише + - убавь звук + - уменьши громкость + - потише + action: + type: multi + steps: + - {type: keys, sequence: 'volumedown'} + - {type: keys, sequence: 'volumedown'} + - {type: keys, sequence: 'volumedown'} + - {type: keys, sequence: 'volumedown'} + - {type: keys, sequence: 'volumedown'} sound_off: phrases: @@ -120,6 +137,146 @@ sound_on: - {type: system, op: volume_unmute} - {type: play_sound, name: ok} +music: + phrases: + - давай музыку + - музыка + - яндекс музыка + - включи музыку + - открой музыку + - послушаем музыку + - хочу музыку + - врубай музло + - включи что-то послушать + - давай что-то послушаем + action: {type: keys, sequence: 'playpause'} + +music_off: + phrases: + - выключи музыку + - отруби музыку + - убери музыку + - отключи музыку + - закрой музыку + - останови музыку + - хватит музыки + - пауза + - поставь на паузу + action: {type: keys, sequence: 'playpause'} + +music_next: + phrases: + - следующий трек + - следующая мелодия + - следующая песня + - переключи трек + - переключи мелодию + - переключи песню + - следующая музыка + action: {type: keys, sequence: 'nexttrack'} + +music_prev: + phrases: + - предыдущая музыка + - предыдущий трек + - предыдущая мелодия + - предыдущая песня + - верни трек + - верни мелодию + - верни песню + - верни прошлый трек + - переключи на прошлый трек + action: {type: keys, sequence: 'prevtrack'} + +media_stop: + phrases: + - стоп музыка + - полная тишина + action: {type: keys, sequence: 'stop'} + +switch_audio_device: + phrases: + - переключи устройство + - смени вывод + - переключи на наушники + - переключи на колонки + - переключи на динамики + - открой настройки звука + action: {type: audio_devices_panel} + +sysinfo_time: + phrases: + - сколько времени + - который час + - какой сегодня день + - какое сегодня число + action: {type: sysinfo, topic: time} + +sysinfo_battery: + phrases: + - сколько заряда + - заряд батареи + - сколько процентов батареи + action: {type: sysinfo, topic: battery} + +sysinfo_cpu: + phrases: + - загрузка процессора + - сколько процессор + - нагрузка цпу + action: {type: sysinfo, topic: cpu} + +sysinfo_ram: + phrases: + - сколько памяти + - сколько оперативки + - загрузка оперативки + - сколько свободной памяти + action: {type: sysinfo, topic: ram} + +sysinfo_disk: + phrases: + - сколько свободно на диске + - сколько места на диске + - сколько свободно на компе + action: {type: sysinfo, topic: disk} + +sysinfo_all: + phrases: + - статус системы + - что с компьютером + - состояние пк + - что по системе + action: {type: sysinfo, topic: all} + +file_search: + phrases: + - найди файл + - найди документ + - поиск файла + - ищи файл + - где файл + action: {type: file_search} + +codegen: + phrases: + - напиши код + - сгенерируй код + - набросай код + - сделай скрипт + - напиши скрипт + - сгенерируй скрипт + action: {type: groq_codegen} + +read_screen: + phrases: + - прочитай экран + - что на экране + - распознай текст с экрана + - что написано на экране + - сними текст с экрана + action: {type: ocr_screen} + thanks: phrases: - спасибо @@ -141,85 +298,6 @@ stupid: - ты тупой action: {type: play_sound, name: stupid} -gaming_mode_on: - phrases: - - включи игровой режим - - перейди в игровой режим - - я хочу поиграть - - переключись на телевизор - action: - type: multi - steps: - - {type: play_sound, name: ok} - - {type: exe, file: "Switch to gaming mode.exe", wait: true} - - {type: play_sound, name: ready} - -gaming_mode_off: - phrases: - - рабочий режим - - вернись в рабочий режим - - переключись на компьютер - - обратно на компьютер - - отключи игровой режим - - выйди из игрового режима - - вернись на компьютер - - выход с игрового режима - - рабочее пространство - - вернись в игровой режим - - выключи игровой режим - action: - type: multi - steps: - - {type: play_sound, name: ok} - - {type: exe, file: "Switch back to workspace.exe", wait: true} - - {type: play_sound, name: ready} - -switch_to_headphones: - phrases: - - переключи на наушники - - включи наушники - - перейди на наушники - - давай звук в наушники - - давай звук на наушники - - переключи звук в наушники - - переключи звук на наушники - - переключи на кракены - - включи кракены - - перейди на кракены - - давай звук в кракены - - давай звук на кракены - - переключи звук в кракены - - переключи звук на кракены - action: - type: multi - steps: - - {type: play_sound, name: ok} - - {type: exe, file: "Switch to headphones.exe", wait: true, delay_ms: 500} - - {type: play_sound, name: ready} - -switch_to_dynamics: - phrases: - - переключи на динамики - - включи динамики - - перейди на динамики - - давай звук в динамики - - давай звук на динамики - - переключи звук в динамики - - переключи звук на динамики - - переключи на колонки - - включи колонки - - перейди на колонки - - давай звук в колонки - - давай звук на колонки - - переключи звук в колонки - - переключи звук на колонки - action: - type: multi - steps: - - {type: play_sound, name: ok} - - {type: exe, file: "Switch to dynamics.exe", wait: true, delay_ms: 500} - - {type: play_sound, name: ready} - 'off': phrases: - выключись diff --git a/config.py b/config.py index b78f82c..e7af1c9 100644 --- a/config.py +++ b/config.py @@ -31,6 +31,12 @@ GROQ_TOKEN = os.getenv('GROQ_TOKEN') GROQ_BASE_URL = "https://api.groq.com/openai/v1" GROQ_MODEL = "llama-3.3-70b-versatile" +# LLM auto-fallback: route unrecognized commands straight to Groq instead of +# playing the "not_found" sound. The old "скажи X" trigger still works (TBR +# strips it before intent matching, so what remains hits this fallback anyway). +LLM_AUTO_FALLBACK = True +LLM_AUTO_FALLBACK_MIN_CHARS = 4 + # VAD (webrtcvad) — определяет конец команды по тишине вместо фиксированных 10 сек. # 0..3, выше — агрессивнее режет шум (но и обрезает речь). VAD_AGGRESSIVENESS = 2 diff --git a/main.py b/main.py index dbb75a7..09dd4dc 100644 --- a/main.py +++ b/main.py @@ -168,10 +168,12 @@ def va_respond(voice: str): if len(cmd['cmd'].strip()) <= 0: return False elif cmd['percent'] < min_percent or cmd['cmd'] not in VA_CMD_LIST.keys(): - # play("not_found") - # tts.va_speak("Что?") - if fuzz.ratio(voice.join(voice.split()[:1]).strip(), "скажи") > 75: - + auto_ok = ( + config.LLM_AUTO_FALLBACK + and config.GROQ_TOKEN + and len(voice.strip()) >= config.LLM_AUTO_FALLBACK_MIN_CHARS + ) + if auto_ok: message_log.append({"role": "user", "content": voice}) response = gpt_answer() message_log.append({"role": "assistant", "content": response}) @@ -224,6 +226,214 @@ def _shutdown(): sys.exit(0) +def _speak(text: str): + recorder.stop() + tts.va_speak(text) + time.sleep(0.3) + recorder.start() + + +def _set_clipboard(text: str): + try: + subprocess.run( + ['powershell', '-NoProfile', '-Command', 'Set-Clipboard -Value $input'], + input=text, text=True, encoding='utf-8', timeout=10, check=False, + ) + except Exception as e: + print(f"clipboard set failed: {e}") + + +_CODEGEN_TRIGGERS = ( + 'сгенерируй скрипт', 'напиши скрипт', 'сделай скрипт', + 'сгенерируй код', 'набросай код', 'напиши код', +) + + +def _strip_trigger(voice: str, triggers) -> str: + lower = voice.lower() + for t in triggers: + if lower.startswith(t): + return voice[len(t):].strip() + return voice.strip() + + +def do_codegen(action, voice: str): + query = _strip_trigger(voice, _CODEGEN_TRIGGERS) + if not query: + _speak("Что писать, сэр?") + return + if not config.GROQ_TOKEN: + _speak("Groq токен не задан.") + return + + sys_prompt = ( + "Ты Senior Engineer. На запрос пользователя верни ТОЛЬКО исходный код, " + "без обрамления тройными кавычками и без объяснений. Если язык не задан " + "— выбери самый подходящий (по умолчанию Python). Не пиши комментариев-болтовни." + ) + try: + resp = client.chat.completions.create( + model=config.GROQ_MODEL, + messages=[ + {"role": "system", "content": sys_prompt}, + {"role": "user", "content": query}, + ], + max_tokens=1024, + temperature=0.2, + ) + code = resp.choices[0].message.content.strip() + except Exception as e: + print(f"codegen api failed: {e}") + _speak("Не получилось сгенерировать код.") + return + + import re + code = re.sub(r'^```[\w+\-]*\n?', '', code) + code = re.sub(r'\n?```\s*$', '', code).strip() + + _set_clipboard(code) + lines = code.count('\n') + 1 if code else 0 + _speak(f"Код в буфере, сэр. Строк: {lines}.") + + +def _find_tesseract(): + candidates = ( + r'C:\Program Files\Tesseract-OCR\tesseract.exe', + r'C:\Program Files (x86)\Tesseract-OCR\tesseract.exe', + ) + on_path = subprocess.run(['where', 'tesseract.exe'], capture_output=True, text=True) + if on_path.returncode == 0 and on_path.stdout.strip(): + return 'tesseract.exe' + for c in candidates: + if os.path.isfile(c): + return c + return None + + +def do_ocr(action, voice: str): + import tempfile + tess = _find_tesseract() + if not tess: + _speak("Tesseract не установлен.") + return + + tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False) + tmp.close() + png = tmp.name + + capture_ps = ( + "Add-Type -AssemblyName System.Windows.Forms,System.Drawing; " + "$b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; " + "$bmp=New-Object Drawing.Bitmap $b.Width,$b.Height; " + "$g=[Drawing.Graphics]::FromImage($bmp); " + "$g.CopyFromScreen($b.Location,[Drawing.Point]::Empty,$b.Size); " + f"$bmp.Save('{png.replace(chr(92), chr(92)+chr(92))}'); " + "$g.Dispose(); $bmp.Dispose()" + ) + cap = subprocess.run( + ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', capture_ps], + capture_output=True, text=True, timeout=15, + ) + if cap.returncode != 0 or not os.path.isfile(png): + try: os.unlink(png) + except OSError: pass + _speak("Не смог снять экран.") + return + + try: + ocr = subprocess.run( + [tess, png, '-', '-l', 'rus+eng'], + capture_output=True, text=True, encoding='utf-8', timeout=20, + ) + text = (ocr.stdout or '').strip() + finally: + try: os.unlink(png) + except OSError: pass + + if not text: + _speak("Текста на экране не нашёл.") + return + + _set_clipboard(text) + preview = ' '.join(text.split())[:120] + _speak(f"Текст в буфере. Начало: {preview}") + + +_FILE_SEARCH_TRIGGERS = ( + 'найди документ', 'найди файл', 'поиск файла', 'ищи файл', 'где файл', +) + + +def do_file_search(action, voice: str): + query = _strip_trigger(voice, _FILE_SEARCH_TRIGGERS) + if not query: + _speak("Что искать?") + return + user_profile = os.environ.get('USERPROFILE', os.path.expanduser('~')) + roots = [ + os.path.join(user_profile, 'Desktop'), + os.path.join(user_profile, 'Documents'), + os.path.join(user_profile, 'Downloads'), + ] + escaped = query.replace("'", "''") + roots_arg = ",".join(f"'{r}'" for r in roots) + ps = ( + f"$ErrorActionPreference='SilentlyContinue'; $r=@({roots_arg}); $q='*{escaped}*'; " + "$hits=Get-ChildItem -Path $r -Filter $q -Recurse -Depth 3 -File | Select-Object -First 5; " + "foreach($h in $hits){ Write-Output $h.FullName }" + ) + res = subprocess.run( + ['powershell', '-NoProfile', '-Command', ps], + capture_output=True, text=True, encoding='utf-8', timeout=15, + ) + lines = [l for l in (res.stdout or '').splitlines() if l.strip()] + if not lines: + _speak(f"Не нашёл «{query}».") + return + first = lines[0] + subprocess.Popen(['explorer.exe', '/select,', first]) + _speak(f"Найдено {len(lines)}, открываю первое: {os.path.basename(first)}.") + + +def do_audio_devices_panel(action, voice: str): + # Full programmatic switching needs IPolicyConfig + IMMDeviceEnumerator + # via comtypes; out of scope for this pass. Opens Sound control panel so + # the user can pick visually. Rust fork has the COM-based version. + subprocess.Popen(['rundll32.exe', 'shell32.dll,Control_RunDLL', 'mmsys.cpl,,0']) + _speak("Открываю настройки звука, сэр.") + + +def do_sysinfo(action, voice: str): + topic = action.get('topic', 'all') + + if topic in ('time', 'all'): + now = datetime.datetime.now() + weekday = ['понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье'][now.weekday()] + _speak(f"Сейчас {now.strftime('%H:%M')}, {weekday}.") + if topic == 'time': + return + + ps_scripts = { + 'battery': '$b=Get-CimInstance Win32_Battery -EA SilentlyContinue; if($b){"$([int]$b.EstimatedChargeRemaining)%"}else{"батареи нет"}', + 'cpu': '$c=Get-CimInstance Win32_Processor -EA SilentlyContinue|Select-Object -First 1; $load=(Get-CimInstance Win32_PerfFormattedData_PerfOS_Processor -Filter "Name=\'_Total\'" -EA SilentlyContinue).PercentProcessorTime; if($null -eq $load){$load=$c.LoadPercentage}; "$load%"', + 'ram': '$o=Get-CimInstance Win32_OperatingSystem; $tg=[math]::Round($o.TotalVisibleMemorySize/1MB,1); $ug=[math]::Round(($o.TotalVisibleMemorySize-$o.FreePhysicalMemory)/1MB,1); "$ug из $tg гигабайт"', + 'disk': '$d=Get-CimInstance Win32_LogicalDisk -Filter "DeviceID=\'$env:SystemDrive\'"; $fg=[math]::Round($d.FreeSpace/1GB,0); $tg=[math]::Round($d.Size/1GB,0); "свободно $fg из $tg гигабайт"', + } + labels = {'battery': 'Питание', 'cpu': 'Процессор', 'ram': 'Память', 'disk': 'Диск'} + order = ['battery', 'cpu', 'ram', 'disk'] if topic == 'all' else [topic] + + for t in order: + ps = ps_scripts.get(t) + if not ps: + continue + result = subprocess.run( + ['powershell', '-NoProfile', '-Command', ps], + capture_output=True, text=True, encoding='utf-8', timeout=10, + ) + out = result.stdout.strip() if result.returncode == 0 else 'ошибка' + _speak(f"{labels[t]}: {out}.") + + def run_action(action): t = action['type'] if t == 'exe': @@ -269,14 +479,32 @@ def run_action(action): elif t == 'multi': for step in action['steps']: run_action(step) + elif t == 'groq_codegen': + do_codegen(action, _current_voice or '') + elif t == 'ocr_screen': + do_ocr(action, _current_voice or '') + elif t == 'sysinfo': + do_sysinfo(action, _current_voice or '') + elif t == 'file_search': + do_file_search(action, _current_voice or '') + elif t == 'audio_devices_panel': + do_audio_devices_panel(action, _current_voice or '') + + +_current_voice: str = '' def execute_cmd(cmd: str, voice: str): + global _current_voice spec = VA_CMD_LIST.get(cmd) if not spec: return action = spec['action'] - run_action(action) + _current_voice = voice + try: + run_action(action) + finally: + _current_voice = '' confirm = spec.get('confirm_sound') if confirm is None and action['type'] == 'exe': confirm = 'ok'