"""Фоновый запуск llama-server при старте WESP (если WESP_LLM_AUTOSTART и LLM включён).""" from __future__ import annotations import logging import os import shutil import subprocess import sys from pathlib import Path from typing import Any, Optional from urllib.parse import urlparse logger = logging.getLogger(__name__) _proc: Optional[subprocess.Popen] = None def _should_run_in_this_process(app: Any) -> bool: """Не дублировать в родителе Flask reloader (как sync_client).""" if bool(app.config.get("DEBUG", False)): return os.environ.get("WERKZEUG_RUN_MAIN") == "true" return True def _parse_base_url(url: str) -> tuple[str, int]: u = urlparse((url or "").strip() or "http://127.0.0.1:8080") host = u.hostname or "127.0.0.1" port = int(u.port or 8080) return host, port def _resolve_llama_binary(base_dir: Path) -> Optional[Path]: env = os.getenv("LLAMA_SERVER", "").strip() if env: p = Path(env) if p.is_file(): return p if sys.platform == "win32": for rel in ("tools/llama-server.exe", "tools/llama-server/llama-server.exe"): cand = base_dir / rel.replace("/", os.sep) if cand.is_file(): return cand else: cand = base_dir / "tools" / "llama-server" if cand.is_file(): return cand which = shutil.which("llama-server") if which: return Path(which) return None def _gguf_path(app: Any) -> Path: cfg = app.config root = Path(str(cfg.get("WESP_ASSISTANT_DIR") or "")) name = os.getenv("WESP_LLM_GGUF_FILE", "").strip() or str( cfg.get("WESP_LLM_MODEL") or "" ).strip() if not name: name = "qwen2.5-1.5b-instruct-q5_k_m.gguf" return root / "models" / name def schedule_llm_server_autostart(app: Any) -> None: """Запускает llama-server дочерним процессом; idempotent.""" global _proc if _proc is not None: return if app.config.get("TESTING"): return if not app.config.get("WESP_LLM_AUTOSTART"): return if not app.config.get("WESP_ADMIN_LLM_ENABLED"): logger.warning( "WESP_LLM_AUTOSTART пропущен: LLM в админке выключен (галка или admin_llm_enabled в wesp_security_settings.json, либо WESP_ADMIN_LLM_ENABLED=0)." ) return if not _should_run_in_this_process(app): return base_dir = Path(str(app.config.get("BASE_DIR") or ".")).resolve() gguf = _gguf_path(app) if not gguf.is_file(): logger.warning( "WESP_LLM_AUTOSTART пропущен: нет файла модели %s", gguf, ) return bin_path = _resolve_llama_binary(base_dir) if not bin_path: logger.warning( "WESP_LLM_AUTOSTART пропущен: не найден llama-server " "(переменная LLAMA_SERVER или %s/tools/).", base_dir, ) return host, port = _parse_base_url(str(app.config.get("WESP_LLM_BASE_URL") or "")) args = [ str(bin_path), "-m", str(gguf), "--host", host, "--port", str(port), "-c", "8192", ] cwd = str(bin_path.parent) popen_kw: dict = {"cwd": cwd} if sys.platform == "win32": popen_kw["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP try: _proc = subprocess.Popen(args, **popen_kw) except OSError as exc: logger.warning("WESP_LLM_AUTOSTART: не удалось запустить llama-server: %s", exc) return logger.info( "WESP_LLM_AUTOSTART: llama-server PID=%s, %s:%s, модель %s", _proc.pid, host, port, gguf.name, ) def stop_llm_server_process() -> None: """Завершить дочерний llama-server при выходе из процесса WESP.""" global _proc if _proc is None: return p = _proc _proc = None try: p.terminate() p.wait(timeout=8) except Exception: try: p.kill() except Exception: pass