From 20ae7bd83acfca82ea7288391cb5e43d535462c6 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 23 Aug 2026 08:28:31 +0300 Subject: [PATCH] Support Assistent persona packs via git and local sync. Replace assistent-personas overlay seed with assistent-extensions SFTP and an assistent: section in extensions.yaml so personalities install like other extensions without private URLs in the public repo. Co-authored-by: Cursor --- .gitignore | 2 + assistent-extensions.example/.gitkeep | 0 assistent-extensions.example/README.md | 23 ++++ assistent-personas.example/README.md | 16 --- .../_base/assistant.json | 3 - docs/cli.md | 6 +- docs/decisions.md | 2 +- docs/extensions.md | 17 ++- docs/llm.md | 2 +- docs/local-folders.md | 10 +- docs/setup.md | 2 +- extensions.example.yaml | 11 +- src/gpu_rent/capture.py | 6 +- src/gpu_rent/cli.py | 9 +- src/gpu_rent/debug_assistent.py | 39 ++++-- src/gpu_rent/manifests.py | 11 +- src/gpu_rent/paths.py | 14 +- src/gpu_rent/payload.py | 3 + src/gpu_rent/provision.py | 126 +++++++----------- src/gpu_rent/remote/clone_ext.py | 1 + src/gpu_rent/remote/scan_inventory.py | 1 + src/gpu_rent/setup_wizard.py | 10 +- tests/test_assistent_personas_seed.py | 114 ++++++++-------- tests/test_debug_api.py | 8 +- tests/test_manifests.py | 28 +++- 25 files changed, 258 insertions(+), 206 deletions(-) create mode 100644 assistent-extensions.example/.gitkeep create mode 100644 assistent-extensions.example/README.md delete mode 100644 assistent-personas.example/README.md delete mode 100644 assistent-personas.example/_base/assistant.json diff --git a/.gitignore b/.gitignore index 80c5e40..06a1a3d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ models.yaml assistent-personas.yaml assistent-personas/ !assistent-personas.example/ +assistent-extensions/ +!assistent-extensions.example/ extensions.yaml # SwarmUI checkpoints / LoRA dropped into the app tree diff --git a/assistent-extensions.example/.gitkeep b/assistent-extensions.example/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assistent-extensions.example/README.md b/assistent-extensions.example/README.md new file mode 100644 index 0000000..d2cf1ef --- /dev/null +++ b/assistent-extensions.example/README.md @@ -0,0 +1,23 @@ +# Assistent persona packs (laptop → VM) + +Clone git persona packs into this folder (one subdirectory per pack). + +On `gpu-rent up` / `seed-personas` / `push` each pack is SFTP'd to +`/mnt/swarm_data/Assistent/extensions//` (``.git`` is not uploaded). + +Pack layout (repo root): + +``` +assistent-pack.yaml # kind: persona / id: my_persona +persona.json +voice.json +… +``` + +Or multi-pack: `personas//persona.json` under the clone. + +Bundled personas ship with swarm-assistent (`neutral`, `aggressive`, `dreamer`). +Private packs stay only here — do not put their URLs in `extensions.yaml` if you +do not want them captured/shared. + +Overlay clones created in the Assistent UI are **not** deleted by seed. diff --git a/assistent-personas.example/README.md b/assistent-personas.example/README.md deleted file mode 100644 index 13903d9..0000000 --- a/assistent-personas.example/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Assistent personas (laptop → VM overlay) - -Copy this folder to `assistent-personas/` (gitignored) and edit shelves. - -On `gpu-rent up` / `seed-personas` files under `personas//` are pushed to -`/mnt/swarm_data/Assistent/personas//`. Bundled personas (including **leonid**) -ship with the swarm-assistent extension — you do not need to duplicate them here. - -- `_base/assistant.json` — only `default_persona` (and gpu-rent adds `num_ctx`) -- `personas//*.json` — sparse identity shelves (bio, voice, humor, …) -- Overlay personas created in the Assistent UI are **not** deleted by seed - -This is a **seed overlay**, not `gpu-rent push` (see [docs/local-folders.md](../docs/local-folders.md)). -Debug overlay vs laptop: `GET http://127.0.0.1:17821/assistent/overlay`. - -See swarm-assistent `Config/personas/README.md` for the shelf schema. diff --git a/assistent-personas.example/_base/assistant.json b/assistent-personas.example/_base/assistant.json deleted file mode 100644 index 01af867..0000000 --- a/assistent-personas.example/_base/assistant.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "default_persona": "neutral" -} diff --git a/docs/cli.md b/docs/cli.md index 1e123cf..a174b36 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -79,7 +79,7 @@ gpu-rent up --yes --ollama | `gpu-rent ssh` | Оболочка на VM | | `gpu-rent seed-models` | Докачать новые строки Civitai-манифеста на живой диск | | `gpu-rent seed-extensions` | Доклонировать/обновить git-репы; restart swarmui | -| `gpu-rent seed-personas` | Пуш `assistent-personas/` → overlay на VM (без полного up); также `search.jsonl` → FTS, если есть | +| `gpu-rent seed-personas` | Пуш `assistent-extensions/` → `Assistent/extensions/` (+ `_base`); также `search.jsonl` → FTS, если есть | | `gpu-rent push` / `push-models` | Локальные деревья → VM | | `gpu-rent pull-output` | VM `Output/` → `./Output` | | `gpu-rent capture` / `capture all` | Инвентарь VM → merge **ссылок** в `models.yaml` + `extensions.yaml` (веса не качать) | @@ -151,7 +151,7 @@ Exit 0 → можно `up`. Exit 1 → причина в таблице / кра | `/assistent` | Сводка: extension + overlay + roles + memory + live API + hints + playbook | | `/assistent/diagnose` | То же + journal по умолчанию + session store meta | | `/assistent/extension` | DLL, Sqlite deps, Tab/Assets, git HEAD | -| `/assistent/overlay` | personas на VM vs локальный `assistent-personas/` | +| `/assistent/overlay` | personas/packs на VM vs локальный `assistent-extensions/` | | `/assistent/roles` | `ollama-roles.json` ↔ `/api/tags` (`default_chat`) | | `/assistent/memory` | `assistent.sqlite` + counts | | `/assistent/api` | `AssistentListPersonas/Models/Memory/Chats` через туннель (или SSH) | @@ -198,7 +198,7 @@ curl -sS -X DELETE "$BASE/assistent/session/$SID" # One-shot convenience (session + one chat + delete) curl -sS -X POST "$BASE/assistent/chat-eval" \ -H 'Content-Type: application/json' \ - -d '{"message":"какие steps/cfg для turbo?","persona":"leonid","timeout":120}' \ + -d '{"message":"какие steps/cfg для turbo?","persona":"neutral","timeout":120}' \ | jq '{ok,reply,patch,trace}' ``` diff --git a/docs/decisions.md b/docs/decisions.md index cadf2b7..68d133d 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -10,7 +10,7 @@ | Civitai хост | Дефолт API `civitai.red` (полный каталог). `.com` — SFW-витрина, NSFW с неё часто 404. Ссылки `.com`/`.red`/`.green` в манифесте принимаем. 404 → один retry на второй хост. Один токен на оба домена | | Пул GPU | Перед `up`/`flavors` сканируем `SCAN_POOLS` (дефолт `ru-6,ru-7`) по compute URL. Сегменты `ru-6a/b/c` берём из Nova `availability_zones`, не отдельными пулами. Пресет размера `FLAVOR_SIZE_PRESET` (дефолт `cheap` = 1×GPU, min vCPU/RAM). Диск уже есть — AZ диска, не дешёвый чужой сегмент. Первый запуск — дешёвый SKU в сегменте, где есть карта из `FLAVOR_PREFERENCE`. `.env` автоматом не пишем | | Манифест моделей | `/models.yaml`, типы: checkpoint / lora / vae / embedding / controlnet / upscaler. В git только `models.example.yaml` | -| Расширения | `/extensions.yaml`: git-репы `swarmui` → `src/Extensions`, `comfy` → DLNodes. Поле `requires: none\|ollama\|any-llm` фильтрует по `LLM_RUNTIME`. Пример: swarm-assistent с `requires: ollama`. Personas: `assistent-personas/` → overlay `Assistent/personas//` на `up` / `seed-personas` (не сносит UI-клоны) | +| Расширения | `/extensions.yaml`: git-репы `swarmui` → `src/Extensions`, `comfy` → DLNodes, `assistent` → `Assistent/extensions`. Поле `requires: none\|ollama\|any-llm` фильтрует по `LLM_RUNTIME`. Пример: swarm-assistent с `requires: ollama`. Persona packs: также `assistent-extensions/` → SFTP на `up` / `seed-personas` (не сносит UI-клоны) | | Autocomplete | До первого старта: скачать word-list в `Data/Autocompletions`, прописать `DefaultUser.AutoComplete.Source`. На каждом `up` сверить GitHub blob sha и обновить файл, если изменился. Дефолт: `tags/danbooru.csv` из a1111-sd-webui-tagcomplete (как в доке SwarmUI) | | Доступ | Браузер на туннеле; MCP переключается на облако, пока оно живо; HTTP API SwarmUI через тот же туннель | | Preemptible | По умолчанию всегда. Обычный сервер — только `--no-spot` | diff --git a/docs/extensions.md b/docs/extensions.md index 8494009..68275a9 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -26,6 +26,7 @@ copy extensions.example.yaml extensions.yaml | --- | --- | --- | | `swarmui` | `/mnt/swarm_data/Extensions/` → `/opt/swarmui/src/Extensions/` | C# extension SwarmUI (класс `: Extension`). После рестарта подхватывается сам | | `comfy` | `/mnt/swarm_data/DLNodes/` → `…/ComfyUIBackend/DLNodes/` | Custom nodes ComfyUI (Python). `requirements.txt` ставит SwarmUI/Comfy при первом старте backend | +| `assistent` | `/mnt/swarm_data/Assistent/extensions//` | Persona pack (полки JSON + `assistent-pack.yaml`). Без рестарта C# — Assistent читает с диска | Пустой манифест или его нет — ничего не клонируем, SwarmUI как из коробки. Это не связано с Civitai-токеном. @@ -42,6 +43,11 @@ swarmui: - url: https://github.com/example/SwarmUI-SomeExt.git ref: main dir: SomeExt +assistent: [] + # - url: https://example.com/org/some-persona-pack.git + # ref: main + # dir: some-persona-pack + # requires: ollama comfy: - url: https://github.com/example/ComfyUI-Something.git ref: v1.2.0 @@ -53,7 +59,16 @@ comfy: | `ollama` | только при `LLM_RUNTIME=ollama` | | `any-llm` | при `LLM_RUNTIME=ollama` (сейчас единственный LLM-рантайм; llama.cpp снят) | -Строки с несовпавшим `requires` пропускаются (лог), остальные ставятся как обычно. В `extensions.example.yaml` по умолчанию — **swarm-assistent** с `requires: ollama` (чат + доска Generate/Ref + persona/Cards, ветка `main`). После `seed-extensions` + рестарта SwarmUI подтягивается текущий `main` (с **0.13.1+**: `CopyLocalLockFileAssemblies` копирует `Microsoft.Data.Sqlite` + `SQLitePCLRaw*` рядом с extension DLL — иначе ListMemory/ListChats/SaveChat падают; с **0.10.11**: default chat = `default_chat` / старший тег, warm той же моделью). На `up` (и `gpu-rent seed-personas`) сидится папка `assistent-personas/` → overlay `/mnt/swarm_data/Assistent/personas//*.json`, плюс `_base/assistant.json` (`default_persona`, опционально `num_ctx` с GPU tier). Seed **не** удаляет overlay-личности, созданные в UI. Legacy `personas.json` / yaml-prompt больше не пишутся. +Строки с несовпавшим `requires` пропускаются (лог), остальные ставятся как обычно. В `extensions.example.yaml` по умолчанию — **swarm-assistent** с `requires: ollama` (чат + доска Generate/Ref + persona/Cards, ветка `main`). После `seed-extensions` + рестарта SwarmUI подтягивается текущий `main` (с **0.13.1+**: `CopyLocalLockFileAssemblies` копирует `Microsoft.Data.Sqlite` + `SQLitePCLRaw*` рядом с extension DLL — иначе ListMemory/ListChats/SaveChat падают; с **0.10.11**: default chat = `default_chat` / старший тег, warm той же моделью). + +### Persona packs + +Bundled в swarm-assistent: `neutral` (Нормальный), `aggressive`, `dreamer`. Дополнительные личности — git-паки: + +1. **Конфиг** — секция `assistent:` в `extensions.yaml` (clone на VM в `Assistent/extensions//`). +2. **Локально** — клоны в `assistent-extensions//` (gitignored). На `up` / `seed-personas` / `push` уезжают SFTP **без** `.git` в тот же dest. Приватные паки можно держать только так — без URL в yaml (capture их не увидит). + +Формат пака: `assistent-pack.yaml` (`kind: persona`, `id: …`) + полки JSON в корне (или `personas//` для мульти-пака). На `up` также пишется `_base/assistant.json` (`default_persona: neutral`, опционально `num_ctx` с GPU tier). Seed **не** удаляет overlay-личности, созданные в UI. Legacy `assistent-personas/` / `personas.json` больше не сидятся. Если есть `datasets/civitai/search.jsonl`, на том же шаге пушится FTS-индекс → `/mnt/swarm_data/Assistent/civitai-examples.jsonl` ([datasets/README](../datasets/README.md)). Диагностика Assistent: Debug API `/assistent/*` ([cli.md](cli.md)). diff --git a/docs/llm.md b/docs/llm.md index 5d4de64..8f22eff 100644 --- a/docs/llm.md +++ b/docs/llm.md @@ -113,7 +113,7 @@ Unit `gpu-rent-ollama` читает `/mnt/swarm_data/.gpu-rent-gpu.json`: \*Flash на Ampere+ (compute ≥ 8.0). `NUM_PARALLEL=2`, `MAX_LOADED_MODELS=2` (chat VL + memory embed). Memory models use `use: memory` and a CPU Modelfile (`num_gpu 0`) so embed does not steal VRAM from the chat model. Ollama default `num_ctx` is 4096; we set `OLLAMA_CONTEXT_LENGTH` so Assistent + vision fits. Mid/high keep-alive is **5m** because Assistent parks the chat model before Generate (`AssistentParkLlm`) and warms after. Env: `/mnt/swarm_data/.gpu-rent-ollama.env`. -Personas: laptop `assistent-personas/` → on `up` / `seed-personas` overlay folders under `/mnt/swarm_data/Assistent/personas//` (+ `_base/assistant.json` with `default_persona` / `num_ctx`). Shelves are short JSON files (not a long prompt). See [extensions.md](extensions.md). Optional FTS examples: [datasets/README](../datasets/README.md) (`search.jsonl` → `Assistent/civitai-examples.jsonl`). Journal: `gpu-rent logs -u ollama`. +Personas: bundled `neutral` / `aggressive` / `dreamer` in swarm-assistent; extra packs via `extensions.yaml` `assistent:` or laptop `assistent-extensions/` → `Assistent/extensions/` on `up` / `seed-personas` / `push` (+ `_base/assistant.json` with `default_persona` / `num_ctx`). See [extensions.md](extensions.md). Optional FTS examples: [datasets/README](../datasets/README.md) (`search.jsonl` → `Assistent/civitai-examples.jsonl`). Journal: `gpu-rent logs -u ollama`. `ollama-models.yaml` entries: diff --git a/docs/local-folders.md b/docs/local-folders.md index 2ff047f..d0f3cb2 100644 --- a/docs/local-folders.md +++ b/docs/local-folders.md @@ -19,18 +19,18 @@ Веса, картинки, воркфлоу в git не коммитить (`.gitignore` + `.gitkeep`). -## Seed overlay (не `gpu-rent push`) +## Seed overlay (не только `gpu-rent push`) -Другой механизм — не SHA256-синк четырёх деревьев выше: +Другой механизм — не SHA256-синк Models/Wildcards/workflows: | Локально | На VM | Когда | | --- | --- | --- | -| `assistent-personas/` | `/mnt/swarm_data/Assistent/personas//` | `up` / `seed-personas`; см. [extensions.md](extensions.md) | +| `assistent-extensions//` | `/mnt/swarm_data/Assistent/extensions//` | `up` / `seed-personas` / `push` (без `.git`); см. [extensions.md](extensions.md) | | `datasets/civitai/search.jsonl` | `Assistent/civitai-examples.jsonl` | `up` / `seed-personas`, если файл есть; см. [datasets/README](../datasets/README.md) | -## Push (Models / Wildcards / CustomWorkflows) +## Push (Models / Wildcards / CustomWorkflows / packs) -Как у моделей: нет содержимого кроме README/`.gitkeep` — skip. Новый или изменённый файл — SFTP. Метадата рядом с весами едет парой. Команда на все три дерева: `gpu-rent push`. +Как у моделей: нет содержимого кроме README/`.gitkeep` — skip. Новый или изменённый файл — SFTP. Метадата рядом с весами едет парой. Команда: `gpu-rent push` (также сидит persona-паки). Wildcards: `.txt` (одна строка — один вариант), как в доке SwarmUI `Data/Wildcards`. diff --git a/docs/setup.md b/docs/setup.md index 62aee1d..e5e1a3a 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -181,7 +181,7 @@ copy models.example.yaml models.yaml Опционально позже (не нужно для первой картинки): - `SELECTEL_API_TOKEN` (X-Token панели) + `gpu-rent watchdog install` — toast по балансу ₽; см. [cli.md](cli.md) / [decisions.md](decisions.md). -- `copy assistent-personas.example assistent-personas` — overlay personas; см. [extensions.md](extensions.md). +- `copy assistent-extensions.example assistent-extensions` — persona packs; см. [extensions.md](extensions.md). - `.\civitai-dataset.bat` — scrape метаданных Krea2 → Assistent FTS; см. [datasets/README](../datasets/README.md). --- diff --git a/extensions.example.yaml b/extensions.example.yaml index b1fabf6..6c2cc3a 100644 --- a/extensions.example.yaml +++ b/extensions.example.yaml @@ -2,6 +2,7 @@ # Empty/missing file → no extra extensions, stock SwarmUI. # swarmui = C# repos cloned to src/Extensions # comfy = Python custom nodes cloned to ComfyUI DLNodes +# assistent = persona packs cloned to Assistent/extensions (requires ollama) # requires: none (default) | ollama | any-llm # — clone only when LLM_RUNTIME matches (ollama / either LLM) @@ -14,6 +15,14 @@ swarmui: # ref: main # dir: MyExt +# Persona packs (git): optional. Or clone packs into assistent-extensions/ locally +# (SFTP on up / seed-personas / push — no URL needed in this file). +assistent: [] + # - url: https://example.com/org/some-persona-pack.git + # ref: main + # dir: some-persona-pack + # requires: ollama + comfy: [] - # - url: https://github.com/org/ComfyUI-CustomNodes.git + # - url: https://github.com/example/ComfyUI-CustomNodes.git # ref: v1.0.0 diff --git a/src/gpu_rent/capture.py b/src/gpu_rent/capture.py index 3ceea1e..a8dc6da 100644 --- a/src/gpu_rent/capture.py +++ b/src/gpu_rent/capture.py @@ -331,11 +331,11 @@ def merge_extensions_yaml( dry_run: bool, ) -> tuple[list[ExtCaptureItem], list[ExtCaptureItem], list[str]]: """Return (added, updated, skipped). Same dir + different URL → update url/ref.""" - data: dict[str, list[dict[str, Any]]] = {"swarmui": [], "comfy": []} + data: dict[str, list[dict[str, Any]]] = {"swarmui": [], "comfy": [], "assistent": []} if path.is_file(): raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} if isinstance(raw, dict): - for kind in ("swarmui", "comfy"): + for kind in ("swarmui", "comfy", "assistent"): items = raw.get(kind) or [] if isinstance(items, list): for it in items: @@ -701,7 +701,7 @@ def capture_extensions( report.ext_unknown.append(f"{raw.get('kind')}/{name} ({reason})") continue kind = str(raw.get("kind") or "") - if kind not in ("swarmui", "comfy"): + if kind not in ("swarmui", "comfy", "assistent"): continue resolved.append( ExtCaptureItem( diff --git a/src/gpu_rent/cli.py b/src/gpu_rent/cli.py index 7e0a87f..80dd818 100644 --- a/src/gpu_rent/cli.py +++ b/src/gpu_rent/cli.py @@ -923,13 +923,18 @@ def seed_models() -> None: @app.command("push") def push_all() -> None: - """SFTP Models + Wildcards + CustomWorkflows.""" + """SFTP Models + Wildcards + CustomWorkflows + assistent-extensions.""" try: + from gpu_rent.paths import assistent_extensions_dir + from gpu_rent.provision import seed_assistent_personas + cfg, host = _live() push_tree(cfg, host, cfg.local_models_dir, "/mnt/swarm_data/Models", log, models=True) push_tree(cfg, host, cfg.local_wildcards_dir, "/mnt/swarm_data/Data/Wildcards", log, models=False) push_tree(cfg, host, cfg.local_workflows_dir, "/mnt/swarm_data/CustomWorkflows", log, models=False) + if assistent_extensions_dir().is_dir(): + seed_assistent_personas(cfg, host, log) except GpuRentError as exc: _die(exc) @@ -985,7 +990,7 @@ def seed_extensions_cmd() -> None: @app.command("seed-personas") def seed_personas_cmd() -> None: - """Push assistent-personas/ → VM Assistent overlay (без полного up).""" + """Push assistent-extensions/ → VM Assistent/extensions (+ _base).""" try: from gpu_rent.provision import seed_assistent_personas diff --git a/src/gpu_rent/debug_assistent.py b/src/gpu_rent/debug_assistent.py index 4cde077..dfc79a1 100644 --- a/src/gpu_rent/debug_assistent.py +++ b/src/gpu_rent/debug_assistent.py @@ -118,6 +118,12 @@ 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(): @@ -186,6 +192,7 @@ print(json.dumps({ "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, @@ -610,30 +617,31 @@ def collect_assistent_overlay(cfg: Config, *, fs: dict[str, Any] | None = None) if not fs.get("ok"): return fs overlay = fs.get("overlay") or {} - local_ids: list[str] = [] + local_packs: list[str] = [] try: - from gpu_rent.paths import assistent_personas_dir + from gpu_rent.paths import assistent_extensions_dir - pdir = assistent_personas_dir() + pdir = assistent_extensions_dir() if pdir.is_dir(): - local_ids = sorted( - x.name for x in pdir.iterdir() if x.is_dir() and not x.name.startswith("_") + local_packs = 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)) + remote_packs = list(overlay.get("pack_dirs") or []) + missing_on_vm = sorted(set(local_packs) - set(remote_packs)) hints: list[str] = [] - if local_ids and missing_on_vm: + if local_packs and missing_on_vm: hints.append( - f"локальные personas не на VM: {', '.join(missing_on_vm[:8])} — gpu-rent seed-personas" + 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_persona_ids": local_ids, + "local_pack_dirs": local_packs, + "local_persona_ids": local_packs, # back-compat for older clients "missing_on_vm": missing_on_vm, "hints": hints, } @@ -1034,16 +1042,17 @@ def _ollama_chat_smoke_ssh(cfg: Config, *, model: str) -> dict[str, Any]: def _local_assistent_bits(cfg: Config) -> dict[str, Any]: local: dict[str, Any] = {} try: - from gpu_rent.paths import assistent_personas_dir + from gpu_rent.paths import assistent_extensions_dir - pdir = assistent_personas_dir() - local["personas_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["personas_dir"] = {"error": str(exc)[:120]} + local["extensions_dir"] = {"error": str(exc)[:120]} try: from gpu_rent.manifests import parse_extensions, repo_dirname @@ -1051,9 +1060,11 @@ def _local_assistent_bits(cfg: Config) -> dict[str, Any]: 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: diff --git a/src/gpu_rent/manifests.py b/src/gpu_rent/manifests.py index a7c3025..495596c 100644 --- a/src/gpu_rent/manifests.py +++ b/src/gpu_rent/manifests.py @@ -127,9 +127,9 @@ def parse_extensions(path: Path) -> list[GitRepo]: if not data: return [] if not isinstance(data, dict): - raise ConfigError(f"{path}: корень swarmui: / comfy:") + raise ConfigError(f"{path}: корень swarmui: / comfy: / assistent:") repos: list[GitRepo] = [] - for kind in ("swarmui", "comfy"): + for kind in ("swarmui", "comfy", "assistent"): items = data.get(kind) or [] if not items: continue @@ -184,5 +184,10 @@ def is_commit_sha(ref: str) -> bool: def remote_root_for(repo: GitRepo) -> str: - base = "/mnt/swarm_data/Extensions" if repo.kind == "swarmui" else "/mnt/swarm_data/DLNodes" + if repo.kind == "swarmui": + base = "/mnt/swarm_data/Extensions" + elif repo.kind == "assistent": + base = "/mnt/swarm_data/Assistent/extensions" + else: + base = "/mnt/swarm_data/DLNodes" return f"{base}/{repo_dirname(repo)}" diff --git a/src/gpu_rent/paths.py b/src/gpu_rent/paths.py index 7349a27..76f30ea 100644 --- a/src/gpu_rent/paths.py +++ b/src/gpu_rent/paths.py @@ -48,12 +48,22 @@ def models_manifest_path() -> Path: return app_root() / "models.yaml" +def assistent_extensions_dir() -> Path: + """Working persona-pack clones on the laptop (gitignored).""" + return app_root() / "assistent-extensions" + + +def assistent_extensions_example_dir() -> Path: + return app_root() / "assistent-extensions.example" + + def assistent_personas_dir() -> Path: - """Working overlay source on the laptop (gitignored).""" + """Deprecated overlay-shelf folder — kept for migration warning only.""" return app_root() / "assistent-personas" def assistent_personas_example_dir() -> Path: + """Deprecated example — prefer assistent_extensions_example_dir().""" return app_root() / "assistent-personas.example" @@ -63,7 +73,7 @@ def assistent_personas_manifest_path() -> Path: def assistent_personas_example_path() -> Path: - """Deprecated yaml example — prefer assistent_personas_example_dir().""" + """Deprecated yaml example — prefer assistent_extensions_example_dir().""" return app_root() / "assistent-personas.example.yaml" diff --git a/src/gpu_rent/payload.py b/src/gpu_rent/payload.py index 4a93868..8805d42 100644 --- a/src/gpu_rent/payload.py +++ b/src/gpu_rent/payload.py @@ -14,6 +14,9 @@ def is_skipped(path: Path) -> bool: name = path.name if name in SKIP_NAMES or name.startswith("."): return True + # Never sync git metadata (persona packs push shelves only). + if any(part == ".git" for part in path.parts): + return True return False diff --git a/src/gpu_rent/provision.py b/src/gpu_rent/provision.py index 3b5a8ed..bd3ecc9 100644 --- a/src/gpu_rent/provision.py +++ b/src/gpu_rent/provision.py @@ -895,97 +895,64 @@ def _overlay_num_ctx(cfg: Config, host: str) -> int | None: def seed_assistent_personas(cfg: Config, host: str, log: Log) -> None: - """Push local assistent-personas/ folder → VM Assistent/ overlay. + """Push local assistent-extensions/ packs + _base/assistant.json to VM Assistent/. - Copies personas//*.json (+ optional extra.md) and sparse _base/assistant.json - (default_persona + optional num_ctx). Does not delete overlay personas missing - from the laptop (VM-created clones stay). Does not clobber exact.controls. - Legacy assistent-personas.yaml is no longer read. + Each subdirectory of assistent-extensions/ is SFTP'd to + Assistent/extensions// (``.git`` skipped). Overlay UI clones under + Assistent/personas/ are never deleted. Legacy assistent-personas/ shelf seed + is no longer read. """ from gpu_rent.paths import ( + assistent_extensions_dir, assistent_personas_dir, - assistent_personas_example_dir, assistent_personas_manifest_path, ) - - local = Path( - (getattr(cfg, "assistent_personas_dir", None) or assistent_personas_dir()) - ) - if not local.is_dir(): - example = assistent_personas_example_dir() - if example.is_dir(): - local = example - else: - legacy = assistent_personas_manifest_path() - if legacy.is_file(): - log( - "assistent-personas: yaml больше не читается — " - "скопируй полки в assistent-personas/ (см. assistent-personas.example/)" - ) - else: - log("assistent-personas: нет папки — skip (bundled из расширения)") - return + from gpu_rent.sync_files import push_tree remote_dir = f"{DATA}/Assistent" run_ssh( cfg, host, - f"mkdir -p {shlex.quote(remote_dir)}/personas {shlex.quote(remote_dir)}/_base", + f"mkdir -p {shlex.quote(remote_dir)}/extensions {shlex.quote(remote_dir)}/_base", check=False, ) + legacy = Path( + (getattr(cfg, "assistent_personas_dir", None) or assistent_personas_dir()) + ) + if legacy.is_dir() and any(legacy.iterdir()): + log( + "assistent-personas/: больше не сидится — " + "клонируй паки в assistent-extensions/ (см. assistent-extensions.example/)" + ) + legacy_yaml = assistent_personas_manifest_path() + if legacy_yaml.is_file(): + log( + "assistent-personas.yaml больше не читается — " + "используй assistent: в extensions.yaml или assistent-extensions/" + ) + + local_ext = Path( + getattr(cfg, "assistent_extensions_dir", None) or assistent_extensions_dir() + ) + pushed = 0 + if local_ext.is_dir(): + for child in sorted(local_ext.iterdir()): + if not child.is_dir() or child.name.startswith("."): + continue + # Require a pack manifest or persona.json so empty clone dirs are skipped. + if not ( + (child / "assistent-pack.yaml").is_file() + or (child / "persona.json").is_file() + or (child / "personas").is_dir() + ): + log(f"assistent-extensions/{child.name}: нет assistent-pack.yaml — skip") + continue + remote = f"{remote_dir}/extensions/{child.name}" + push_tree(cfg, host, child, remote, log, models=False) + pushed += 1 + default_id = "neutral" - base_asst = local / "_base" / "assistant.json" - if base_asst.is_file(): - try: - data = json.loads(base_asst.read_text(encoding="utf-8")) - if isinstance(data, dict): - default_id = _safe_persona_id(data.get("default_persona")) or default_id - except (json.JSONDecodeError, OSError) as exc: - log(f"assistent-personas: _base/assistant.json — {exc}") - - personas_root = local / "personas" - written = 0 - if personas_root.is_dir(): - for pdir in sorted(personas_root.iterdir()): - if not pdir.is_dir(): - continue - pid = _safe_persona_id(pdir.name) - if not pid: - continue - remote_p = f"{remote_dir}/personas/{pid}" - run_ssh(cfg, host, f"mkdir -p {shlex.quote(remote_p)}", check=False) - for f in sorted(pdir.iterdir()): - if not f.is_file(): - continue - name = f.name - if name.endswith(".json") or name == "extra.md": - if ".." in name or "/" in name or "\\" in name: - continue - # Never overwrite exact.json controls from laptop if remote has - # user-tuned values — merge only when seeding a missing exact, - # or push non-controls shelves always. For simplicity: push all - # shelves except skip exact.json when remote already has it - # (preserves preference_bias). First-time still seeds exact. - remote_f = f"{remote_p}/{name}" - if name == "exact.json": - exists = run_ssh( - cfg, - host, - f"test -f {shlex.quote(remote_f)} && echo yes || true", - check=False, - timeout=15, - ).strip() - if exists == "yes": - continue - put_text( - cfg, - host, - remote_f, - f.read_text(encoding="utf-8"), - ) - written += 1 - assistant_overlay: dict = {"default_persona": default_id} num_ctx = _overlay_num_ctx(cfg, host) if num_ctx: @@ -1005,7 +972,10 @@ def seed_assistent_personas(cfg: Config, host: str, log: Log) -> None: 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})") + log( + f"assistent-extensions → packs/{pushed} " + f"(default={default_id}{ctx_note})" + ) seed_civitai_examples(cfg, host, log) @@ -1623,7 +1593,7 @@ def provision_vm( try: seed_assistent_personas(cfg, host, log) except Exception as exc: - log(f"assistent-personas: {exc}") + log(f"assistent packs: {exc}") push_tree(cfg, host, cfg.local_models_dir, f"{DATA}/Models", log, models=True) push_tree( cfg, host, cfg.local_wildcards_dir, f"{DATA}/Data/Wildcards", log, models=False diff --git a/src/gpu_rent/remote/clone_ext.py b/src/gpu_rent/remote/clone_ext.py index a1a7f5a..c2f844d 100644 --- a/src/gpu_rent/remote/clone_ext.py +++ b/src/gpu_rent/remote/clone_ext.py @@ -16,6 +16,7 @@ MARKER = Path("/mnt/swarm_data/.gpu-rent-extensions-seeded") EXTRA_ROOTS = ( Path("/mnt/swarm_data/Extensions"), Path("/mnt/swarm_data/DLNodes"), + Path("/mnt/swarm_data/Assistent/extensions"), ) diff --git a/src/gpu_rent/remote/scan_inventory.py b/src/gpu_rent/remote/scan_inventory.py index d83929b..d6c3a3c 100644 --- a/src/gpu_rent/remote/scan_inventory.py +++ b/src/gpu_rent/remote/scan_inventory.py @@ -30,6 +30,7 @@ FOLDER_TO_KIND = { EXT_ROOTS = ( ("swarmui", DATA / "Extensions"), ("comfy", DATA / "DLNodes"), + ("assistent", DATA / "Assistent" / "extensions"), ) diff --git a/src/gpu_rent/setup_wizard.py b/src/gpu_rent/setup_wizard.py index 482c0eb..4cdd4ac 100644 --- a/src/gpu_rent/setup_wizard.py +++ b/src/gpu_rent/setup_wizard.py @@ -16,8 +16,8 @@ from gpu_rent.llm_runtime import ( ) from gpu_rent.paths import ( app_root, - assistent_personas_dir, - assistent_personas_example_dir, + assistent_extensions_dir, + assistent_extensions_example_dir, env_path, extensions_manifest_path, models_manifest_path, @@ -68,9 +68,9 @@ def run_setup( _copy_if_missing(root / "env.example", env_path(), ".env", log) _copy_if_missing(root / "models.example.yaml", models_manifest_path(), "models.yaml", log) _copy_tree_if_missing( - assistent_personas_example_dir(), - assistent_personas_dir(), - "assistent-personas/", + assistent_extensions_example_dir(), + assistent_extensions_dir(), + "assistent-extensions/", log, ) _copy_if_missing( diff --git a/tests/test_assistent_personas_seed.py b/tests/test_assistent_personas_seed.py index 53b352f..8b75996 100644 --- a/tests/test_assistent_personas_seed.py +++ b/tests/test_assistent_personas_seed.py @@ -1,4 +1,4 @@ -"""Unit tests for assistent personas overlay seed helpers.""" +"""Unit tests for assistent persona-pack seed helpers.""" from __future__ import annotations @@ -6,35 +6,29 @@ import json from pathlib import Path from unittest.mock import MagicMock -from gpu_rent.provision import _safe_persona_id, count_wanted_models_on_vm, seed_assistent_personas +from gpu_rent.provision import _safe_persona_id, seed_assistent_personas def test_safe_persona_id(): assert _safe_persona_id("neutral") == "neutral" - assert _safe_persona_id("lewd_v2") == "lewd_v2" + assert _safe_persona_id("dreamer_v2") == "dreamer_v2" assert _safe_persona_id("../x") is None assert _safe_persona_id("a/b") is None assert _safe_persona_id("") is None -def test_seed_assistent_personas_writes_overlay(tmp_path: Path, monkeypatch): - root = tmp_path / "assistent-personas" - (root / "_base").mkdir(parents=True) - (root / "personas" / "cinema").mkdir(parents=True) - (root / "_base" / "assistant.json").write_text( - json.dumps({"default_persona": "cinema"}), encoding="utf-8" - ) - (root / "personas" / "cinema" / "persona.json").write_text( - json.dumps({"title": "Кино", "accent": "#7aa2f7"}), encoding="utf-8" - ) - (root / "personas" / "cinema" / "bio.json").write_text( - json.dumps({"role": "DP"}), encoding="utf-8" - ) - (root / "personas" / "cinema" / "humor.json").write_text( - json.dumps({"frequency": "rare"}), encoding="utf-8" +def test_seed_assistent_personas_pushes_packs(tmp_path: Path, monkeypatch): + root = tmp_path / "assistent-extensions" + pack = root / "sample-pack" + pack.mkdir(parents=True) + (pack / "assistent-pack.yaml").write_text("kind: persona\nid: sample\n", encoding="utf-8") + (pack / "persona.json").write_text( + json.dumps({"title": "Sample", "accent": "#7aa2f7"}), encoding="utf-8" ) + (pack / "voice.json").write_text(json.dumps({"tone": ["calm"]}), encoding="utf-8") puts: dict[str, str] = {} + pushed: list[tuple[str, str]] = [] ssh_cmds: list[str] = [] def fake_put(cfg, host, remote, text, mode=0o644): @@ -46,76 +40,72 @@ def test_seed_assistent_personas_writes_overlay(tmp_path: Path, monkeypatch): return json.dumps({"vram_mib": 24576, "name": "4090", "compute_cap": "8.9"}) return "" + def fake_push(cfg, host, local_root, remote_root, log, *, models): + pushed.append((str(local_root), remote_root)) + log(f"push {Path(local_root).name}") + return 2 + monkeypatch.setattr("gpu_rent.provision.put_text", fake_put) monkeypatch.setattr("gpu_rent.provision.run_ssh", fake_ssh) - monkeypatch.setattr("gpu_rent.paths.assistent_personas_dir", lambda: root) + monkeypatch.setattr("gpu_rent.sync_files.push_tree", fake_push) + monkeypatch.setattr("gpu_rent.paths.assistent_extensions_dir", lambda: root) monkeypatch.setattr( - "gpu_rent.paths.assistent_personas_example_dir", lambda: tmp_path / "missing" + "gpu_rent.paths.assistent_personas_dir", lambda: tmp_path / "no-personas" ) monkeypatch.setattr( "gpu_rent.paths.assistent_personas_manifest_path", lambda: tmp_path / "no.yaml" ) + monkeypatch.setattr( + "gpu_rent.provision.seed_civitai_examples", lambda *a, **k: None + ) cfg = MagicMock() - cfg.assistent_personas_dir = str(root) + cfg.assistent_extensions_dir = str(root) logs: list[str] = [] seed_assistent_personas(cfg, "1.2.3.4", logs.append) - assert any(p.endswith("personas/cinema/persona.json") for p in puts) - assert any(p.endswith("personas/cinema/bio.json") for p in puts) - assert any(p.endswith("personas/cinema/humor.json") for p in puts) - assert not any(p.endswith("extra.md") for p in puts) + assert len(pushed) == 1 + assert pushed[0][1].endswith("/Assistent/extensions/sample-pack") assert "/mnt/swarm_data/Assistent/_base/assistant.json" in puts asst = json.loads(puts["/mnt/swarm_data/Assistent/_base/assistant.json"]) - assert asst["default_persona"] == "cinema" + assert asst["default_persona"] == "neutral" assert asst["num_ctx"] == 16384 assert any("rm -f" in c and "personas.json" in c for c in ssh_cmds) - assert any("overlay personas/1" in m for m in logs) + assert any("packs/1" in m for m in logs) -def test_seed_skips_exact_if_remote_exists(tmp_path: Path, monkeypatch): - root = tmp_path / "assistent-personas" - (root / "personas" / "leonid").mkdir(parents=True) - (root / "personas" / "leonid" / "persona.json").write_text( - '{"title":"L"}', encoding="utf-8" - ) - (root / "personas" / "leonid" / "exact.json").write_text( - '{"controls":{"preference_bias":0.9}}', encoding="utf-8" - ) - puts: dict[str, str] = {} +def test_seed_skips_empty_pack_dir(tmp_path: Path, monkeypatch): + root = tmp_path / "assistent-extensions" + (root / "empty").mkdir(parents=True) - def fake_put(cfg, host, remote, text, mode=0o644): - puts[remote] = text + pushed: list[str] = [] - def fake_ssh(cfg, host, cmd, check=False, timeout=60): - if "exact.json" in cmd and "test -f" in cmd: - return "yes" - if ".gpu-rent-gpu.json" in cmd: - return "{}" - return "" + def fake_push(cfg, host, local_root, remote_root, log, *, models): + pushed.append(remote_root) + return 0 - monkeypatch.setattr("gpu_rent.provision.put_text", fake_put) - monkeypatch.setattr("gpu_rent.provision.run_ssh", fake_ssh) - monkeypatch.setattr("gpu_rent.paths.assistent_personas_dir", lambda: root) + monkeypatch.setattr("gpu_rent.provision.put_text", lambda *a, **k: None) monkeypatch.setattr( - "gpu_rent.paths.assistent_personas_example_dir", lambda: tmp_path / "missing" + "gpu_rent.provision.run_ssh", + lambda *a, **k: json.dumps({"vram_mib": 24576}) if ".gpu-rent-gpu" in str(a) else "", + ) + monkeypatch.setattr("gpu_rent.sync_files.push_tree", fake_push) + monkeypatch.setattr("gpu_rent.paths.assistent_extensions_dir", lambda: root) + monkeypatch.setattr( + "gpu_rent.paths.assistent_personas_dir", lambda: tmp_path / "no-personas" ) monkeypatch.setattr( "gpu_rent.paths.assistent_personas_manifest_path", lambda: tmp_path / "no.yaml" ) + monkeypatch.setattr( + "gpu_rent.provision.seed_civitai_examples", lambda *a, **k: None + ) cfg = MagicMock() - cfg.assistent_personas_dir = str(root) - seed_assistent_personas(cfg, "h", lambda *_: None) - assert not any(p.endswith("exact.json") for p in puts) - assert any(p.endswith("persona.json") for p in puts) + cfg.assistent_extensions_dir = str(root) + logs: list[str] = [] + seed_assistent_personas(cfg, "1.2.3.4", logs.append) - -def test_count_wanted_models(monkeypatch): - raw = "lora:\n - url: https://civitai.red/x?modelVersionId=1\n title: A\n" - - monkeypatch.setattr( - "gpu_rent.provision.run_ssh", - lambda *a, **k: raw, - ) - assert count_wanted_models_on_vm(MagicMock(), "h") == 1 + assert pushed == [] + assert any("нет assistent-pack.yaml" in m for m in logs) + assert any("packs/0" in m for m in logs) diff --git a/tests/test_debug_api.py b/tests/test_debug_api.py index 56e3b6e..4503bb5 100644 --- a/tests/test_debug_api.py +++ b/tests/test_debug_api.py @@ -221,7 +221,7 @@ def test_chat_eval_mocked_http(monkeypatch, tmp_path): if url.endswith("/API/AssistentGetConfig"): return True, { "success": True, - "persona": "leonid", + "persona": "neutral", "exact": { "profiles": {"turbo": {"steps": 8, "cfg": 1, "sigma_shift": 1.15}} }, @@ -245,14 +245,14 @@ def test_chat_eval_mocked_http(monkeypatch, tmp_path): out = debug_assistent.run_assistent_chat_eval( cfg, message="какой checkpoint?", - persona="leonid", + persona="neutral", timeout=60, ) assert out["ok"] is True assert out["via"] == "local" assert out["model"] == "qwen3-vl:8b" assert out["preferred"] == "qwen3-vl:8b" - assert out["persona"] == "leonid" + assert out["persona"] == "neutral" assert out["patch"]["steps"] == 4 assert out["patch"]["cfg"] == 1 assert out["patch"]["aspect"] == "3:4" @@ -317,7 +317,7 @@ def test_assistent_compact_and_roles_local(monkeypatch, tmp_path): ], "overlay": { "exists": True, - "persona_ids": ["leonid"], + "persona_ids": ["neutral"], "roles_present": True, "roles": { "chat": ["qwen3-vl:8b"], diff --git a/tests/test_manifests.py b/tests/test_manifests.py index e2994c5..d21b3f0 100644 --- a/tests/test_manifests.py +++ b/tests/test_manifests.py @@ -3,7 +3,12 @@ from pathlib import Path import pytest from gpu_rent.errors import ConfigError -from gpu_rent.manifests import parse_extensions, parse_models, repo_matches_runtime +from gpu_rent.manifests import ( + parse_extensions, + parse_models, + remote_root_for, + repo_matches_runtime, +) def test_models_skips_version_zero(tmp_path: Path): @@ -77,3 +82,24 @@ def test_extensions_requires_invalid(tmp_path: Path): ) with pytest.raises(ConfigError, match="requires"): parse_extensions(path) + + +def test_extensions_assistent_kind(tmp_path: Path): + path = tmp_path / "extensions.yaml" + path.write_text( + "assistent:\n" + " - url: https://example.com/org/sample-pack.git\n" + " ref: main\n" + " dir: sample-pack\n" + " requires: ollama\n", + encoding="utf-8", + ) + repos = parse_extensions(path) + assert len(repos) == 1 + assert repos[0].kind == "assistent" + assert repos[0].directory == "sample-pack" + assert remote_root_for(repos[0]) == ( + "/mnt/swarm_data/Assistent/extensions/sample-pack" + ) + assert repo_matches_runtime(repos[0], "ollama") + assert not repo_matches_runtime(repos[0], "none")