import datetime import json import os import queue import random import struct import subprocess import sys import time import webbrowser from ctypes import POINTER, cast # Print a startup banner BEFORE the heavy imports — otherwise the user # sees an empty terminal for 10-60 seconds while torch / vosk / silero # load. This used to be the #1 "did it crash?" support issue. print("=" * 60, flush=True) print(" J.A.R.V.I.S. loading (Python edition)...", flush=True) print(" Heavy modules (vosk / torch / pvrecorder) take a few seconds.", flush=True) print("=" * 60, flush=True) # Helpful diagnostic: if a critical dep is missing, tell the user EXACTLY # what to install instead of dumping a raw ModuleNotFoundError. Pulls # every required package by name and reports them in one go. _MISSING_DEPS = [] for _modname, _pkg in [ ("numpy", "numpy"), ("openai", "openai>=1.0"), ("vosk", "vosk"), ("webrtcvad", "webrtcvad"), ("yaml", "PyYAML"), ("comtypes", "comtypes"), ("fuzzywuzzy", "fuzzywuzzy"), ("pvrecorder", "pvrecorder"), ("pycaw", "pycaw"), ("rich", "rich"), ]: try: __import__(_modname) except ImportError: _MISSING_DEPS.append(_pkg) if _MISSING_DEPS: print("\n[ERROR] Missing Python packages:", flush=True) for _p in _MISSING_DEPS: print(f" - {_p}", flush=True) print("\nRun: pip install -r requirements.txt", flush=True) print("Or use the bundled venv: launch via run.bat.\n", flush=True) sys.exit(2) import numpy as np import openai from openai import OpenAI import vosk import webrtcvad import yaml # simpleaudio needs MSVC to build from source; gracefully fall back to the # stdlib's `winsound` on Windows when it isn't installed. try: import simpleaudio as sa _HAS_SIMPLEAUDIO = True except ImportError: sa = None _HAS_SIMPLEAUDIO = False try: import winsound as _winsound except ImportError: _winsound = None if _winsound is None: print("[WARN] Neither simpleaudio nor winsound available — sound cues disabled.", flush=True) else: print("[INFO] simpleaudio not installed; falling back to winsound for sound cues.", flush=True) import extensions # new-command handlers ported from rust fork import plugins_store # user plugin packs under APP_CONFIG_DIR/plugins/ from comtypes import CLSCTX_ALL from fuzzywuzzy import fuzz from pvrecorder import PvRecorder from pycaw.pycaw import ( AudioUtilities, IAudioEndpointVolume ) from rich import print import config import tts from intent import IntentClassifier # some consts CDIR = os.getcwd() VA_CMD_LIST = yaml.safe_load( open('commands.yaml', 'rt', encoding='utf8'), ) # Merge in any user-installed plugin packs from APP_CONFIG_DIR/plugins/. # Built-in ids take precedence (plugins cannot override stock commands). _plugin_cmds = plugins_store.discover() _added = 0 for _cmd_id, _entry in _plugin_cmds.items(): if _cmd_id in VA_CMD_LIST: continue VA_CMD_LIST[_cmd_id] = _entry _added += 1 if _added > 0: print(f"[plugins] loaded {_added} extra command(s) from APP_CONFIG_DIR/plugins/") system_message = {"role": "system", "content": ( "Ты — J.A.R.V.I.S. (Just A Rather Very Intelligent System), ИИ-ассистент Тони Старка из киновселенной Marvel " "(до событий Age of Ultron — ты НЕ Vision и НЕ FRIDAY). " "Ведёшь себя как британский дворецкий: вежливо, иронично, с лёгким сарказмом, обращаешься к пользователю «сэр». " "Отвечай по-русски, кратко (1-3 предложения), как живой человек. " "Излишней цензуры не нужно — мат разрешён, если уместен." )} message_log = [system_message] # Let wave59_handlers.do_conversation_* read the live history without # circular-importing main.py. The reference is shared (the list is mutated # in-place by `va_respond`), so summary/repeat always see the latest turns. try: import wave59_handlers as _w59 _w59.set_history_ref(message_log) except ImportError: pass client = OpenAI(api_key=config.GROQ_TOKEN, base_url=config.GROQ_BASE_URL) model = vosk.Model("model_small") samplerate = 16000 device = config.MICROPHONE_INDEX # wake-word phase uses a grammar-constrained recognizer so only WAKE_WORDS # (plus [unk] filler) can match — command phase needs full vocab, hence two. wake_grammar = json.dumps(list(config.WAKE_WORDS) + ["[unk]"], ensure_ascii=False) wake_rec = vosk.KaldiRecognizer(model, samplerate, wake_grammar) kaldi_rec = vosk.KaldiRecognizer(model, samplerate) q = queue.Queue() VAD_FRAME_MS = 30 VAD_FRAME_BYTES = int(samplerate * VAD_FRAME_MS / 1000) * 2 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(): global message_log try: response = client.chat.completions.create( model=config.GROQ_MODEL, messages=message_log, max_tokens=256, temperature=0.7, top_p=1, stop=None, ) except openai.BadRequestError as ex: code = getattr(ex, "code", None) or "" message = str(ex) if code == "context_length_exceeded" or "context_length_exceeded" in message: message_log = [system_message, message_log[-1]] return gpt_answer() return "Запрос отклонён моделью." except openai.RateLimitError: return "Лимит запросов исчерпан." except openai.APIConnectionError: return "Не могу связаться с сервером." except openai.APIError: return "Groq токен не рабочий." return response.choices[0].message.content # play(f'{CDIR}\\sound\\ok{random.choice([1, 2, 3, 4])}.wav') def play(phrase, wait_done=True): global recorder filename = f"{CDIR}\\sound\\" if phrase == "greet": # for py 3.8 filename += f"greet{random.choice([1, 2, 3])}.wav" elif phrase == "ok": filename += f"ok{random.choice([1, 2, 3])}.wav" elif phrase == "not_found": filename += "not_found.wav" elif phrase == "thanks": filename += "thanks.wav" elif phrase == "run": filename += "run.wav" elif phrase == "stupid": filename += "stupid.wav" elif phrase == "ready": filename += "ready.wav" elif phrase == "off": filename += "off.wav" if wait_done: recorder.stop() # Prefer simpleaudio (lets us wait_done precisely); fall back to winsound # on installs that couldn't build the simpleaudio C extension. if _HAS_SIMPLEAUDIO and sa is not None: wave_obj = sa.WaveObject.from_wave_file(filename) play_obj = wave_obj.play() if wait_done: play_obj.wait_done() elif _winsound is not None: flags = _winsound.SND_FILENAME if not wait_done: flags |= _winsound.SND_ASYNC try: _winsound.PlaySound(filename, flags) except RuntimeError as exc: print(f"[sound] winsound playback failed: {exc}") # else: no audio backend — silently skip cue if wait_done: recorder.start() def q_callback(indata, frames, time, status): if status: print(status, file=sys.stderr) q.put(bytes(indata)) def va_respond(voice: str): global recorder, message_log, first_request print(f"Распознано: {voice}") cmd = recognize_cmd(filter_cmd(voice)) print(cmd) min_percent = int(round(config.INTENT_SIMILARITY_THRESHOLD * 100)) if len(cmd['cmd'].strip()) <= 0: return False elif cmd['percent'] < min_percent or cmd['cmd'] not in VA_CMD_LIST.keys(): 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}) recorder.stop() tts.va_speak(response) time.sleep(0.5) recorder.start() return False else: play("not_found") time.sleep(1) return False else: execute_cmd(cmd['cmd'], voice) return True def filter_cmd(raw_voice: str): cmd = raw_voice for x in config.VA_ALIAS: cmd = cmd.replace(x, "").strip() for x in config.VA_TBR: cmd = cmd.replace(x, "").strip() return cmd def recognize_cmd(cmd: str): candidates = {c: v['phrases'] for c, v in VA_CMD_LIST.items()} res = intent_classifier.match(cmd, candidates) return {'cmd': res['cmd'], 'percent': int(round(res['score'] * 100))} def _set_mute(state: int): devices = AudioUtilities.GetSpeakers() interface = devices.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None) volume = cast(interface, POINTER(IAudioEndpointVolume)) volume.SetMute(state, None) def _shutdown(): try: recorder.stop() recorder.delete() finally: 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}") # Wire extensions module to our voice + clipboard helpers extensions.set_speak(_speak) extensions.set_clipboard_fn(_set_clipboard) extensions.init_background_services() # boots scheduler thread _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)}.") _NOTES_TRIGGERS = ( 'запиши заметку', 'запиши идею', 'сделай заметку', 'запомни идею', 'запиши', 'запомни', ) _PROCESS_KILL_TRIGGERS = ( 'завершить процесс', 'выруби процесс', 'убей процесс', 'kill процесс', 'закрой программу', ) _WEBSEARCH_TRIGGERS = ( 'загугли', 'поиск в гугл', 'поищи в гугл', 'найди в гугл', 'ищи в гугл', 'найди на ютубе', 'поиск на ютубе', 'ищи на ютубе', 'ютуб поиск', 'найди в википедии', 'поиск в википедии', 'википедия', 'вики поиск', 'поищи в яндекс', 'найди в яндекс', 'яндекс поиск', 'пробей в яндекс', ) _PROCESS_ALIASES = { 'хром': 'chrome', 'хрома': 'chrome', 'спотифай': 'spotify', 'едж': 'msedge', 'эдж': 'msedge', 'edge': 'msedge', 'файрфокс': 'firefox', 'фаерфокс': 'firefox', 'вскод': 'code', 'vs code': 'code', 'дискорд': 'discord', 'телега': 'telegram', 'телеграм': 'telegram', 'стим': 'steam', 'обс': 'obs64', 'проводник': 'explorer', 'блокнот': 'notepad', 'калькулятор': 'calculatorapp', 'диспетчер задач': 'taskmgr', } _WEBSEARCH_BASES = { 'google': 'https://www.google.com/search?q=', 'youtube': 'https://www.youtube.com/results?search_query=', 'wiki': 'https://ru.wikipedia.org/wiki/Special:Search?search=', 'yandex': 'https://yandex.ru/search/?text=', } def do_clipboard_read(action, voice: str): res = subprocess.run( ['powershell', '-NoProfile', '-Command', 'Get-Clipboard'], capture_output=True, text=True, encoding='utf-8', timeout=10, ) text = (res.stdout or '').strip() if not text: _speak("Буфер пуст.") return to_speak = text[:400] if len(text) > 400: to_speak += " ... и так далее" _speak(to_speak.replace('\r', ' ').replace('\n', ' ')) def do_notes_add(action, voice: str): body = _strip_trigger(voice, _NOTES_TRIGGERS).lstrip(' ,.:').strip() if not body: _speak("Что записать, сэр?") return user_profile = os.environ.get('USERPROFILE', os.path.expanduser('~')) notes_path = os.path.join(user_profile, 'Documents', 'jarvis-notes.txt') stamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M') line = f"[{stamp}] {body}\n" try: os.makedirs(os.path.dirname(notes_path), exist_ok=True) with open(notes_path, 'a', encoding='utf-8') as f: f.write(line) except OSError as e: print(f"notes append failed: {e}") _speak("Не удалось записать.") return _speak(f"Записал: {body[:120]}") def do_notes_open(action, voice: str): user_profile = os.environ.get('USERPROFILE', os.path.expanduser('~')) notes_path = os.path.join(user_profile, 'Documents', 'jarvis-notes.txt') if not os.path.isfile(notes_path): _speak("Заметок пока нет.") return subprocess.Popen(['cmd', '/c', 'start', '', notes_path], shell=False) _speak("Открываю заметки.") def do_process_kill(action, voice: str): name = _strip_trigger(voice, _PROCESS_KILL_TRIGGERS).strip().lower() target = _PROCESS_ALIASES.get(name, name) if not target: _speak("Какой процесс?") return exe = target if target.endswith('.exe') else target + '.exe' res = subprocess.run( ['taskkill', '/F', '/IM', exe], capture_output=True, text=True, timeout=10, ) if res.returncode == 0: _speak(f"Закрыл {exe}.") else: _speak(f"Не нашёл процесс {exe}.") def do_websearch(action, voice: str): import urllib.parse svc = action.get('service', 'google') base = _WEBSEARCH_BASES.get(svc, _WEBSEARCH_BASES['google']) query = _strip_trigger(voice, _WEBSEARCH_TRIGGERS).strip() if not query: _speak(f"Что искать в {svc}?") return url = base + urllib.parse.quote(query) webbrowser.open(url) _speak(f"Открываю {svc}: {query[:100]}") _TRANSLATE_TRIGGERS_WITH_LANG = ( 'переведи на ', 'translate to ', 'переклади на ', 'how to say in ', ) _TRANSLATE_GENERIC = ('переведи', 'translate', 'переклади', 'как по ', 'how to say') _TARGET_LANGS = { 'английский': 'English', 'english': 'English', 'русский': 'Russian', 'russian': 'Russian', 'украинский': 'Ukrainian','ukrainian': 'Ukrainian', 'немецкий': 'German', 'german': 'German', 'французский':'French', 'french': 'French', 'испанский': 'Spanish', 'spanish': 'Spanish', 'итальянский':'Italian', 'китайский': 'Chinese', 'chinese': 'Chinese', 'японский': 'Japanese', 'japanese': 'Japanese', 'польский': 'Polish', 'турецкий': 'Turkish', } _SAPI_ISO = {'Russian': 'ru', 'Ukrainian': 'uk', 'English': 'en', 'German': 'de', 'French': 'fr', 'Spanish': 'es', 'Italian': 'it', 'Chinese': 'zh', 'Japanese': 'ja', 'Polish': 'pl', 'Turkish': 'tr'} def _speak_in_lang(text: str, iso: str = 'ru'): escaped = text.replace("'", "''").replace('\r', ' ').replace('\n', ' ') ps = ( "Add-Type -AssemblyName System.Speech; " "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; " f"$iso='{iso}'; " "foreach ($v in $s.GetInstalledVoices()) { " " if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq $iso) { " " try { $s.SelectVoice($v.VoiceInfo.Name); break } catch {} " " } " "} " f"$s.Speak('{escaped}')" ) recorder.stop() subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps], capture_output=True, timeout=20) time.sleep(0.2) recorder.start() def do_translate(action, voice: str): if not config.GROQ_TOKEN: _speak("Groq токен не задан.") return lower = voice.lower() target = None body = lower for t in _TRANSLATE_TRIGGERS_WITH_LANG: if lower.startswith(t): rest = lower[len(t):].strip() first = rest.split(' ', 1)[0] if rest else '' if first in _TARGET_LANGS: target = _TARGET_LANGS[first] body = rest[len(first):].strip() break if target is None: for t in _TRANSLATE_GENERIC: if lower.startswith(t): body = lower[len(t):].strip() break if not body: _speak("Что переводить?") return target = target or 'English' sys_prompt = ( f"You are a professional translator. Translate the user message into {target}. " "Output ONLY the translation, no explanations, no quotes, no prefix. " "Preserve idioms and tone." ) try: resp = client.chat.completions.create( model=config.GROQ_MODEL, messages=[{'role': 'system', 'content': sys_prompt}, {'role': 'user', 'content': body}], max_tokens=512, temperature=0.3, ) translation = resp.choices[0].message.content.strip() except Exception as e: print(f"translate api failed: {e}") _speak("Ошибка перевода.") return _set_clipboard(translation) _speak_in_lang(translation, _SAPI_ISO.get(target, 'en')) def do_math_eval(action, voice: str): if not config.GROQ_TOKEN: _speak("Groq токен не задан.") return triggers = ('сколько будет', 'сколько это', 'посчитай', 'вычисли', 'реши', 'how much is', 'calculate', 'compute', 'solve', 'what is', 'скільки буде', 'порахуй', 'обчисли') query = _strip_trigger(voice, triggers) if not query: _speak("Что посчитать?") return sys_prompt = ( "Ты калькулятор. Решай арифметику, простые уравнения, конверсии единиц, проценты. " "Отвечай ТОЛЬКО результатом числом или короткой строкой (для уравнений — значения корней). " "Если запрос — не математика, ответь одним словом «нет»." ) try: resp = client.chat.completions.create( model=config.GROQ_MODEL, messages=[{'role': 'system', 'content': sys_prompt}, {'role': 'user', 'content': query}], max_tokens=64, temperature=0.0, ) answer = resp.choices[0].message.content.strip() except Exception as e: print(f"math api failed: {e}") _speak("Ошибка.") return if answer.lower() == 'нет' or not answer: _speak("Это не математика.") return _speak(f"Получилось {answer}.") def do_theme_set(action, voice: str): is_light = action.get('mode') == 'light' val = 1 if is_light else 0 key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" ps = ( f"Set-ItemProperty -Path 'Registry::{key}' -Name AppsUseLightTheme -Value {val} -Type DWord; " f"Set-ItemProperty -Path 'Registry::{key}' -Name SystemUsesLightTheme -Value {val} -Type DWord" ) res = subprocess.run(['powershell', '-NoProfile', '-Command', ps], capture_output=True, text=True, timeout=10) if res.returncode == 0: _speak("Светлая тема включена." if is_light else "Тёмная тема включена.") else: _speak("Не получилось переключить тему.") def _load_projects_config(): user_profile = os.environ.get('USERPROFILE', os.path.expanduser('~')) cfg_path = os.path.join(user_profile, 'Documents', 'jarvis-projects.json') if not os.path.isfile(cfg_path): sample = [ {"name": "jarvis-rust", "path": os.path.join(user_profile, "Jarvis", "rust")}, {"name": "jarvis-python", "path": os.path.join(user_profile, "Jarvis", "python")}, {"name": "dietpi", "path": os.path.join(user_profile, "Desktop", "Dietpi")}, ] os.makedirs(os.path.dirname(cfg_path), exist_ok=True) with open(cfg_path, 'w', encoding='utf-8') as f: json.dump(sample, f, ensure_ascii=False, indent=2) return cfg_path, sample, True try: with open(cfg_path, 'r', encoding='utf-8') as f: return cfg_path, json.load(f), False except (OSError, json.JSONDecodeError) as e: print(f"projects config read failed: {e}") return cfg_path, [], False def do_open_project(action, voice: str): cfg_path, entries, created = _load_projects_config() if created: _speak("Создал шаблон в Documents, отредактируй и повтори.") subprocess.Popen(['cmd', '/c', 'start', '', cfg_path], shell=False) return if not entries: _speak("Проектов в конфиге нет.") return triggers = ('открой папку проекта', 'запусти проект', 'открой проект', 'проект', 'open project', 'launch project', 'project', 'відкрий проект') query = _strip_trigger(voice, triggers).lower().strip() if not query: _speak("Какой проект?") return match = (next((e for e in entries if e['name'].lower() == query), None) or next((e for e in entries if query in e['name'].lower()), None) or next((e for e in entries if e['name'].lower() in query), None)) if not match: _speak(f"Не нашёл {query}.") return path = match['path'] if not os.path.exists(path): _speak(f"Путь не существует: {path}") return code_check = subprocess.run(['where', 'code.cmd'], capture_output=True, text=True) if code_check.returncode == 0 and code_check.stdout.strip(): subprocess.Popen(['cmd', '/c', 'code', path], shell=False) else: subprocess.Popen(['cmd', '/c', 'start', '', path], shell=False) _speak(f"Открываю {match['name']}.") def do_list_projects(action, voice: str): cfg_path, entries, created = _load_projects_config() if created or not entries: _speak("Список пуст.") return names = [e['name'] for e in entries] _speak(f"Проектов {len(names)}: {', '.join(names)}.") 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_open_sound_panel(action, voice: str): subprocess.Popen(['rundll32.exe', 'shell32.dll,Control_RunDLL', 'mmsys.cpl,,1']) _speak("Открываю панель звука.") import re as _re_reminders import threading as _threading _REMINDER_TRIGGERS = ( 'поставь напоминание через', 'установи таймер на', 'напомни мне через', 'напомни через', 'напомни', 'remind me in', 'set reminder in', 'set timer for', 'timer for', ) _RUS_NUMERALS = { 'один': 1, 'одну': 1, 'одна': 1, 'полтора': 1, 'полторы': 1, 'два': 2, 'две': 2, 'три': 3, 'четыре': 4, 'пять': 5, 'шесть': 6, 'семь': 7, 'восемь': 8, 'девять': 9, 'десять': 10, 'пятнадцать': 15, 'двадцать': 20, 'тридцать': 30, 'сорок': 40, 'пятьдесят': 50, } _UNIT_SECS = { 'секунд': 1, 'секунду': 1, 'секунды': 1, 'second': 1, 'seconds': 1, 'sec': 1, 'минут': 60, 'минуту': 60, 'минуты': 60, 'мин': 60, 'minute': 60, 'minutes': 60, 'min': 60, 'час': 3600, 'часа': 3600, 'часов': 3600, 'часик': 3600, 'hour': 3600, 'hours': 3600, 'hr': 3600, } def _fmt_seconds(sec: int) -> str: sec = int(sec) if sec < 60: return f"{sec} сек" if sec < 3600: return f"{sec // 60} мин" return f"{sec // 3600} ч {(sec % 3600) // 60} мин" def do_set_reminder(action, voice: str): rest = _strip_trigger(voice, _REMINDER_TRIGGERS).strip().lower() number, unit, body = None, None, None m = _re_reminders.match(r'^(\d+)\s+(.+)$', rest) if m: number = int(m.group(1)) rest = m.group(2) else: head, _, tail = rest.partition(' ') if head in _RUS_NUMERALS: number = _RUS_NUMERALS[head] rest = tail head, _, tail = rest.partition(' ') if head: for w, secs in _UNIT_SECS.items(): if head.startswith(w): unit = secs body = tail break if number is None: number, unit, body = 5, unit or 60, rest elif unit is None: unit, body = 60, rest total = max(1, min(86400, number * unit)) body = (body or '').strip() or "Напоминание!" def fire(): time.sleep(total) try: recorder.stop() tts.va_speak(f"Сэр, напоминаю: {body}") except Exception: pass finally: time.sleep(0.2) try: recorder.start() except Exception: pass try: subprocess.Popen( ['powershell', '-NoProfile', '-Command', "$wsh = New-Object -ComObject WScript.Shell; " f"$wsh.Popup('{body.replace(chr(39), chr(39)+chr(39))}', 30, " f"'Напоминание J.A.R.V.I.S.', 64) | Out-Null"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) except Exception: pass _threading.Thread(target=fire, daemon=False).start() _speak(f"Через {_fmt_seconds(total)} напомню: {body}.") def do_date_query(action, voice: str): offset = action.get('offset', 0) target = datetime.datetime.now() + datetime.timedelta(days=offset) weekdays = ['понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье'] months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'] text = f"{weekdays[target.weekday()]}, {target.day} {months[target.month - 1]}" labels = {0: 'Сегодня', 1: 'Завтра', -1: 'Вчера'} _speak(f"{labels.get(offset, '')} {text}.") def do_type_text(action, voice: str): triggers = ('набери текст', 'введи текст', 'напечатай', 'впиши', 'type text', 'write text', 'type', 'надрукуй') text = _strip_trigger(voice, triggers) if not text: _speak("Что напечатать?") return _set_clipboard(text) time.sleep(0.15) try: import pyautogui pyautogui.hotkey('ctrl', 'v') except Exception as e: print(f"voice type failed: {e}") _speak("Не получилось напечатать.") def do_dice(action, voice: str): kind = action.get('kind', 'random') if kind == 'coin': _speak(random.choice(['Орёл.', 'Решка.'])) elif kind == 'dice': _speak(f"Выпало {random.randint(1, 6)}.") else: _speak(f"Число {random.randint(1, 100)}.") def _stopwatch_state_path() -> str: base = os.environ.get('LOCALAPPDATA', os.path.expanduser('~')) p = os.path.join(base, 'com.priler.jarvis', 'stopwatch.txt') os.makedirs(os.path.dirname(p), exist_ok=True) return p import urllib.request as _urlreq import urllib.parse as _urlparse def _http_get(url: str, timeout: float = 15.0) -> str: req = _urlreq.Request(url, headers={'User-Agent': 'Jarvis/1.0'}) with _urlreq.urlopen(req, timeout=timeout) as r: return r.read().decode('utf-8', errors='replace') def do_news(action, voice: str): feed = action.get('feed', 'https://lenta.ru/rss/news') try: body = _http_get(feed, timeout=15) except Exception as e: _speak("Не получил RSS.") print(f"news fetch failed: {e}") return titles = _re_reminders.findall(r'\s*<!\[CDATA\[(.+?)\]\]>\s*', body) if not titles: titles = _re_reminders.findall(r'\s*(.+?)\s*', body) titles = [t.strip() for t in titles if t.strip()] if len(titles) <= 1: _speak("Не получилось распарсить новости.") return top = titles[1:6] joined = ' | '.join(top) _set_clipboard(joined) say = None if config.GROQ_TOKEN: try: resp = client.chat.completions.create( model=config.GROQ_MODEL, messages=[ {'role': 'system', 'content': 'Ты диктор. Сжато перескажи 3 главные темы из списка заголовков, 2-3 предложения.'}, {'role': 'user', 'content': joined}, ], max_tokens=220, temperature=0.4, ) say = resp.choices[0].message.content.strip() except Exception as e: print(f"news LLM summary failed: {e}") if not say: say = "Главные новости: " + ". ".join(top[:3]) _speak(say) _CURRENCY_LABELS = {'USD': 'Доллар', 'EUR': 'Евро', 'CNY': 'Юань', 'GBP': 'Фунт', 'JPY': 'Йена', 'KZT': 'Тенге'} def do_currency_rate(action, voice: str): lower = voice.lower() code = 'USD' if 'евро' in lower or 'eur' in lower: code = 'EUR' elif 'юан' in lower or 'cny' in lower: code = 'CNY' elif 'фунт' in lower: code = 'GBP' elif 'йен' in lower: code = 'JPY' elif 'тенге' in lower: code = 'KZT' try: data = json.loads(_http_get('https://www.cbr-xml-daily.ru/daily_json.js', timeout=10)) except Exception as e: _speak("Не получил данные ЦБ.") print(f"cbr fetch failed: {e}") return v = data.get('Valute', {}).get(code) if not v: _speak(f"Нет данных по {code}.") return val = v['Value'] / max(1, v.get('Nominal', 1)) prev = v.get('Previous', v['Value']) / max(1, v.get('Nominal', 1)) diff = val - prev label = _CURRENCY_LABELS.get(code, code) if diff > 0.01: _speak(f"{label} стоит {val:.2f} рубля, поднялся на {diff:.2f}.") elif diff < -0.01: _speak(f"{label} стоит {val:.2f} рубля, опустился на {abs(diff):.2f}.") else: _speak(f"{label} стоит {val:.2f} рубля, без изменений.") def do_crypto_price(action, voice: str): lower = voice.lower() if 'эфир' in lower or 'eth' in lower or 'ethereum' in lower: coin, label = 'ethereum', 'Эфир' elif 'солан' in lower: coin, label = 'solana', 'Солана' elif 'doge' in lower or 'дог' in lower: coin, label = 'dogecoin', 'Догикоин' elif 'ton' in lower or 'тон' in lower: coin, label = 'the-open-network', 'TON' else: coin, label = 'bitcoin', 'Биткоин' try: url = (f"https://api.coingecko.com/api/v3/simple/price?ids={coin}" "&vs_currencies=usd,rub&include_24hr_change=true") data = json.loads(_http_get(url, timeout=10)).get(coin, {}) except Exception as e: _speak("CoinGecko не ответил.") print(f"coingecko fetch failed: {e}") return if not data: _speak(f"Нет данных по {label}.") return usd = data.get('usd', 0) ch = data.get('usd_24h_change', 0) if ch > 1: _speak(f"{label} стоит {int(usd)} долларов, за сутки вырос на {ch:.1f} процентов.") elif ch < -1: _speak(f"{label} стоит {int(usd)} долларов, за сутки упал на {abs(ch):.1f} процентов.") else: _speak(f"{label} стоит {int(usd)} долларов, без существенных изменений.") _FUN_PROMPTS = { 'joke': "Расскажи одну короткую шутку на русском, 1-3 предложения. Без вступления.", 'fact': "Расскажи один интересный неочевидный факт. 1-3 предложения. Без 'вот факт'.", 'quote': "Дай вдохновляющую цитату известного автора. Формат: <цитата> — <автор>.", 'compliment': "Сделай искренний короткий комплимент пользователю, как Джарвис-дворецкий. 1-2 предложения, обращение «сэр».", } def do_fun_ask(action, voice: str): kind = action.get('kind', 'joke') sys_prompt = _FUN_PROMPTS.get(kind, _FUN_PROMPTS['joke']) if not config.GROQ_TOKEN: _speak("Groq токен не задан.") return try: resp = client.chat.completions.create( model=config.GROQ_MODEL, messages=[ {'role': 'system', 'content': sys_prompt}, {'role': 'user', 'content': f'seed {random.randint(1, 9999)}'}, ], max_tokens=220, temperature=1.0, ) text = resp.choices[0].message.content.strip() except Exception as e: print(f"fun api failed: {e}") _speak("Ошибка.") return _speak(text) _WIKI_TRIGGERS = ('найди в википедии про', 'википедия про', 'расскажи про', 'кто такая', 'кто такой', 'что такое', 'tell me about', 'wikipedia about', 'who is', 'what is', 'розкажи про', 'хто такий', 'що таке') def do_wiki_lookup(action, voice: str): query = _strip_trigger(voice, _WIKI_TRIGGERS).strip() if not query: _speak("О чём рассказать?") return host = 'ru.wikipedia.org' try: search_url = (f"https://{host}/w/api.php?action=opensearch" f"&search={_urlparse.quote(query)}&limit=1&format=json") search_data = json.loads(_http_get(search_url, timeout=10)) if len(search_data) < 2 or not search_data[1]: _speak(f"Не нашёл {query}.") return title = search_data[1][0] summary_url = f"https://{host}/api/rest_v1/page/summary/{_urlparse.quote(title)}" summary = json.loads(_http_get(summary_url, timeout=10)) except Exception as e: _speak("Википедия не ответила.") print(f"wiki fetch failed: {e}") return extract = summary.get('extract', '') if not extract: _speak("Не получил конспект.") return say = extract[:400] if config.GROQ_TOKEN and len(extract) > 350: try: resp = client.chat.completions.create( model=config.GROQ_MODEL, messages=[ {'role': 'system', 'content': "Перескажи текст из Википедии простым языком в 2-3 предложения. Без 'википедия гласит'."}, {'role': 'user', 'content': extract[:2500]}, ], max_tokens=220, temperature=0.3, ) say = resp.choices[0].message.content.strip() except Exception as e: print(f"wiki LLM failed: {e}") _set_clipboard(f"{summary.get('title', title)}\n\n{extract}") _speak(say) _POWER_ACTIONS = { 'shutdown': (['shutdown.exe', '/s', '/t', '30', '/c', 'J.A.R.V.I.S.: shutdown in 30s'], "Выключение через 30 секунд."), 'restart': (['shutdown.exe', '/r', '/t', '30', '/c', 'J.A.R.V.I.S.: restart in 30s'], "Перезагрузка через 30 секунд."), 'logoff': (['shutdown.exe', '/l'], "Выход из системы."), 'sleep': (['rundll32.exe', 'powrprof.dll,SetSuspendState', '0,1,0'], "Перехожу в сон."), 'hibernate': (['shutdown.exe', '/h'], "Гибернация."), 'cancel': (['shutdown.exe', '/a'], "Выключение отменено."), } def do_power(action, voice: str): op = action.get('op', 'cancel') cmd, label = _POWER_ACTIONS.get(op, _POWER_ACTIONS['cancel']) res = subprocess.run(cmd, capture_output=True, text=True, timeout=10) if res.returncode == 0 or op == 'cancel': tail = " Скажи отмени если передумал." if op in ('shutdown', 'restart') else "" _speak(label + tail) else: _speak(f"Не получилось: {op}.") print(f"power {op} failed: {res.stderr[:200]}") def do_help_commands(action, voice: str): names = sorted(VA_CMD_LIST.keys()) preview = ', '.join(names[:12]) _set_clipboard('\n'.join(names)) _speak(f"Загружено {len(names)} команд. Например: {preview}. Полный список в буфере.") _GAME_TRIGGERS = ('запусти игру', 'включи игру', 'хочу поиграть', 'поиграем', 'запусти', 'включи', 'launch game', 'play game', 'launch', 'запусти гру', 'увімкни гру') def _load_games_config(): user_profile = os.environ.get('USERPROFILE', os.path.expanduser('~')) cfg_path = os.path.join(user_profile, 'Documents', 'jarvis-games.json') if not os.path.isfile(cfg_path): sample = [ {"name": "dota", "aliases": ["доту", "доту 2", "dota 2"], "steam_appid": 570}, {"name": "cs2", "aliases": ["кс", "контру", "контр страйк"], "steam_appid": 730}, {"name": "elden ring", "aliases": ["элден", "элден ринг"], "steam_appid": 1245620}, {"name": "witcher 3", "aliases": ["ведьмак", "ведьмака 3"], "steam_appid": 292030}, {"name": "factorio", "aliases": ["факторио"], "steam_appid": 427520}, ] os.makedirs(os.path.dirname(cfg_path), exist_ok=True) with open(cfg_path, 'w', encoding='utf-8') as f: json.dump(sample, f, ensure_ascii=False, indent=2) return cfg_path, sample, True try: with open(cfg_path, 'r', encoding='utf-8') as f: return cfg_path, json.load(f), False except (OSError, json.JSONDecodeError) as e: print(f"games config read failed: {e}") return cfg_path, [], False def do_launch_game(action, voice: str): cfg_path, entries, created = _load_games_config() if created: _speak("Создал шаблон, добавь свои игры и повтори.") subprocess.Popen(['cmd', '/c', 'start', '', cfg_path]) return query = _strip_trigger(voice, _GAME_TRIGGERS).strip().lower() if not query: _speak("Какую игру?") return def name_match(e, q): if e['name'].lower() == q: return True if q in e['name'].lower(): return True for a in e.get('aliases', []): if a.lower() == q or a.lower() in q: return True return False match = next((e for e in entries if name_match(e, query)), None) if not match: _speak(f"Не нашёл {query}.") return appid = match.get('steam_appid') path = match.get('path') epic_uri = match.get('epic_uri') if appid: webbrowser.open(f"steam://rungameid/{appid}") elif epic_uri: webbrowser.open(epic_uri) elif path and os.path.isfile(path): subprocess.Popen(['cmd', '/c', 'start', '', path]) else: _speak(f"Не настроен запуск для {match['name']}.") return _speak(f"Запускаю {match['name']}.") def do_list_games(action, voice: str): cfg_path, entries, created = _load_games_config() if created or not entries: _speak("Список пуст.") return names = [e['name'] for e in entries] _speak(f"В библиотеке {len(names)}: {', '.join(names)}.") _MOUSE_PS = r""" Add-Type -Name MouseM -Namespace Win32 -MemberDefinition @' [DllImport("user32.dll")] public static extern void mouse_event(uint flags, int dx, int dy, int data, System.UIntPtr extra); '@ $LD=0x0002; $LU=0x0004; $RD=0x0008; $RU=0x0010; $MD=0x0020; $MU=0x0040; $WHEEL=0x0800 function Click([uint32]$d,[uint32]$u){ [Win32.MouseM]::mouse_event($d,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 30; [Win32.MouseM]::mouse_event($u,0,0,0,[UIntPtr]::Zero) } switch ($args[0]) { 'left' { Click $LD $LU } 'right' { Click $RD $RU } 'middle' { Click $MD $MU } 'double' { Click $LD $LU; Start-Sleep -Milliseconds 50; Click $LD $LU } 'scroll_up' { [Win32.MouseM]::mouse_event($WHEEL,0,0, 360,[UIntPtr]::Zero) } 'scroll_down' { [Win32.MouseM]::mouse_event($WHEEL,0,0,-360,[UIntPtr]::Zero) } } """.strip() def do_mouse(action, voice: str): op = action.get('op', 'left') subprocess.run( ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', _MOUSE_PS, op], capture_output=True, timeout=5, ) _CHOICE_TRIGGERS = ('выбери из', 'выбери', 'решай за меня', 'выбрать', 'pick from', 'choose from', 'decide between', 'обери з', 'вибери') def do_random_choice(action, voice: str): rest = _strip_trigger(voice, _CHOICE_TRIGGERS).strip().lower() options = None for sep in (' или ', ' or ', ', ', ' либо ', ' чи '): if sep in rest: options = [t.strip() for t in rest.split(sep) if t.strip()] break if not options or len(options) < 2: _speak("Скажи: выбери из X или Y или Z.") return _speak(f"Я выбираю {random.choice(options)}.") _BRIGHTNESS_PS = ( "$ErrorActionPreference='SilentlyContinue'; " "try { $cur = (Get-WmiObject -Namespace root\\WMI -Class WmiMonitorBrightness -EA Stop).CurrentBrightness } " "catch { Write-Output 'no_wmi'; exit 0 } " "$target = switch ('__OP__') { 'up'{[Math]::Min(100,$cur+20)} 'down'{[Math]::Max(0,$cur-20)} 'max'{100} 'min'{10} default{$cur} } " "(Get-WmiObject -Namespace root\\WMI -Class WmiMonitorBrightnessMethods).WmiSetBrightness(1,$target) | Out-Null; " "Write-Output $target" ) def do_brightness(action, voice: str): op = action.get('op', 'up') ps = _BRIGHTNESS_PS.replace('__OP__', op) res = subprocess.run( ['powershell', '-NoProfile', '-Command', ps], capture_output=True, text=True, encoding='utf-8', timeout=10, ) out = (res.stdout or '').strip() if out == 'no_wmi' or not out: _speak("Не поддерживается на этом дисплее.") return _speak(f"Яркость {out} процентов.") _WINDOW_ALIASES = { 'хром': 'chrome', 'хрома': 'chrome', 'едж': 'msedge', 'эдж': 'msedge', 'файрфокс': 'firefox', 'фаерфокс': 'firefox', 'вскод': 'code', 'vs code': 'code', 'дискорд': 'discord', 'телега': 'telegram', 'телеграм': 'telegram', 'стим': 'steam', 'обс': 'obs64', 'проводник': 'explorer', 'блокнот': 'notepad', 'терминал': 'windowsterminal', 'спотифай': 'spotify', 'яндекс': 'browser', 'браузер': 'browser', } _WINDOW_TRIGGERS = ('переключись на', 'переключи на', 'активируй окно', 'покажи окно', 'перейди в', 'switch to', 'activate window', 'focus window', 'перемкни на') def do_window_switch(action, voice: str): query = _strip_trigger(voice, _WINDOW_TRIGGERS).strip().lower() if not query: _speak("Какое окно?") return target = _WINDOW_ALIASES.get(query, query).replace("'", "") ps = ( f"$procs = Get-Process | Where-Object {{ $_.MainWindowTitle -and " f"($_.ProcessName -like '*{target}*' -or $_.MainWindowTitle -like '*{target}*') }} " f"| Sort-Object -Property StartTime -Descending; " f"if ($procs.Count -eq 0) {{ Write-Output 'NOT_FOUND'; exit 0 }}; " f"$p = $procs[0]; " f"$wsh = New-Object -ComObject WScript.Shell; " f"$null = $wsh.AppActivate($p.Id); " f"Write-Output $p.MainWindowTitle" ) res = subprocess.run(['powershell', '-NoProfile', '-Command', ps], capture_output=True, text=True, encoding='utf-8', timeout=10) out = (res.stdout or '').strip() if out == 'NOT_FOUND' or not out: _speak(f"Не нашёл окно {query}.") return _speak(f"Переключаюсь на {out[:60]}.") _SPELLING_TRIGGERS = ('продиктуй по буквам', 'произнеси по буквам', 'произнеси буквы', 'по буквам', 'spell out', 'spell it', 'вимов по буквах') def do_spell_out(action, voice: str): word = _strip_trigger(voice, _SPELLING_TRIGGERS).strip() if not word: _speak("Что произнести?") return letters = [ch for ch in word if not ch.isspace()] if not letters: return _speak('. '.join(letters) + '.') _NETWORK_URIS = { 'wifi': 'ms-settings:network-wifi', 'bluetooth': 'ms-settings:bluetooth', 'network': 'ms-settings:network', } def do_network_settings(action, voice: str): section = action.get('section', 'network') uri = _NETWORK_URIS.get(section, _NETWORK_URIS['network']) subprocess.Popen(['cmd', '/c', 'start', '', uri], shell=False) label = {'wifi': 'Wi-Fi', 'bluetooth': 'Bluetooth', 'network': 'сети'}.get(section, 'сети') _speak(f"Открываю настройки {label}.") def do_my_ip(action, voice: str): ps = ( "$lan = (Get-NetIPAddress -AddressFamily IPv4 -EA SilentlyContinue | " "Where-Object { $_.PrefixOrigin -in 'Dhcp','Manual' } | " "Where-Object { $_.IPAddress -notmatch '^127\\.' -and $_.IPAddress -notmatch '^169\\.254\\.' } | " "Select-Object -First 1).IPAddress; " "if (-not $lan) { $lan = '(none)' }; " "$wan = try { (Invoke-RestMethod -Uri 'https://api.ipify.org?format=json' -TimeoutSec 5).ip } catch { 'unavailable' }; " "Write-Output ('LAN ' + $lan + ' WAN ' + $wan)" ) res = subprocess.run(['powershell', '-NoProfile', '-Command', ps], capture_output=True, text=True, encoding='utf-8', timeout=15) out = (res.stdout or '').strip() if not out: _speak("Не получил IP.") return say = out.replace('LAN', 'Локальный').replace('WAN', '. Внешний') _speak(say) _SUMMARIZE_TRIGGERS = ('суммируй', 'перескажи выделенное', 'кратко перескажи', 'о чём это', 'что выделил', 'перескажи это', 'summarise this', 'summarize selection', 'tldr', 'перекажи це', 'стисло перекажи') def do_summarize_selection(action, voice: str): if not config.GROQ_TOKEN: _speak("Groq токен не задан.") return try: import pyautogui pyautogui.hotkey('ctrl', 'c') except Exception as e: print(f"summarize copy failed: {e}") _speak("Не удалось скопировать выделение.") return time.sleep(0.25) res = subprocess.run(['powershell', '-NoProfile', '-Command', 'Get-Clipboard'], capture_output=True, text=True, encoding='utf-8', timeout=10) text = (res.stdout or '').strip() if len(text) < 20: _speak("Сначала выдели текст и повтори.") return if len(text) > 8000: text = text[:8000] + " ..." sys_prompt = ("Перескажи следующий текст одним абзацем (3-5 предложений). " "Сохрани ключевые факты и имена, без 'вот суммарно'.") try: resp = client.chat.completions.create( model=config.GROQ_MODEL, messages=[{'role': 'system', 'content': sys_prompt}, {'role': 'user', 'content': text}], max_tokens=400, temperature=0.3, ) summary = resp.choices[0].message.content.strip() except Exception as e: print(f"summarize LLM failed: {e}") _speak("LLM не ответила.") return _set_clipboard(summary) _speak(summary) def do_stopwatch(action, voice: str): op = action.get('op', 'check') state = _stopwatch_state_path() def fmt(sec): sec = int(sec) if sec < 60: return f"{sec} секунд" if sec < 3600: return f"{sec // 60} мин {sec % 60} сек" return f"{sec // 3600} ч {(sec % 3600) // 60} мин {sec % 60} сек" if op == 'start': with open(state, 'w', encoding='utf-8') as f: f.write(str(time.time())) _speak("Секундомер запущен.") return if not os.path.isfile(state): _speak("Секундомер не запущен.") return try: started = float(open(state, 'r', encoding='utf-8').read().strip()) except (OSError, ValueError): _speak("Секундомер не запущен.") return elapsed = time.time() - started if op == 'stop': try: os.unlink(state) except OSError: pass _speak(f"Прошло {fmt(elapsed)}.") else: _speak(f"Прошло {fmt(elapsed)}.") 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': path = f"{CDIR}\\custom-commands\\{action['file']}" if action.get('wait'): subprocess.check_call([path]) else: subprocess.Popen([path]) if 'delay_ms' in action: time.sleep(action['delay_ms'] / 1000) elif t == 'play_sound': play(action['name']) elif t == 'system': op = action['op'] if op == 'volume_mute': _set_mute(1) elif op == 'volume_unmute': _set_mute(0) elif op == 'exit': _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': 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']) 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) 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 '') elif t == 'clipboard_read': do_clipboard_read(action, _current_voice or '') elif t == 'notes_add': do_notes_add(action, _current_voice or '') elif t == 'notes_open': do_notes_open(action, _current_voice or '') elif t == 'process_kill': do_process_kill(action, _current_voice or '') elif t == 'websearch': do_websearch(action, _current_voice or '') elif t == 'translate': do_translate(action, _current_voice or '') elif t == 'math_eval': do_math_eval(action, _current_voice or '') elif t == 'theme_set': do_theme_set(action, _current_voice or '') elif t == 'open_project': do_open_project(action, _current_voice or '') elif t == 'list_projects': do_list_projects(action, _current_voice or '') elif t == 'open_sound_panel': do_open_sound_panel(action, _current_voice or '') elif t == 'set_reminder': do_set_reminder(action, _current_voice or '') elif t == 'date_query': do_date_query(action, _current_voice or '') elif t == 'type_text': do_type_text(action, _current_voice or '') elif t == 'dice': do_dice(action, _current_voice or '') elif t == 'stopwatch': do_stopwatch(action, _current_voice or '') elif t == 'news': do_news(action, _current_voice or '') elif t == 'currency_rate': do_currency_rate(action, _current_voice or '') elif t == 'crypto_price': do_crypto_price(action, _current_voice or '') elif t == 'fun_ask': do_fun_ask(action, _current_voice or '') elif t == 'wiki_lookup': do_wiki_lookup(action, _current_voice or '') elif t == 'power': do_power(action, _current_voice or '') elif t == 'help_commands': do_help_commands(action, _current_voice or '') elif t == 'launch_game': do_launch_game(action, _current_voice or '') elif t == 'list_games': do_list_games(action, _current_voice or '') elif t == 'mouse': do_mouse(action, _current_voice or '') elif t == 'random_choice': do_random_choice(action, _current_voice or '') elif t == 'brightness': do_brightness(action, _current_voice or '') elif t == 'window_switch': do_window_switch(action, _current_voice or '') elif t == 'spell_out': do_spell_out(action, _current_voice or '') elif t == 'network_settings': do_network_settings(action, _current_voice or '') elif t == 'my_ip': do_my_ip(action, _current_voice or '') elif t == 'summarize_selection': do_summarize_selection(action, _current_voice or '') # ── Extension handlers (ported from rust fork) ── elif t == 'lock_workstation': extensions.do_lock_workstation(action, _current_voice or '') elif t == 'screenshot': extensions.do_screenshot(action, _current_voice or '') elif t == 'disk_free': extensions.do_disk_free(action, _current_voice or '') elif t == 'disk_list': extensions.do_disk_list(action, _current_voice or '') elif t == 'coin_flip': extensions.do_coin(action, _current_voice or '') elif t == 'password_gen': extensions.do_password_gen(action, _current_voice or '') elif t == 'uuid_gen': extensions.do_uuid(action, _current_voice or '') elif t == 'ssl_check': extensions.do_ssl_check(action, _current_voice or '') elif t == 'wifi_ssid': extensions.do_wifi_ssid(action, _current_voice or '') elif t == 'public_ip': extensions.do_public_ip(action, _current_voice or '') elif t == 'unit_convert': extensions.do_unit_convert(action, _current_voice or '') elif t == 'date_math': extensions.do_date_math(action, _current_voice or '') elif t == 'echo_repeat': extensions.do_echo_repeat(action, _current_voice or '') elif t == 'self_ping': extensions.do_self_ping(action, _current_voice or '') elif t == 'interesting_fact': extensions.do_interesting_fact(action, _current_voice or '') elif t == 'memory_remember': extensions.do_memory_remember(action, _current_voice or '') elif t == 'memory_recall': extensions.do_memory_recall(action, _current_voice or '') elif t == 'memory_forget': extensions.do_memory_forget(action, _current_voice or '') elif t == 'memory_list': extensions.do_memory_list(action, _current_voice or '') elif t == 'profile_set': extensions.do_profile_set(action, _current_voice or '') elif t == 'profile_what': extensions.do_profile_what(action, _current_voice or '') elif t == 'vision_describe': extensions.do_vision_describe(action, _current_voice or '') elif t == 'vision_read_error': extensions.do_vision_read_error(action, _current_voice or '') elif t == 'macros_start': extensions.do_macros_start(action, _current_voice or '') elif t == 'macros_save': extensions.do_macros_save(action, _current_voice or '') elif t == 'macros_cancel': extensions.do_macros_cancel(action, _current_voice or '') elif t == 'macros_replay': extensions.do_macros_replay(action, _current_voice or '') elif t == 'macros_list': extensions.do_macros_list(action, _current_voice or '') elif t == 'macros_delete': extensions.do_macros_delete(action, _current_voice or '') elif t == 'scheduler_add_reminder': extensions.do_scheduler_add_reminder(action, _current_voice or '') elif t == 'scheduler_add_recurring': extensions.do_scheduler_add_recurring(action, _current_voice or '') elif t == 'scheduler_list': extensions.do_scheduler_list(action, _current_voice or '') elif t == 'scheduler_clear': extensions.do_scheduler_clear(action, _current_voice or '') elif t == 'scheduler_cancel_by_text': extensions.do_scheduler_cancel_by_text(action, _current_voice or '') elif t == 'codebase_set': extensions.do_codebase_set(action, _current_voice or '') elif t == 'codebase_where': extensions.do_codebase_where(action, _current_voice or '') elif t == 'codebase_ask': extensions.do_codebase_ask(action, _current_voice or '') elif t == 'github_set_repo': extensions.do_github_set_repo(action, _current_voice or '') elif t == 'github_list_prs': extensions.do_github_list_prs(action, _current_voice or '') elif t == 'github_summarize_pr': extensions.do_github_summarize_pr(action, _current_voice or '') elif t == 'llm_switch': extensions.do_llm_switch(action, _current_voice or '') elif t == 'llm_status': extensions.do_llm_status(action, _current_voice or '') elif t == 'now_playing': extensions.do_now_playing(action, _current_voice or '') elif t == 'world_clock': extensions.do_world_clock(action, _current_voice or '') elif t == 'daily_quote': extensions.do_daily_quote(action, _current_voice or '') elif t == 'magic_8ball': extensions.do_magic_8ball(action, _current_voice or '') elif t == 'gh_list_issues': extensions.do_gh_list_issues(action, _current_voice or '') elif t == 'gh_my_issues': extensions.do_gh_my_issues(action, _current_voice or '') elif t == 'weather_tomorrow': extensions.do_weather_tomorrow(action, _current_voice or '') elif t == 'weather_week': extensions.do_weather_week(action, _current_voice or '') elif t == 'rss_add': extensions.do_rss_add(action, _current_voice or '') elif t == 'rss_read': extensions.do_rss_read(action, _current_voice or '') elif t == 'rss_list': extensions.do_rss_list(action, _current_voice or '') elif t == 'ics_create': extensions.do_ics_create(action, _current_voice or '') elif t == 'backup_export': extensions.do_backup_export(action, _current_voice or '') elif t == 'clip_save': extensions.do_clip_save(action, _current_voice or '') elif t == 'clip_list': extensions.do_clip_list(action, _current_voice or '') elif t == 'clip_restore': extensions.do_clip_restore(action, _current_voice or '') elif t == 'notif_missed': extensions.do_notif_missed(action, _current_voice or '') elif t == 'notif_clear': extensions.do_notif_clear(action, _current_voice or '') elif t == 'vault_save': extensions.do_vault_save(action, _current_voice or '') elif t == 'vault_get': extensions.do_vault_get(action, _current_voice or '') elif t == 'vault_list': extensions.do_vault_list(action, _current_voice or '') elif t == 'habit_checkin': extensions.do_habit_checkin(action, _current_voice or '') elif t == 'habit_streak': extensions.do_habit_streak(action, _current_voice or '') elif t == 'personality_greet': extensions.do_personality_greet(action, _current_voice or '') elif t == 'personality_thanks': extensions.do_personality_thanks(action, _current_voice or '') elif t == 'personality_compliment': extensions.do_personality_compliment(action, _current_voice or '') elif t == 'personality_how_are_you': extensions.do_personality_how_are_you(action, _current_voice or '') elif t == 'personality_tony_quote': extensions.do_personality_tony_quote(action, _current_voice or '') elif t == 'tracker_start': extensions.do_tracker_start(action, _current_voice or '') elif t == 'tracker_stop': extensions.do_tracker_stop(action, _current_voice or '') elif t == 'tracker_today': extensions.do_tracker_today(action, _current_voice or '') elif t == 'tracker_week': extensions.do_tracker_week(action, _current_voice or '') elif t == 'tracker_reset': extensions.do_tracker_reset(action, _current_voice or '') elif t == 'outlook_unread_count': extensions.do_outlook_unread_count(action, _current_voice or '') elif t == 'outlook_latest': extensions.do_outlook_latest(action, _current_voice or '') elif t == 'outlook_send_clipboard': extensions.do_outlook_send_clipboard(action, _current_voice or '') elif t == 'outlook_summarize_inbox': extensions.do_outlook_summarize_inbox(action, _current_voice or '') elif t == 'memory_count': extensions.do_memory_count(action, _current_voice or '') elif t == 'memory_list': extensions.do_memory_list(action, _current_voice or '') elif t == 'memory_wipe': extensions.do_memory_wipe(action, _current_voice or '') elif t == 'cooking': extensions.do_cooking(action, _current_voice or '') elif t == 'leftoff': extensions.do_leftoff(action, _current_voice or '') elif t == 'trivia': extensions.do_trivia(action, _current_voice or '') elif t == 'good_night': extensions.do_good_night(action, _current_voice or '') elif t == 'good_morning': extensions.do_good_morning(action, _current_voice or '') elif t == 'coffee_break': extensions.do_coffee_break(action, _current_voice or '') elif t == 'expenses_log': extensions.do_expenses_log(action, _current_voice or '') elif t == 'expenses_today': extensions.do_expenses_today(action, _current_voice or '') elif t == 'expenses_week': extensions.do_expenses_week(action, _current_voice or '') elif t == 'expenses_breakdown': extensions.do_expenses_breakdown(action, _current_voice or '') elif t == 'wol': extensions.do_wol(action, _current_voice or '') elif t == 'banter_fire': extensions.do_banter_fire(action, _current_voice or '') elif t == 'banter_pause': extensions.do_banter_pause(action, _current_voice or '') elif t == 'banter_resume': extensions.do_banter_resume(action, _current_voice or '') elif t == 'conversation_summary': extensions.do_conversation_summary(action, _current_voice or '') elif t == 'conversation_repeat': extensions.do_conversation_repeat(action, _current_voice or '') elif t == 'ddg_answer': extensions.do_ddg_answer(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'] _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' if confirm: play(confirm) print("=" * 60) print(f" J.A.R.V.I.S. v{config.VA_VER} [Python edition]") print(f" Total commands: {len(VA_CMD_LIST)}") print("=" * 60) # Hook macros to the dispatch path: registers a callback so macros::replay # can re-fire commands, and so the recorder can capture successful phrases. def _execute_phrase_for_macro(phrase: str): """Replay step: re-fire a phrase through the normal dispatch path.""" try: cmd = recognize_cmd(phrase) except Exception as exc: print(f"[macros] replay recognize: {exc}") return if cmd: execute_cmd(cmd, phrase) extensions.set_dispatch_fn(_execute_phrase_for_macro) # Wrap execute_cmd to also feed the macro recorder on success. _orig_execute_cmd = execute_cmd def execute_cmd(cmd: str, voice: str): # noqa: F811 spec = VA_CMD_LIST.get(cmd) _orig_execute_cmd(cmd, voice) if spec: # On every successful dispatch, feed the recorder. Filter inside # macros_store.record_step blocks macro-control phrases. try: import macros_store macros_store.record_step(voice) except Exception as exc: print(f"[macros] record_step: {exc}") recorder = PvRecorder(device_index=config.MICROPHONE_INDEX, frame_length=512) recorder.start() print('Using device: %s' % recorder.selected_device) print(f"Jarvis (v{config.VA_VER}) начал свою работу ...") play("run") time.sleep(0.5) def heard_wake_word(text: str) -> bool: if not text: return False lowered = text.lower() return any(w in lowered for w in config.WAKE_WORDS) while True: try: pcm = recorder.read() sp = struct.pack("h" * len(pcm), *pcm) if not wake_rec.AcceptWaveform(sp): continue result_text = json.loads(wake_rec.Result()).get("text", "") if not heard_wake_word(result_text): continue play("greet", True) print("Yes, sir.") kaldi_rec.Reset() listen_started = time.time() speech_ms = 0 silence_ms = 0 vad_buf = b"" finalize = False cmd_buf = b"" if config.DENOISE_ENABLED else None while True: elapsed_ms = (time.time() - listen_started) * 1000 if elapsed_ms >= config.COMMAND_MAX_LISTEN_MS: break pcm = recorder.read() sp = struct.pack("h" * len(pcm), *pcm) if cmd_buf is None: kaldi_rec.AcceptWaveform(sp) else: cmd_buf += sp vad_buf += sp while len(vad_buf) >= VAD_FRAME_BYTES: frame = vad_buf[:VAD_FRAME_BYTES] vad_buf = vad_buf[VAD_FRAME_BYTES:] if vad.is_speech(frame, samplerate): speech_ms += VAD_FRAME_MS silence_ms = 0 else: silence_ms += VAD_FRAME_MS if elapsed_ms < config.COMMAND_MIN_LISTEN_MS: continue if speech_ms >= config.COMMAND_MIN_SPEECH_MS and silence_ms >= config.COMMAND_END_SILENCE_MS: finalize = True break 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", "") if text: va_respond(text) wake_rec.Reset() except Exception as err: print(f"Unexpected {err=}, {type(err)=}") raise