Closes the last functional parity gap with rust. Python now has voice-driven
Ollama↔Groq switching, persistent across restarts.
llm_backend.py (new, ~130 lines)
Singleton module:
current_client() → active OpenAI client (or None)
current_backend() → 'groq' | 'ollama' | 'none'
current_model() → active model name
swap_to(name) → hot-swap + persist to <here>/llm_backend.txt
parse_backend(name) → ru/en alias normaliser ('облако'→groq, 'локальный'→ollama)
Backends:
- Groq: config.GROQ_TOKEN + GROQ_BASE_URL + GROQ_MODEL (defaults)
- Ollama: OLLAMA_BASE_URL (default http://localhost:11434/v1) +
OLLAMA_MODEL (default qwen2.5:3b). api_key='ollama' (placeholder,
openai lib insists on a non-empty string; Ollama ignores it).
Init precedence (idempotent _ensure_init):
1. Persisted choice from llm_backend.txt
2. JARVIS_LLM env var
3. Auto-detect: Groq if token present, else Ollama
Voice commands (commands.yaml, +3 entries → 151)
llm_switch_local → ollama
llm_switch_cloud → groq
llm_status → speaks current backend
extensions.py
+ do_llm_switch / do_llm_status handlers
+ llm_backend import
do_interesting_fact migrated off direct OpenAI(...) to use llm_backend.current_*
dev_handlers.py
+ llm_backend import
do_codebase_ask + do_github_summarize_pr migrated to llm_backend.current_*
vision_handler.py
Vision call documented as Groq-specific (Ollama doesn't expose vision via
OpenAI-compat in our stack), kept direct config.GROQ_TOKEN reading.
Tests: ast.parse passes for all 5 modules. yaml.safe_load = 151 entries.
PYTHON PARITY VS RUST IS NOW FUNCTIONALLY COMPLETE.
Only remaining rust-only feature: the Tauri GUI (python is console-only).
152 lines
4.7 KiB
Python
152 lines
4.7 KiB
Python
"""Vision handler — port of `crates/jarvis-core/src/lua/api/vision.rs`.
|
||
|
||
Captures the primary screen via PowerShell System.Drawing, base64-encodes
|
||
the PNG, sends to Groq's vision-capable model. Requires GROQ_TOKEN.
|
||
|
||
Action types in commands.yaml:
|
||
vision_describe "что на экране" / "опиши экран"
|
||
vision_read_error "прочитай ошибку"
|
||
"""
|
||
|
||
import base64
|
||
import os
|
||
import subprocess
|
||
import tempfile
|
||
|
||
_speak_fn = print
|
||
|
||
|
||
def set_speak(fn):
|
||
global _speak_fn
|
||
_speak_fn = fn
|
||
|
||
|
||
def _speak(text):
|
||
try:
|
||
_speak_fn(text)
|
||
except Exception as exc:
|
||
print(f"[vision] speak: {exc}")
|
||
|
||
|
||
_SCREENSHOT_PS_TEMPLATE = """
|
||
Add-Type -AssemblyName System.Windows.Forms;
|
||
Add-Type -AssemblyName System.Drawing;
|
||
$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds;
|
||
$bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height;
|
||
$g = [System.Drawing.Graphics]::FromImage($bmp);
|
||
$g.CopyFromScreen($b.Location, [System.Drawing.Point]::Empty, $b.Size);
|
||
$bmp.Save('{path}', [System.Drawing.Imaging.ImageFormat]::Png);
|
||
$g.Dispose(); $bmp.Dispose();
|
||
"""
|
||
|
||
|
||
def _take_screenshot() -> str | None:
|
||
"""Save full screen to a temp PNG, return path or None on failure."""
|
||
fd, path = tempfile.mkstemp(prefix='jarvis-screen-', suffix='.png')
|
||
os.close(fd)
|
||
ps = _SCREENSHOT_PS_TEMPLATE.format(path=path.replace("'", "''"))
|
||
try:
|
||
subprocess.run(
|
||
['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps],
|
||
capture_output=True, timeout=10, check=True,
|
||
)
|
||
except Exception as exc:
|
||
print(f"[vision] screenshot: {exc}")
|
||
try:
|
||
os.unlink(path)
|
||
except OSError:
|
||
pass
|
||
return None
|
||
if not os.path.isfile(path) or os.path.getsize(path) < 1000:
|
||
try:
|
||
os.unlink(path)
|
||
except OSError:
|
||
pass
|
||
return None
|
||
return path
|
||
|
||
|
||
def _vision_call(prompt: str, image_b64: str) -> str | None:
|
||
# Vision is Groq-specific (Ollama doesn't expose vision via OpenAI-compat
|
||
# endpoint in our stack). Use config.GROQ_TOKEN directly regardless of the
|
||
# active text-LLM backend.
|
||
try:
|
||
import config as cfg
|
||
except ImportError:
|
||
return None
|
||
if not getattr(cfg, 'GROQ_TOKEN', None):
|
||
return None
|
||
|
||
try:
|
||
from openai import OpenAI
|
||
except ImportError:
|
||
return None
|
||
|
||
client = OpenAI(
|
||
api_key=cfg.GROQ_TOKEN,
|
||
base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'),
|
||
)
|
||
|
||
model = os.environ.get('GROQ_VISION_MODEL', 'llama-3.2-11b-vision-preview')
|
||
|
||
try:
|
||
resp = client.chat.completions.create(
|
||
model=model,
|
||
messages=[{
|
||
'role': 'user',
|
||
'content': [
|
||
{'type': 'text', 'text': prompt},
|
||
{'type': 'image_url', 'image_url': {
|
||
'url': f'data:image/png;base64,{image_b64}',
|
||
}},
|
||
],
|
||
}],
|
||
max_tokens=400, temperature=0.2, timeout=30,
|
||
)
|
||
return resp.choices[0].message.content.strip()
|
||
except Exception as exc:
|
||
print(f"[vision] LLM call: {exc}")
|
||
return None
|
||
|
||
|
||
def _describe(prompt: str) -> str | None:
|
||
"""Take a screenshot and ask vision-LLM. Returns description or None."""
|
||
path = _take_screenshot()
|
||
if not path:
|
||
return None
|
||
try:
|
||
with open(path, 'rb') as f:
|
||
b64 = base64.b64encode(f.read()).decode('ascii')
|
||
finally:
|
||
try:
|
||
os.unlink(path)
|
||
except OSError:
|
||
pass
|
||
return _vision_call(prompt, b64)
|
||
|
||
|
||
def do_vision_describe(action, voice):
|
||
_speak("Сейчас посмотрю.")
|
||
prompt = (
|
||
"Опиши коротко (1-3 предложения) что сейчас на экране. По-русски."
|
||
)
|
||
result = _describe(prompt)
|
||
if not result:
|
||
_speak("Не удалось разобрать экран. Проверь GROQ_TOKEN.")
|
||
return
|
||
_speak(result)
|
||
|
||
|
||
def do_vision_read_error(action, voice):
|
||
_speak("Смотрю на ошибку.")
|
||
prompt = (
|
||
"Найди на экране текст ошибки (сообщение об ошибке, stack trace, диалог "
|
||
"об исключении). Прочитай ключевое сообщение и одним коротким "
|
||
"предложением подскажи возможную причину. Если ошибки нет — скажи "
|
||
"'ошибок не вижу'. Отвечай по-русски. 1-3 предложения максимум."
|
||
)
|
||
result = _describe(prompt)
|
||
if not result:
|
||
_speak("Не получилось прочитать.")
|
||
return
|
||
_speak(result)
|