ROOT CAUSE
User reported the Python edition showed "только пустой терминал" — empty
terminal — when launched. Three compounding issues:
1. `tts.py` called `torch.hub.load(...)` at MODULE IMPORT time. On first
use this downloads a 60MB Silero model with no progress output beyond
one "Using cache found in..." line. With no prior banner the user
couldn't tell if it crashed or was loading.
2. main.py's startup banner (`print("=" * 60)...J.A.R.V.I.S. v...`)
appeared AFTER all the heavy imports. So even when nothing was wrong,
visible feedback was delayed by 10-60 seconds.
3. If `pip install -r requirements.txt` failed for any package
(`simpleaudio` notoriously needs MSVC to build), `import simpleaudio`
crashed the whole process with a raw ModuleNotFoundError.
FIXES
- `tts.py`: Silero model load moved into `_ensure_model()` called lazily
from `synthesize()`. First synth pays the load cost (with a one-line
progress hint) — everything before that is visible immediately.
- `main.py`: print "J.A.R.V.I.S. loading..." banner BEFORE any heavy
imports. User sees the terminal is alive within ~100ms.
- `main.py`: scan required deps before importing them; if any are
missing, print "Run: pip install -r requirements.txt" with the
specific list and exit cleanly (exit code 2) instead of stack-tracing.
- `main.py`: `simpleaudio` is now soft-optional. Falls back to stdlib
`winsound` on Windows installs that couldn't build the C extension.
Sound cues still work; only the precise wait_done semantics are
slightly different.
VERIFIED LOCALLY
`.venv\Scripts\python.exe main.py` now prints:
```
============================================================
J.A.R.V.I.S. loading (Python edition)...
Heavy modules (vosk / torch / pvrecorder) take a few seconds.
============================================================
[scheduler] thread started (1 tasks loaded)
============================================================
J.A.R.V.I.S. v0.4.1 [Python edition]
Total commands: 200
============================================================
Using device: Микрофон (5- Fifine Microphone)
Jarvis (v0.4.1) начал свою работу ...
Yes, sir.
```
Total time to first banner: ~100ms (was 10-60s of nothing).
All 104 pytest tests still pass.
1946 lines
75 KiB
Python
1946 lines
75 KiB
Python
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]
|
||
|
||
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'<title>\s*<!\[CDATA\[(.+?)\]\]>\s*</title>', body)
|
||
if not titles:
|
||
titles = _re_reminders.findall(r'<title>\s*(.+?)\s*</title>', 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 '')
|
||
|
||
|
||
_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
|