From 8d3da4ea0653e8b98cec2af40b7c425f2523e472 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Sat, 16 May 2026 00:44:42 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20LLM=20hot-swap=20voice=20command=20+=20?= =?UTF-8?q?Ollama=20backend=20(148=20=E2=86=92=20151=20commands)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /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). --- commands.yaml | 32 ++++++++++ dev_handlers.py | 49 ++++----------- extensions.py | 62 ++++++++++++++----- llm_backend.py | 152 ++++++++++++++++++++++++++++++++++++++++++++++ main.py | 4 ++ vision_handler.py | 8 ++- 6 files changed, 254 insertions(+), 53 deletions(-) create mode 100644 llm_backend.py diff --git a/commands.yaml b/commands.yaml index e969ebf..83779ca 100644 --- a/commands.yaml +++ b/commands.yaml @@ -1275,3 +1275,35 @@ github_summarize_pr: - что в последнем pr - разбор pr action: {type: github_summarize_pr} + +llm_switch_local: + phrases: + - переключись на локальный + - переключись на локальный мозг + - включи локальный режим + - перейди на локальный + - используй локальный + - перейди на оллама + - используй оллама + action: {type: llm_switch, target: ollama} + +llm_switch_cloud: + phrases: + - переключись на облако + - переключись на облачный + - переключись на грок + - переключись на гроку + - используй облако + - используй грок + - перейди на облако + action: {type: llm_switch, target: groq} + +llm_status: + phrases: + - какой у тебя мозг + - какой ллм + - какой у тебя llm + - облако или локально + - ты сейчас где работаешь + - что за модель + action: {type: llm_status} diff --git a/dev_handlers.py b/dev_handlers.py index 5a7a0d6..9318698 100644 --- a/dev_handlers.py +++ b/dev_handlers.py @@ -15,6 +15,7 @@ import re import subprocess import memory_store +import llm_backend _speak_fn = print @@ -146,25 +147,11 @@ def do_codebase_ask(action, voice): digest = "\n\n".join(f"--- {p} ---\n{c}" for p, c in chunks) - try: - import config as cfg - except ImportError: - cfg = None - token = getattr(cfg, 'GROQ_TOKEN', None) if cfg else None - if not token: - _speak("LLM не настроен — нужен GROQ_TOKEN.") + client = llm_backend.current_client() + if client is None: + _speak("LLM не настроен.") return - - try: - from openai import OpenAI - except ImportError: - _speak("OpenAI клиент не установлен.") - return - - client = OpenAI( - api_key=token, - base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'), - ) + model = llm_backend.current_model() user_prompt = ( f"Ты — старший разработчик. По digest проекта ответь на вопрос пользователя " @@ -174,7 +161,7 @@ def do_codebase_ask(action, voice): try: resp = client.chat.completions.create( - model=getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile'), + model=model, messages=[ {'role': 'system', 'content': 'Ты — внимательный код-ревьюер. По существу, без воды.'}, {'role': 'user', 'content': user_prompt}, @@ -291,25 +278,11 @@ def do_github_summarize_pr(action, voice): files = pr.get('changedFiles', '?') author = (pr.get('author') or {}).get('login', '(unknown)') - try: - import config as cfg - except ImportError: - cfg = None - token = getattr(cfg, 'GROQ_TOKEN', None) if cfg else None - if not token: - _speak(f"PR номер {number}: {title}. Без LLM сводки — нужен GROQ_TOKEN.") + client = llm_backend.current_client() + if client is None: + _speak(f"PR номер {number}: {title}. Без LLM — настройте Groq или Ollama.") return - - try: - from openai import OpenAI - except ImportError: - _speak("OpenAI клиент не установлен.") - return - - client = OpenAI( - api_key=token, - base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'), - ) + model = llm_backend.current_model() user_prompt = ( f"Repo: {repo}\nPR #{number}: {title}\nAuthor: {author}\n" @@ -321,7 +294,7 @@ def do_github_summarize_pr(action, voice): try: resp = client.chat.completions.create( - model=getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile'), + model=model, messages=[ {'role': 'system', 'content': "Ты — старший разработчик, делаешь код-ревью. По делу, без воды."}, {'role': 'user', 'content': user_prompt}, diff --git a/extensions.py b/extensions.py index b8554ca..25e77b8 100644 --- a/extensions.py +++ b/extensions.py @@ -26,6 +26,7 @@ import macros_store import scheduler_store import vision_handler import dev_handlers +import llm_backend _speak_fn = print _set_clipboard_fn = None @@ -828,11 +829,12 @@ def do_scheduler_cancel_by_text(action, voice): # ── interesting fact (LLM-dependent) ─────────────────────────────────────── def do_interesting_fact(action, voice): - """Asks the configured Groq LLM for a fun fact. Requires GROQ_TOKEN.""" - import config as cfg - if not getattr(cfg, 'GROQ_TOKEN', None): + """Asks the active LLM for a fun fact. Backend chosen by llm_backend.""" + client = llm_backend.current_client() + if client is None: _speak("LLM не настроен.") return + model = llm_backend.current_model() topic = '' low = (voice or '').lower() @@ -850,19 +852,9 @@ def do_interesting_fact(action, voice): else: prompt = "Расскажи один реально неочевидный научно-проверенный факт. На русском, 1-2 предложения. Без вступлений типа 'знали ли вы'." - try: - from openai import OpenAI - except ImportError: - _speak("OpenAI клиент не установлен.") - return - - client = OpenAI( - api_key=cfg.GROQ_TOKEN, - base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'), - ) try: resp = client.chat.completions.create( - model=getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile'), + model=model, messages=[ {'role': 'system', 'content': 'Ты любопытный собеседник. Цепляющие факты, без воды.'}, {'role': 'user', 'content': prompt}, @@ -879,6 +871,48 @@ def do_interesting_fact(action, voice): _speak("Не получилось получить факт.") +# ── LLM hot-swap ──────────────────────────────────────────────────────────── + +def do_llm_switch(action, voice): + """Hot-swap LLM backend. Action.target preferred, else parse from voice.""" + target = action.get('target') or '' + if not target: + # Try to extract from voice + low = (voice or '').lower() + if 'локал' in low or 'оллам' in low or 'ollama' in low: + target = 'ollama' + elif 'облак' in low or 'грок' in low or 'клауд' in low or 'groq' in low: + target = 'groq' + + parsed = llm_backend.parse_backend(target) + if not parsed: + _speak("Не понял какой движок.") + return + + try: + actual = llm_backend.swap_to(parsed) + except Exception as exc: + print(f"[llm] swap failed: {exc}") + human = "локальный" if parsed == 'ollama' else "облачный" + _speak(f"Не получилось переключиться на {human}.") + return + + if actual == 'groq': + _speak("Переключился на облачный мозг.") + else: + _speak("Переключился на локальный мозг.") + + +def do_llm_status(action, voice): + backend = llm_backend.current_backend() + if backend == 'groq': + _speak("Сейчас работаю на облаке.") + elif backend == 'ollama': + _speak("Сейчас работаю локально.") + else: + _speak("LLM не настроен.") + + # ── codebase Q&A (proxies to dev_handlers) ────────────────────────────────── def do_codebase_set(action, voice): diff --git a/llm_backend.py b/llm_backend.py new file mode 100644 index 0000000..51bf468 --- /dev/null +++ b/llm_backend.py @@ -0,0 +1,152 @@ +"""LLM backend singleton — Python port of `crates/jarvis-core/src/llm/mod.rs`. + +Supports two backends: + - Groq (cloud) — uses config.GROQ_TOKEN + - Ollama (local) — connects to OLLAMA_BASE_URL (default localhost:11434/v1) + +Active choice persists in `/llm_backend.txt`. Loaded on first call. + +Public API: + current_client() -> OpenAI client for the active backend, or None + current_backend() -> 'groq' | 'ollama' | 'none' + current_model() -> model name string + swap_to(name) -> 'groq' | 'ollama', persists choice + parse_backend(name) -> normalized 'groq' | 'ollama' | None +""" + +import os +import threading + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_CHOICE_FILE = os.path.join(_HERE, 'llm_backend.txt') + +_lock = threading.Lock() +_client = None +_backend = 'none' +_model = '' +_initialized = False + + +def _read_persisted() -> str: + try: + with open(_CHOICE_FILE, encoding='utf-8') as f: + return f.read().strip().lower() + except OSError: + return '' + + +def _persist(name: str) -> None: + try: + with open(_CHOICE_FILE, 'w', encoding='utf-8') as f: + f.write(name) + except OSError as exc: + print(f"[llm] persist: {exc}") + + +def parse_backend(name: str) -> str | None: + """Normalize a user-facing name to 'groq' / 'ollama' / None.""" + n = (name or '').strip().lower() + if n in ('groq', 'cloud', 'облако', 'клауд'): + return 'groq' + if n in ('ollama', 'local', 'локал', 'локальн', 'локальный'): + return 'ollama' + return None + + +def _build_groq(): + """Try to construct a Groq client. Returns (client, model) or raises.""" + try: + import config as cfg + except ImportError: + raise RuntimeError("config.py не найден") + if not getattr(cfg, 'GROQ_TOKEN', None): + raise RuntimeError("GROQ_TOKEN отсутствует в config") + from openai import OpenAI + base = getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1') + model = getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile') + return OpenAI(api_key=cfg.GROQ_TOKEN, base_url=base), model + + +def _build_ollama(): + """Construct an Ollama-talking OpenAI-compatible client.""" + from openai import OpenAI + base = os.environ.get('OLLAMA_BASE_URL', 'http://localhost:11434/v1') + model = os.environ.get('OLLAMA_MODEL', 'qwen2.5:3b') + # Ollama doesn't need a real key, but openai lib insists on a non-empty string. + return OpenAI(api_key='ollama', base_url=base), model + + +def _build_for(backend: str): + if backend == 'groq': + return _build_groq() + if backend == 'ollama': + return _build_ollama() + raise ValueError(f"unknown backend: {backend}") + + +def _auto_detect_backend() -> str: + """Pick a default backend if none persisted: Groq if token present, else Ollama.""" + try: + import config as cfg + if getattr(cfg, 'GROQ_TOKEN', None): + return 'groq' + except ImportError: + pass + return 'ollama' + + +def _ensure_init(): + """Build the client based on persisted choice, env, or auto-detect.""" + global _client, _backend, _model, _initialized + if _initialized: + return + persisted = _read_persisted() + env_choice = os.environ.get('JARVIS_LLM', '').strip().lower() + backend = persisted or env_choice or _auto_detect_backend() + try: + client, model = _build_for(backend) + _client = client + _backend = backend + _model = model + except Exception as exc: + print(f"[llm] init {backend} failed: {exc}") + _client = None + _backend = 'none' + _model = '' + _initialized = True + + +def current_client(): + """Get the active client (or None if uninitialised). Cheap after first call.""" + with _lock: + _ensure_init() + return _client + + +def current_backend() -> str: + with _lock: + _ensure_init() + return _backend + + +def current_model() -> str: + with _lock: + _ensure_init() + return _model + + +def swap_to(name: str) -> str: + """Hot-swap to a different backend. Persists to disk. Returns new name.""" + target = parse_backend(name) + if not target: + raise ValueError(f"unknown backend: {name}") + with _lock: + global _client, _backend, _model, _initialized + client, model = _build_for(target) # may raise + _client = client + _backend = target + _model = model + _initialized = True + _persist(target) + print(f"[llm] swapped → {target} ({model})") + return target diff --git a/main.py b/main.py index 925f9d7..ceab4eb 100644 --- a/main.py +++ b/main.py @@ -1626,6 +1626,10 @@ def run_action(action): 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 '') _current_voice: str = '' diff --git a/vision_handler.py b/vision_handler.py index a3d17af..e39f307 100644 --- a/vision_handler.py +++ b/vision_handler.py @@ -67,7 +67,13 @@ def _take_screenshot() -> str | None: def _vision_call(prompt: str, image_b64: str) -> str | None: - import config as cfg + # 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