"""Deep Assistent diagnostics for the debug HTTP sidecar. Probes extension compile/load markers, overlay personas, ollama-roles, sqlite, live SwarmUI Assistent* APIs, optional 1-token Ollama chat smoke, opt-in AssistentChat evaluation (/assistent/chat-eval), and multi-turn debug sessions (/assistent/session*). """ from __future__ import annotations import json import re import time import urllib.request from typing import Any from gpu_rent.config import Config from gpu_rent import debug_checks from gpu_rent.llm_runtime import normalize_runtime from gpu_rent.state import load_state # Opt-in AssistentChat eval defaults (not used by /snapshot). DEFAULT_CHAT_EVAL_MESSAGE = "какой checkpoint и какие steps/cfg должны быть" DEFAULT_CHAT_EVAL_TIMEOUT = 120.0 MAX_CHAT_EVAL_TIMEOUT = 300.0 MIN_CHAT_EVAL_TIMEOUT = 15.0 # Mirrors swarm-assistent src/patch.js DEFAULT_PATCH_KEYS (sparse subset for eval). _PATCH_KEYS = frozenset( { "prompt", "negative", "loras", "width", "height", "steps", "cfg", "seed", "sigma_shift", "sampler", "scheduler", "actions", "generate", "ask", "aspect", "images", "batch", "pack", "persona", "controls", "look_at", "vision_from", "vision_slots", "variants", "checkpoint", } ) _FENCE_RE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE) # Remote filesystem probe — one SSH round-trip. _REMOTE_FS = r''' from pathlib import Path import json, os, time DATA = Path("/mnt/swarm_data") OPT = Path("/opt/swarmui/src/Extensions") roots = [DATA / "Extensions", OPT] found = [] dll_seen = set() for search in (DATA, Path("/opt/swarmui")): if not search.is_dir(): continue for dll in search.rglob("SwarmAssistentExtension.dll"): if any(p in {"obj", "node_modules"} for p in dll.parts): continue dll_seen.add(dll) for root in roots: if not root.is_dir(): continue for p in sorted(root.iterdir()): if "assistent" not in p.name.lower(): continue dlls = [d for d in dll_seen if p in d.parents or d.parent == p] # Prefer TFM output dirs (Debug/Release net*), then any match ranked = [] for cfg_name in ("Debug", "Release"): ranked.extend(sorted(p.glob(f"bin/{cfg_name}/net*/SwarmAssistentExtension.dll"))) if not ranked: ranked = sorted(dlls) or sorted(p.glob("bin/**/SwarmAssistentExtension.dll")) dll = ranked[0] if ranked else None dll_dir = dll.parent if dll else None sqlite_dll = (dll_dir / "Microsoft.Data.Sqlite.dll") if dll_dir else None csproj = next(p.glob("*.csproj"), None) tab = p / "Tabs" / "Text2Image" / "Assistent.html" bundle = p / "Assets" / "assistent.bundle.js" head = "" try: import subprocess head = subprocess.check_output( ["git", "-C", str(p), "rev-parse", "--short", "HEAD"], text=True, stderr=subprocess.DEVNULL, timeout=5, ).strip() except Exception: head = "" found.append({ "path": str(p), "name": p.name, "csproj": str(csproj) if csproj else None, "dll": str(dll) if dll else None, "dll_mtime": int(dll.stat().st_mtime) if dll else None, "dll_dir": str(dll_dir) if dll_dir else None, "sqlite_dll": bool(sqlite_dll and sqlite_dll.is_file()), "sqlitepcl": any(dll_dir.glob("SQLitePCLRaw*.dll")) if dll_dir else False, "tab_html": tab.is_file(), "bundle_js": bundle.is_file(), "git_head": head or None, }) overlay = DATA / "Assistent" personas_root = overlay / "personas" persona_ids = [] if personas_root.is_dir(): for d in sorted(personas_root.iterdir()): if d.is_dir(): persona_ids.append(d.name) pack_root = overlay / "extensions" pack_dirs = [] if pack_root.is_dir(): for d in sorted(pack_root.iterdir()): if d.is_dir(): pack_dirs.append(d.name) base_json = {} base_path = overlay / "_base" / "assistant.json" if base_path.is_file(): try: base_json = json.loads(base_path.read_text(encoding="utf-8", errors="replace")) except Exception as e: base_json = {"_error": str(e)} roles = {} roles_path = overlay / "ollama-roles.json" if roles_path.is_file(): try: roles = json.loads(roles_path.read_text(encoding="utf-8", errors="replace")) except Exception as e: roles = {"_error": str(e)} settings = {} settings_path = overlay / "settings.json" if settings_path.is_file(): try: settings = json.loads(settings_path.read_text(encoding="utf-8", errors="replace")) except Exception as e: settings = {"_error": str(e)} db = overlay / "memory" / "assistent.sqlite" db_info = { "path": str(db), "exists": db.is_file(), "size": db.stat().st_size if db.is_file() else 0, "mtime": int(db.stat().st_mtime) if db.is_file() else None, } # sqlite counts if sqlite3 CLI present counts = None if db.is_file(): try: import subprocess out = subprocess.check_output( ["sqlite3", str(db), "SELECT 'memories', COUNT(*) FROM memories; " "SELECT 'chats', COUNT(*) FROM chats; " "SELECT 'user_prefs', COUNT(*) FROM user_prefs;"], text=True, stderr=subprocess.DEVNULL, timeout=8, ) counts = {} for line in out.splitlines(): parts = line.strip().split("|") if len(parts) == 2: counts[parts[0]] = int(parts[1]) except Exception: counts = None db_info["counts"] = counts wanted_path = DATA / ".gpu-rent-wanted-models.yaml" wanted_n = 0 wanted_sample = [] if wanted_path.is_file(): text = wanted_path.read_text(encoding="utf-8", errors="replace") for line in text.splitlines(): s = line.strip() if s.startswith("- url:") or s.startswith("url:"): wanted_n += 1 if len(wanted_sample) < 5: wanted_sample.append(s.split(":", 1)[-1].strip()) print(json.dumps({ "extensions": found, "overlay": { "path": str(overlay), "exists": overlay.is_dir(), "entries": sorted(x.name for x in overlay.iterdir())[:50] if overlay.is_dir() else [], "persona_ids": persona_ids, "pack_dirs": pack_dirs, "default_persona": base_json.get("default_persona") if isinstance(base_json, dict) else None, "num_ctx": base_json.get("num_ctx") if isinstance(base_json, dict) else None, "embed_model": base_json.get("embed_model") if isinstance(base_json, dict) else None, "base_error": base_json.get("_error") if isinstance(base_json, dict) else None, "roles": roles, "roles_present": roles_path.is_file(), "settings_keys": sorted(settings.keys()) if isinstance(settings, dict) and "_error" not in settings else [], }, "sqlite": db_info, "wanted": {"count": wanted_n, "sample": wanted_sample}, }, ensure_ascii=False)) ''' _REMOTE_LOGS = r''' import subprocess, json cmd = [ "sudo", "-n", "journalctl", "-u", "swarmui", "-n", "250", "--no-pager", "-o", "short-iso", ] try: raw = subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT, timeout=25, errors="replace") except Exception as e: print(json.dumps({"ok": False, "error": str(e)})) raise SystemExit(0) keys = ("assistent", "sqlite", "build of extension", "prepping extension", "private dep", "microsoft.data.sqlite", "webapi") hits = [] for line in raw.splitlines(): low = line.lower() if any(k in low for k in keys): hits.append(line[:300]) print(json.dumps({"ok": True, "lines": hits[-80:], "scanned": len(raw.splitlines())}, ensure_ascii=False)) ''' def _http_json( url: str, *, method: str = "GET", body: dict | None = None, timeout: float = 12.0, ) -> tuple[bool, Any, float]: t0 = time.perf_counter() data = None headers: dict[str, str] = {} if body is not None: data = json.dumps(body).encode("utf-8") headers["Content-Type"] = "application/json" req = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=timeout) as resp: raw = resp.read().decode("utf-8", "replace") ms = (time.perf_counter() - t0) * 1000 try: return True, json.loads(raw), ms except json.JSONDecodeError: return True, raw[:500], ms except Exception as exc: ms = (time.perf_counter() - t0) * 1000 return False, str(exc)[:240], ms def _swarm_base(cfg: Config) -> tuple[str | None, str]: """Return (base_url, via) preferring local tunnel.""" port = int(getattr(cfg, "swarmui_local_port", 17801)) if debug_checks._port_open(port): return f"http://127.0.0.1:{port}", "local" return None, "none" def _session_id(base: str) -> tuple[str | None, str | None, float]: ok, data, ms = _http_json(f"{base}/API/GetNewSession", method="POST", body={}) if not ok or not isinstance(data, dict): return None, str(data), ms sid = data.get("session_id") if not sid: return None, "no session_id", ms return str(sid), None, ms def _api_call(base: str, name: str, payload: dict, *, timeout: float = 20.0) -> dict[str, Any]: ok, data, ms = _http_json( f"{base}/API/{name}", method="POST", body=payload, timeout=timeout, ) err = None if not ok: err = str(data) elif isinstance(data, dict) and data.get("error"): err = str(data.get("error"))[:300] ok = False return { "name": name, "ok": ok, "ms": round(ms, 1), "error": err, "data": _compact_api(name, data) if ok else None, } def _compact_api(name: str, data: Any) -> Any: if not isinstance(data, dict): return data if name == "AssistentListPersonas": personas = data.get("personas") or data.get("items") or data.get("list") if isinstance(personas, list): ids = [] for p in personas[:40]: if isinstance(p, dict): ids.append(p.get("id") or p.get("name") or p.get("persona")) elif isinstance(p, str): ids.append(p) return { "count": len(personas), "ids": [x for x in ids if x], "default": data.get("default") or data.get("default_persona"), } return {"keys": list(data.keys())[:20]} if name == "AssistentListModels": models = data.get("models") if isinstance(data.get("models"), list) else [] mem = data.get("memory_models") if isinstance(data.get("memory_models"), list) else [] return { "models": models[:30], "memory_models": mem[:20], "preferred": data.get("preferred"), "base_url": data.get("base_url"), "model_count": len(models), "memory_count": len(mem), } if name == "AssistentGetConfig": return { "keys": sorted(data.keys())[:40], "persona": data.get("persona") or data.get("id"), "has_assistant": "assistant" in data or "packs" in data, } if name == "AssistentListMemory": items = data.get("items") or data.get("memories") or data.get("list") n = len(items) if isinstance(items, list) else data.get("count") return {"count": n, "keys": list(data.keys())[:15]} if name == "AssistentListChats": items = data.get("chats") or data.get("items") or data.get("list") n = len(items) if isinstance(items, list) else data.get("count") return {"count": n, "keys": list(data.keys())[:15]} if name == "AssistentGetUiState": return {"keys": list(data.keys())[:20], "has_state": bool(data)} return {"keys": list(data.keys())[:20]} def _generate_flag_on(obj: dict[str, Any] | None) -> bool: if not isinstance(obj, dict): return False g = obj.get("generate") if g is True or g == 1: return True if isinstance(g, str) and g.strip().lower() in {"true", "1", "yes", "on"}: return True acts = obj.get("actions") return isinstance(acts, list) and "generate" in [str(a) for a in acts] # Mirrors swarm-assistent src/intent.js (0.15.2). Keep in lockstep. _CYR_BOUND = r"(^|[^0-9A-Za-z_А-Яа-яЁё])" _CYR_END = r"(?=$|[^0-9A-Za-z_А-Яа-яЁё])" _PERSONA_CHIP = { "neutral": "Нормальный", "aggressive": "Агрессивный", "dreamer": "Мечтатель", } def _cyr_token_re(alts: str) -> re.Pattern[str]: return re.compile(f"{_CYR_BOUND}(?:{alts}){_CYR_END}", re.IGNORECASE) def user_asks_no_generate(text: str | None) -> bool: t = (text or "").strip() if not t: return False if re.search( r"\b(remember|save\s+(this\s+)?(as\s+)?(the\s+)?(base\s+)?(prompt|template)|" r"don'?t\s+generat|do\s+not\s+generat|no\s+generat|without\s+generat)\b", t, re.I, ): return True return bool( _cyr_token_re( r"запомн|запомни|запомним|сохрани|сохраним|шаблон|" r"базов(ый|ого|ому|ым|ая|ую|ое)?\s+промпт|" r"не\s+генерир[а-яё]*|без\s+генерац[а-яё]*|не\s+надо\s+генер[а-яё]*|" r"только\s+запомн[а-яё]*|пока\s+запомн[а-яё]*|" r"не\s+рисуй|не\s+запускай\s+генер[а-яё]*|" r"только\s+(ответь|скажи|объясни)" ).search(t) ) def user_asks_generate(text: str | None) -> bool: t = (text or "").strip() if not t or user_asks_no_generate(t): return False if re.search( r"\b(generat(e|ion)|draw|render|make\s+(an?\s+)?image|run\s+generate)\b", t, re.I, ): return True return bool( _cyr_token_re( r"сгенер[а-яё]*|нарисуй|нарисуйте|нарисуем|" r"запусти\s+генер[а-яё]*|" r"сдела(й|ем|йте)\s+(кадр|картинк[а-яё]*|изображ[а-яё]*)" ).search(t) ) def predict_client_turn( message: str | None, patch: dict[str, Any] | None, *, persona: str | None = None, pack: str | None = None, ) -> dict[str, Any]: """What swarm-assistent 0.15.3 JS would do after this HTTP reply. Generate is user-owned: model generate:true is advisory. AssistentChat never starts Swarm Generate. """ model_generate = _generate_flag_on(patch) vetoed = user_asks_no_generate(message) user_gen = user_asks_generate(message) has_prompt = bool(str((patch or {}).get("prompt") or "").strip()) user_asked = user_gen and (model_generate or has_prompt) would_generate = bool(not vetoed and user_asked) if vetoed: reason = "vetoed" elif user_asked: reason = "user_phrase" elif model_generate: reason = "model_flag_ignored" else: reason = "none" pid = (persona or "").strip() or None toast = None if would_generate: toast = "Промпт обновлён · Generate" if has_prompt else "Запускаю Generate" elif patch and has_prompt: toast = None return { "user_asks_generate": user_gen, "user_asks_no_generate": vetoed, "model_generate": model_generate, "would_apply_patch": bool(would_generate and patch), "would_generate": would_generate, "generate_reason": reason, "http_starts_generate": False, "toast": toast, "persona": pid, "persona_chip": _PERSONA_CHIP.get(pid or "", pid) if pid else None, "pack": (pack or "").strip() or None, "note": ( "0.15.3: HTTP AssistentChat never runs Generate. " "would_generate mirrors resolveTurnIntent — user «нарисуй»/" "«сгенерируй»/«сделаем изображение», not model generate:true alone." ), } def analyze_assistent_reply( *, message: str | None = None, reply: str | None = None, persona: str | None = None, pack: str | None = None, ) -> dict[str, Any]: """Offline extract + 0.15.2 client prediction. No Swarm / VRAM.""" extracted = extract_assistent_patch(reply) patch = extracted.get("patch") client = predict_client_turn(message, patch, persona=persona, pack=pack) return { "ok": True, "offline": True, "message": message or "", "reply": reply or "", "reply_prose": extracted.get("prose") or "", "patch": _summarize_patch(patch), "client": client, "note": "No AssistentChat — paste a live UI reply to see what 0.15.2 would apply/Generate.", } def _normalize_extracted_patch(obj: dict[str, Any]) -> dict[str, Any]: patch = dict(obj) if _generate_flag_on(patch): patch["generate"] = True if isinstance(patch.get("ask"), str): one = patch["ask"].strip() if one: patch["ask"] = [one] else: patch.pop("ask", None) return patch def extract_assistent_patch(text: str | None) -> dict[str, Any]: """Pull last fenced JSON patch from Assistent reply (mirrors SA.extractPatch).""" if not text: return {"prose": text or "", "patch": None} last_any: dict[str, Any] | None = None last_any_span: tuple[int, int] | None = None last_term: dict[str, Any] | None = None last_term_span: tuple[int, int] | None = None for match in _FENCE_RE.finditer(text): try: obj = json.loads(match.group(1).strip()) except (json.JSONDecodeError, TypeError, ValueError): continue if not isinstance(obj, dict): continue if not any(k in obj and obj[k] is not None for k in _PATCH_KEYS): continue patch = _normalize_extracted_patch(obj) last_any = patch last_any_span = (match.start(), match.end()) if _generate_flag_on(patch) or patch.get("look_at") is not None or patch.get("ask"): last_term = patch last_term_span = last_any_span elif isinstance(patch.get("prompt"), str) and len(patch["prompt"].strip()) >= 48: last_term = patch last_term_span = last_any_span chosen = last_term or last_any span = last_term_span or last_any_span if chosen and span: prose = (text[: span[0]] + text[span[1] :]).strip() return {"prose": prose, "patch": chosen} brace = text.rfind("{") if brace >= 0: try: obj = json.loads(text[brace:].strip()) except (json.JSONDecodeError, TypeError, ValueError): obj = None if isinstance(obj, dict) and any(k in obj and obj[k] is not None for k in _PATCH_KEYS): return {"prose": text[:brace].strip(), "patch": _normalize_extracted_patch(obj)} return {"prose": text, "patch": None} def _summarize_patch(patch: dict[str, Any] | None) -> dict[str, Any] | None: if not isinstance(patch, dict): return None keys = ( "prompt", "negative", "steps", "cfg", "aspect", "actions", "generate", "ask", "width", "height", "seed", "sigma_shift", "pack", "persona", "images", ) out: dict[str, Any] = {} for k in keys: if k in patch and patch[k] is not None: val = patch[k] if k == "prompt" and isinstance(val, str) and len(val) > 800: out[k] = val[:800] + "…" else: out[k] = val return out or None def _clamp_chat_eval_timeout(raw: float | int | str | None) -> float: try: val = float(raw) if raw is not None else DEFAULT_CHAT_EVAL_TIMEOUT except (TypeError, ValueError): val = DEFAULT_CHAT_EVAL_TIMEOUT return max(MIN_CHAT_EVAL_TIMEOUT, min(val, MAX_CHAT_EVAL_TIMEOUT)) def _resolve_chat_model( cfg: Config, base: str | None, sid: str | None, *, model: str | None, ) -> tuple[str | None, str | None]: """Return (model, preferred_from_list). Explicit model wins.""" preferred: str | None = None if base and sid: call = _api_call( base, "AssistentListModels", {"session_id": sid, "baseUrl": "http://127.0.0.1:11434"}, timeout=25.0, ) if call.get("ok") and isinstance(call.get("data"), dict): preferred = call["data"].get("preferred") if isinstance(preferred, str): preferred = preferred.strip() or None if not preferred: roles = collect_assistent_roles(cfg) preferred = (roles.get("roles") or {}).get("default_chat") if isinstance(preferred, str): preferred = preferred.strip() or None chosen = (model or "").strip() or preferred return chosen, preferred def run_assistent_chat_eval( cfg: Config, *, message: str | None = None, persona: str | None = None, pack: str | None = None, model: str | None = None, timeout: float | int | str | None = None, context: dict | None = None, ) -> dict[str, Any]: """Opt-in AssistentChat round-trip (session create → one chat → delete). May load the chat model into VRAM and write chat history if Sqlite works. Not part of /snapshot. Prefer POST /assistent/session for multi-turn. """ from gpu_rent.debug_assistent_session import run_assistent_chat_eval_via_session return run_assistent_chat_eval_via_session( cfg, message=message, persona=persona, pack=pack, model=model, timeout=timeout, context=context, ) def _finish_chat_eval( *, ok: bool, data: Any, ms_call: float, ms_total: float, via: str, session_ms: float, message: str, persona: str, pack: str, model: str | None, preferred: str | None, hints: list[str], errors: list[str], timeout: float, ) -> dict[str, Any]: reply = None raw_preview = None err: str | None = None if not ok: err = str(data)[:400] errors.append(err) elif isinstance(data, dict): if data.get("error"): err = str(data.get("error"))[:400] errors.append(err) ok = False reply = data.get("reply") if reply is not None: reply = str(reply) raw = data.get("raw") if isinstance(raw, dict): raw_preview = {k: raw[k] for k in list(raw.keys())[:12]} elif raw is not None: raw_preview = str(raw)[:400] blob = json.dumps(data, ensure_ascii=False).lower() if "sqlite" in blob or "savechat" in blob: hints.append("response mentions Sqlite/SaveChat — check /assistent/memory") else: err = f"unexpected AssistentChat body: {str(data)[:200]}" errors.append(err) ok = False extracted = extract_assistent_patch(reply) if err and "sqlite" in err.lower(): hints.append( "Sqlite — Microsoft.Data.Sqlite рядом с extension DLL (seed-extensions ≥0.13.1)" ) if err and ("timeout" in err.lower() or "timed out" in err.lower()): hints.append(f"timeout {timeout:.0f}s — увеличь timeout (cap {MAX_CHAT_EVAL_TIMEOUT:.0f})") success = bool(ok and reply is not None and not err) out: dict[str, Any] = { "ok": success, "via": via, "ms": round(ms_total, 1), "chat_ms": round(ms_call, 1), "session_ms": round(session_ms, 1), "timeout_sec": timeout, "message": message, "persona": persona, "pack": pack, "model": model, "preferred": preferred, "reply": reply, "reply_prose": extracted.get("prose"), "patch": _summarize_patch(extracted.get("patch")), "raw": raw_preview, "errors": errors, "hints": hints, "error": err, } if isinstance(data, dict) and success: for k in ("system_chars", "prompt_eval_count", "civitai_results"): if k in data: out[k] = data[k] if data.get("model"): out["model"] = data.get("model") return out _FS_CACHE: tuple[float, str, dict[str, Any]] | None = None _FS_TTL = 8.0 def collect_assistent_fs(cfg: Config) -> dict[str, Any]: global _FS_CACHE state = load_state() if not debug_checks.ssh_ready(cfg, state): return debug_checks._ssh_fail(state.phase) host = debug_checks.ssh_host(state) assert host is not None now = time.time() if _FS_CACHE and _FS_CACHE[1] == host and now - _FS_CACHE[0] < _FS_TTL: return _FS_CACHE[2] try: from gpu_rent.ssh_ops import run_ssh out = run_ssh( cfg, host, "python3 - <<'PY'\n" + _REMOTE_FS + "\nPY", check=False, timeout=45, ).strip() data = json.loads(out.splitlines()[-1]) result = {"ok": True, "via": "ssh", **data} _FS_CACHE = (now, host, result) return result except Exception as exc: return {"ok": False, "error": str(exc)[:240]} def collect_assistent_extension(cfg: Config, *, fs: dict[str, Any] | None = None) -> dict[str, Any]: fs = fs if fs is not None else collect_assistent_fs(cfg) if not fs.get("ok"): return fs exts = fs.get("extensions") or [] hints: list[str] = [] ok = bool(exts) if not exts: hints.append("swarm-assistent не найден в /mnt/swarm_data/Extensions — seed-extensions") else: for e in exts: if not e.get("dll"): hints.append(f"{e.get('name')}: нет DLL — compile fail / Swarm не билдил") ok = False elif not e.get("sqlite_dll"): where = e.get("dll_dir") or "bin/{Debug,Release}/net*" hints.append( f"{e.get('name')}: DLL есть ({where}), но рядом нет " "Microsoft.Data.Sqlite.dll (+ SQLitePCLRaw*) — чат/memory API упадут. " "Нужен swarm-assistent ≥0.13.1 (CopyLocalLockFileAssemblies) + " "gpu-rent seed-extensions + restart SwarmUI" ) ok = False if not e.get("tab_html") or not e.get("bundle_js"): hints.append(f"{e.get('name')}: нет Tab/Assets — вкладка не зарегистрируется") ok = False return { "ok": ok, "extensions": exts, "hints": hints, } def collect_assistent_overlay(cfg: Config, *, fs: dict[str, Any] | None = None) -> dict[str, Any]: fs = fs if fs is not None else collect_assistent_fs(cfg) if not fs.get("ok"): return fs overlay = fs.get("overlay") or {} local_packs: list[str] = [] try: from gpu_rent.paths import assistent_extensions_dir pdir = assistent_extensions_dir() if pdir.is_dir(): local_packs = sorted( x.name for x in pdir.iterdir() if x.is_dir() and not x.name.startswith(".") ) except Exception: pass remote_packs = list(overlay.get("pack_dirs") or []) missing_on_vm = sorted(set(local_packs) - set(remote_packs)) hints: list[str] = [] if local_packs and missing_on_vm: hints.append( f"локальные packs не на VM: {', '.join(missing_on_vm[:8])} — gpu-rent seed-personas" ) if not overlay.get("exists"): hints.append("нет /mnt/swarm_data/Assistent — seed ещё не писал overlay (bundled personas ок)") return { "ok": True, "overlay": overlay, "local_pack_dirs": local_packs, "local_persona_ids": local_packs, # back-compat for older clients "missing_on_vm": missing_on_vm, "hints": hints, } def collect_assistent_roles(cfg: Config, *, fs: dict[str, Any] | None = None) -> dict[str, Any]: fs = fs if fs is not None else collect_assistent_fs(cfg) ollama = debug_checks.collect_ollama(cfg) models = set(ollama.get("models") or []) roles: dict[str, Any] = {} if fs.get("ok"): roles = (fs.get("overlay") or {}).get("roles") or {} elif fs.get("error") == debug_checks.SSH_UNAVAILABLE: return {**fs, "ollama_models": sorted(models)} chat = [x for x in (roles.get("chat") or []) if isinstance(x, str)] memory = [x for x in (roles.get("memory") or []) if isinstance(x, str)] default_chat = roles.get("default_chat") if isinstance(roles.get("default_chat"), str) else None hints: list[str] = [] ok = True if not (fs.get("overlay") or {}).get("roles_present"): hints.append("нет ollama-roles.json — Assistent эвристика chat/memory; preferred может плавать") ok = False if roles.get("_error"): hints.append(f"roles JSON broken: {roles['_error']}") ok = False if not chat and (fs.get("overlay") or {}).get("roles_present"): hints.append("roles.chat пуст — dropdown моделей пустой/эвристика") ok = False if default_chat and models and default_chat not in models: hints.append(f"default_chat={default_chat!r} нет в /api/tags — wrong model / stale roles") ok = False if not models and normalize_runtime(getattr(cfg, "llm_runtime", "none")) == "ollama": hints.append("Ollama tags пусты — Assistent chat не заработает") ok = False missing_chat = [m for m in chat if models and m not in models] missing_mem = [m for m in memory if models and m not in models] if missing_chat: ok = False from gpu_rent.llm_runtime import is_stacked_cpu_ollama_tag stacked = [ m for m in list(memory) + [x for x in models if isinstance(x, str)] if isinstance(m, str) and is_stacked_cpu_ollama_tag(m) ] if stacked: hints.append( "recursive *-cpu Ollama tags — roles.memory должен быть nomic-embed-text-cpu; " "ollama rm … затем re-up / provision_llm" ) ok = False return { "ok": ok, "roles": { "chat": chat, "memory": memory, "default_chat": default_chat, }, "ollama_models": sorted(models), "missing_chat_in_tags": missing_chat, "missing_memory_in_tags": missing_mem, "hints": hints, } def collect_assistent_memory(cfg: Config, *, fs: dict[str, Any] | None = None) -> dict[str, Any]: fs = fs if fs is not None else collect_assistent_fs(cfg) if not fs.get("ok"): return fs sqlite = fs.get("sqlite") or {} hints: list[str] = [] ok = True exts = fs.get("extensions") or [] if exts and any(e.get("dll") and not e.get("sqlite_dll") for e in exts): hints.append("Sqlite DLL отсутствует рядом с extension — ListMemory/ListChats упадут") ok = False if not sqlite.get("exists"): hints.append("assistent.sqlite ещё нет — появится после первого UI/API обращения") return { "ok": ok, "sqlite": sqlite, "embed_model": (fs.get("overlay") or {}).get("embed_model"), "hints": hints, } def collect_assistent_wanted(cfg: Config, *, fs: dict[str, Any] | None = None) -> dict[str, Any]: fs = fs if fs is not None else collect_assistent_fs(cfg) if not fs.get("ok"): return fs wanted = fs.get("wanted") or {} return { "ok": True, "wanted": wanted, "note": ( "Assistent ≥0.14 убрал Cards/wanted hops — очередь может быть stale; " "полезна для gpu-rent capture wanted" ), } def collect_assistent_logs(cfg: Config) -> dict[str, Any]: state = load_state() if not debug_checks.ssh_ready(cfg, state): return debug_checks._ssh_fail(state.phase) host = debug_checks.ssh_host(state) assert host is not None try: from gpu_rent.ssh_ops import run_ssh out = run_ssh( cfg, host, "python3 - <<'PY'\n" + _REMOTE_LOGS + "\nPY", check=False, timeout=35, ).strip() data = json.loads(out.splitlines()[-1]) lines = data.get("lines") or [] low = "\n".join(lines).lower() hints: list[str] = [] ok = True if "build of extension" in low and "failed" in low: hints.append("journal: Build of extension failed — вкладка исчезнет") ok = False if "microsoft.data.sqlite" in low or "private dep" in low: hints.append("journal: Sqlite private dep — обнови extension ≥0.13.1") ok = False loaded = "assistent extension loaded" in low or "prepping extension" in low and "assistent" in low if lines and not loaded and ok: hints.append("нет явной строки loaded — проверь полный journal / restart swarmui") return { "ok": ok, "loaded_hint": loaded, "lines": lines, "hints": hints, "scanned": data.get("scanned"), } except Exception as exc: return {"ok": False, "error": str(exc)[:240]} def collect_assistent_api( cfg: Config, *, chat_smoke: bool = False, ) -> dict[str, Any]: """Live SwarmUI Assistent* API smoke via localhost tunnel (preferred).""" base, via = _swarm_base(cfg) if not base: # Fall back: ask VM via SSH curl/python return _assistent_api_via_ssh(cfg, chat_smoke=chat_smoke) sid, err, ms_sess = _session_id(base) if not sid: return { "ok": False, "via": via, "error": f"GetNewSession failed: {err}", "session_ms": round(ms_sess, 1), } calls = [ _api_call(base, "AssistentListPersonas", {"session_id": sid}), _api_call( base, "AssistentListModels", {"session_id": sid, "baseUrl": "http://127.0.0.1:11434"}, ), _api_call(base, "AssistentGetConfig", {"session_id": sid}), _api_call(base, "AssistentListMemory", {"session_id": sid, "limit": 5}), _api_call(base, "AssistentListChats", {"session_id": sid, "limit": 5}), _api_call(base, "AssistentGetUiState", {"session_id": sid}), ] hints: list[str] = [] for c in calls: if not c["ok"] and c["error"]: err_l = (c["error"] or "").lower() if "unknown" in err_l or "not found" in err_l or "no such" in err_l: hints.append( f"{c['name']}: route unknown — extension не загружен / не скомпилирован" ) elif "sqlite" in err_l: hints.append(f"{c['name']}: Sqlite — нет Microsoft.Data.Sqlite рядом с DLL") else: hints.append(f"{c['name']}: {c['error'][:120]}") personas = next((c for c in calls if c["name"] == "AssistentListPersonas"), None) models_c = next((c for c in calls if c["name"] == "AssistentListModels"), None) if personas and personas["ok"] and (personas.get("data") or {}).get("count") == 0: hints.append("AssistentListPersonas пуст — странно (bundled должны быть)") if models_c and models_c["ok"]: d = models_c.get("data") or {} if not d.get("model_count"): hints.append("AssistentListModels: 0 chat models — Ollama/roles") smoke: dict[str, Any] | None = None if chat_smoke: preferred = None if models_c and models_c.get("data"): preferred = models_c["data"].get("preferred") if not preferred: roles = collect_assistent_roles(cfg) preferred = (roles.get("roles") or {}).get("default_chat") smoke = _ollama_chat_smoke(cfg, model=preferred) ok = all(c["ok"] for c in calls[:2]) # personas + models are critical return { "ok": ok, "via": via, "session_ms": round(ms_sess, 1), "calls": calls, "chat_smoke": smoke, "hints": hints, } def _assistent_api_via_ssh(cfg: Config, *, chat_smoke: bool) -> dict[str, Any]: state = load_state() if not debug_checks.ssh_ready(cfg, state): return { **debug_checks._ssh_fail(state.phase), "hint": "туннель SwarmUI закрыт и SSH нет — gpu-rent tunnel / up", } host = debug_checks.ssh_host(state) assert host is not None script = r''' import json, urllib.request, time def post(path, payload, timeout=15): t0 = time.time() req = urllib.request.Request( "http://127.0.0.1:7801" + path, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=timeout) as resp: raw = resp.read().decode("utf-8", "replace") ms = (time.time() - t0) * 1000 try: data = json.loads(raw) except Exception: data = raw[:400] return True, data, ms except Exception as e: return False, str(e), (time.time() - t0) * 1000 ok, sess, ms = post("/API/GetNewSession", {}) if not ok or not isinstance(sess, dict) or not sess.get("session_id"): print(json.dumps({"ok": False, "error": sess, "session_ms": ms})) raise SystemExit(0) sid = sess["session_id"] calls = [] for name, extra in [ ("AssistentListPersonas", {}), ("AssistentListModels", {"baseUrl": "http://127.0.0.1:11434"}), ("AssistentGetConfig", {}), ("AssistentListMemory", {"limit": 5}), ("AssistentListChats", {"limit": 5}), ("AssistentGetUiState", {}), ]: payload = {"session_id": sid, **extra} cok, data, cms = post("/API/" + name, payload) err = None if not cok: err = str(data) elif isinstance(data, dict) and data.get("error"): err = str(data.get("error"))[:300] cok = False compact = None if cok and isinstance(data, dict): if name == "AssistentListPersonas": personas = data.get("personas") or data.get("items") or [] compact = {"count": len(personas) if isinstance(personas, list) else None} elif name == "AssistentListModels": models = data.get("models") if isinstance(data.get("models"), list) else [] compact = { "model_count": len(models), "preferred": data.get("preferred"), "memory_count": len(data.get("memory_models") or []), } else: compact = {"keys": list(data.keys())[:15]} calls.append({"name": name, "ok": cok, "ms": round(cms, 1), "error": err, "data": compact}) print(json.dumps({"ok": all(c["ok"] for c in calls[:2]), "via": "ssh", "session_ms": round(ms, 1), "calls": calls}, ensure_ascii=False)) ''' try: from gpu_rent.ssh_ops import run_ssh out = run_ssh( cfg, host, "python3 - <<'PY'\n" + script + "\nPY", check=False, timeout=90, ).strip() data = json.loads(out.splitlines()[-1]) if chat_smoke: preferred = None for c in data.get("calls") or []: if c.get("name") == "AssistentListModels" and isinstance(c.get("data"), dict): preferred = c["data"].get("preferred") data["chat_smoke"] = _ollama_chat_smoke(cfg, model=preferred) data.setdefault("hints", []) return data except Exception as exc: return {"ok": False, "via": "ssh", "error": str(exc)[:240]} def _ollama_chat_smoke(cfg: Config, *, model: str | None) -> dict[str, Any]: """1-token /api/chat — proves generate path (may briefly load model into VRAM).""" if not model: return {"ok": False, "skipped": True, "error": "no preferred/default_chat model"} port = int(getattr(cfg, "ollama_local_port", 17811)) if not debug_checks._port_open(port): # try via SSH return _ollama_chat_smoke_ssh(cfg, model=model) body = { "model": model, "messages": [{"role": "user", "content": "ping"}], "stream": False, "options": {"num_predict": 1}, "keep_alive": "0", } ok, data, ms = _http_json( f"http://127.0.0.1:{port}/api/chat", method="POST", body=body, timeout=90.0, ) msg = None if ok and isinstance(data, dict): message = data.get("message") or {} if isinstance(message, dict): msg = str(message.get("content") or "")[:80] return { "ok": ok and msg is not None, "model": model, "ms": round(ms, 1), "reply_preview": msg, "error": None if ok else str(data)[:200], "via": "local", } def _ollama_chat_smoke_ssh(cfg: Config, *, model: str) -> dict[str, Any]: state = load_state() if not debug_checks.ssh_ready(cfg, state): return {"ok": False, "skipped": True, "error": debug_checks.SSH_UNAVAILABLE} host = debug_checks.ssh_host(state) assert host is not None payload = json.dumps( { "model": model, "messages": [{"role": "user", "content": "ping"}], "stream": False, "options": {"num_predict": 1}, "keep_alive": "0", }, ensure_ascii=False, ) script = ( "import json,urllib.request,time\n" f"body={payload!r}.encode()\n" "t0=time.time()\n" "req=urllib.request.Request('http://127.0.0.1:11434/api/chat',data=body," "headers={'Content-Type':'application/json'},method='POST')\n" "try:\n" " with urllib.request.urlopen(req,timeout=90) as r:\n" " data=json.loads(r.read().decode())\n" " msg=(data.get('message') or {}).get('content')\n" " print(json.dumps({'ok': True, 'ms': (time.time()-t0)*1000, " "'reply_preview': (msg or '')[:80]}))\n" "except Exception as e:\n" " print(json.dumps({'ok': False, 'ms': (time.time()-t0)*1000, 'error': str(e)[:200]}))\n" ) try: from gpu_rent.ssh_ops import run_ssh out = run_ssh( cfg, host, "python3 - <<'PY'\n" + script + "\nPY", check=False, timeout=100, ).strip() data = json.loads(out.splitlines()[-1]) data["model"] = model data["via"] = "ssh" if "ms" in data: data["ms"] = round(float(data["ms"]), 1) return data except Exception as exc: return {"ok": False, "model": model, "via": "ssh", "error": str(exc)[:200]} def _local_assistent_bits(cfg: Config) -> dict[str, Any]: local: dict[str, Any] = {} try: from gpu_rent.paths import assistent_extensions_dir pdir = assistent_extensions_dir() local["extensions_dir"] = { "path": str(pdir), "exists": pdir.is_dir(), "entries": sorted(x.name for x in pdir.iterdir())[:40] if pdir.is_dir() else [], } local["personas_dir"] = local["extensions_dir"] # back-compat except Exception as exc: local["extensions_dir"] = {"error": str(exc)[:120]} try: from gpu_rent.manifests import parse_extensions, repo_dirname repos = parse_extensions(cfg.extensions_manifest) has = any( "assistent" in repo_dirname(r).lower() or "assistent" in (r.url or "").lower() for r in repos if r.kind == "swarmui" ) local["extensions_yaml"] = { "has_swarm_assistent": has, "assistent_packs": sum(1 for r in repos if r.kind == "assistent"), "manifest": str(cfg.extensions_manifest), } except Exception as exc: local["extensions_yaml"] = {"error": str(exc)[:120]} return local def collect_assistent_deep( cfg: Config, *, chat_smoke: bool = False, include_logs: bool = False, ) -> dict[str, Any]: """Full Assistent diagnostic bundle for GET /assistent.""" local = _local_assistent_bits(cfg) ollama = debug_checks.collect_ollama(cfg) fs = collect_assistent_fs(cfg) extension = collect_assistent_extension(cfg, fs=fs) overlay = collect_assistent_overlay(cfg, fs=fs) roles = collect_assistent_roles(cfg, fs=fs) memory = collect_assistent_memory(cfg, fs=fs) wanted = collect_assistent_wanted(cfg, fs=fs) api = collect_assistent_api(cfg, chat_smoke=chat_smoke) logs = collect_assistent_logs(cfg) if include_logs else None hints: list[str] = [] for block in (extension, overlay, roles, memory, api, logs): if isinstance(block, dict): hints.extend(block.get("hints") or []) if not (local.get("extensions_yaml") or {}).get("has_swarm_assistent"): if normalize_runtime(getattr(cfg, "llm_runtime", "none")) == "ollama": hints.append("в extensions.yaml нет swarm-assistent (requires:ollama)") seen: set[str] = set() uniq_hints: list[str] = [] for h in hints: if h not in seen: seen.add(h) uniq_hints.append(h) ok = bool(api.get("ok")) or ( bool(extension.get("ok")) and bool(ollama.get("models")) ) if any( "Build of extension failed" in h or "route unknown" in h or "compile fail" in h for h in uniq_hints ): ok = False return { "ok": ok, "local": local, "ollama": { "ok": ollama.get("ok"), "enabled": ollama.get("enabled", True), "models": ollama.get("models") or [], "hint": ollama.get("hint"), }, "extension": extension, "overlay": { "data": overlay.get("overlay"), "local_persona_ids": overlay.get("local_persona_ids"), "missing_on_vm": overlay.get("missing_on_vm"), "ok": overlay.get("ok"), }, "roles": roles, "memory": memory, "wanted": wanted.get("wanted") if isinstance(wanted, dict) else None, "api": api, "logs": logs, "hints": uniq_hints, "playbook": { "no_tab": "GET /assistent/extension + /assistent/logs", "empty_chat": "GET /assistent/roles + /ollama + /assistent/api", "wrong_model": "GET /assistent/roles (default_chat vs tags)", "memory_broken": "GET /assistent/memory + /assistent/api (ListMemory)", "personas_missing": "GET /assistent/overlay + ListPersonas in /assistent/api", "chat_smoke": "GET /assistent/api?chat_smoke=1", "chat_eval": "POST /assistent/chat-eval (one-shot; not in /snapshot)", "session": ( "POST /assistent/session → POST .../chat (multi-turn + trace) " "→ DELETE /assistent/session/{id}" ), "diagnose": "GET /assistent/diagnose or /assistent?logs=1", "compact_context_gap": ( "AssistentChat does not return compactContext; " "trace fills from GetConfig Exact + synthetic context_json (see trace.gaps)" ), }, }