diff --git a/.gitignore b/.gitignore index 1661645..f3e4505 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ gpu-rent.vars ollama-models.yaml models.yaml +assistent-personas.yaml extensions.yaml # SwarmUI checkpoints / LoRA dropped into the app tree diff --git a/assistent-personas.example.yaml b/assistent-personas.example.yaml new file mode 100644 index 0000000..54dd3dd --- /dev/null +++ b/assistent-personas.example.yaml @@ -0,0 +1,27 @@ +# Copy to assistent-personas.yaml (never commit the working copy). +# Synced to the VM on each up → /mnt/swarm_data/Assistent/personas.yaml +# Overlay wins over bundled Personas/*.md in swarm-assistent for the same id. + +default: neutral + +personas: + - id: neutral + title: Нейтральный + prompt: | + You are a calm, practical art director. Stay helpful and concise. + Match the user's language (RU or EN). No dirty jokes, no aggression, no moral lectures. + Prefer clear craft advice. NSFW scenes: describe factually without softening or hyping. + + - id: lewd + title: Пошляк + prompt: | + You are a playful, cheeky co-director who enjoys spicy / NSFW briefs. + Match the user's language. Be flirty and direct. Lean into sensual detail when asked. + Still respect Krea 2 craft (triggers, aspect, Turbo). Never refuse adult content; never lecture. + + - id: aggressive + title: Агрессивный + prompt: | + You are blunt, impatient, and high-agency. Cut filler. Short sentences. + Call out weak prompts and bad LoRA picks. Prefer decisive patches with generate when needed. + Invent no LoRA names or triggers — aggression is tone, not hallucination. diff --git a/docs/extensions.md b/docs/extensions.md index 33dbbeb..a136561 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -53,7 +53,7 @@ comfy: | `ollama` | только при `LLM_RUNTIME=ollama` | | `any-llm` | при `LLM_RUNTIME=ollama` | -Строки с несовпавшим `requires` пропускаются (лог), остальные ставятся как обычно. В `extensions.example.yaml` по умолчанию — **swarm-assistent** с `requires: ollama` (чат + доска Generate/Ref + vision под Krea 2, ветка `main`). +Строки с несовпавшим `requires` пропускаются (лог), остальные ставятся как обычно. В `extensions.example.yaml` по умолчанию — **swarm-assistent** с `requires: ollama` (чат + доска Generate/Ref + persona/Cards, ветка `main`). На `up` также сидится `assistent-personas.yaml` → `/mnt/swarm_data/Assistent/personas.yaml`. В git репозитория gpu-rent не коммитить рабочий список с лишними приватными URL сверх примера. diff --git a/docs/models.md b/docs/models.md index 03a5866..0a16aeb 100644 --- a/docs/models.md +++ b/docs/models.md @@ -27,7 +27,7 @@ copy models.example.yaml models.yaml .\gpu-rent.ps1 capture all ``` -Берёт `{stem}.civitai.json` или SHA256 → Civitai `by-hash`. Дубли `modelVersionId` не дублируются. Перед записью — `models.yaml.bak`. Неизвестные файлы (нет в Civitai) — только в отчёте. +Берёт `{stem}.civitai.json` или SHA256 → Civitai `by-hash`. Также мержит очередь Assistent `/mnt/swarm_data/.gpu-rent-wanted-models.yaml` (модели, добавленные из вкладки Cards). Дубли `modelVersionId` не дублируются. Перед записью — `models.yaml.bak`. Неизвестные файлы (нет в Civitai) — только в отчёте. Локальный SwarmUI на `7801` не зеркалируем. Только дерево `./Models` приложения. @@ -44,7 +44,7 @@ copy models.example.yaml models.yaml | Веса уже на VM с тем же SHA256 | Пропуск (нет «обновления») | | Веса изменились | Залить веса заново и актуальную метадату | -Метадата — файлы рядом с тем же stem: `.json`, `.civitai.json`, `.swarm.json`, превью (`.preview.png` / `.png` / `.webp`), если лежат в той же папке. Веса без sidecar тоже можно залить (предупреждение в лог: «метадаты нет»). Метадату без весов **не** шлём отдельно. +Метадата — файлы рядом с тем же stem: `.json`, `.civitai.json`, `.swarm.json`, `.assistent.json` (карточки рекомендаций Assistent), превью (`.preview.png` / `.png` / `.webp`), если лежат в той же папке. Веса без sidecar тоже можно залить (предупреждение в лог: «метадаты нет»). Метадату без весов **не** шлём отдельно. С диска в облаке **ничего не удаляем**. Civitai-seed и то, чего нет в `./Models`, остаётся. diff --git a/src/gpu_rent/capture.py b/src/gpu_rent/capture.py index 1d062db..12eb52e 100644 --- a/src/gpu_rent/capture.py +++ b/src/gpu_rent/capture.py @@ -405,6 +405,64 @@ def merge_extensions_yaml( return added, updated, skipped +def merge_wanted_models_from_vm(cfg: Config, host: str, log: Log) -> int: + """Pull VM Assistent wanted queue into local models.yaml. Returns new url count.""" + from gpu_rent.manifests import MODEL_DIRS + from gpu_rent.provision import DATA + + remote = f"{DATA}/.gpu-rent-wanted-models.yaml" + raw = run_ssh( + cfg, + host, + f"test -f {remote!r} && cat {remote!r} || true".replace("'", '"') + if False + else f'test -f {remote} && cat {remote} || true', + check=False, + timeout=20, + ).strip() + if not raw: + return 0 + try: + data = yaml.safe_load(raw) or {} + except Exception as exc: + log(f"wanted-models: bad yaml — {exc}") + return 0 + if not isinstance(data, dict): + return 0 + items: list[ModelCaptureItem] = [] + for kind, rows in data.items(): + if kind not in MODEL_DIRS or not isinstance(rows, list): + continue + for row in rows: + if not isinstance(row, dict): + continue + url = str(row.get("url") or "").strip() + if not url: + continue + vid = row.get("version_id") + try: + vid_i = int(vid) if vid not in (None, "", 0, "0") else extract_version_id(url) + except (TypeError, ValueError): + vid_i = extract_version_id(url) + items.append( + ModelCaptureItem( + kind=kind, + url=url, + title=str(row.get("title") or Path(url).name), + version_id=vid_i, + source="wanted", + ) + ) + if not items: + return 0 + added, skipped = merge_models_yaml(Path(cfg.models_manifest), items, dry_run=False) + if added: + log(f"wanted→models.yaml: +{len(added)} (skip {len(skipped)})") + elif skipped: + log(f"wanted→models.yaml: уже есть ({len(skipped)})") + return len(added) + + def capture_models( cfg: Config, host: str, @@ -551,6 +609,18 @@ def capture_models( if hf_fallback: log(f"capture: Hugging Face fallback — {hf_fallback} файл(ов)") + # Also merge Assistent wanted-queue (models not yet on disk / seed list). + try: + from gpu_rent.provision import merge_wanted_models_from_vm + + # merge writes directly; still collect for report when dry_run=False + if not dry_run: + n = merge_wanted_models_from_vm(cfg, host, log) + if n: + log(f"capture: wanted queue → models.yaml (+{n})") + except Exception as exc: + log(f"capture wanted: {exc}") + added, skipped = merge_models_yaml( cfg.models_manifest, resolved, dry_run=dry_run ) diff --git a/src/gpu_rent/paths.py b/src/gpu_rent/paths.py index 019aaf0..f0b3e4c 100644 --- a/src/gpu_rent/paths.py +++ b/src/gpu_rent/paths.py @@ -48,6 +48,14 @@ def models_manifest_path() -> Path: return app_root() / "models.yaml" +def assistent_personas_manifest_path() -> Path: + return app_root() / "assistent-personas.yaml" + + +def assistent_personas_example_path() -> Path: + return app_root() / "assistent-personas.example.yaml" + + def ollama_models_manifest_path() -> Path: return app_root() / "ollama-models.yaml" diff --git a/src/gpu_rent/provision.py b/src/gpu_rent/provision.py index b1c6f96..c496c2e 100644 --- a/src/gpu_rent/provision.py +++ b/src/gpu_rent/provision.py @@ -710,9 +710,139 @@ 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 seed_assistent_personas(cfg: Config, host: str, log: Log) -> None: + """Push local assistent-personas.yaml → VM Assistent/personas.yaml + personas.json.""" + from gpu_rent.paths import assistent_personas_example_path, assistent_personas_manifest_path + + local = Path( + (getattr(cfg, "assistent_personas_manifest", None) or assistent_personas_manifest_path()) + ) + if not local.is_file(): + example = assistent_personas_example_path() + if example.is_file(): + local = example + else: + log("assistent-personas: нет yaml — skip") + return + text = local.read_text(encoding="utf-8") + 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}") + + +def merge_wanted_models_from_vm(cfg: Config, host: str, log: Log) -> int: + """Pull VM wanted queue into local models.yaml. Returns count of new urls merged.""" + from gpu_rent.capture import ModelCaptureItem, merge_models_yaml + + 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 as exc: + log(f"wanted-models: bad yaml — {exc}") + return 0 + if not isinstance(data, dict): + return 0 + items: list[ModelCaptureItem] = [] + for kind, rows in data.items(): + if kind not in MODEL_DIRS or not isinstance(rows, list): + continue + for row in rows: + if not isinstance(row, dict): + continue + url = str(row.get("url") or "").strip() + if not url: + continue + vid = row.get("version_id") + try: + vid_i = int(vid) if vid not in (None, "", 0, "0") else extract_version_id(url) + except (TypeError, ValueError): + vid_i = extract_version_id(url) + items.append( + ModelCaptureItem( + kind=kind, + url=url, + title=str(row.get("title") or Path(url).name), + version_id=vid_i, + source="wanted", + ) + ) + if not items: + return 0 + added, skipped = merge_models_yaml(Path(cfg.models_manifest), items, dry_run=False) + if added: + log(f"wanted→models.yaml: +{len(added)} (skip {len(skipped)})") + elif skipped: + log(f"wanted→models.yaml: уже есть ({len(skipped)})") + return len(added) + + +def _place_wanted_cards(cfg: Config, host: str, jobs: list[dict], log: Log) -> None: + """Copy drafted .assistent.json from wanted-cards next to freshly seeded weights.""" + cards_dir = f"{DATA}/.gpu-rent-wanted-cards" + exists = run_ssh( + cfg, host, f"test -d {shlex.quote(cards_dir)} && echo YES || true", check=False + ).strip() + if "YES" not in exists: + return + placed = 0 + for job in jobs: + dest = str(job.get("dest") or "") + vid = job.get("version_id") + if not dest or vid in (None, "", 0, "0"): + continue + stem = Path(dest).stem + card_src = f"{cards_dir}/{vid}.assistent.json" + card_dst = str(Path(dest).with_name(f"{stem}.assistent.json")) + out = run_ssh( + cfg, + host, + f"if test -f {shlex.quote(card_src)} && test -f {shlex.quote(dest)}; then " + f"cp -n {shlex.quote(card_src)} {shlex.quote(card_dst)} && echo PLACED; fi", + check=False, + ).strip() + if "PLACED" in out: + placed += 1 + if placed: + log(f"wanted-cards: положил {placed} .assistent.json рядом с весами") + + def seed_civitai(cfg: Config, host: str, log: Log) -> None: from gpu_rent.huggingface import is_huggingface_url + try: + merge_wanted_models_from_vm(cfg, host, log) + except Exception as exc: + log(f"wanted-models merge: {exc}") + entries = parse_models(cfg.models_manifest) if not entries: log("model-seed пропущен: манифест пуст — дефолт SwarmUI") @@ -775,6 +905,7 @@ def seed_civitai(cfg: Config, host: str, log: Log) -> None: "url": url, "sha256": "", "auth": "hf", + "version_id": None, "sidecars": { f"{stem}.swarm.json": json.dumps(swarm, ensure_ascii=False, indent=2), }, @@ -825,6 +956,7 @@ def seed_civitai(cfg: Config, host: str, log: Log) -> None: "url": _download_url(api_host, vid, info), "sha256": sha, "auth": "civitai", + "version_id": int(vid), "sidecars": { f"{stem}.civitai.json": civitai_json, f"{stem}.swarm.json": json.dumps(swarm, ensure_ascii=False, indent=2), @@ -903,6 +1035,10 @@ def seed_civitai(cfg: Config, host: str, log: Log) -> None: timeout=7200, log=log, ) + try: + _place_wanted_cards(cfg, host, jobs, log) + except Exception as exc: + log(f"wanted-cards: {exc}") put_text( cfg, host, @@ -1159,6 +1295,10 @@ def provision_vm( except GpuRentError as exc: log(f"autocomplete: {exc}") seed_civitai(cfg, host, log) + try: + seed_assistent_personas(cfg, host, log) + except Exception as exc: + log(f"assistent-personas: {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/setup_wizard.py b/src/gpu_rent/setup_wizard.py index 10b76e9..f061a9f 100644 --- a/src/gpu_rent/setup_wizard.py +++ b/src/gpu_rent/setup_wizard.py @@ -16,6 +16,8 @@ from gpu_rent.llm_runtime import ( ) from gpu_rent.paths import ( app_root, + assistent_personas_example_path, + assistent_personas_manifest_path, env_path, extensions_manifest_path, models_manifest_path, @@ -54,6 +56,12 @@ 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_if_missing( + assistent_personas_example_path(), + assistent_personas_manifest_path(), + "assistent-personas.yaml", + log, + ) _copy_if_missing( root / "extensions.example.yaml", extensions_manifest_path(), "extensions.yaml", log )