Seed Assistent personas as overlay folders and tighten Ollama/Assistent glue.

gpu-rent now writes personas/<id>/ on the VM (not legacy personas.json), adds seed-personas/doctor checks, and shortens mid/high keep-alive now that Assistent parks the LLM before Generate.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-22 01:00:14 +03:00
co-authored by Cursor
parent 789fa26918
commit 03ba4cb6ed
22 changed files with 430 additions and 53 deletions
+5
View File
@@ -40,6 +40,11 @@ def collect_access_links(cfg: Config, *, tunneled: bool) -> list[AccessLink]:
links.extend(
[
AccessLink("SwarmUI UI", base, "браузер"),
AccessLink(
"Assistent",
base,
"вкладка Assistent в SwarmUI",
),
AccessLink("SwarmUI API", f"{base}/API/", "HTTP JSON"),
AccessLink("SwarmUI MCP", f"{base}/mcp", "Cursor mcp.json"),
]
+22
View File
@@ -324,6 +324,16 @@ def status() -> None:
table.add_row("idle-killer", f"{killer_line} · arm ok (сессия)")
else:
table.add_row("idle-killer", killer_line)
try:
from gpu_rent.provision import count_wanted_models_on_vm
wanted_n = count_wanted_models_on_vm(cfg, state.floating_ip)
table.add_row(
"Assistent wanted",
f"{wanted_n} в очереди" if wanted_n else "пусто",
)
except Exception:
table.add_row("Assistent wanted", "")
except GpuRentError as exc:
table.add_row("диск used/free", f"SSH: {exc}")
table.add_row("idle-killer", "нет SSH")
@@ -932,6 +942,18 @@ def seed_extensions_cmd() -> None:
_die(exc)
@app.command("seed-personas")
def seed_personas_cmd() -> None:
"""Push assistent-personas.yaml → VM Assistent overlay (без полного up)."""
try:
from gpu_rent.provision import seed_assistent_personas
cfg, host = _live()
seed_assistent_personas(cfg, host, log)
except GpuRentError as exc:
_die(exc)
capture_app = typer.Typer(
help=(
"Снять с VM инвентарь → локальные манифесты (только ссылки, без весов). "
+63 -1
View File
@@ -15,7 +15,7 @@ from gpu_rent.inventory import (
pick_volume_type,
rank_flavors,
)
from gpu_rent.manifests import parse_extensions, parse_models
from gpu_rent.manifests import parse_extensions, parse_models, repo_dirname, repo_matches_runtime
from gpu_rent.os_client import (
compute_quotas,
connect,
@@ -243,10 +243,72 @@ def run_doctor() -> list[Check]:
_civitai(cfg, checks)
_huggingface(cfg, checks)
_local_manifests(cfg, checks)
_llm_assistent(cfg, checks)
_local_folders(cfg, checks)
return checks
def _llm_assistent(cfg: Config, checks: list[Check]) -> None:
"""When Ollama is on, warn if Assistent stack pieces are missing locally."""
from gpu_rent.llm_runtime import normalize_runtime, parse_ollama_models
from gpu_rent.paths import ollama_models_manifest_path
runtime = normalize_runtime(getattr(cfg, "llm_runtime", "none"))
if runtime != "ollama":
checks.append(Check("LLM / Assistent", True, False, f"LLM_RUNTIME={runtime}"))
return
ollama_path = Path(
getattr(cfg, "ollama_models_manifest", None) or ollama_models_manifest_path()
)
if not ollama_path.is_file():
checks.append(
Check(
"ollama-models.yaml",
False,
True,
f"нет {ollama_path} — Assistent будет пустой. gpu-rent setup / скопируй example",
)
)
else:
try:
entries = parse_ollama_models(ollama_path)
chat = [e for e in entries if getattr(e, "use", "chat") != "memory"]
checks.append(
Check(
"ollama-models.yaml",
True,
True,
f"{len(entries)} тег(ов), chat={len(chat)}",
)
)
except Exception as exc:
checks.append(Check("ollama-models.yaml", False, True, str(exc)))
try:
repos = parse_extensions(cfg.extensions_manifest)
except ConfigError as exc:
checks.append(Check("Assistent ext", False, True, str(exc)))
return
has_assistent = any(
"assistent" in repo_dirname(r).lower()
or "assistent" in (r.url or "").lower()
for r in repos
if repo_matches_runtime(r, runtime)
)
if has_assistent:
checks.append(Check("Assistent ext", True, False, "swarm-assistent в extensions.yaml"))
else:
checks.append(
Check(
"Assistent ext",
True,
False,
"нет swarm-assistent (requires:ollama) в extensions.yaml — чат не поставится",
)
)
def _civitai(cfg: Config, checks: list[Check]) -> None:
if not cfg.civitai_api_token:
checks.append(
+10 -9
View File
@@ -21,10 +21,13 @@ VALID_RUNTIMES = frozenset({"none", "ollama"})
MEMORY_EMBED_MODEL = "nomic-embed-text"
OLLAMA_PRESETS: dict[str, list[str]] = {
# Requirement: uncensored (abliterated) + solid Russian. Qwen2.5 family.
# Vision tags preferred for SwarmUI prompt help with images.
# Memory embed (nomic) is appended separately with use: memory.
"recommended": ["huihui_ai/qwen2.5-vl-abliterated:7b"], # ~6GB
# Requirement: uncensored (abliterated) + solid Russian. Vision for prompt help.
# Pin :8b-instruct — :latest on this library is Thinking, not chat.
# Qwen3-VL needs Ollama ≥ 0.12.7. Memory embed (nomic) appended with use: memory.
"recommended": [
"huihui_ai/qwen3-vl-abliterated:8b-instruct", # ~6.1GB, default
"huihui_ai/qwen2.5-vl-abliterated:7b", # ~6GB, second chat tag
],
"light": ["huihui_ai/qwen2.5-vl-abliterated:3b"], # ~3GB
"text": ["huihui_ai/qwen2.5-abliterate:7b"], # ~5GB, no vision
"big": ["huihui_ai/qwen2.5-vl-abliterated:32b"], # ~21GB
@@ -32,7 +35,7 @@ OLLAMA_PRESETS: dict[str, list[str]] = {
}
OLLAMA_PRESET_LABELS: dict[str, str] = {
"recommended": "VL 7B abliterate — RU + картинки (~6GB)",
"recommended": "VL 8B Instruct abliterate — RU + картинки (~6GB)",
"light": "VL 3B abliterate — RU + картинки, мало VRAM (~3GB)",
"text": "7B abliterate text — RU, без vision (~5GB)",
"big": "VL 32B abliterate — RU + картинки (~21GB)",
@@ -40,10 +43,8 @@ OLLAMA_PRESET_LABELS: dict[str, str] = {
"keep": "не менять ollama-models.yaml",
}
# Deprecated text blob — prefer menu helpers below.
PRESET_HELP = "\n".join(
f"{k}{v}" for k, v in OLLAMA_PRESET_LABELS.items() if k != "keep"
)
# Deprecated: use ollama_preset_menu / OLLAMA_PRESET_LABELS
PRESET_HELP = "" # kept empty; menus use OLLAMA_PRESET_LABELS
LLM_RUNTIME_LABELS: dict[str, str] = {
"none": "только SwarmUI",
+4 -4
View File
@@ -77,16 +77,16 @@ def ollama_tune_for(info: GpuInfo) -> OllamaTune:
note = "ultra: flash+q8 KV, 20GiB reserved for Swarm, ctx 32k, keep 30m"
elif info.tier == TIER_HIGH:
overhead = 14 * 1024**3
keep = "15m"
keep = "5m" # Assistent parks LLM before Generate; short keep is enough
kv = "q8_0"
ctx = 16384
note = "high: flash+q8 KV, 14GiB reserved for Swarm, ctx 16k, keep 15m"
note = "high: flash+q8 KV, 14GiB reserved for Swarm, ctx 16k, keep 5m"
elif info.tier == TIER_MID:
overhead = 10 * 1024**3
keep = "15m"
keep = "5m"
kv = "q8_0"
ctx = 16384
note = "mid: flash+q8 KV, 10GiB reserved for Swarm, ctx 16k, keep 15m"
note = "mid: flash+q8 KV, 10GiB reserved for Swarm, ctx 16k, keep 5m"
else:
overhead = 6 * 1024**3
keep = "2m"
+136 -15
View File
@@ -710,8 +710,55 @@ def seed_swarmui_api_keys(cfg: Config, host: str, log: Log) -> None:
run_ssh(cfg, host, "rm -f /tmp/gpu-rent-swarm-api-keys.json", check=False)
def _safe_persona_id(raw: object) -> str | None:
s = str(raw or "").strip().replace("\\", "/")
if not s or ".." in s or "/" in s:
return None
import re
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_\-]{0,63}", s):
return None
return s
def _overlay_num_ctx(cfg: Config, host: str) -> int | None:
"""Prefer GPU-tier context from .gpu-rent-gpu.json when present."""
raw = run_ssh(
cfg,
host,
f"test -f {DATA}/.gpu-rent-gpu.json && cat {DATA}/.gpu-rent-gpu.json || true",
check=False,
timeout=15,
).strip()
if not raw:
return None
try:
probe = json.loads(raw)
vram = int(probe.get("vram_mib") or probe.get("memory_total_mib") or 0)
except (json.JSONDecodeError, TypeError, ValueError):
return None
if vram <= 0:
return None
from gpu_rent.perf_tiers import GpuInfo, ollama_tune_for, tier_for_vram_mib
info = GpuInfo(
name=str(probe.get("name") or "gpu"),
vram_mib=vram,
compute_cap=str(probe.get("compute_cap") or "0.0"),
uuid=str(probe.get("uuid") or ""),
tier=tier_for_vram_mib(vram),
)
return ollama_tune_for(info).context_length
def seed_assistent_personas(cfg: Config, host: str, log: Log) -> None:
"""Push local assistent-personas.yaml → VM Assistent/personas.yaml + personas.json."""
"""Push local assistent-personas.yaml → VM Assistent/personas/<id>/ overlay.
Laptop yaml stays the editor; on VM we write persona.json + extra.md and
overlay assistant.json (default_persona, optional num_ctx). Does not clobber
voice/likes/dislikes/rules already on disk. No longer writes legacy
personas.json / personas.yaml dumps.
"""
from gpu_rent.paths import assistent_personas_example_path, assistent_personas_manifest_path
local = Path(
@@ -728,24 +775,96 @@ def seed_assistent_personas(cfg: Config, host: str, log: Log) -> None:
if not text.strip():
log("assistent-personas: пустой файл — skip")
return
remote_dir = f"{DATA}/Assistent"
remote_yaml = f"{remote_dir}/personas.yaml"
remote_json = f"{remote_dir}/personas.json"
run_ssh(cfg, host, f"mkdir -p {shlex.quote(remote_dir)}", check=False)
put_text(cfg, host, remote_yaml, text if text.endswith("\n") else text + "\n")
try:
import yaml
data = yaml.safe_load(text) or {}
put_text(
cfg,
host,
remote_json,
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
)
except Exception as exc:
log(f"assistent-personas: json convert{exc}")
log(f"assistent-personas → {remote_yaml}")
log(f"assistent-personas: yaml parse{exc}")
return
if not isinstance(data, dict):
log("assistent-personas: корень должен быть mapping — skip")
return
remote_dir = f"{DATA}/Assistent"
run_ssh(cfg, host, f"mkdir -p {shlex.quote(remote_dir)}/personas {shlex.quote(remote_dir)}/_base", check=False)
default_id = _safe_persona_id(data.get("default")) or "neutral"
personas = data.get("personas") or []
written = 0
if isinstance(personas, list):
for row in personas:
if not isinstance(row, dict):
continue
pid = _safe_persona_id(row.get("id"))
if not pid:
continue
title = str(row.get("title") or pid).strip() or pid
prompt = str(row.get("prompt") or "").strip()
pdir = f"{remote_dir}/personas/{pid}"
run_ssh(cfg, host, f"mkdir -p {shlex.quote(pdir)}", check=False)
meta = {"title": title, "tagline": title, "accent": "#8b949e"}
put_text(
cfg,
host,
f"{pdir}/persona.json",
json.dumps(meta, ensure_ascii=False, indent=2) + "\n",
)
if prompt:
put_text(
cfg,
host,
f"{pdir}/extra.md",
prompt if prompt.endswith("\n") else prompt + "\n",
)
written += 1
assistant_overlay: dict = {"default_persona": default_id}
num_ctx = _overlay_num_ctx(cfg, host)
if num_ctx:
assistant_overlay["num_ctx"] = num_ctx
put_text(
cfg,
host,
f"{remote_dir}/_base/assistant.json",
json.dumps(assistant_overlay, ensure_ascii=False, indent=2) + "\n",
)
# Drop legacy dumps so Assistent does not double-inject prompts.
run_ssh(
cfg,
host,
f"rm -f {shlex.quote(remote_dir + '/personas.json')} "
f"{shlex.quote(remote_dir + '/personas.yaml')}",
check=False,
)
ctx_note = f", num_ctx={num_ctx}" if num_ctx else ""
log(f"assistent-personas → overlay personas/{written} (default={default_id}{ctx_note})")
def count_wanted_models_on_vm(cfg: Config, host: str) -> int:
"""Count entries in Assistent wanted queue on the VM (best-effort)."""
remote = f"{DATA}/.gpu-rent-wanted-models.yaml"
raw = run_ssh(
cfg,
host,
f"test -f {shlex.quote(remote)} && cat {shlex.quote(remote)} || true",
check=False,
timeout=20,
).strip()
if not raw:
return 0
try:
import yaml
data = yaml.safe_load(raw) or {}
except Exception:
return sum(1 for line in raw.splitlines() if line.strip().startswith("- url:"))
n = 0
if isinstance(data, dict):
for rows in data.values():
if isinstance(rows, list):
n += sum(1 for r in rows if isinstance(r, dict) and r.get("url"))
return n
def merge_wanted_models_from_vm(cfg: Config, host: str, log: Log) -> int:
@@ -1072,6 +1191,8 @@ def provision_llm(cfg: Config, host: str, log: Log) -> None:
)
# Drop LLM units that should not hold VRAM for this runtime.
# Tombstone: still stop gpu-rent-llamacpp if an old disk left that unit behind
# (llamacpp runtime was removed; do not reinstall it).
if runtime == "none":
log("LLM: none — останавливаю gpu-rent-ollama / gpu-rent-llamacpp если были")
_stop_units("gpu-rent-ollama", "gpu-rent-llamacpp")
@@ -1083,7 +1204,7 @@ def provision_llm(cfg: Config, host: str, log: Log) -> None:
return
still: list[str] = []
if runtime == "ollama":
_stop_units("gpu-rent-llamacpp")
_stop_units("gpu-rent-llamacpp") # tombstone: disable leftover llamacpp unit
log("LLM: ставим/запускаем Ollama")
run_script_sudo(
cfg,
+5 -1
View File
@@ -129,7 +129,11 @@ def swarm_busy(swarm_url: str, timeout: float = 8.0) -> tuple[bool, str]:
def llm_busy(timeout: float = 3.0) -> tuple[bool, str]:
"""Ollama pull / loaded models count as busy."""
"""Ollama pull / loaded models count as busy.
Empty /api/ps after AssistentParkLlm is NOT busy by itself — Swarm queue
is checked separately in main() and keeps the GPU alive during Generate.
"""
pull_marker = DATA / ".gpu-rent-ollama-pulling"
if pull_marker.is_file():
try:
+2 -2
View File
@@ -47,9 +47,9 @@ except ValueError:
if gib >= 48:
tier, overhead, keep, kv, flash, ctx = "ultra", 20 * 1024**3, "30m", "q8_0", True, 32768
elif gib >= 24:
tier, overhead, keep, kv, flash, ctx = "high", 14 * 1024**3, "15m", "q8_0", True, 16384
tier, overhead, keep, kv, flash, ctx = "high", 14 * 1024**3, "5m", "q8_0", True, 16384
elif gib >= 16:
tier, overhead, keep, kv, flash, ctx = "mid", 10 * 1024**3, "15m", "q8_0", True, 16384
tier, overhead, keep, kv, flash, ctx = "mid", 10 * 1024**3, "5m", "q8_0", True, 16384
else:
tier, overhead, keep, kv, flash, ctx = "low", 6 * 1024**3, "2m", "q4_0", False, 8192
flash = bool(flash and (ampere or "A100" in name.upper() or "H100" in name.upper() or gib >= 16))