- 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>
1334 lines
48 KiB
Python
1334 lines
48 KiB
Python
"""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)"
|
||
),
|
||
},
|
||
}
|