Add deep Assistent probes to the Debug API.
Expose /assistent subpaths for extension/DLL, overlay personas, roles, memory sqlite, live Assistent* API smoke, and optional chat_smoke so agents can diagnose missing tab, empty chat, and wrong models over HTTP. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -50,7 +50,7 @@
|
||||
| `sync_files` | SFTP `Models` / Wildcards / workflows / Output |
|
||||
| `notify` | Toast/звук при backend Idle |
|
||||
| `tunnel` | sshtunnel + Nova EXPIRED watchdog |
|
||||
| `debug_api` / `debug_checks` | localhost read-only HTTP sidecar (`:17821`) для агента |
|
||||
| `debug_api` / `debug_checks` / `debug_assistent` | localhost read-only HTTP sidecar (`:17821`); deep Assistent probes |
|
||||
| `local_watchdog` | Опциональный локальный тик → stop при unclean exit |
|
||||
| `llm_runtime` / `setup_wizard` | Opt-in Ollama + `ollama-models.yaml` |
|
||||
| `idle_killer` / `hold` | systemd на VM + hold-файл |
|
||||
|
||||
+17
-1
@@ -128,13 +128,29 @@ Exit 0 → можно `up`. Exit 1 → причина в таблице / кра
|
||||
|
||||
Агент: `GET /openapi.json` → `GET /snapshot` → точечные пути (`/progress`, `/events?since=`, `/checks`, `/logs`, `/diag`, `/gpu`, `/swarm`, `/ollama`, `/assistent`). Пока SSH нет — VM-эндпоинты отвечают `ssh_unavailable`; прогресс установщика всё равно виден в `/progress` и `/events`.
|
||||
|
||||
### Assistent (глубокая отладка)
|
||||
|
||||
| Путь | Зачем |
|
||||
| --- | --- |
|
||||
| `/assistent` | Сводка: extension + overlay + roles + memory + live API + hints + playbook |
|
||||
| `/assistent/extension` | DLL, Sqlite deps, Tab/Assets, git HEAD |
|
||||
| `/assistent/overlay` | personas на VM vs локальный `assistent-personas/` |
|
||||
| `/assistent/roles` | `ollama-roles.json` ↔ `/api/tags` (`default_chat`) |
|
||||
| `/assistent/memory` | `assistent.sqlite` + counts |
|
||||
| `/assistent/api` | `AssistentListPersonas/Models/Memory/Chats` через туннель (или SSH) |
|
||||
| `/assistent/api?chat_smoke=1` | + 1-token Ollama `/api/chat` (кратко грузит модель) |
|
||||
| `/assistent/logs` | journalctl swarmui: build/load/Sqlite |
|
||||
| `/assistent?logs=1` | Сводка + journal |
|
||||
|
||||
Симптомы → куда смотреть: поле `playbook` в `/assistent`.
|
||||
|
||||
После `up --no-tunnel` процесс завершается и sidecar гаснет — держи отдельно:
|
||||
|
||||
```text
|
||||
gpu-rent debug
|
||||
```
|
||||
|
||||
Мутаций нет (restart/hold/stop — как раньше через CLI).
|
||||
Мутаций конфига/GPU нет (restart/hold/stop — как раньше через CLI). `chat_smoke` только пингует Ollama.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ def collect_access_links(cfg: Config, *, tunneled: bool) -> list[AccessLink]:
|
||||
AccessLink(
|
||||
"Debug API",
|
||||
f"{debug_api_base(cfg)}/",
|
||||
"диагностика · /snapshot · /openapi.json",
|
||||
"диагностика · /assistent · /snapshot · /openapi.json",
|
||||
)
|
||||
)
|
||||
if tunneled:
|
||||
|
||||
+132
-4
@@ -84,7 +84,54 @@ _OPENAPI: dict[str, Any] = {
|
||||
"/gpu": {"get": {"summary": "nvidia-smi / CUDA / torch probe"}},
|
||||
"/swarm": {"get": {"summary": "SwarmUI backend_status + ListBackends"}},
|
||||
"/ollama": {"get": {"summary": "Ollama tags + ps"}},
|
||||
"/assistent": {"get": {"summary": "Extension + personas + LLM readiness"}},
|
||||
"/assistent": {
|
||||
"get": {
|
||||
"summary": "Deep Assistent bundle (extension/roles/api/hints)",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "chat_smoke",
|
||||
"in": "query",
|
||||
"schema": {"type": "boolean"},
|
||||
"description": "1-token Ollama /api/chat with preferred model",
|
||||
},
|
||||
{
|
||||
"name": "logs",
|
||||
"in": "query",
|
||||
"schema": {"type": "boolean"},
|
||||
"description": "Include filtered swarmui journal lines",
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
"/assistent/extension": {
|
||||
"get": {"summary": "DLL/Sqlite deps/Tab assets under Extensions/swarm-assistent"}
|
||||
},
|
||||
"/assistent/overlay": {
|
||||
"get": {"summary": "Overlay personas + _base/assistant.json vs local seed"}
|
||||
},
|
||||
"/assistent/roles": {
|
||||
"get": {"summary": "ollama-roles.json vs /api/tags (default_chat)"}
|
||||
},
|
||||
"/assistent/memory": {
|
||||
"get": {"summary": "assistent.sqlite presence + counts + Sqlite DLL hint"}
|
||||
},
|
||||
"/assistent/api": {
|
||||
"get": {
|
||||
"summary": "Live AssistentListPersonas/Models/Memory/Chats smoke",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "chat_smoke",
|
||||
"in": "query",
|
||||
"schema": {"type": "boolean"},
|
||||
"description": "Also run 1-token Ollama chat",
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
"/assistent/wanted": {"get": {"summary": "Wanted-models queue on data volume"}},
|
||||
"/assistent/logs": {
|
||||
"get": {"summary": "journalctl swarmui filtered for Assistent/Sqlite/build"}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -420,12 +467,92 @@ def _make_handler(hub: DebugHub):
|
||||
)
|
||||
self._json(200, payload)
|
||||
return
|
||||
if path == "/assistent":
|
||||
if path == "/assistent" or path.startswith("/assistent/"):
|
||||
from gpu_rent import debug_assistent
|
||||
|
||||
def _flag(name: str) -> bool:
|
||||
raw = (qs.get(name) or ["0"])[0].strip().lower()
|
||||
return raw in {"1", "true", "yes", "on"}
|
||||
|
||||
chat_smoke = _flag("chat_smoke")
|
||||
include_logs = _flag("logs")
|
||||
sub = path[len("/assistent") :].lstrip("/") or ""
|
||||
|
||||
if sub == "":
|
||||
cache_key = f"assistent:{int(chat_smoke)}:{int(include_logs)}"
|
||||
payload = hub.cached(
|
||||
"assistent",
|
||||
cache_key,
|
||||
CACHE_TTL_DEFAULT,
|
||||
lambda: debug_checks.collect_assistent(hub.cfg),
|
||||
lambda: debug_checks.collect_assistent(
|
||||
hub.cfg,
|
||||
chat_smoke=chat_smoke,
|
||||
include_logs=include_logs,
|
||||
),
|
||||
)
|
||||
elif sub == "extension":
|
||||
payload = hub.cached(
|
||||
"assistent/extension",
|
||||
CACHE_TTL_DEFAULT,
|
||||
lambda: debug_assistent.collect_assistent_extension(hub.cfg),
|
||||
)
|
||||
elif sub == "overlay":
|
||||
payload = hub.cached(
|
||||
"assistent/overlay",
|
||||
CACHE_TTL_DEFAULT,
|
||||
lambda: debug_assistent.collect_assistent_overlay(hub.cfg),
|
||||
)
|
||||
elif sub == "roles":
|
||||
payload = hub.cached(
|
||||
"assistent/roles",
|
||||
CACHE_TTL_DEFAULT,
|
||||
lambda: debug_assistent.collect_assistent_roles(hub.cfg),
|
||||
)
|
||||
elif sub == "memory":
|
||||
payload = hub.cached(
|
||||
"assistent/memory",
|
||||
CACHE_TTL_DEFAULT,
|
||||
lambda: debug_assistent.collect_assistent_memory(hub.cfg),
|
||||
)
|
||||
elif sub == "api":
|
||||
cache_key = f"assistent/api:{int(chat_smoke)}"
|
||||
payload = hub.cached(
|
||||
cache_key,
|
||||
CACHE_TTL_DEFAULT,
|
||||
lambda: debug_assistent.collect_assistent_api(
|
||||
hub.cfg, chat_smoke=chat_smoke
|
||||
),
|
||||
)
|
||||
elif sub == "wanted":
|
||||
payload = hub.cached(
|
||||
"assistent/wanted",
|
||||
CACHE_TTL_DEFAULT,
|
||||
lambda: debug_assistent.collect_assistent_wanted(hub.cfg),
|
||||
)
|
||||
elif sub == "logs":
|
||||
payload = hub.cached(
|
||||
"assistent/logs",
|
||||
CACHE_TTL_LOGS,
|
||||
lambda: debug_assistent.collect_assistent_logs(hub.cfg),
|
||||
)
|
||||
else:
|
||||
self._json(
|
||||
404,
|
||||
{
|
||||
"ok": False,
|
||||
"error": f"unknown path {path}",
|
||||
"try": [
|
||||
"/assistent",
|
||||
"/assistent/extension",
|
||||
"/assistent/overlay",
|
||||
"/assistent/roles",
|
||||
"/assistent/memory",
|
||||
"/assistent/api",
|
||||
"/assistent/wanted",
|
||||
"/assistent/logs",
|
||||
],
|
||||
},
|
||||
)
|
||||
return
|
||||
self._json(200, payload)
|
||||
return
|
||||
|
||||
@@ -483,6 +610,7 @@ def start_debug_server(
|
||||
f"Debug API {base}/",
|
||||
f" {base}/openapi.json",
|
||||
f" {base}/snapshot",
|
||||
f" {base}/assistent",
|
||||
]
|
||||
if log:
|
||||
for line in lines:
|
||||
|
||||
@@ -0,0 +1,863 @@
|
||||
"""Deep read-only Assistent diagnostics for the debug HTTP sidecar.
|
||||
|
||||
Probes extension compile/load markers, overlay personas, ollama-roles,
|
||||
sqlite, live SwarmUI Assistent* APIs, and optional 1-token Ollama chat smoke.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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
|
||||
|
||||
# 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 = []
|
||||
for root in roots:
|
||||
if not root.is_dir():
|
||||
continue
|
||||
for p in sorted(root.iterdir()):
|
||||
if "assistent" not in p.name.lower():
|
||||
continue
|
||||
dll_dir = p / "bin" / "Debug" / "net8.0"
|
||||
dll = dll_dir / "SwarmAssistentExtension.dll"
|
||||
sqlite_dll = dll_dir / "Microsoft.Data.Sqlite.dll"
|
||||
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.is_file() else None,
|
||||
"dll_mtime": int(dll.stat().st_mtime) if dll.is_file() else None,
|
||||
"sqlite_dll": sqlite_dll.is_file(),
|
||||
"sqlitepcl": any(dll_dir.glob("SQLitePCLRaw*.dll")) if dll_dir.is_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)
|
||||
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,
|
||||
"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]}
|
||||
|
||||
|
||||
_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"):
|
||||
hints.append(
|
||||
f"{e.get('name')}: DLL есть, но нет Microsoft.Data.Sqlite.dll — "
|
||||
"чат/memory API могут падать (нужен ≥0.13.1 + seed-extensions)"
|
||||
)
|
||||
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_ids: list[str] = []
|
||||
try:
|
||||
from gpu_rent.paths import assistent_personas_dir
|
||||
|
||||
pdir = assistent_personas_dir()
|
||||
if pdir.is_dir():
|
||||
local_ids = sorted(
|
||||
x.name for x in pdir.iterdir() if x.is_dir() and not x.name.startswith("_")
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
remote_ids = list(overlay.get("persona_ids") or [])
|
||||
missing_on_vm = sorted(set(local_ids) - set(remote_ids))
|
||||
hints: list[str] = []
|
||||
if local_ids and missing_on_vm:
|
||||
hints.append(
|
||||
f"локальные personas не на 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_persona_ids": local_ids,
|
||||
"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
|
||||
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_personas_dir
|
||||
|
||||
pdir = assistent_personas_dir()
|
||||
local["personas_dir"] = {
|
||||
"path": str(pdir),
|
||||
"exists": pdir.is_dir(),
|
||||
"entries": sorted(x.name for x in pdir.iterdir())[:40] if pdir.is_dir() else [],
|
||||
}
|
||||
except Exception as exc:
|
||||
local["personas_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
|
||||
)
|
||||
local["extensions_yaml"] = {
|
||||
"has_swarm_assistent": has,
|
||||
"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",
|
||||
},
|
||||
}
|
||||
@@ -588,107 +588,11 @@ def collect_ollama(cfg: Config) -> dict[str, Any]:
|
||||
return {"ok": False, "via": "ssh", "error": str(exc)[:200]}
|
||||
|
||||
|
||||
def collect_assistent(cfg: Config) -> dict[str, Any]:
|
||||
state = load_state()
|
||||
local: dict[str, Any] = {"personas_dir": None, "extensions_yaml": None}
|
||||
try:
|
||||
from gpu_rent.paths import assistent_personas_dir
|
||||
|
||||
pdir = assistent_personas_dir()
|
||||
local["personas_dir"] = {
|
||||
"path": str(pdir),
|
||||
"exists": pdir.is_dir(),
|
||||
"entries": sorted(x.name for x in pdir.iterdir())[:40] if pdir.is_dir() else [],
|
||||
}
|
||||
except Exception as exc:
|
||||
local["personas_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
|
||||
)
|
||||
local["extensions_yaml"] = {
|
||||
"has_swarm_assistent": has,
|
||||
"manifest": str(cfg.extensions_manifest),
|
||||
}
|
||||
except Exception as exc:
|
||||
local["extensions_yaml"] = {"error": str(exc)[:120]}
|
||||
|
||||
ollama = collect_ollama(cfg)
|
||||
out: dict[str, Any] = {
|
||||
"ok": True,
|
||||
"local": local,
|
||||
"ollama_models": ollama.get("models") if ollama.get("ok") else [],
|
||||
"ollama": {
|
||||
"ok": ollama.get("ok"),
|
||||
"enabled": ollama.get("enabled", True),
|
||||
"hint": ollama.get("hint"),
|
||||
"error": ollama.get("error"),
|
||||
},
|
||||
}
|
||||
|
||||
if not ssh_ready(cfg, state):
|
||||
out["vm"] = _ssh_fail(state.phase)
|
||||
out["ok"] = False
|
||||
out["error"] = SSH_UNAVAILABLE
|
||||
return out
|
||||
|
||||
host = ssh_host(state)
|
||||
assert host is not None
|
||||
try:
|
||||
from gpu_rent.provision import count_wanted_models_on_vm
|
||||
from gpu_rent.ssh_ops import run_ssh
|
||||
|
||||
remote = run_ssh(
|
||||
cfg,
|
||||
host,
|
||||
"python3 - <<'PY'\n"
|
||||
"from pathlib import Path\n"
|
||||
"import json, os\n"
|
||||
"DATA = Path('/mnt/swarm_data')\n"
|
||||
"ext_roots = [\n"
|
||||
" DATA / 'Data' / 'Extensions',\n"
|
||||
" Path('/opt/swarmui/src/BuiltinExtensions'),\n"
|
||||
" Path('/opt/swarmui/src/Extensions'),\n"
|
||||
"]\n"
|
||||
"found = []\n"
|
||||
"for root in ext_roots:\n"
|
||||
" if not root.is_dir():\n"
|
||||
" continue\n"
|
||||
" for p in root.iterdir():\n"
|
||||
" if 'assistent' in p.name.lower():\n"
|
||||
" found.append(str(p))\n"
|
||||
"personas = DATA / 'Assistent'\n"
|
||||
"entries = sorted(x.name for x in personas.iterdir())[:40] if personas.is_dir() else []\n"
|
||||
"print(json.dumps({\n"
|
||||
" 'extension_paths': found,\n"
|
||||
" 'assistent_dir': str(personas),\n"
|
||||
" 'assistent_exists': personas.is_dir(),\n"
|
||||
" 'assistent_entries': entries,\n"
|
||||
"}, ensure_ascii=False))\n"
|
||||
"PY",
|
||||
check=False,
|
||||
timeout=30,
|
||||
).strip()
|
||||
vm = json.loads(remote.splitlines()[-1])
|
||||
wanted = count_wanted_models_on_vm(cfg, host)
|
||||
vm["wanted_models"] = wanted
|
||||
out["vm"] = vm
|
||||
if not vm.get("extension_paths"):
|
||||
out["ok"] = False
|
||||
out["hint"] = "swarm-assistent не найден на VM — seed-extensions / extensions.yaml"
|
||||
elif not (ollama.get("models") or []):
|
||||
out["ok"] = False
|
||||
out["hint"] = "Ollama без моделей — вкладка Assistent будет пустой"
|
||||
except Exception as exc:
|
||||
out["vm"] = {"error": str(exc)[:200]}
|
||||
out["ok"] = False
|
||||
return out
|
||||
def collect_assistent(cfg: Config, *, chat_smoke: bool = False, include_logs: bool = False) -> dict[str, Any]:
|
||||
"""Deep Assistent diagnostics — see debug_assistent.collect_assistent_deep."""
|
||||
from gpu_rent.debug_assistent import collect_assistent_deep
|
||||
|
||||
return collect_assistent_deep(cfg, chat_smoke=chat_smoke, include_logs=include_logs)
|
||||
|
||||
def collect_snapshot(
|
||||
cfg: Config,
|
||||
|
||||
@@ -131,12 +131,139 @@ def test_debug_server_routes(monkeypatch, tmp_path):
|
||||
code, data = get("/logs")
|
||||
assert data["error"] == debug_checks.SSH_UNAVAILABLE
|
||||
|
||||
code, data = get("/assistent")
|
||||
assert "playbook" in data
|
||||
assert "hints" in data
|
||||
assert data.get("error") == debug_checks.SSH_UNAVAILABLE or data.get("extension", {}).get(
|
||||
"error"
|
||||
) == debug_checks.SSH_UNAVAILABLE or not data.get("ok")
|
||||
|
||||
code, data = get("/assistent/api")
|
||||
assert "ok" in data
|
||||
|
||||
code, data = get("/openapi.json")
|
||||
assert "/assistent/extension" in data["paths"]
|
||||
assert "/assistent/api" in data["paths"]
|
||||
|
||||
with pytest.raises(Exception):
|
||||
urllib.request.urlopen(f"http://127.0.0.1:{port}/nope", timeout=2)
|
||||
finally:
|
||||
debug_api.stop_debug_server(srv)
|
||||
|
||||
|
||||
def test_assistent_compact_and_roles_local(monkeypatch, tmp_path):
|
||||
from gpu_rent import debug_assistent
|
||||
|
||||
compact = debug_assistent._compact_api(
|
||||
"AssistentListModels",
|
||||
{
|
||||
"models": ["a:8b", "b:32b"],
|
||||
"memory_models": ["nomic-embed-text"],
|
||||
"preferred": "b:32b",
|
||||
"base_url": "http://127.0.0.1:11434",
|
||||
},
|
||||
)
|
||||
assert compact["model_count"] == 2
|
||||
assert compact["preferred"] == "b:32b"
|
||||
|
||||
_auth_env(monkeypatch, tmp_path)
|
||||
cfg = load_config(require_auth=True)
|
||||
monkeypatch.setattr(
|
||||
debug_assistent.debug_checks,
|
||||
"load_state",
|
||||
lambda: __import__("gpu_rent.state", fromlist=["SessionState"]).SessionState(
|
||||
phase="idle"
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
debug_assistent,
|
||||
"collect_assistent_fs",
|
||||
lambda cfg, **k: {
|
||||
"ok": True,
|
||||
"extensions": [
|
||||
{
|
||||
"name": "swarm-assistent",
|
||||
"path": "/mnt/swarm_data/Extensions/swarm-assistent",
|
||||
"dll": "/x/SwarmAssistentExtension.dll",
|
||||
"sqlite_dll": True,
|
||||
"tab_html": True,
|
||||
"bundle_js": True,
|
||||
"git_head": "abc",
|
||||
}
|
||||
],
|
||||
"overlay": {
|
||||
"exists": True,
|
||||
"persona_ids": ["leonid"],
|
||||
"roles_present": True,
|
||||
"roles": {
|
||||
"chat": ["qwen3-vl:8b"],
|
||||
"memory": ["nomic-embed-text"],
|
||||
"default_chat": "qwen3-vl:8b",
|
||||
},
|
||||
"embed_model": "nomic-embed-text",
|
||||
},
|
||||
"sqlite": {"exists": True, "size": 100, "counts": {"memories": 2}},
|
||||
"wanted": {"count": 0, "sample": []},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
debug_assistent.debug_checks,
|
||||
"collect_ollama",
|
||||
lambda cfg: {"ok": True, "models": ["qwen3-vl:8b", "nomic-embed-text"]},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
debug_assistent,
|
||||
"collect_assistent_api",
|
||||
lambda cfg, chat_smoke=False: {
|
||||
"ok": True,
|
||||
"via": "local",
|
||||
"calls": [
|
||||
{"name": "AssistentListPersonas", "ok": True, "data": {"count": 3}},
|
||||
{"name": "AssistentListModels", "ok": True, "data": {"model_count": 1}},
|
||||
],
|
||||
"hints": [],
|
||||
},
|
||||
)
|
||||
deep = debug_assistent.collect_assistent_deep(cfg)
|
||||
assert deep["ok"] is True
|
||||
assert deep["extension"]["ok"] is True
|
||||
assert deep["roles"]["roles"]["default_chat"] == "qwen3-vl:8b"
|
||||
assert "playbook" in deep
|
||||
|
||||
# wrong default_chat
|
||||
monkeypatch.setattr(
|
||||
debug_assistent,
|
||||
"collect_assistent_fs",
|
||||
lambda cfg, **k: {
|
||||
"ok": True,
|
||||
"extensions": [
|
||||
{
|
||||
"name": "swarm-assistent",
|
||||
"dll": "/x.dll",
|
||||
"sqlite_dll": True,
|
||||
"tab_html": True,
|
||||
"bundle_js": True,
|
||||
}
|
||||
],
|
||||
"overlay": {
|
||||
"exists": True,
|
||||
"persona_ids": [],
|
||||
"roles_present": True,
|
||||
"roles": {
|
||||
"chat": ["missing:model"],
|
||||
"memory": [],
|
||||
"default_chat": "missing:model",
|
||||
},
|
||||
},
|
||||
"sqlite": {"exists": False, "size": 0},
|
||||
"wanted": {"count": 0, "sample": []},
|
||||
},
|
||||
)
|
||||
roles = debug_assistent.collect_assistent_roles(cfg)
|
||||
assert roles["ok"] is False
|
||||
assert any("default_chat" in h for h in roles["hints"])
|
||||
|
||||
|
||||
def test_bind_fail_returns_none(monkeypatch, tmp_path):
|
||||
_auth_env(monkeypatch, tmp_path)
|
||||
cfg = load_config(require_auth=True)
|
||||
|
||||
Reference in New Issue
Block a user