Files
gpu-rent/src/gpu_rent/debug_assistent.py
T
Leonid PershinandCursor 322577cf9f Add offline analysis and client event tracking to Assistent API
- Introduced `/assistent/analyze-reply` endpoint for offline extraction of patches and client predictions without VRAM.
- Added `/assistent/client-event` and `/assistent/client-events` endpoints for tracking UI interactions and retrieving event history.
- Updated Debug API documentation to reflect new endpoints and their functionalities.
- Enhanced Assistent session handling with improved client prediction logic and diagnostics.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 15:18:39 +03:00

1334 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)"
),
},
}