Compare commits

..
13 Commits
Author SHA1 Message Date
EthanfelandClaude Opus 4.6 b39ea7a647 fix: address bug review findings
- Debounce MutationObserver (200ms) and scope to #modelGrid when available
- Sanitize data.updated via parseInt before innerHTML to prevent XSS
- Guard pagination against page_size=0 (division by zero) and negative values
- Reject non-POST requests on /api/lm-extra/fetch-stats (state-mutating)
- Early return in upsert_batch for empty rows list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 14:48:14 +01:00
EthanfelandClaude Opus 4.6 f6e39df157 fix: address code review findings
- Add retry (2 attempts) on CivitAI 429 rate limit instead of silently skipping
- Use check_same_thread=False for SQLite connection (aiohttp context)
- Add debug logging to silent exception handlers in proxy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 14:44:12 +01:00
EthanfelandClaude Opus 4.6 63ddaaa70d chore: add stats DB cleanup and gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 14:40:32 +01:00
EthanfelandClaude Opus 4.6 467aea0f47 feat: add stats API endpoints and frontend JS with proxy injection
- Add /api/lm-extra/fetch-stats (POST) endpoint that bulk-fetches CivitAI
  stats for all remote models and stores them in the local stats DB with
  optional WebSocket progress broadcasting
- Add /api/lm-extra/stats-status (GET) endpoint to check DB population
- Add /lm-extra/stats-ui.js handler that serves the client-side JS file
- Create static/lm_stats_ui.js: intercepts list API responses to capture
  stats, patches model cards with download/rating/likes badges, adds
  CivitAI Stats sort options to the dropdown, and adds a Fetch Stats
  toolbar button
- Inject stats-ui.js script tag into proxied HTML page responses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 14:39:56 +01:00
EthanfelandClaude Sonnet 4.6 1ce1e7d48a feat: add stats-based sorting (downloads, rating, thumbsup)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:36:55 +01:00
EthanfelandClaude Sonnet 4.6 c3cd35add7 feat: enrich list API responses with CivitAI stats from local DB
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:34:51 +01:00
EthanfelandClaude Sonnet 4.6 c1e124bcc8 feat: add CivitAI stats fetch service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:32:52 +01:00
EthanfelandClaude Sonnet 4.6 a8308874d2 feat: add civitai_api_key to config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:31:10 +01:00
EthanfelandClaude Sonnet 4.6 44088649d8 feat: add SQLite stats database layer for CivitAI stats
Implements StatsDB class with upsert, batch upsert, get_by_hashes,
get_all, count, and close methods backed by a WAL-mode SQLite database.
Also guards __init__.py relative imports behind __package__ check so
pytest can run without ComfyUI context, and removes empty tests/__init__.py
to prevent pytest Package collector from traversing into the plugin root.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:29:59 +01:00
EthanfelandClaude Sonnet 4.6 aefb129e07 chore: add test infrastructure with conftest for import paths
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:14:08 +01:00
Ethanfel be6d8bcef7 chore: add .gitignore with worktrees exclusion 2026-03-15 14:12:21 +01:00
EthanfelandClaude Opus 4.6 c084fb3da8 docs: add CivitAI stats shim implementation plan
7-task plan covering: SQLite DB layer, fetch service, proxy enrichment,
stats-based sorting, frontend UI (badges/sort/button), and cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 13:49:47 +01:00
EthanfelandClaude Opus 4.6 afb3840105 docs: add CivitAI extended stats shim design
Design for a shim that stores CivitAI stats (downloads, rating,
thumbs up) in a separate SQLite DB and enriches the list API
response via the proxy middleware. Includes bulk fetch button,
card badges, and new sort options.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 13:41:51 +01:00
30 changed files with 2887 additions and 6480 deletions
+2 -4
View File
@@ -1,4 +1,2 @@
__pycache__/ .worktrees
*.py[cod] civitai_stats.db
node_modules/
config.json
+20 -42
View File
@@ -40,13 +40,15 @@ git clone https://github.com/ethanfel/ComfyUI-LM-Remote.git
## Configuration ## Configuration
Open the configuration panel from any of these places: Edit `config.json` in the package directory:
- Click the gear in the **LoRA Info** sidebar ```json
- Open **Settings**, select **LM Remote**, then click **Configure LM Remote** {
- Run **Configure LM Remote** from the command palette "remote_url": "http://192.168.1.3:8188",
"timeout": 30,
Enter the remote URL, adjust the timeout if needed, and click **Test connection**. **Save** validates the settings and applies them to new requests immediately; the ComfyUI process does not need to restart. If the remote URL changes, the panel offers a page reload so Manager assets and live progress reconnect to the new server. Save workflow edits before using it. "path_mappings": {}
}
```
| Field | Type | Default | Description | | Field | Type | Default | Description |
|-------|------|---------|-------------| |-------|------|---------|-------------|
@@ -54,19 +56,14 @@ Enter the remote URL, adjust the timeout if needed, and click **Test connection*
| `timeout` | int | `30` | HTTP request timeout in seconds | | `timeout` | int | `30` | HTTP request timeout in seconds |
| `path_mappings` | object | `{}` | Remote-to-local path prefix mapping (see below) | | `path_mappings` | object | `{}` | Remote-to-local path prefix mapping (see below) |
Configuration is stored under `<ComfyUI user directory>/ComfyUI-LM-Remote/config.json`, outside the custom-node checkout. An existing package-level `config.json` is loaded as a legacy fallback and is left untouched. The first save through the panel migrates its values into ComfyUI user data.
The panel is the normal way to manage this file; manual editing is not required. For a headless or automated deployment, start from [`config.example.json`](config.example.json), place the copy outside the custom-node checkout, and point `LM_REMOTE_CONFIG` to it.
### Environment Variable Overrides ### Environment Variable Overrides
Environment variables take priority over the stored configuration without rewriting it. Overridden fields are shown as managed in the panel. Environment variables take priority over `config.json`:
| Variable | Purpose | | Variable | Overrides |
|----------|---------| |----------|-----------|
| `LM_REMOTE_URL` | Overrides `remote_url`; the field is shown as managed in the panel | | `LM_REMOTE_URL` | `remote_url` |
| `LM_REMOTE_TIMEOUT` | Overrides `timeout`; the field is shown as managed in the panel | | `LM_REMOTE_TIMEOUT` | `timeout` |
| `LM_REMOTE_CONFIG` | Uses an explicit configuration file instead of the ComfyUI user-data path |
### Path Mappings ### Path Mappings
@@ -98,23 +95,6 @@ All nodes appear under the **Lora Manager** category in the ComfyUI node menu, w
| **WanVideo Lora Select (Remote)** | Select LoRAs for WanVideo with block-level control. | | **WanVideo Lora Select (Remote)** | Select LoRAs for WanVideo with block-level control. |
| **WanVideo Lora Select From Text (Remote)** | Select WanVideo LoRAs from text syntax. | | **WanVideo Lora Select From Text (Remote)** | Select WanVideo LoRAs from text syntax. |
## LoRA Info Sidebar
Selecting a LoRA loader opens the **LoRA Info** sidebar and follows the node's current selection. It supports the stock ComfyUI loader, LM Remote nodes, and third-party loaders that expose standard `lora_name`, numbered LoRA, stack, or `<lora:name:strength>` values.
- If the selected LoRA is indexed by the remote LoRA Manager, the sidebar shows its image or video preview, file details, base model, trigger words, tags, usage tips, and direct model links.
- Cached community creations and Civitai examples appear with video controls, their shared prompt and negative prompt, generation settings, attribution, navigation, and one-click prompt copying.
- If a node contains multiple active LoRAs, use the selector at the top of the sidebar to switch cards.
- LoRA Info closes automatically after the selected LoRA loader is cleared, but stays open while switching directly between loaders.
- If no Manager card exists, the sidebar offers name searches on LoRA Manager, Civitai, Civitai Red, and CivArchive.
- Duplicate filenames are not guessed: the sidebar asks you to choose the matching Manager path.
ComfyUI does not currently expose an extension API for adding custom tabs to the built-in Properties panel, so this feature uses its supported custom-sidebar API. It follows ComfyUI's configured sidebar location, including a right-side layout like Templates.
Auto-open is enabled by default. Disable it under **Settings > LM Remote > LoRA Info > Auto-open** if you prefer to open **LoRA Info** manually from the sidebar or command palette.
The gear beside the refresh button opens LM Remote connection settings without leaving the sidebar.
## How It Works ## How It Works
### Reverse Proxy ### Reverse Proxy
@@ -125,7 +105,7 @@ An aiohttp middleware is registered at startup that intercepts requests to LoRA
- `/api/lm/*` -- all REST API endpoints (except send_sync routes below) - `/api/lm/*` -- all REST API endpoints (except send_sync routes below)
- `/extensions/ComfyUI-Lora-Manager/*` -- widget JS files and Vue widget bundle - `/extensions/ComfyUI-Lora-Manager/*` -- widget JS files and Vue widget bundle
- `/loras_static/*`, `/locales/*`, `/example_images_static/*` -- static assets - `/loras_static/*`, `/locales/*`, `/example_images_static/*` -- static assets
- `/loras`, `/checkpoints`, `/embeddings`, `/loras/recipes`, `/community`, `/statistics` -- web UI pages - `/loras`, `/checkpoints`, `/embeddings`, `/loras/recipes`, `/statistics` -- web UI pages
- `/ws/fetch-progress`, `/ws/download-progress`, `/ws/init-progress` -- WebSocket connections - `/ws/fetch-progress`, `/ws/download-progress`, `/ws/init-progress` -- WebSocket connections
**Handled locally** (events broadcast to local browser via `send_sync`): **Handled locally** (events broadcast to local browser via `send_sync`):
@@ -152,14 +132,12 @@ After fetching the relative path from the remote metadata, LoRA files are loaded
After installation and configuration: After installation and configuration:
1. Restart ComfyUI once after installing the custom node 1. Restart ComfyUI
2. Open **Configure LM Remote**, enter the URL, and run **Test connection** 2. Check logs for: `[LM-Remote] Proxy routes registered -> http://192.168.1.3:8188`
3. Save the configuration; no ComfyUI process restart is required 3. Open ComfyUI -- the LoRA Manager web UI should load (proxied from remote)
4. If prompted after a remote URL change, save workflow edits and reload the browser page 4. Add a "Lora Loader (Remote, LoraManager)" node to a workflow
5. Open the LoRA Manager web UI -- it should load through the remote proxy 5. Select a LoRA -- trigger words should populate from remote metadata
6. Add a stock or remote LoRA loader and click the node -- **LoRA Info** should open 6. Run the workflow -- the LoRA loads from local shared storage
7. Select a LoRA -- its Manager card (or external search links) should appear and remote trigger words should populate where supported
8. Run the workflow -- the LoRA loads from local shared storage
## License ## License
+4
View File
@@ -19,6 +19,10 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Guard relative imports: only execute when loaded as a proper package
# (i.e. inside ComfyUI). Skipped when the file is imported standalone by
# pytest or other tools that add the directory directly to sys.path.
if __package__:
# ── Import node classes ──────────────────────────────────────────────── # ── Import node classes ────────────────────────────────────────────────
from .nodes.lora_loader import LoraLoaderRemoteLM, LoraTextLoaderRemoteLM from .nodes.lora_loader import LoraLoaderRemoteLM, LoraTextLoaderRemoteLM
from .nodes.lora_stacker import LoraStackerRemoteLM from .nodes.lora_stacker import LoraStackerRemoteLM
-5
View File
@@ -1,5 +0,0 @@
{
"remote_url": "",
"timeout": 30,
"path_mappings": {}
}
+6
View File
@@ -0,0 +1,6 @@
{
"remote_url": "http://192.168.1.3:8188",
"timeout": 30,
"path_mappings": {},
"civitai_api_key": ""
}
+39 -506
View File
@@ -1,535 +1,68 @@
"""Validated, reloadable configuration for ComfyUI-LM-Remote.""" """Configuration for ComfyUI-LM-Remote."""
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import logging import logging
import os import os
import re
import stat
import tempfile
import threading
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Mapping
from urllib.parse import urlsplit, urlunsplit
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_PACKAGE_DIR = Path(__file__).resolve().parent _PACKAGE_DIR = Path(__file__).resolve().parent
_LEGACY_CONFIG_FILE = _PACKAGE_DIR / "config.json" _CONFIG_FILE = _PACKAGE_DIR / "config.json"
_CONFIG_DIRECTORY_NAME = "ComfyUI-LM-Remote"
_CONFIG_FILE_NAME = "config.json"
_KNOWN_FIELDS = frozenset({"remote_url", "timeout", "path_mappings"})
_MAX_TIMEOUT = 300
_MAX_MAPPINGS = 100
_MAX_VALUE_LENGTH = 4096
_CONTROL_CHARACTERS = re.compile(r"[\x00-\x1f\x7f]")
class ConfigValidationError(ValueError):
"""Raised when a proposed configuration value is invalid."""
def __init__(self, field: str, message: str):
super().__init__(message)
self.field = field
class ConfigConflictError(RuntimeError):
"""Raised when a browser attempts to replace a stale configuration."""
@dataclass(frozen=True)
class ConfigSnapshot:
"""One coherent set of effective runtime values."""
generation: int
remote_url: str
timeout: int
path_mappings: tuple[tuple[str, str], ...]
def mappings_dict(self) -> dict[str, str]:
return dict(self.path_mappings)
def map_path(self, remote_path: str) -> str:
"""Map a path using only the values captured by this snapshot."""
if not isinstance(remote_path, str):
return remote_path
normalized_path = remote_path.replace("\\", "/")
for remote_prefix, local_prefix in self.path_mappings:
is_root = remote_prefix == "/"
if normalized_path == remote_prefix:
remainder = ""
elif is_root and normalized_path.startswith("/"):
remainder = normalized_path[1:]
elif normalized_path.startswith(f"{remote_prefix}/"):
remainder = normalized_path[len(remote_prefix) + 1 :]
else:
continue
if not remainder:
return local_prefix
separator = (
"\\" if "\\" in local_prefix and "/" not in local_prefix else os.sep
)
local_base = local_prefix.rstrip("/\\")
return f"{local_base}{separator}{remainder.replace('/', separator)}"
return remote_path
def _default_user_config_file(environ: Mapping[str, str]) -> Path:
explicit_path = environ.get("LM_REMOTE_CONFIG", "").strip()
if explicit_path:
return Path(explicit_path).expanduser()
try:
import folder_paths # type: ignore
user_directory = Path(folder_paths.get_user_directory())
return user_directory / _CONFIG_DIRECTORY_NAME / _CONFIG_FILE_NAME
except Exception:
# Outside ComfyUI (for example, documentation tools), preserve the
# historical package-level behaviour.
return _LEGACY_CONFIG_FILE
def _normalize_url(value: object, *, allow_empty: bool = True) -> str:
if not isinstance(value, str):
raise ConfigValidationError("remote_url", "Remote URL must be text.")
value = value.strip()
if not value:
if allow_empty:
return ""
raise ConfigValidationError("remote_url", "Enter a remote LoRA Manager URL.")
if _CONTROL_CHARACTERS.search(value):
raise ConfigValidationError(
"remote_url", "Remote URL contains invalid characters."
)
try:
parsed = urlsplit(value)
# Accessing .port performs its own range and syntax validation.
parsed.port
except ValueError as exc:
raise ConfigValidationError(
"remote_url", "Remote URL has an invalid port."
) from exc
if parsed.scheme.lower() not in {"http", "https"}:
raise ConfigValidationError(
"remote_url", "Remote URL must use http:// or https://."
)
if not parsed.hostname:
raise ConfigValidationError(
"remote_url", "Remote URL must include a host name."
)
if parsed.username is not None or parsed.password is not None:
raise ConfigValidationError(
"remote_url", "Credentials are not allowed in the remote URL."
)
if parsed.query or parsed.fragment:
raise ConfigValidationError(
"remote_url", "Remote URL cannot contain a query or fragment."
)
path = parsed.path.rstrip("/")
return urlunsplit((parsed.scheme.lower(), parsed.netloc, path, "", ""))
def _normalize_timeout(value: object) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise ConfigValidationError("timeout", "Timeout must be a whole number.")
if value < 1 or value > _MAX_TIMEOUT:
raise ConfigValidationError(
"timeout", f"Timeout must be between 1 and {_MAX_TIMEOUT} seconds."
)
return value
def _normalize_remote_prefix(value: str) -> str:
normalized = value.replace("\\", "/")
if normalized != "/":
normalized = normalized.rstrip("/")
return normalized
def _normalize_mapping_value(field: str, value: object) -> str:
if not isinstance(value, str):
raise ConfigValidationError("path_mappings", f"{field} path must be text.")
value = value.strip()
if not value:
raise ConfigValidationError("path_mappings", f"{field} path cannot be empty.")
if len(value) > _MAX_VALUE_LENGTH:
raise ConfigValidationError("path_mappings", f"{field} path is too long.")
if _CONTROL_CHARACTERS.search(value):
raise ConfigValidationError(
"path_mappings", f"{field} path contains invalid characters."
)
return value
def _normalize_mappings(value: object) -> tuple[tuple[str, str], ...]:
if not isinstance(value, dict):
raise ConfigValidationError("path_mappings", "Path mappings must be an object.")
if len(value) > _MAX_MAPPINGS:
raise ConfigValidationError(
"path_mappings", f"No more than {_MAX_MAPPINGS} path mappings are allowed."
)
normalized: dict[str, str] = {}
for remote_value, local_value in value.items():
remote_prefix = _normalize_remote_prefix(
_normalize_mapping_value("Remote", remote_value)
)
if not remote_prefix:
raise ConfigValidationError(
"path_mappings",
"Remote path cannot consist only of path separators.",
)
local_prefix = _normalize_mapping_value("Local", local_value)
if remote_prefix in normalized:
raise ConfigValidationError(
"path_mappings", f"Duplicate remote path prefix: {remote_prefix}"
)
normalized[remote_prefix] = local_prefix
# Specific mappings must win over broader parent mappings.
return tuple(
sorted(normalized.items(), key=lambda pair: len(pair[0]), reverse=True)
)
def validate_config(data: object, *, allow_empty_url: bool = True) -> dict[str, object]:
"""Validate and normalize a complete stored configuration."""
if not isinstance(data, dict):
raise ConfigValidationError("config", "Configuration must be a JSON object.")
unknown = set(data) - _KNOWN_FIELDS
if unknown:
names = ", ".join(sorted(str(name) for name in unknown))
raise ConfigValidationError(
"config", f"Unknown configuration field(s): {names}"
)
return {
"remote_url": _normalize_url(
data.get("remote_url", ""), allow_empty=allow_empty_url
),
"timeout": _normalize_timeout(data.get("timeout", 30)),
"path_mappings": dict(_normalize_mappings(data.get("path_mappings", {}))),
}
def _revision_for(data: Mapping[str, object]) -> str:
encoded = json.dumps(data, sort_keys=True, separators=(",", ":")).encode("utf-8")
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
def _revision_for_file(path: Path) -> str:
return f"sha256:{hashlib.sha256(path.read_bytes()).hexdigest()}"
class RemoteConfig: class RemoteConfig:
"""Thread-safe configuration with legacy fallback and atomic persistence.""" """Holds remote LoRA Manager connection settings."""
def __init__( def __init__(self):
self, self.remote_url: str = ""
config_file: str | Path | None = None, self.timeout: int = 30
legacy_config_file: str | Path | None = None, self.path_mappings: dict[str, str] = {}
environ: Mapping[str, str] | None = None, self.civitai_api_key: str = ""
) -> None: self._load()
self._environ = environ if environ is not None else os.environ
self._explicit_target = config_file is None and bool(
self._environ.get("LM_REMOTE_CONFIG", "").strip()
)
self._config_file = (
Path(config_file)
if config_file
else _default_user_config_file(self._environ)
)
self._legacy_config_file = (
Path(legacy_config_file) if legacy_config_file else _LEGACY_CONFIG_FILE
)
self._lock = threading.RLock()
self._snapshot = ConfigSnapshot(0, "", 30, ())
self._configured: dict[str, object] = {
"remote_url": "",
"timeout": 30,
"path_mappings": {},
}
self._overrides: dict[str, str | None] = {
"remote_url": None,
"timeout": None,
}
self._revision = _revision_for({})
self._source = "defaults"
self._warnings: list[str] = []
self.reload()
def _read_source(self) -> tuple[dict[str, object], str]: # ------------------------------------------------------------------
if self._config_file.exists(): def _load(self):
path = self._config_file # Environment variable takes priority
source = "explicit" if self._explicit_target else "user" env_url = os.environ.get("LM_REMOTE_URL", "")
elif self._explicit_target: env_timeout = os.environ.get("LM_REMOTE_TIMEOUT", "")
return {}, "explicit"
elif self._legacy_config_file.exists():
path = self._legacy_config_file
source = "legacy"
else:
return {}, "defaults"
with path.open("r", encoding="utf-8") as handle: # Load config.json defaults
data = json.load(handle) if _CONFIG_FILE.exists():
if not isinstance(data, dict): try:
raise ConfigValidationError( with open(_CONFIG_FILE, "r", encoding="utf-8") as f:
"config", "Configuration file must contain an object." data = json.load(f)
) self.remote_url = data.get("remote_url", "")
return data, source self.timeout = int(data.get("timeout", 30))
self.path_mappings = data.get("path_mappings", {})
self.civitai_api_key = data.get("civitai_api_key", "")
except Exception as exc:
logger.warning("[LM-Remote] Failed to read config.json: %s", exc)
def _source_location(self) -> tuple[Path | None, str]: # Env overrides
if self._config_file.exists():
source = "explicit" if self._explicit_target else "user"
return self._config_file, source
if self._explicit_target:
return None, "explicit"
if self._legacy_config_file.exists():
return self._legacy_config_file, "legacy"
return None, "defaults"
def _effective_values(
self, configured: dict[str, object]
) -> tuple[dict[str, object], dict[str, str | None], list[str]]:
effective = {
"remote_url": configured["remote_url"],
"timeout": configured["timeout"],
"path_mappings": dict(configured["path_mappings"]),
}
overrides: dict[str, str | None] = {"remote_url": None, "timeout": None}
warnings: list[str] = []
env_url = self._environ.get("LM_REMOTE_URL", "")
if env_url: if env_url:
try: self.remote_url = env_url
effective["remote_url"] = _normalize_url(env_url)
overrides["remote_url"] = "LM_REMOTE_URL"
except ConfigValidationError as exc:
warnings.append(f"Ignoring invalid LM_REMOTE_URL: {exc}")
env_timeout = self._environ.get("LM_REMOTE_TIMEOUT", "")
if env_timeout: if env_timeout:
try: self.timeout = int(env_timeout)
if isinstance(env_timeout, str) and env_timeout.strip().isdigit():
parsed_timeout: object = int(env_timeout.strip())
else:
parsed_timeout = env_timeout
effective["timeout"] = _normalize_timeout(parsed_timeout)
overrides["timeout"] = "LM_REMOTE_TIMEOUT"
except ConfigValidationError as exc:
warnings.append(f"Ignoring invalid LM_REMOTE_TIMEOUT: {exc}")
return effective, overrides, warnings env_api_key = os.environ.get("LM_CIVITAI_API_KEY", "")
if env_api_key:
self.civitai_api_key = env_api_key
def reload(self) -> ConfigSnapshot: # Strip trailing slash
"""Reload persisted and environment-managed values without partial mutation.""" self.remote_url = self.remote_url.rstrip("/")
with self._lock:
warnings: list[str] = []
try:
raw, source = self._read_source()
known_values = {key: raw[key] for key in _KNOWN_FIELDS if key in raw}
configured = validate_config(known_values)
revision = _revision_for(raw)
except (OSError, json.JSONDecodeError, ConfigValidationError) as exc:
logger.warning("[LM-Remote] Failed to read configuration: %s", exc)
source_path, source = self._source_location()
raw = {}
configured = validate_config({})
try:
revision = (
_revision_for_file(source_path)
if source_path is not None
else _revision_for(raw)
)
except OSError:
revision = _revision_for(raw)
warnings.append(f"Stored configuration could not be loaded: {exc}")
effective, overrides, env_warnings = self._effective_values(configured)
warnings.extend(env_warnings)
generation = self._snapshot.generation + 1
snapshot = ConfigSnapshot(
generation=generation,
remote_url=str(effective["remote_url"]),
timeout=int(effective["timeout"]),
path_mappings=_normalize_mappings(effective["path_mappings"]),
)
self._configured = configured
self._overrides = overrides
self._revision = revision
self._source = source
self._warnings = warnings
self._snapshot = snapshot
return snapshot
@property
def snapshot(self) -> ConfigSnapshot:
with self._lock:
return self._snapshot
@property
def generation(self) -> int:
return self.snapshot.generation
@property
def remote_url(self) -> str:
return self.snapshot.remote_url
@property
def timeout(self) -> int:
return self.snapshot.timeout
@property
def path_mappings(self) -> dict[str, str]:
return self.snapshot.mappings_dict()
@property @property
def is_configured(self) -> bool: def is_configured(self) -> bool:
return bool(self.snapshot.remote_url) return bool(self.remote_url)
def as_dict(self) -> dict[str, object]:
"""Return browser-safe configured/effective values and source metadata."""
with self._lock:
snapshot = self._snapshot
return {
"configured": {
"remote_url": self._configured["remote_url"],
"timeout": self._configured["timeout"],
"path_mappings": dict(self._configured["path_mappings"]),
},
"effective": {
"remote_url": snapshot.remote_url,
"timeout": snapshot.timeout,
"path_mappings": snapshot.mappings_dict(),
},
"overrides": dict(self._overrides),
"revision": self._revision,
"generation": snapshot.generation,
"storage": {
"source": self._source,
"writable": self._storage_writable(),
},
"warnings": list(self._warnings),
"restart_required": False,
}
def _storage_writable(self) -> bool:
"""Return whether an atomic write can be created beside the target."""
target = self._config_file
try:
if target.exists() and target.is_dir():
return False
ancestor = target.parent
while not ancestor.exists():
parent = ancestor.parent
if parent == ancestor:
return False
ancestor = parent
if not ancestor.is_dir():
return False
mode = ancestor.stat().st_mode
write_bits = stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH
execute_bits = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
if not mode & write_bits or not mode & execute_bits:
return False
return os.access(ancestor, os.W_OK | os.X_OK)
except OSError:
return False
def save(
self, data: object, *, expected_revision: str | None = None
) -> ConfigSnapshot:
"""Atomically persist a complete configuration and activate it."""
normalized = validate_config(data)
serializable = {
"remote_url": normalized["remote_url"],
"timeout": normalized["timeout"],
"path_mappings": normalized["path_mappings"],
}
with self._lock:
try:
current_raw, _ = self._read_source()
except (OSError, json.JSONDecodeError, ConfigValidationError):
source_path, _ = self._source_location()
try:
current_revision = (
_revision_for_file(source_path)
if source_path is not None
else _revision_for({})
)
except OSError as revision_exc:
raise ConfigConflictError(
"The stored configuration changed and can no longer be read."
) from revision_exc
current_raw = {}
else:
current_revision = _revision_for(current_raw)
if expected_revision is not None and expected_revision != current_revision:
raise ConfigConflictError(
"The configuration changed in another window. Reload it before saving."
)
# Preserve future/third-party keys from an existing file while replacing
# only fields owned by LM Remote.
output = dict(current_raw)
output.update(serializable)
self._write_atomic(output)
return self.reload()
def _write_atomic(self, data: Mapping[str, object]) -> None:
target = self._config_file
target.parent.mkdir(parents=True, exist_ok=True)
existing_mode = (
stat.S_IMODE(target.stat().st_mode) if target.exists() else 0o600
)
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=target.parent,
prefix=f".{target.name}.",
suffix=".tmp",
delete=False,
) as handle:
temporary_path = Path(handle.name)
json.dump(data, handle, indent=2, ensure_ascii=False)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary_path, existing_mode)
os.replace(temporary_path, target)
temporary_path = None
try:
directory_fd = os.open(target.parent, os.O_RDONLY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
except OSError:
# Directory fsync is not supported by every filesystem.
pass
finally:
if temporary_path is not None:
try:
temporary_path.unlink()
except FileNotFoundError:
pass
def map_path(self, remote_path: str) -> str: def map_path(self, remote_path: str) -> str:
"""Apply the longest boundary-aware remote-to-local path mapping.""" """Apply remote->local path prefix mappings."""
return self.snapshot.map_path(remote_path) for remote_prefix, local_prefix in self.path_mappings.items():
if remote_path.startswith(remote_prefix):
return local_prefix + remote_path[len(remote_prefix):]
return remote_path
remote_config = RemoteConfig() remote_config = RemoteConfig()
+3
View File
@@ -0,0 +1,3 @@
# Root-level conftest: prevent pytest from collecting the package __init__.py
# as a test module (it uses relative imports that require ComfyUI context).
collect_ignore = ["__init__.py"]
@@ -0,0 +1,157 @@
# CivitAI Extended Stats Shim — Design
## Problem
The LoRA Manager fetches metadata from CivitAI but discards stats (download count, rating, thumbs up). Users want to sort and filter loras by popularity/quality, and see these stats on model cards — like CivitAI's own UI.
## Constraints
- No modifications to the original LoRA Manager codebase (avoid merge conflicts on upstream sync)
- Lives inside ComfyUI-LM-Remote, which already has proxy middleware intercepting API responses
- Must handle CivitAI API rate limits gracefully when bulk-fetching stats
- Stats should persist across restarts (SQLite)
## Architecture
### Hybrid Shim Approach
- **Separate SQLite DB** (`civitai_stats.db`) for extended stats — avoids schema conflicts with the manager's own cache
- **Proxy enrichment** — the existing middleware intercepts `/api/lm/loras/list` responses from the remote and merges stats into each item before forwarding to the browser
- **New API endpoints** on LM-Remote for the bulk fetch button and status checks
- **Frontend JS** injected via LM-Remote's `web/comfyui/` directory, patches sort dropdown and card rendering
### Data Flow
```
List request:
Browser → GET /api/lm/loras/list
→ proxy fetches response from remote
→ enriches each item with stats from local civitai_stats.db
→ returns merged response to browser
Bulk fetch (initial):
Browser → POST /api/lm-extra/fetch-stats
→ handler reads models from remote list API
→ extracts civitai_model_id + sha256 for each
→ batches CivitAI API calls (/api/v1/models?ids=id1,id2,...)
→ stores stats in civitai_stats.db
→ WebSocket progress updates
→ frontend auto-refreshes grid when done
Piggyback (ongoing):
When proxy intercepts metadata sync responses that contain stats,
opportunistically store them in civitai_stats.db
```
## SQLite Schema
File: `civitai_stats.db` (stored alongside LM-Remote's config)
```sql
CREATE TABLE model_stats (
sha256 TEXT PRIMARY KEY,
civitai_model_id INTEGER,
civitai_version_id INTEGER,
download_count INTEGER,
rating REAL,
rating_count INTEGER,
thumbs_up_count INTEGER,
fetched_at REAL
);
```
Keyed by `sha256` — the common identifier between the manager's cache and CivitAI. Joins naturally regardless of file paths or renames.
## API Endpoints (LM-Remote)
| Method | Path | Purpose |
|--------|------|---------|
| POST | `/api/lm-extra/fetch-stats` | Bulk fetch stats for all models (button) |
| GET | `/api/lm-extra/stats-status` | Check if stats DB is populated, staleness info |
## Proxy Response Enrichment
The middleware intercepts the `/api/lm/loras/list` response and:
1. Parses the JSON response body
2. Collects all `sha256` values from items
3. Batch-queries `civitai_stats.db` (single SELECT with IN clause)
4. Merges `download_count`, `rating`, `rating_count`, `thumbs_up_count` into each item
5. Returns the enriched response
## Sorting
### New Sort Options
Added to the sort dropdown alongside existing options (name, date, size, usage):
| Value | Label |
|-------|-------|
| `downloads:desc` | Most downloaded |
| `downloads:asc` | Least downloaded |
| `rating:desc` | Highest rated |
| `rating:asc` | Lowest rated |
| `thumbsup:desc` | Most liked |
| `thumbsup:asc` | Least liked |
### Sort Implementation
When the proxy sees these sort values in the list request, sorting is handled locally:
1. Fetch the full (unpaginated) list from remote
2. Join with stats DB
3. Sort locally
4. Apply pagination
5. Return result
Items without stats sort to the end.
## Frontend Changes
All in `ComfyUI-LM-Remote/web/comfyui/`.
### Card Badges
Small badges on each model card (styled like the existing base model badge):
- Download icon + count (e.g., "12.3k")
- Star icon + rating (e.g., "4.8")
- Thumbs-up icon + count (e.g., "89")
Numbers use compact formatting (1000 → "1k", 12345 → "12.3k").
### Toolbar Button
A "Fetch Stats" button in the toolbar (next to existing Fetch/Download buttons):
- Chart-bar icon
- Triggers POST to `/api/lm-extra/fetch-stats`
- Shows progress via WebSocket
- Auto-refreshes grid on completion
### Sort Dropdown Patch
JS extension adds the new sort options to the existing `#sortSelect` dropdown on page load.
## Bulk Fetch Strategy
CivitAI's `/api/v1/models?ids=` endpoint accepts multiple model IDs per request.
1. Collect all unique `civitai_model_id` values from the lora list
2. Batch into groups (e.g., 50 IDs per request)
3. For each batch, fetch model data and extract `stats` from the matching version
4. Rate-limit: 1-2 requests/second with backoff on 429 responses
5. Broadcast progress via WebSocket (e.g., "Fetching stats: 150/300 models")
## Piggyback Strategy
When the existing metadata sync runs (triggered by the manager's refresh/fetch), the proxy can intercept the CivitAI API response if it flows through, and opportunistically extract stats. This covers new models added after the initial bulk fetch.
## Files to Create/Modify
| File | Action |
|------|--------|
| `stats_db.py` | NEW — SQLite wrapper for civitai_stats.db |
| `stats_service.py` | NEW — bulk fetch logic, CivitAI API calls, rate limiting |
| `proxy.py` | MODIFY — intercept list response, enrich with stats; add sort handling |
| `web/comfyui/lm_stats_ui.js` | NEW — card badges, toolbar button, sort dropdown patch |
File diff suppressed because it is too large Load Diff
-8
View File
@@ -1,8 +0,0 @@
{
"name": "comfyui-lm-remote",
"private": true,
"type": "module",
"scripts": {
"test": "node --test tests/frontend/*.test.js"
}
}
+319 -566
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1,3 +1,2 @@
[pytest] [pytest]
addopts = --import-mode=importlib --confcutdir=tests
testpaths = tests testpaths = tests
+64 -193
View File
@@ -1,35 +1,29 @@
"""HTTP client for the remote LoRA Manager instance.""" """HTTP client for the remote LoRA Manager instance."""
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
import os
import posixpath
import threading
import time import time
from typing import Any from typing import Any
import aiohttp import aiohttp
from .config import ConfigSnapshot, remote_config try:
from .config import remote_config
except ImportError:
from config import remote_config # type: ignore[no-redef]
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Cache TTL in seconds — how long before we re-fetch the full LoRA list # Cache TTL in seconds — how long before we re-fetch the full LoRA list
_CACHE_TTL = 60 _CACHE_TTL = 60
_LIST_PAGE_SIZE = 100
_MAX_LIST_PAGES = 1000
class _ConfigurationChanged(RuntimeError):
"""Raised when a multi-page read outlives its configuration snapshot."""
class RemoteLoraClient: class RemoteLoraClient:
"""Singleton HTTP client that talks to the remote LoRA Manager. """Singleton HTTP client that talks to the remote LoRA Manager.
Uses the actual LoRA Manager REST API endpoints: Uses the actual LoRA Manager REST API endpoints:
- ``GET /api/lm/loras/list?page=N&page_size=100`` — paginated LoRA list - ``GET /api/lm/loras/list?page_size=9999`` — paginated LoRA list
- ``GET /api/lm/loras/get-trigger-words?name=X`` — trigger words - ``GET /api/lm/loras/get-trigger-words?name=X`` — trigger words
- ``POST /api/lm/loras/random-sample`` — random LoRA selection - ``POST /api/lm/loras/random-sample`` — random LoRA selection
- ``POST /api/lm/loras/cycler-list`` — sorted LoRA list for cycler - ``POST /api/lm/loras/cycler-list`` — sorted LoRA list for cycler
@@ -39,15 +33,13 @@ class RemoteLoraClient:
""" """
_instance: RemoteLoraClient | None = None _instance: RemoteLoraClient | None = None
_session: aiohttp.ClientSession | None = None
def __init__(self): def __init__(self):
self._lora_cache: list[dict] = [] self._lora_cache: list[dict] = []
self._lora_cache_ts: float = 0 self._lora_cache_ts: float = 0
self._lora_cache_generation: int = -1
self._checkpoint_cache: list[dict] = [] self._checkpoint_cache: list[dict] = []
self._checkpoint_cache_ts: float = 0 self._checkpoint_cache_ts: float = 0
self._checkpoint_cache_generation: int = -1
self._cache_lock = threading.RLock()
@classmethod @classmethod
def get_instance(cls) -> RemoteLoraClient: def get_instance(cls) -> RemoteLoraClient:
@@ -55,176 +47,71 @@ class RemoteLoraClient:
cls._instance = cls() cls._instance = cls()
return cls._instance return cls._instance
async def close(self): async def _get_session(self) -> aiohttp.ClientSession:
"""Compatibility hook; requests use loop-safe, short-lived sessions.""" if self._session is None or self._session.closed:
timeout = aiohttp.ClientTimeout(total=remote_config.timeout)
self._session = aiohttp.ClientSession(timeout=timeout)
return self._session
def invalidate_caches(self) -> None: async def close(self):
"""Forget results associated with a previous remote configuration.""" if self._session and not self._session.closed:
with self._cache_lock: await self._session.close()
self._lora_cache = [] self._session = None
self._lora_cache_ts = 0
self._lora_cache_generation = -1
self._checkpoint_cache = []
self._checkpoint_cache_ts = 0
self._checkpoint_cache_generation = -1
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Core HTTP helpers # Core HTTP helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def _get_json( async def _get_json(self, path: str, params: dict | None = None) -> Any:
self, url = f"{remote_config.remote_url}{path}"
path: str, session = await self._get_session()
params: dict | None = None,
*,
snapshot: ConfigSnapshot | None = None,
) -> Any:
snapshot = snapshot or remote_config.snapshot
url = f"{snapshot.remote_url}{path}"
timeout = aiohttp.ClientTimeout(total=snapshot.timeout)
# Node execution can invoke this singleton from several short-lived event
# loops. A request-scoped session avoids retaining a loop-bound session.
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, params=params) as resp: async with session.get(url, params=params) as resp:
resp.raise_for_status() resp.raise_for_status()
return await resp.json() return await resp.json()
async def _post_json( async def _post_json(self, path: str, json_body: dict | None = None) -> Any:
self, url = f"{remote_config.remote_url}{path}"
path: str, session = await self._get_session()
json_body: dict | None = None,
*,
snapshot: ConfigSnapshot | None = None,
) -> Any:
snapshot = snapshot or remote_config.snapshot
url = f"{snapshot.remote_url}{path}"
timeout = aiohttp.ClientTimeout(total=snapshot.timeout)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, json=json_body) as resp: async with session.post(url, json=json_body) as resp:
resp.raise_for_status() resp.raise_for_status()
return await resp.json() return await resp.json()
async def _get_all_pages(
self, path: str, *, snapshot: ConfigSnapshot
) -> list[dict]:
"""Fetch a complete bounded listing from an API capped at 100 rows."""
items: list[dict] = []
page = 1
while page <= _MAX_LIST_PAGES:
if remote_config.generation != snapshot.generation:
raise _ConfigurationChanged
data = await self._get_json(
path,
params={"page": str(page), "page_size": str(_LIST_PAGE_SIZE)},
snapshot=snapshot,
)
if not isinstance(data, dict):
raise ValueError("Remote listing response must be an object.")
page_items = data.get("items", [])
if not isinstance(page_items, list):
raise ValueError("Remote listing items must be an array.")
items.extend(item for item in page_items if isinstance(item, dict))
raw_total_pages = data.get("total_pages")
total_pages: int | None = None
if raw_total_pages is not None:
try:
total_pages = int(raw_total_pages)
except (TypeError, ValueError) as exc:
raise ValueError(
"Remote listing has an invalid total_pages value."
) from exc
if total_pages < 0:
raise ValueError("Remote listing has an invalid total_pages value.")
if total_pages > _MAX_LIST_PAGES:
raise ValueError(
f"Remote listing exceeds the {_MAX_LIST_PAGES}-page safety limit."
)
if not page_items:
break
if total_pages is not None:
if page >= total_pages:
break
elif len(page_items) < _LIST_PAGE_SIZE:
break
page += 1
else:
raise ValueError(
f"Remote listing exceeds the {_MAX_LIST_PAGES}-page safety limit."
)
if remote_config.generation != snapshot.generation:
raise _ConfigurationChanged
return items
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Cached list helpers # Cached list helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def _get_lora_list_cached( async def _get_lora_list_cached(self) -> list[dict]:
self, *, snapshot: ConfigSnapshot | None = None
) -> list[dict]:
"""Return the full LoRA list, using a short-lived cache.""" """Return the full LoRA list, using a short-lived cache."""
now = time.monotonic() now = time.monotonic()
snapshot = snapshot or remote_config.snapshot if self._lora_cache and (now - self._lora_cache_ts) < _CACHE_TTL:
with self._cache_lock: return self._lora_cache
if (
self._lora_cache_generation == snapshot.generation
and (now - self._lora_cache_ts) < _CACHE_TTL
):
return list(self._lora_cache)
try: try:
items = await self._get_all_pages("/api/lm/loras/list", snapshot=snapshot) data = await self._get_json(
if remote_config.generation == snapshot.generation: "/api/lm/loras/list", params={"page_size": "9999"}
with self._cache_lock: )
self._lora_cache = list(items) self._lora_cache = data.get("items", [])
self._lora_cache_ts = now self._lora_cache_ts = now
self._lora_cache_generation = snapshot.generation
return list(items)
except _ConfigurationChanged:
pass
except Exception as exc: except Exception as exc:
logger.warning("[LM-Remote] Failed to fetch LoRA list: %s", exc) logger.warning("[LM-Remote] Failed to fetch LoRA list: %s", exc)
# Return stale cache on error, or empty list # Return stale cache on error, or empty list
with self._cache_lock: return self._lora_cache
if self._lora_cache_generation == snapshot.generation:
return list(self._lora_cache)
return []
async def _get_checkpoint_list_cached( async def _get_checkpoint_list_cached(self) -> list[dict]:
self, *, snapshot: ConfigSnapshot | None = None
) -> list[dict]:
"""Return the full checkpoint list, using a short-lived cache.""" """Return the full checkpoint list, using a short-lived cache."""
now = time.monotonic() now = time.monotonic()
snapshot = snapshot or remote_config.snapshot if self._checkpoint_cache and (now - self._checkpoint_cache_ts) < _CACHE_TTL:
with self._cache_lock: return self._checkpoint_cache
if (
self._checkpoint_cache_generation == snapshot.generation
and (now - self._checkpoint_cache_ts) < _CACHE_TTL
):
return list(self._checkpoint_cache)
try: try:
items = await self._get_all_pages( data = await self._get_json(
"/api/lm/checkpoints/list", snapshot=snapshot "/api/lm/checkpoints/list", params={"page_size": "9999"}
) )
if remote_config.generation == snapshot.generation: self._checkpoint_cache = data.get("items", [])
with self._cache_lock:
self._checkpoint_cache = list(items)
self._checkpoint_cache_ts = now self._checkpoint_cache_ts = now
self._checkpoint_cache_generation = snapshot.generation
return list(items)
except _ConfigurationChanged:
pass
except Exception as exc: except Exception as exc:
logger.warning("[LM-Remote] Failed to fetch checkpoint list: %s", exc) logger.warning("[LM-Remote] Failed to fetch checkpoint list: %s", exc)
with self._cache_lock: return self._checkpoint_cache
if self._checkpoint_cache_generation == snapshot.generation:
return list(self._checkpoint_cache)
return []
def _find_item_by_name(self, items: list[dict], name: str) -> dict | None: def _find_item_by_name(self, items: list[dict], name: str) -> dict | None:
"""Find an item in a list by file_name.""" """Find an item in a list by file_name."""
@@ -233,31 +120,6 @@ class RemoteLoraClient:
return item return item
return None return None
@staticmethod
def _relative_lora_path(mapped_file_path: str, folder: str) -> str:
"""Convert a mapped absolute path to a local ComfyUI LoRA name."""
try:
import folder_paths # type: ignore
candidate = os.path.normpath(mapped_file_path)
for root in folder_paths.get_folder_paths("loras"):
try:
relative = os.path.relpath(candidate, os.path.normpath(str(root)))
except ValueError:
# Windows paths on different drives cannot be relativized.
continue
if relative == os.pardir or relative.startswith(f"{os.pardir}{os.sep}"):
continue
return relative.replace(os.sep, "/")
except Exception:
# ComfyUI's folder registry is not present in lightweight tooling.
pass
normalized_path = mapped_file_path.replace("\\", "/")
basename = posixpath.basename(normalized_path)
normalized_folder = str(folder or "").replace("\\", "/").strip("/")
return f"{normalized_folder}/{basename}" if normalized_folder else basename
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# LoRA metadata # LoRA metadata
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -268,17 +130,35 @@ class RemoteLoraClient:
Uses the cached ``/api/lm/loras/list`` data. Falls back to the Uses the cached ``/api/lm/loras/list`` data. Falls back to the
per-LoRA ``get-trigger-words`` endpoint if the list lookup fails. per-LoRA ``get-trigger-words`` endpoint if the list lookup fails.
""" """
import posixpath
try: try:
snapshot = remote_config.snapshot items = await self._get_lora_list_cached()
items = await self._get_lora_list_cached(snapshot=snapshot)
item = self._find_item_by_name(items, lora_name) item = self._find_item_by_name(items, lora_name)
if item: if item:
file_path = item.get("file_path", "") file_path = item.get("file_path", "")
file_path = snapshot.map_path(file_path) file_path = remote_config.map_path(file_path)
# file_path is the absolute path (forward-slashed) from
# the remote. We need a relative path that the local
# folder_paths.get_full_path("loras", ...) can resolve.
#
# The ``folder`` field gives the subfolder within the
# model root (e.g. "anime" or "anime/characters").
# The basename of file_path has the extension.
#
# Example: file_path="/mnt/loras/anime/test.safetensors"
# folder="anime"
# -> basename="test.safetensors"
# -> relative="anime/test.safetensors"
folder = item.get("folder", "") folder = item.get("folder", "")
relative = self._relative_lora_path(file_path, folder) basename = posixpath.basename(file_path) # "test.safetensors"
if folder:
relative = f"{folder}/{basename}"
else:
relative = basename
civitai = item.get("civitai") or {} civitai = item.get("civitai") or {}
trigger_words = civitai.get("trainedWords", []) if civitai else [] trigger_words = civitai.get("trainedWords", []) if civitai else []
@@ -288,7 +168,6 @@ class RemoteLoraClient:
tw_data = await self._get_json( tw_data = await self._get_json(
"/api/lm/loras/get-trigger-words", "/api/lm/loras/get-trigger-words",
params={"name": lora_name}, params={"name": lora_name},
snapshot=snapshot,
) )
trigger_words = tw_data.get("trigger_words", []) trigger_words = tw_data.get("trigger_words", [])
return lora_name, trigger_words return lora_name, trigger_words
@@ -300,8 +179,7 @@ class RemoteLoraClient:
async def get_lora_hash(self, lora_name: str) -> str | None: async def get_lora_hash(self, lora_name: str) -> str | None:
"""Return the SHA-256 hash for a LoRA by display name.""" """Return the SHA-256 hash for a LoRA by display name."""
try: try:
snapshot = remote_config.snapshot items = await self._get_lora_list_cached()
items = await self._get_lora_list_cached(snapshot=snapshot)
item = self._find_item_by_name(items, lora_name) item = self._find_item_by_name(items, lora_name)
if item: if item:
return item.get("sha256") or item.get("hash") return item.get("sha256") or item.get("hash")
@@ -312,23 +190,18 @@ class RemoteLoraClient:
async def get_checkpoint_hash(self, checkpoint_name: str) -> str | None: async def get_checkpoint_hash(self, checkpoint_name: str) -> str | None:
"""Return the SHA-256 hash for a checkpoint by display name.""" """Return the SHA-256 hash for a checkpoint by display name."""
try: try:
snapshot = remote_config.snapshot items = await self._get_checkpoint_list_cached()
items = await self._get_checkpoint_list_cached(snapshot=snapshot)
item = self._find_item_by_name(items, checkpoint_name) item = self._find_item_by_name(items, checkpoint_name)
if item: if item:
return item.get("sha256") or item.get("hash") return item.get("sha256") or item.get("hash")
except Exception as exc: except Exception as exc:
logger.warning( logger.warning("[LM-Remote] get_checkpoint_hash(%s) failed: %s", checkpoint_name, exc)
"[LM-Remote] get_checkpoint_hash(%s) failed: %s", checkpoint_name, exc
)
return None return None
async def get_random_loras(self, **kwargs) -> list[dict]: async def get_random_loras(self, **kwargs) -> list[dict]:
"""Ask the remote to generate random LoRAs (for Randomizer node).""" """Ask the remote to generate random LoRAs (for Randomizer node)."""
try: try:
result = await self._post_json( result = await self._post_json("/api/lm/loras/random-sample", json_body=kwargs)
"/api/lm/loras/random-sample", json_body=kwargs
)
return result if isinstance(result, list) else result.get("loras", []) return result if isinstance(result, list) else result.get("loras", [])
except Exception as exc: except Exception as exc:
logger.warning("[LM-Remote] get_random_loras failed: %s", exc) logger.warning("[LM-Remote] get_random_loras failed: %s", exc)
@@ -337,9 +210,7 @@ class RemoteLoraClient:
async def get_cycler_list(self, **kwargs) -> list[dict]: async def get_cycler_list(self, **kwargs) -> list[dict]:
"""Ask the remote for a sorted LoRA list (for Cycler node).""" """Ask the remote for a sorted LoRA list (for Cycler node)."""
try: try:
result = await self._post_json( result = await self._post_json("/api/lm/loras/cycler-list", json_body=kwargs)
"/api/lm/loras/cycler-list", json_body=kwargs
)
return result if isinstance(result, list) else result.get("loras", []) return result if isinstance(result, list) else result.get("loras", [])
except Exception as exc: except Exception as exc:
logger.warning("[LM-Remote] get_cycler_list failed: %s", exc) logger.warning("[LM-Remote] get_cycler_list failed: %s", exc)
+230
View File
@@ -0,0 +1,230 @@
// static/lm_stats_ui.js
/**
* CivitAI Stats UI — card badges, sort dropdown options, fetch button.
*
* Injected into LoRA Manager pages by the LM-Remote proxy.
* Reads stats from enriched /api/lm/loras/list responses and
* patches the DOM to show badges, sort options, and a fetch button.
*/
(function () {
"use strict";
// ── Compact number formatting ──────────────────────────────────
function formatCompact(n) {
if (n == null) return null;
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, "") + "k";
return String(n);
}
// ── Badge creation ─────────────────────────────────────────────
function createStatBadge(icon, value, title) {
if (value == null) return null;
const badge = document.createElement("span");
badge.className = "lm-stat-badge";
badge.title = title;
badge.innerHTML = `<i class="fas fa-${icon}"></i> ${formatCompact(value)}`;
return badge;
}
// ── Inject CSS ─────────────────────────────────────────────────
function injectStyles() {
if (document.getElementById("lm-stats-styles")) return;
const style = document.createElement("style");
style.id = "lm-stats-styles";
style.textContent = `
.lm-stat-badges {
display: flex;
gap: 6px;
flex-wrap: wrap;
padding: 2px 6px;
}
.lm-stat-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
padding: 1px 5px;
border-radius: 4px;
background: rgba(255,255,255,0.1);
color: rgba(255,255,255,0.8);
white-space: nowrap;
}
.lm-stat-badge i {
font-size: 10px;
opacity: 0.7;
}
`;
document.head.appendChild(style);
}
// ── Intercept list API to capture stats ────────────────────────
const _statsMap = {};
const _origFetch = window.fetch;
window.fetch = async function (...args) {
const response = await _origFetch.apply(this, args);
const url = typeof args[0] === "string" ? args[0] : args[0]?.url;
if (url && url.includes("/api/lm/") && url.includes("/list")) {
try {
const clone = response.clone();
const data = await clone.json();
(data.items || []).forEach((item) => {
if (item.sha256 && item.download_count != null) {
_statsMap[item.sha256] = {
download_count: item.download_count,
rating: item.rating,
rating_count: item.rating_count,
thumbs_up_count: item.thumbs_up_count,
};
}
});
} catch (e) { /* ignore parse errors */ }
}
return response;
};
// ── Patch model cards ──────────────────────────────────────────
function patchCards() {
const cards = document.querySelectorAll(".model-card:not([data-stats-patched])");
cards.forEach((card) => {
card.setAttribute("data-stats-patched", "1");
const sha = card.dataset.sha256;
if (!sha || !_statsMap[sha]) return;
const stats = _statsMap[sha];
const container = document.createElement("div");
container.className = "lm-stat-badges";
const dlBadge = createStatBadge("download", stats.download_count, "Downloads");
const ratingBadge = createStatBadge("star",
stats.rating ? Number(stats.rating.toFixed(1)) : null, "Rating");
const thumbsBadge = createStatBadge("thumbs-up", stats.thumbs_up_count, "Likes");
[dlBadge, ratingBadge, thumbsBadge].forEach((b) => {
if (b) container.appendChild(b);
});
if (container.children.length > 0) {
// Insert inside .model-info, after .model-name
const modelInfo = card.querySelector(".model-info");
if (modelInfo) {
modelInfo.appendChild(container);
}
}
});
}
// ── Patch sort dropdown ────────────────────────────────────────
function patchSortDropdown() {
const select = document.getElementById("sortSelect");
if (!select || select.querySelector('[value="downloads:desc"]')) return;
const group = document.createElement("optgroup");
group.label = "CivitAI Stats";
const options = [
["downloads:desc", "Most downloaded"],
["downloads:asc", "Least downloaded"],
["rating:desc", "Highest rated"],
["rating:asc", "Lowest rated"],
["thumbsup:desc", "Most liked"],
["thumbsup:asc", "Least liked"],
];
options.forEach(([value, label]) => {
const opt = document.createElement("option");
opt.value = value;
opt.textContent = label;
group.appendChild(opt);
});
select.appendChild(group);
}
// ── Toolbar "Fetch Stats" button ───────────────────────────────
function addFetchStatsButton() {
const toolbar = document.querySelector(".action-buttons");
if (!toolbar || document.getElementById("fetchStatsBtn")) return;
const group = document.createElement("div");
group.className = "control-group";
group.innerHTML = `
<button id="fetchStatsBtn" data-action="fetch-stats"
title="Fetch CivitAI stats (downloads, ratings, likes)">
<i class="fas fa-chart-bar"></i> <span>Fetch Stats</span>
</button>
`;
// Insert before the bulk operations button
const bulkBtn = document.getElementById("bulkOperationsBtn");
if (bulkBtn && bulkBtn.closest(".control-group")) {
toolbar.insertBefore(group, bulkBtn.closest(".control-group"));
} else {
toolbar.appendChild(group);
}
group.querySelector("button").addEventListener("click", async () => {
const btn = document.getElementById("fetchStatsBtn");
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> <span>Fetching...</span>';
try {
const resp = await _origFetch("/api/lm-extra/fetch-stats", { method: "POST" });
const data = await resp.json();
if (data.success) {
const count = parseInt(data.updated, 10) || 0;
btn.innerHTML = `<i class="fas fa-check"></i> <span>${count} updated</span>`;
setTimeout(() => {
btn.innerHTML = '<i class="fas fa-chart-bar"></i> <span>Fetch Stats</span>';
btn.disabled = false;
}, 3000);
// Trigger page reload to show new stats
const sortSelect = document.getElementById("sortSelect");
if (sortSelect) {
sortSelect.dispatchEvent(new Event("change"));
}
} else {
throw new Error(data.error || "Unknown error");
}
} catch (err) {
btn.innerHTML = '<i class="fas fa-exclamation-triangle"></i> <span>Error</span>';
console.error("[LM-Stats] Fetch failed:", err);
setTimeout(() => {
btn.innerHTML = '<i class="fas fa-chart-bar"></i> <span>Fetch Stats</span>';
btn.disabled = false;
}, 3000);
}
});
}
// ── Observe DOM for card rendering ─────────────────────────────
function startObserver() {
injectStyles();
patchSortDropdown();
addFetchStatsButton();
patchCards();
let debounceTimer = null;
const observer = new MutationObserver(() => {
if (debounceTimer) return;
debounceTimer = setTimeout(() => {
debounceTimer = null;
patchCards();
patchSortDropdown();
addFetchStatsButton();
}, 200);
});
const grid = document.getElementById("modelGrid");
observer.observe(grid || document.body, { childList: true, subtree: true });
}
// ── Init ───────────────────────────────────────────────────────
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", startObserver);
} else {
startObserver();
}
})();
+137
View File
@@ -0,0 +1,137 @@
"""SQLite storage for CivitAI extended stats."""
from __future__ import annotations
import sqlite3
import time
from pathlib import Path
_PACKAGE_DIR = Path(__file__).resolve().parent
_DEFAULT_DB_PATH = _PACKAGE_DIR / "civitai_stats.db"
_SCHEMA = """
CREATE TABLE IF NOT EXISTS model_stats (
sha256 TEXT PRIMARY KEY,
civitai_model_id INTEGER,
civitai_version_id INTEGER,
download_count INTEGER DEFAULT 0,
rating REAL DEFAULT 0,
rating_count INTEGER DEFAULT 0,
thumbs_up_count INTEGER DEFAULT 0,
fetched_at REAL
);
"""
class StatsDB:
"""Thin wrapper around a SQLite database for CivitAI stats."""
def __init__(self, db_path: Path | None = None):
self._db_path = db_path or _DEFAULT_DB_PATH
self._conn: sqlite3.Connection | None = None
def init(self) -> None:
"""Create the database and table if they don't exist."""
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.executescript(_SCHEMA)
def _ensure_conn(self) -> sqlite3.Connection:
if self._conn is None:
self.init()
return self._conn # type: ignore[return-value]
def upsert(self, sha256: str, data: dict) -> None:
"""Insert or update stats for a single model."""
conn = self._ensure_conn()
conn.execute(
"""INSERT INTO model_stats
(sha256, civitai_model_id, civitai_version_id,
download_count, rating, rating_count, thumbs_up_count, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(sha256) DO UPDATE SET
civitai_model_id = COALESCE(excluded.civitai_model_id, civitai_model_id),
civitai_version_id = COALESCE(excluded.civitai_version_id, civitai_version_id),
download_count = excluded.download_count,
rating = excluded.rating,
rating_count = excluded.rating_count,
thumbs_up_count = excluded.thumbs_up_count,
fetched_at = excluded.fetched_at
""",
(
sha256,
data.get("civitai_model_id"),
data.get("civitai_version_id"),
data.get("download_count", 0),
data.get("rating", 0),
data.get("rating_count", 0),
data.get("thumbs_up_count", 0),
time.time(),
),
)
conn.commit()
def upsert_batch(self, rows: list[tuple[str, dict]]) -> None:
"""Insert or update stats for multiple models."""
if not rows:
return
conn = self._ensure_conn()
now = time.time()
conn.executemany(
"""INSERT INTO model_stats
(sha256, civitai_model_id, civitai_version_id,
download_count, rating, rating_count, thumbs_up_count, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(sha256) DO UPDATE SET
civitai_model_id = COALESCE(excluded.civitai_model_id, civitai_model_id),
civitai_version_id = COALESCE(excluded.civitai_version_id, civitai_version_id),
download_count = excluded.download_count,
rating = excluded.rating,
rating_count = excluded.rating_count,
thumbs_up_count = excluded.thumbs_up_count,
fetched_at = excluded.fetched_at
""",
[
(
sha256,
d.get("civitai_model_id"),
d.get("civitai_version_id"),
d.get("download_count", 0),
d.get("rating", 0),
d.get("rating_count", 0),
d.get("thumbs_up_count", 0),
now,
)
for sha256, d in rows
],
)
conn.commit()
def get_by_hashes(self, hashes: list[str]) -> dict[str, dict]:
"""Return stats keyed by sha256 for the given hashes."""
if not hashes:
return {}
conn = self._ensure_conn()
placeholders = ",".join("?" for _ in hashes)
rows = conn.execute(
f"SELECT * FROM model_stats WHERE sha256 IN ({placeholders})",
hashes,
).fetchall()
return {row["sha256"]: dict(row) for row in rows}
def get_all(self) -> dict[str, dict]:
"""Return all stats keyed by sha256."""
conn = self._ensure_conn()
rows = conn.execute("SELECT * FROM model_stats").fetchall()
return {row["sha256"]: dict(row) for row in rows}
def count(self) -> int:
"""Return number of rows in model_stats."""
conn = self._ensure_conn()
return conn.execute("SELECT COUNT(*) FROM model_stats").fetchone()[0]
def close(self) -> None:
if self._conn:
self._conn.close()
self._conn = None
+145
View File
@@ -0,0 +1,145 @@
"""Service for fetching CivitAI stats and storing them in the local DB."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
import aiohttp
try:
from .stats_db import StatsDB
except ImportError:
from stats_db import StatsDB
logger = logging.getLogger(__name__)
_CIVITAI_API = "https://civitai.com/api/v1"
_RATE_LIMIT_DELAY = 1.5 # seconds between requests
def extract_version_stats(model_data: dict) -> list[tuple[str, dict]]:
"""Extract (sha256, stats_dict) pairs from a CivitAI model response.
Each model has multiple versions; each version has files with hashes.
Returns one entry per version that has a SHA256 hash.
"""
model_id = model_data.get("id")
results = []
for version in model_data.get("modelVersions", []):
version_id = version.get("id")
sha256 = None
for f in version.get("files", []):
sha256 = (f.get("hashes") or {}).get("SHA256")
if sha256:
break
if not sha256:
continue
stats = version.get("stats") or {}
results.append((
sha256.lower(),
{
"civitai_model_id": model_id,
"civitai_version_id": version_id,
"download_count": stats.get("downloadCount", 0),
"rating": stats.get("rating", 0),
"rating_count": stats.get("ratingCount", 0),
"thumbs_up_count": stats.get("thumbsUpCount", 0),
},
))
return results
class StatsFetchService:
"""Fetches CivitAI stats for models and stores them in StatsDB."""
def __init__(self, db: StatsDB, api_key: str | None = None):
self.db = db
self._api_key = api_key
self._session: aiohttp.ClientSession | None = None
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
headers = {}
if self._api_key:
headers["Authorization"] = f"Bearer {self._api_key}"
timeout = aiohttp.ClientTimeout(total=30)
self._session = aiohttp.ClientSession(
headers=headers, timeout=timeout
)
return self._session
async def _fetch_model(self, model_id: int, _retries: int = 2) -> dict | None:
"""Fetch a single model's data from CivitAI API."""
url = f"{_CIVITAI_API}/models/{model_id}"
session = await self._get_session()
for attempt in range(_retries + 1):
try:
async with session.get(url) as resp:
if resp.status == 429:
logger.warning("[LM-Remote] CivitAI rate limited, backing off (attempt %d)",
attempt + 1)
await asyncio.sleep(5 * (attempt + 1))
continue
if resp.status != 200:
logger.debug("[LM-Remote] CivitAI returned %d for model %d",
resp.status, model_id)
return None
return await resp.json()
except Exception as exc:
logger.warning("[LM-Remote] CivitAI fetch failed for model %d: %s",
model_id, exc)
return None
return None
async def fetch_stats_for_models(
self,
models: list[dict],
progress_callback: Any = None,
) -> int:
"""Fetch stats from CivitAI for a list of models.
Args:
models: list of dicts with 'sha256' and 'civitai_model_id' keys.
progress_callback: optional async callable(current, total).
Returns:
Number of model versions successfully updated.
"""
# Deduplicate by model_id (multiple versions share the same model)
seen_model_ids: set[int] = set()
unique_models: list[dict] = []
for m in models:
mid = m.get("civitai_model_id")
if mid and mid not in seen_model_ids:
seen_model_ids.add(mid)
unique_models.append(m)
total = len(unique_models)
updated = 0
for i, model in enumerate(unique_models):
model_id = model["civitai_model_id"]
data = await self._fetch_model(model_id)
if data:
rows = extract_version_stats(data)
if rows:
self.db.upsert_batch(rows)
updated += len(rows)
if progress_callback:
await progress_callback(i + 1, total)
# Rate limiting between requests
if i < total - 1:
await asyncio.sleep(_RATE_LIMIT_DELAY)
return updated
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
self._session = None
+9
View File
@@ -0,0 +1,9 @@
"""Test configuration — adds package root to sys.path so bare imports work."""
import sys
from pathlib import Path
# Add the package root so `from stats_db import StatsDB` works in tests
# without requiring the full package to be installed.
_PACKAGE_ROOT = Path(__file__).resolve().parent.parent
if str(_PACKAGE_ROOT) not in sys.path:
sys.path.insert(0, str(_PACKAGE_ROOT))
@@ -1,421 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildExternalLinks,
closeActiveSidebarTab,
extractLoraNames,
getActiveSidebarTabId,
getSelectedGraphNodes,
isVideoMedia,
matchModelItems,
mergeCivitaiMetadata,
normalizeLoraIdentifier,
normalizeMediaSettings,
normalizeSharedMedia,
normalizeUsageTips,
} from "../../web/comfyui/lora_manager_sidebar_utils.js";
test("closes only the active LoRA sidebar across ComfyUI API shapes", () => {
const managed = {
activeSidebarTabId: "lm-remote-lora-info",
setActiveSidebarTab(id) {
this.activeSidebarTabId = id;
},
};
assert.equal(
closeActiveSidebarTab(managed, "lm-remote-lora-info"),
true
);
assert.equal(getActiveSidebarTabId(managed), null);
const activeRef = { value: "lm-remote-lora-info" };
const refManager = { sidebarTab: { activeSidebarTabId: activeRef } };
assert.equal(
closeActiveSidebarTab(refManager, "lm-remote-lora-info"),
true
);
assert.equal(activeRef.value, null);
let otherTabToggleCount = 0;
const otherTabManager = {
sidebarTab: {
activeSidebarTabId: "node-library",
toggleSidebarTab() {
otherTabToggleCount += 1;
},
},
};
assert.equal(
closeActiveSidebarTab(otherTabManager, "lm-remote-lora-info"),
false
);
assert.equal(otherTabToggleCount, 0);
});
test("falls back to a guarded toggle for readonly sidebar state", () => {
let activeId = "lm-remote-lora-info";
let toggleCount = 0;
const sidebarTab = {
toggleSidebarTab(tabId) {
toggleCount += 1;
activeId = activeId === tabId ? null : tabId;
},
};
Object.defineProperty(sidebarTab, "activeSidebarTabId", {
get: () => activeId,
});
assert.equal(
closeActiveSidebarTab({ sidebarTab }, "lm-remote-lora-info"),
true
);
assert.equal(activeId, null);
assert.equal(toggleCount, 1);
});
test("normalizes loader paths and weight extensions", () => {
assert.equal(
normalizeLoraIdentifier("Styles\\Portrait.safetensors"),
"styles/portrait"
);
});
test("formats Manager usage presets and hides empty JSON", () => {
assert.deepEqual(normalizeUsageTips("{}"), []);
assert.deepEqual(
normalizeUsageTips('{"strength_min":0.7,"clipStrength":1}'),
[
{ label: "Strength min", value: "0.7" },
{ label: "Clip Strength", value: "1" },
]
);
assert.deepEqual(normalizeUsageTips("Use at low strength"), [
{ label: "Note", value: "Use at low strength" },
]);
});
test("detects direct and encoded video preview URLs", () => {
assert.equal(
isVideoMedia(
"/api/lm/previews?path=%2Fmodels%2Floras%2Fexample.MP4"
),
true
);
assert.equal(isVideoMedia("https://example.com/demo.WEBM?download=1"), true);
assert.equal(isVideoMedia("https://example.com/media/42", "video"), true);
assert.equal(isVideoMedia("https://example.com/still.png"), false);
assert.doesNotThrow(() => isVideoMedia("/preview?path=%E0%A4%A"));
});
test("merges full Civitai metadata without losing resolve fields", () => {
assert.deepEqual(
mergeCivitaiMetadata(
{ id: 7, modelId: 9, trainedWords: ["portrait"] },
{ description: "Full details", images: [{ id: 1 }] }
),
{
id: 7,
modelId: 9,
trainedWords: ["portrait"],
description: "Full details",
images: [{ id: 1 }],
}
);
});
test("normalizes community and Civitai media with associated prompts", () => {
const media = normalizeSharedMedia(
[
{
civitai_image_id: 10,
preview_url:
"/api/lm/previews?path=%2Fexamples%2Fcommunity-video.mp4",
media_type: "video",
prompt: "community prompt",
negative_prompt: "community negative",
username: "artist",
steps: 20,
cfg_scale: 4.5,
width: 1280,
height: 720,
},
],
{
images: [
{
id: 10,
url: "https://example.com/duplicate.mp4",
type: "video",
nsfwLevel: 16,
meta: { prompt: "duplicate prompt" },
},
{
id: 11,
url: "https://example.com/still.webp",
meta: {
meta: {
prompt: "example prompt",
negativePrompt: "example negative",
sampler: "Euler",
seed: 12,
Size: "640x480",
Model: "Example checkpoint",
clipSkip: 2,
},
},
},
],
customImages: [
{ id: 12, url: "javascript:alert(1)", meta: { prompt: "unsafe" } },
{
id: 14,
url: "https://user:password@example.com/private.webp",
meta: { prompt: "credential leak" },
},
{
id: 13,
url: "https://example.com/failed.webp",
downloadFailed: true,
},
{
id: "local-one",
url: "",
meta: { prompt: "local custom prompt" },
},
],
},
[
{
name: "custom_local-one.webm",
path: "/example_images_static/abc/custom_local-one.webm",
is_video: true,
},
]
);
assert.equal(media.length, 3);
assert.deepEqual(
{
source: media[0].source,
mediaType: media[0].mediaType,
prompt: media[0].prompt,
negativePrompt: media[0].negativePrompt,
username: media[0].username,
steps: media[0].steps,
cfgScale: media[0].cfgScale,
},
{
source: "Community creation",
mediaType: "video",
prompt: "community prompt",
negativePrompt: "community negative",
username: "artist",
steps: 20,
cfgScale: 4.5,
}
);
assert.equal(media[0].url.includes("/api/lm/previews"), true);
assert.equal(media[0].nsfwLevel, 16);
assert.equal(media[1].prompt, "example prompt");
assert.equal(media[1].negativePrompt, "example negative");
assert.equal(media[1].sampler, "Euler");
assert.equal(media[1].seed, 12);
assert.equal(media[1].width, 640);
assert.equal(media[1].height, 480);
assert.equal(media[1].modelName, "Example checkpoint");
assert.equal(media[1].clipSkip, 2);
assert.equal(media[2].url, "/example_images_static/abc/custom_local-one.webm");
assert.equal(media[2].mediaType, "video");
assert.equal(media[2].prompt, "local custom prompt");
});
test("optimizes Civitai media and normalizes mature-content settings", () => {
const media = normalizeSharedMedia([], {
images: [
{
id: 1,
url: "https://image.civitai.com/example/original=true/video.mp4",
type: "video",
},
],
});
assert.equal(media.length, 1);
assert.match(media[0].url, /transcode=true,width=450,optimized=true/);
assert.deepEqual(
normalizeMediaSettings({
blur_mature_content: false,
mature_blur_level: "XXX",
show_only_sfw: true,
}),
{
blurMatureContent: false,
matureBlurLevel: 16,
showOnlySfw: true,
}
);
});
test("extracts stock and numbered LoRA loader widgets", () => {
const node = {
comfyClass: "Power Lora Loader",
widgets: [
{ name: "lora_name", value: "styles/portrait.safetensors" },
{ name: "lora_01", value: "characters/alice.safetensors" },
{ name: "strength_model", value: 0.8 },
],
};
assert.deepEqual(extractLoraNames(node), [
"styles/portrait.safetensors",
"characters/alice.safetensors",
]);
});
test("treats active Manager entries as authoritative over synchronized text", () => {
const node = {
comfyClass: "Lora Loader (Remote, LoraManager)",
lorasWidget: {
value: [
{ name: "one", active: true },
{ name: "two", active: false },
],
},
widgets: [{ name: "text", value: "<lora:one:1> <lora:two:0.7>" }],
};
assert.deepEqual(extractLoraNames(node), ["one"]);
});
test("extracts LoRA syntax from text loaders without a Manager widget", () => {
const node = {
comfyClass: "LoRA Text Loader",
widgets: [{ name: "text", value: "<lora:one:1> <lora:three:0.7>" }],
};
assert.deepEqual(extractLoraNames(node), ["one", "three"]);
});
test("extracts third-party generic selectors and keyed LoRA maps", () => {
const node = {
type: "ThirdPartyLoraLoader",
widgets: [
{ name: "model", value: "styles/four.safetensors" },
{
name: "loras",
value: {
"five.safetensors": 0.7,
"disabled.safetensors": false,
},
},
],
};
assert.deepEqual(extractLoraNames(node), [
"styles/four.safetensors",
"five.safetensors",
]);
});
test("supports dynamic stack widget names and their enable switches", () => {
const node = {
type: "LoRAStackDynamic",
widgets: [
{ name: "input_mode", value: "text" },
{ name: "lora_count", value: 2 },
{ name: "lora_name_1", value: "stale-one.safetensors" },
{ name: "lora_name_text_1", value: "one.safetensors" },
{ name: "enabled_1", value: true },
{ name: "lora_name_2", value: "stale-two.safetensors" },
{ name: "lora_name_text_2", value: "two.safetensors" },
{ name: "enabled_2", value: false },
{ name: "lora_name_text_3", value: "three.safetensors" },
{ name: "enabled_3", value: true },
],
};
assert.deepEqual(extractLoraNames(node), ["one.safetensors"]);
node.widgets.find((widget) => widget.name === "input_mode").value = "dropdown";
node.widgets.find((widget) => widget.name === "lora_count").value = 1;
assert.deepEqual(extractLoraNames(node), ["stale-one.safetensors"]);
});
test("reads current selectedItems with selected_nodes fallback", () => {
const selected = { id: 4, type: "LoraLoader", widgets: [] };
assert.deepEqual(
getSelectedGraphNodes({ selectedItems: new Set([selected]) }),
[selected]
);
assert.deepEqual(
getSelectedGraphNodes({ selected_nodes: { 4: selected } }),
[selected]
);
});
test("prefers exact relative paths and reports ambiguous basenames", () => {
const items = [
{
file_name: "portrait.safetensors",
model_name: "Portrait",
folder: "styles",
file_path: "/models/loras/styles/portrait.safetensors",
},
{
file_name: "portrait.safetensors",
model_name: "Portrait Alt",
folder: "people",
file_path: "/models/loras/people/portrait.safetensors",
},
];
const exact = matchModelItems("styles/portrait.safetensors", items);
assert.equal(exact.found, true);
assert.equal(exact.model.folder, "styles");
const ambiguous = matchModelItems("portrait.safetensors", items);
assert.equal(ambiguous.ambiguous, true);
assert.equal(ambiguous.candidates.length, 2);
const absolute = matchModelItems(
"/models/loras/people/portrait.safetensors",
items
);
assert.equal(absolute.found, true);
assert.equal(absolute.model.folder, "people");
});
test("builds exact Civitai mirrors and hash-based CivArchive search", () => {
const links = buildExternalLinks("portrait", {
model_name: "Portrait",
sha256: "abc123",
civitai: { modelId: 42, id: 84 },
});
assert.equal(
links.civitai,
"https://civitai.com/models/42?modelVersionId=84"
);
assert.equal(
links.civitaiRed,
"https://civitai.red/models/42?modelVersionId=84"
);
assert.equal(links.civArchive, "https://civarchive.com/search?q=abc123");
});
test("builds encoded name searches when the Manager has no card", () => {
const links = buildExternalLinks("Krea 2 portrait");
assert.equal(
links.civitai,
"https://civitai.com/search/models?query=Krea%202%20portrait"
);
assert.equal(
links.civitaiRed,
"https://civitai.red/search/models?query=Krea%202%20portrait"
);
assert.equal(
links.civArchive,
"https://civarchive.com/search?q=Krea%202%20portrait"
);
});
-149
View File
@@ -1,149 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
RemoteConfigFormError,
buildConfigDraft,
buildConnectionDraft,
didEffectiveRemoteUrlChange,
isConfigWritable,
mappingsToRows,
} from "../../web/comfyui/remote_config_utils.js";
test("buildConfigDraft normalizes form values and skips blank mappings", () => {
assert.deepEqual(
buildConfigDraft({
remoteUrl: " http://manager.local:8188/ ",
timeout: "45",
mappingRows: [
{ remote: "/data/loras/", local: "/mnt/loras" },
{ remote: "", local: "" },
],
}),
{
remote_url: "http://manager.local:8188",
timeout: 45,
path_mappings: { "/data/loras": "/mnt/loras" },
}
);
});
test("buildConfigDraft allows an empty URL to disable remote mode", () => {
const draft = buildConfigDraft({
remoteUrl: "",
timeout: 30,
mappingRows: [],
});
assert.equal(draft.remote_url, "");
});
test("buildConfigDraft rejects unsafe URLs and invalid mappings", () => {
assert.throws(
() =>
buildConfigDraft({
remoteUrl: "ftp://manager.local",
timeout: 30,
mappingRows: [],
}),
(error) =>
error instanceof RemoteConfigFormError && error.field === "remote_url"
);
assert.throws(
() =>
buildConfigDraft({
remoteUrl: "http://manager.local",
timeout: 30,
mappingRows: [{ remote: "/remote", local: "" }],
}),
(error) =>
error instanceof RemoteConfigFormError && error.field === "path_mappings"
);
assert.throws(
() =>
buildConfigDraft({
remoteUrl: "http://manager.local",
timeout: 301,
mappingRows: [],
}),
(error) =>
error instanceof RemoteConfigFormError && error.field === "timeout"
);
});
test("buildConfigDraft rejects normalized duplicate path prefixes", () => {
assert.throws(
() =>
buildConfigDraft({
remoteUrl: "http://manager.local",
timeout: 30,
mappingRows: [
{ remote: "/data/loras", local: "/mnt/a" },
{ remote: "\\data\\loras\\", local: "/mnt/b" },
],
}),
/Duplicate remote path prefix/
);
});
test("buildConfigDraft preserves __proto__ as a path mapping key", () => {
const draft = buildConfigDraft({
remoteUrl: "http://manager.local",
timeout: 30,
mappingRows: [{ remote: "__proto__", local: "/mnt/prototype" }],
});
assert.equal(Object.hasOwn(draft.path_mappings, "__proto__"), true);
assert.equal(draft.path_mappings.__proto__, "/mnt/prototype");
assert.equal(
JSON.parse(JSON.stringify(draft.path_mappings)).__proto__,
"/mnt/prototype"
);
});
test("connection tests use effective environment-managed values", () => {
assert.deepEqual(
buildConnectionDraft({
remoteUrl: "http://stored.local:8188",
timeout: "30",
effective: {
remote_url: "https://managed.local:443",
timeout: 12,
},
overrides: {
remote_url: "LM_REMOTE_URL",
timeout: "LM_REMOTE_TIMEOUT",
},
}),
{ remote_url: "https://managed.local:443", timeout: 12 }
);
});
test("mappingsToRows creates editable row values", () => {
assert.deepEqual(mappingsToRows({ "/remote": "/local" }), [
{ remote: "/remote", local: "/local" },
]);
});
test("didEffectiveRemoteUrlChange only tracks the effective URL", () => {
assert.equal(
didEffectiveRemoteUrlChange(
{ effective: { remote_url: "http://old:8188", timeout: 30 } },
{ effective: { remote_url: "http://new:8188", timeout: 30 } }
),
true
);
assert.equal(
didEffectiveRemoteUrlChange(
{ effective: { remote_url: "http://same:8188", timeout: 30 } },
{ effective: { remote_url: "http://same:8188", timeout: 60 } }
),
false
);
});
test("isConfigWritable only disables saving for an explicit false value", () => {
assert.equal(isConfigWritable({ storage: { writable: false } }), false);
assert.equal(isConfigWritable({ storage: { writable: true } }), true);
assert.equal(isConfigWritable({ storage: {} }), true);
assert.equal(isConfigWritable(null), true);
});
-318
View File
@@ -1,318 +0,0 @@
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
import pytest
@pytest.fixture(scope="module")
def config_module():
module_name = "lm_remote_config_test_module"
module_path = Path(__file__).resolve().parents[1] / "config.py"
spec = importlib.util.spec_from_file_location(module_name, module_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
yield module
sys.modules.pop(module_name, None)
def write_json(path: Path, data: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data), encoding="utf-8")
def test_legacy_config_loads_and_first_save_migrates(config_module, tmp_path):
legacy = tmp_path / "package" / "config.json"
user = tmp_path / "user" / "config.json"
original = {
"remote_url": "http://legacy.local:8188",
"timeout": 30,
"path_mappings": {},
"future_setting": {"preserve": True},
}
write_json(legacy, original)
config = config_module.RemoteConfig(user, legacy, environ={})
state = config.as_dict()
assert state["storage"]["source"] == "legacy"
assert state["effective"]["remote_url"] == "http://legacy.local:8188"
config.save(
{
"remote_url": "http://new.local:8188/",
"timeout": 45,
"path_mappings": {"/remote": "/local"},
},
expected_revision=state["revision"],
)
assert json.loads(legacy.read_text(encoding="utf-8")) == original
saved = json.loads(user.read_text(encoding="utf-8"))
assert saved["remote_url"] == "http://new.local:8188"
assert saved["future_setting"] == {"preserve": True}
assert config.as_dict()["storage"]["source"] == "user"
def test_environment_values_override_without_rewriting_stored_values(
config_module, tmp_path
):
user = tmp_path / "config.json"
write_json(
user,
{
"remote_url": "http://stored.local:8188",
"timeout": 30,
"path_mappings": {},
},
)
config = config_module.RemoteConfig(
user,
tmp_path / "missing.json",
environ={
"LM_REMOTE_URL": "https://managed.local:443/",
"LM_REMOTE_TIMEOUT": "12",
},
)
state = config.as_dict()
assert state["configured"]["remote_url"] == "http://stored.local:8188"
assert state["effective"]["remote_url"] == "https://managed.local:443"
assert state["effective"]["timeout"] == 12
assert state["overrides"] == {
"remote_url": "LM_REMOTE_URL",
"timeout": "LM_REMOTE_TIMEOUT",
}
def test_explicit_config_environment_reports_explicit_source(config_module, tmp_path):
explicit = tmp_path / "managed" / "remote.json"
config = config_module.RemoteConfig(
legacy_config_file=tmp_path / "missing-legacy.json",
environ={"LM_REMOTE_CONFIG": str(explicit)},
)
initial = config.as_dict()
config.save(
{"remote_url": "http://manager.local", "timeout": 30, "path_mappings": {}},
expected_revision=initial["revision"],
)
assert config.as_dict()["storage"]["source"] == "explicit"
def test_explicit_missing_target_does_not_fall_back_to_legacy(config_module, tmp_path):
explicit = tmp_path / "managed" / "remote.json"
legacy = tmp_path / "package" / "config.json"
legacy_data = {
"remote_url": "http://legacy.local",
"timeout": 30,
"path_mappings": {},
}
write_json(legacy, legacy_data)
config = config_module.RemoteConfig(
legacy_config_file=legacy,
environ={"LM_REMOTE_CONFIG": str(explicit)},
)
state = config.as_dict()
assert state["storage"]["source"] == "explicit"
assert state["configured"]["remote_url"] == ""
config.save(
{"remote_url": "http://explicit.local", "timeout": 30, "path_mappings": {}},
expected_revision=state["revision"],
)
assert json.loads(legacy.read_text(encoding="utf-8")) == legacy_data
assert (
json.loads(explicit.read_text(encoding="utf-8"))["remote_url"]
== "http://explicit.local"
)
def test_invalid_environment_timeout_is_ignored(config_module, tmp_path):
config = config_module.RemoteConfig(
tmp_path / "missing-user.json",
tmp_path / "missing-legacy.json",
environ={"LM_REMOTE_TIMEOUT": "not-a-number"},
)
state = config.as_dict()
assert state["effective"]["timeout"] == 30
assert state["overrides"]["timeout"] is None
assert any("LM_REMOTE_TIMEOUT" in warning for warning in state["warnings"])
@pytest.mark.parametrize(
("field", "value"),
[
("remote_url", "ftp://manager.local"),
("remote_url", "http://user:secret@manager.local"),
("remote_url", "http://manager.local?query=yes"),
("timeout", True),
("timeout", 0),
("timeout", 301),
("path_mappings", []),
],
)
def test_validation_rejects_invalid_values(config_module, field, value):
candidate = {
"remote_url": "http://manager.local",
"timeout": 30,
"path_mappings": {},
}
candidate[field] = value
with pytest.raises(config_module.ConfigValidationError) as caught:
config_module.validate_config(candidate)
assert caught.value.field == field
def test_mapping_is_longest_first_and_path_boundary_aware(config_module, tmp_path):
user = tmp_path / "config.json"
write_json(
user,
{
"remote_url": "http://manager.local",
"timeout": 30,
"path_mappings": {
"/models": "/mnt/general",
"/models/special": "/mnt/special",
},
},
)
config = config_module.RemoteConfig(user, tmp_path / "missing.json", environ={})
assert (
config.map_path("/models/special/a.safetensors") == "/mnt/special/a.safetensors"
)
assert (
config.map_path("/models/base.safetensors") == "/mnt/general/base.safetensors"
)
assert config.map_path("/models-old/a.safetensors") == "/models-old/a.safetensors"
def test_mapping_rejects_separator_only_prefix_but_allows_root(config_module):
candidate = {
"remote_url": "http://manager.local",
"timeout": 30,
"path_mappings": {"////": "/mnt/invalid"},
}
with pytest.raises(config_module.ConfigValidationError) as caught:
config_module.validate_config(candidate)
assert caught.value.field == "path_mappings"
root_mapping = config_module.validate_config(
{**candidate, "path_mappings": {"/": "/mnt/root"}}
)
snapshot = config_module.ConfigSnapshot(
1,
root_mapping["remote_url"],
root_mapping["timeout"],
config_module._normalize_mappings(root_mapping["path_mappings"]),
)
assert (
snapshot.map_path("/models/a.safetensors") == "/mnt/root/models/a.safetensors"
)
def test_snapshot_mapping_does_not_change_after_reload(config_module, tmp_path):
user = tmp_path / "config.json"
write_json(
user,
{
"remote_url": "http://one.local",
"timeout": 30,
"path_mappings": {"/remote": "/local-one"},
},
)
config = config_module.RemoteConfig(user, tmp_path / "missing.json", environ={})
original = config.snapshot
revision = config.as_dict()["revision"]
config.save(
{
"remote_url": "http://two.local",
"timeout": 30,
"path_mappings": {"/remote": "/local-two"},
},
expected_revision=revision,
)
assert original.map_path("/remote/a.safetensors") == "/local-one/a.safetensors"
assert config.map_path("/remote/a.safetensors") == "/local-two/a.safetensors"
def test_storage_reports_unwritable_when_parent_is_not_a_directory(
config_module, tmp_path
):
blocking_file = tmp_path / "not-a-directory"
blocking_file.write_text("blocked", encoding="utf-8")
config = config_module.RemoteConfig(
blocking_file / "config.json",
tmp_path / "missing-legacy.json",
environ={},
)
assert config.as_dict()["storage"]["writable"] is False
def test_stale_revision_does_not_overwrite_external_change(config_module, tmp_path):
user = tmp_path / "config.json"
initial = {"remote_url": "http://one.local", "timeout": 30, "path_mappings": {}}
write_json(user, initial)
config = config_module.RemoteConfig(user, tmp_path / "missing.json", environ={})
revision = config.as_dict()["revision"]
write_json(
user,
{"remote_url": "http://two.local", "timeout": 30, "path_mappings": {}},
)
with pytest.raises(config_module.ConfigConflictError):
config.save(initial, expected_revision=revision)
assert (
json.loads(user.read_text(encoding="utf-8"))["remote_url"] == "http://two.local"
)
def test_atomic_write_failure_preserves_file_and_live_snapshot(
config_module, tmp_path, monkeypatch
):
user = tmp_path / "config.json"
initial = {"remote_url": "http://one.local", "timeout": 30, "path_mappings": {}}
write_json(user, initial)
config = config_module.RemoteConfig(user, tmp_path / "missing.json", environ={})
before = config.snapshot
revision = config.as_dict()["revision"]
def fail_replace(source, target):
raise OSError("simulated replace failure")
monkeypatch.setattr(config_module.os, "replace", fail_replace)
with pytest.raises(OSError, match="simulated"):
config.save(
{"remote_url": "http://two.local", "timeout": 50, "path_mappings": {}},
expected_revision=revision,
)
assert json.loads(user.read_text(encoding="utf-8")) == initial
assert config.snapshot == before
assert not list(tmp_path.glob(".config.json.*.tmp"))
def test_invalid_json_can_be_repaired_from_loaded_revision(config_module, tmp_path):
user = tmp_path / "config.json"
user.write_text("{not valid", encoding="utf-8")
config = config_module.RemoteConfig(user, tmp_path / "missing.json", environ={})
state = config.as_dict()
assert state["effective"]["remote_url"] == ""
assert state["warnings"]
config.save(
{"remote_url": "http://fixed.local", "timeout": 30, "path_mappings": {}},
expected_revision=state["revision"],
)
assert (
json.loads(user.read_text(encoding="utf-8"))["remote_url"]
== "http://fixed.local"
)
-715
View File
@@ -1,715 +0,0 @@
from __future__ import annotations
import importlib.util
import json
import sys
import types
from contextlib import asynccontextmanager
from pathlib import Path
import pytest
from aiohttp import web
@pytest.fixture(scope="module")
def modules():
root = Path(__file__).resolve().parents[1]
package_name = "lm_remote_proxy_test_package"
package = types.ModuleType(package_name)
package.__path__ = [str(root)]
sys.modules[package_name] = package
loaded = {}
for name in ("config", "remote_client", "proxy"):
full_name = f"{package_name}.{name}"
spec = importlib.util.spec_from_file_location(full_name, root / f"{name}.py")
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[full_name] = module
spec.loader.exec_module(module)
loaded[name] = module
yield types.SimpleNamespace(**loaded)
for name in ("proxy", "remote_client", "config"):
sys.modules.pop(f"{package_name}.{name}", None)
sys.modules.pop(package_name, None)
class DummyContent:
def __init__(self, body: bytes):
self.body = body
self.offset = 0
async def read(self, size: int) -> bytes:
chunk = self.body[self.offset : self.offset + size]
self.offset += len(chunk)
return chunk
class FragmentedContent:
def __init__(self, chunks: list[bytes]):
self.chunks = list(chunks)
async def read(self, size: int) -> bytes:
if not self.chunks:
return b""
chunk = self.chunks.pop(0)
if len(chunk) <= size:
return chunk
self.chunks.insert(0, chunk[size:])
return chunk[:size]
class DummyRequest:
def __init__(
self,
method="GET",
path="/",
payload=None,
headers=None,
*,
raw_body: bytes | None = None,
content_length: int | None | object = ...,
):
self.method = method
self.path = path
self._payload = payload
self.headers = headers or {}
self.query_string = ""
self.can_read_body = payload is not None
self.content_type = (
"application/json" if payload is not None else "application/octet-stream"
)
self._body = (
raw_body
if raw_body is not None
else json.dumps(payload).encode("utf-8")
if payload is not None
else b""
)
self.content = DummyContent(self._body)
if content_length is ...:
self.content_length = len(self._body) if self._body else None
else:
self.content_length = content_length
if raw_body is not None:
self.content_type = "application/json"
async def json(self):
return self._payload
async def read(self):
return self._body
def response_json(response: web.Response) -> dict:
return json.loads(response.body.decode("utf-8"))
@pytest.fixture
def isolated_proxy(modules, tmp_path, monkeypatch):
config = modules.config.RemoteConfig(
tmp_path / "user" / "config.json",
tmp_path / "missing-legacy.json",
environ={},
)
monkeypatch.setattr(modules.proxy, "remote_config", config)
monkeypatch.setattr(modules.remote_client, "remote_config", config)
modules.proxy.RemoteLoraClient._instance = None
modules.proxy._proxy_sessions = {}
modules.proxy._proxy_session_lock = None
modules.proxy._active_proxy_websockets = set()
modules.proxy._active_proxy_websockets_lock = None
return modules.proxy, config
@pytest.mark.asyncio
async def test_config_endpoint_saves_and_hot_enables(isolated_proxy):
proxy, config = isolated_proxy
get_response = await proxy._handle_config(DummyRequest())
initial = response_json(get_response)
assert initial["configured"]["remote_url"] == ""
put_response = await proxy._handle_config(
DummyRequest(
"PUT",
proxy._CONFIG_ROUTE,
{
"revision": initial["revision"],
"config": {
"remote_url": "http://manager.local:8188/",
"timeout": 40,
"path_mappings": {"/remote": "/local"},
},
},
)
)
payload = response_json(put_response)
assert put_response.status == 200
assert payload["effective"]["remote_url"] == "http://manager.local:8188"
assert payload["restart_required"] is False
assert config.is_configured
@pytest.mark.asyncio
async def test_middleware_falls_through_when_disabled_then_uses_new_url(
isolated_proxy, monkeypatch
):
proxy, config = isolated_proxy
async def local_handler(request):
return web.Response(text="local")
disabled_response = await proxy.lm_remote_proxy_middleware(
DummyRequest(path="/loras"), local_handler
)
assert disabled_response.text == "local"
state = config.as_dict()
config.save(
{"remote_url": "http://manager.local", "timeout": 30, "path_mappings": {}},
expected_revision=state["revision"],
)
captured = {}
async def fake_proxy_http(request, snapshot):
captured["url"] = snapshot.remote_url
return web.Response(text="remote")
monkeypatch.setattr(proxy, "_proxy_http", fake_proxy_http)
enabled_response = await proxy.lm_remote_proxy_middleware(
DummyRequest(path="/loras"), local_handler
)
assert enabled_response.text == "remote"
assert captured["url"] == "http://manager.local"
@pytest.mark.asyncio
async def test_config_endpoint_rejects_changes_to_environment_managed_field(
modules, tmp_path, monkeypatch
):
config = modules.config.RemoteConfig(
tmp_path / "user.json",
tmp_path / "legacy.json",
environ={"LM_REMOTE_URL": "http://managed.local"},
)
monkeypatch.setattr(modules.proxy, "remote_config", config)
initial = config.as_dict()
response = await modules.proxy._handle_config(
DummyRequest(
"PUT",
modules.proxy._CONFIG_ROUTE,
{
"revision": initial["revision"],
"config": {
"remote_url": "http://changed.local",
"timeout": 30,
"path_mappings": {},
},
},
)
)
payload = response_json(response)
assert response.status == 409
assert payload["field"] == "remote_url"
assert "LM_REMOTE_URL" in payload["error"]
@pytest.mark.asyncio
async def test_conflict_reload_rotates_runtime_generation(isolated_proxy, monkeypatch):
proxy, config = isolated_proxy
initial = config.as_dict()
config._config_file.parent.mkdir(parents=True, exist_ok=True)
config._config_file.write_text(
json.dumps(
{
"remote_url": "http://external.local",
"timeout": 20,
"path_mappings": {},
}
),
encoding="utf-8",
)
retired = []
rotated = []
async def fake_retire(generation):
retired.append(generation)
async def fake_rotate(generation):
rotated.append(generation)
monkeypatch.setattr(proxy, "_retire_proxy_sessions", fake_retire)
monkeypatch.setattr(proxy, "_rotate_active_websockets", fake_rotate)
response = await proxy._handle_config(
DummyRequest(
"PUT",
proxy._CONFIG_ROUTE,
{
"revision": initial["revision"],
"config": {
"remote_url": "http://browser.local",
"timeout": 30,
"path_mappings": {},
},
},
)
)
payload = response_json(response)
assert response.status == 409
assert payload["latest"]["effective"]["remote_url"] == "http://external.local"
assert retired == [config.generation]
assert rotated == [config.generation]
@pytest.mark.asyncio
async def test_connection_test_uses_unsaved_draft(isolated_proxy, monkeypatch):
proxy, _ = isolated_proxy
captured = {}
async def fake_test(remote_url, timeout):
captured.update(remote_url=remote_url, timeout=timeout)
return 17
monkeypatch.setattr(proxy, "_perform_connection_test", fake_test)
response = await proxy._handle_test_connection(
DummyRequest(
"POST",
proxy._TEST_CONNECTION_ROUTE,
{"remote_url": "http://draft.local:8188/", "timeout": 8},
)
)
payload = response_json(response)
assert response.status == 200
assert payload["latency_ms"] == 17
assert captured == {"remote_url": "http://draft.local:8188", "timeout": 8}
@pytest.mark.asyncio
async def test_connection_test_rejects_empty_url(isolated_proxy):
proxy, _ = isolated_proxy
response = await proxy._handle_test_connection(
DummyRequest(
"POST",
proxy._TEST_CONNECTION_ROUTE,
{"remote_url": "", "timeout": 30},
)
)
assert response.status == 400
assert response_json(response)["field"] == "remote_url"
@pytest.mark.asyncio
async def test_connection_response_reader_accepts_fragmented_json(isolated_proxy):
proxy, _ = isolated_proxy
response = types.SimpleNamespace(
content=FragmentedContent([b'{"sta', b'tus":"', b'ok"}'])
)
assert await proxy._read_small_json(response) == {"status": "ok"}
@pytest.mark.asyncio
async def test_connection_response_reader_enforces_hard_limit(isolated_proxy):
proxy, _ = isolated_proxy
response = types.SimpleNamespace(
content=FragmentedContent([b" " * proxy._MAX_TEST_RESPONSE, b" "])
)
with pytest.raises(proxy._ConnectionTestError, match="unexpectedly large"):
await proxy._read_small_json(response)
@pytest.mark.asyncio
async def test_proxy_loop_header_is_rejected(isolated_proxy):
proxy, config = isolated_proxy
state = config.as_dict()
config.save(
{"remote_url": "http://manager.local", "timeout": 30, "path_mappings": {}},
expected_revision=state["revision"],
)
async def local_handler(request):
return web.Response(text="local")
response = await proxy.lm_remote_proxy_middleware(
DummyRequest(
path="/api/lm/health-check",
headers={proxy._PROXY_HOP_HEADER: "1"},
),
local_handler,
)
assert response.status == 508
def test_register_proxy_is_available_while_unconfigured(isolated_proxy):
proxy, config = isolated_proxy
assert not config.is_configured
app = web.Application()
proxy.register_proxy(app)
assert proxy.lm_remote_proxy_middleware in app.middlewares
assert proxy._cleanup_proxy_session in app.on_shutdown
def test_register_proxy_keeps_existing_security_middleware_first(isolated_proxy):
proxy, _ = isolated_proxy
@web.middleware
async def security_guard(request, handler):
return await handler(request)
app = web.Application(middlewares=[security_guard])
proxy.register_proxy(app)
assert list(app.middlewares) == [security_guard, proxy.lm_remote_proxy_middleware]
@pytest.mark.asyncio
async def test_chunked_config_body_is_hard_limited(isolated_proxy):
proxy, _ = isolated_proxy
response = await proxy._handle_config(
DummyRequest(
"PUT",
proxy._CONFIG_ROUTE,
raw_body=b" " * (proxy._MAX_CONFIG_BODY + 1),
content_length=None,
)
)
assert response.status == 413
@pytest.mark.asyncio
async def test_remote_client_fetches_all_pages_beyond_server_cap(
isolated_proxy, modules, monkeypatch
):
_, config = isolated_proxy
client = modules.remote_client.RemoteLoraClient()
calls = []
async def fake_get_json(path, params=None, *, snapshot=None):
page = int(params["page"])
calls.append((page, int(params["page_size"]), snapshot.generation))
start = (page - 1) * 100
count = 100 if page == 1 else 55
return {
"items": [
{"file_name": f"model-{index}"} for index in range(start, start + count)
],
"total_pages": 2,
}
monkeypatch.setattr(client, "_get_json", fake_get_json)
items = await client._get_lora_list_cached(snapshot=config.snapshot)
assert len(items) == 155
assert items[-1]["file_name"] == "model-154"
assert [call[:2] for call in calls] == [(1, 100), (2, 100)]
@pytest.mark.asyncio
@pytest.mark.parametrize(
("method_name", "expected_path"),
[
("_get_lora_list_cached", "/api/lm/loras/list"),
("_get_checkpoint_list_cached", "/api/lm/checkpoints/list"),
],
)
async def test_successful_empty_listing_is_cached(
isolated_proxy, modules, monkeypatch, method_name, expected_path
):
_, config = isolated_proxy
client = modules.remote_client.RemoteLoraClient()
calls = []
async def fake_get_all_pages(path, *, snapshot):
calls.append((path, snapshot.generation))
return []
monkeypatch.setattr(client, "_get_all_pages", fake_get_all_pages)
cached_method = getattr(client, method_name)
assert await cached_method(snapshot=config.snapshot) == []
assert await cached_method(snapshot=config.snapshot) == []
assert calls == [(expected_path, config.generation)]
@pytest.mark.asyncio
async def test_stale_lookup_never_returns_another_generations_cache(
isolated_proxy, modules, monkeypatch
):
_, config = isolated_proxy
client = modules.remote_client.RemoteLoraClient()
client._lora_cache = [{"file_name": "new-generation"}]
client._lora_cache_generation = config.generation
old_snapshot = modules.config.ConfigSnapshot(
config.generation - 1,
"http://old.local",
30,
(("/remote", "/old-local"),),
)
async def fail_fetch(path, *, snapshot):
raise OSError("old server unavailable")
monkeypatch.setattr(client, "_get_all_pages", fail_fetch)
assert await client._get_lora_list_cached(snapshot=old_snapshot) == []
@pytest.mark.asyncio
async def test_lora_info_maps_with_the_fetch_generation(
isolated_proxy, modules, monkeypatch
):
_, config = isolated_proxy
initial = config.as_dict()
config.save(
{
"remote_url": "http://one.local",
"timeout": 30,
"path_mappings": {"/remote": "/local-one"},
},
expected_revision=initial["revision"],
)
client = modules.remote_client.RemoteLoraClient()
async def fake_list(*, snapshot=None):
latest = config.as_dict()
config.save(
{
"remote_url": "http://two.local",
"timeout": 30,
"path_mappings": {"/remote": "/local-two"},
},
expected_revision=latest["revision"],
)
return [
{
"file_name": "portrait",
"file_path": "/remote/portrait.safetensors",
"folder": "",
"civitai": {},
}
]
monkeypatch.setattr(client, "_get_lora_list_cached", fake_list)
monkeypatch.setattr(client, "_relative_lora_path", lambda path, folder: path)
relative, _ = await client.get_lora_info("portrait")
assert relative == "/local-one/portrait.safetensors"
class FakeSession:
def __init__(self, *args, **kwargs):
self.closed = False
async def close(self):
self.closed = True
@pytest.mark.asyncio
async def test_media_proxy_streams_range_response_without_buffering(
isolated_proxy, modules, monkeypatch
):
proxy, config = isolated_proxy
captured = {}
class UpstreamContent:
async def iter_chunked(self, size):
assert size == proxy._MEDIA_STREAM_CHUNK_SIZE
for chunk in (b"abc", b"def"):
yield chunk
class UpstreamResponse:
status = 206
headers = {
"Content-Type": "video/mp4",
"Content-Length": "6",
"Content-Range": "bytes 0-5/20",
"Accept-Ranges": "bytes",
"Set-Cookie": "remote=secret",
}
content = UpstreamContent()
read_called = False
async def read(self):
self.read_called = True
raise AssertionError("streaming media must not call read()")
upstream = UpstreamResponse()
class RequestContext:
async def __aenter__(self):
return upstream
async def __aexit__(self, *_args):
return None
class StreamingSession:
def request(self, **kwargs):
captured["request"] = kwargs
return RequestContext()
@asynccontextmanager
async def fake_lease(snapshot):
captured["generation"] = snapshot.generation
yield StreamingSession()
class DownstreamResponse:
def __init__(self, *, status, headers):
self.status = status
self.headers = headers
self.prepared = False
self.chunks = []
self.eof = False
async def prepare(self, request):
self.prepared = True
self.request = request
async def write(self, chunk):
self.chunks.append(chunk)
async def write_eof(self):
self.eof = True
monkeypatch.setattr(proxy, "_proxy_session_lease", fake_lease)
monkeypatch.setattr(proxy.web, "StreamResponse", DownstreamResponse)
snapshot = modules.config.ConfigSnapshot(
config.generation,
"http://manager.local:8188",
30,
(),
)
request = DummyRequest(
path="/api/lm/previews",
headers={"Range": "bytes=0-"},
)
response = await proxy._proxy_http(request, snapshot)
assert response.status == 206
assert response.chunks == [b"abc", b"def"]
assert response.eof is True
assert upstream.read_called is False
assert response.headers["Content-Range"] == "bytes 0-5/20"
assert response.headers["Accept-Ranges"] == "bytes"
assert response.headers["Content-Length"] == "6"
assert "Set-Cookie" not in response.headers
assert captured["request"]["headers"]["Range"] == "bytes=0-"
assert captured["request"]["auto_decompress"] is False
timeout = captured["request"]["timeout"]
assert timeout.total is None
assert timeout.sock_connect == 30
assert timeout.sock_read == proxy._MEDIA_IDLE_TIMEOUT
@pytest.mark.asyncio
async def test_proxy_session_does_not_retain_remote_cookies(
isolated_proxy, monkeypatch
):
proxy, config = isolated_proxy
captured = {}
def create_session(*args, **kwargs):
captured.update(kwargs)
return FakeSession()
monkeypatch.setattr(proxy.aiohttp, "ClientSession", create_session)
lease = proxy._proxy_session_lease(config.snapshot)
await lease.__aenter__()
await lease.__aexit__(None, None, None)
assert isinstance(captured["cookie_jar"], proxy.aiohttp.DummyCookieJar)
await proxy._close_all_proxy_sessions()
@pytest.mark.asyncio
async def test_proxy_session_rotation_waits_for_inflight_request(
isolated_proxy, monkeypatch
):
proxy, config = isolated_proxy
created = []
def create_session(*args, **kwargs):
session = FakeSession()
created.append(session)
return session
monkeypatch.setattr(proxy.aiohttp, "ClientSession", create_session)
old_snapshot = config.snapshot
old_lease = proxy._proxy_session_lease(old_snapshot)
old_session = await old_lease.__aenter__()
state = config.as_dict()
new_snapshot = config.save(
{"remote_url": "http://new.local", "timeout": 30, "path_mappings": {}},
expected_revision=state["revision"],
)
await proxy._retire_proxy_sessions(new_snapshot.generation)
assert old_session.closed is False
new_lease = proxy._proxy_session_lease(new_snapshot)
new_session = await new_lease.__aenter__()
assert new_session is not old_session
await new_lease.__aexit__(None, None, None)
assert new_session.closed is False
await old_lease.__aexit__(None, None, None)
assert old_session.closed is True
assert new_session.closed is False
await proxy._close_all_proxy_sessions()
assert new_session.closed is True
class FakeWebSocket:
def __init__(self):
self.closed = False
self.close_code = None
async def close(self, *, code=None, message=None):
self.closed = True
self.close_code = code
@pytest.mark.asyncio
async def test_websocket_rotation_closes_only_retired_generation(isolated_proxy):
proxy, config = isolated_proxy
current_generation = config.generation
old_bridge = proxy._ActiveWebSocket(
current_generation - 1, FakeWebSocket(), FakeSession()
)
current_bridge = proxy._ActiveWebSocket(
current_generation, FakeWebSocket(), FakeSession()
)
proxy._active_proxy_websockets.update({old_bridge, current_bridge})
await proxy._rotate_active_websockets(current_generation)
assert old_bridge.local_ws.closed is True
assert old_bridge.local_ws.close_code == 1012
assert old_bridge.session.closed is True
assert current_bridge.local_ws.closed is False
assert proxy._active_proxy_websockets == {current_bridge}
await proxy._rotate_active_websockets(None)
assert current_bridge.local_ws.closed is True
assert current_bridge.session.closed is True
def test_mapped_local_path_is_resolved_against_comfy_lora_roots(
modules, tmp_path, monkeypatch
):
root = tmp_path / "models" / "loras"
fake_folder_paths = types.SimpleNamespace(
get_folder_paths=lambda model_type: [str(root)]
)
monkeypatch.setitem(sys.modules, "folder_paths", fake_folder_paths)
relative = modules.remote_client.RemoteLoraClient._relative_lora_path(
str(root / "styles" / "portrait.safetensors"),
"wrong-remote-folder",
)
assert relative == "styles/portrait.safetensors"
+41
View File
@@ -0,0 +1,41 @@
"""Tests for proxy response enrichment with stats."""
import pytest
@pytest.fixture
def db(tmp_path):
from stats_db import StatsDB
db = StatsDB(tmp_path / "test.db")
db.init()
return db
def test_enrich_list_response(db):
"""Stats are merged into list response items."""
from proxy import _enrich_list_response
db.upsert("hash_a", {"download_count": 5000, "rating": 4.8,
"rating_count": 120, "thumbs_up_count": 89})
response_data = {
"items": [
{"sha256": "hash_a", "file_name": "lora_a.safetensors"},
{"sha256": "hash_b", "file_name": "lora_b.safetensors"},
],
"total": 2,
}
enriched = _enrich_list_response(response_data, db)
assert enriched["items"][0]["download_count"] == 5000
assert enriched["items"][0]["rating"] == 4.8
assert enriched["items"][0]["thumbs_up_count"] == 89
# Item without stats gets no extra fields
assert "download_count" not in enriched["items"][1]
def test_enrich_empty_items(db):
"""Empty items list returns unchanged."""
from proxy import _enrich_list_response
response_data = {"items": [], "total": 0}
enriched = _enrich_list_response(response_data, db)
assert enriched["items"] == []
+99
View File
@@ -0,0 +1,99 @@
"""Tests for the CivitAI stats database layer."""
import pytest
from pathlib import Path
@pytest.fixture
def db_path(tmp_path):
return tmp_path / "test_stats.db"
@pytest.fixture
def stats_db(db_path):
from stats_db import StatsDB
db = StatsDB(db_path)
db.init()
return db
def test_init_creates_table(stats_db, db_path):
"""DB file is created and table exists."""
assert db_path.exists()
def test_upsert_and_get_single(stats_db):
"""Insert a stat row and retrieve it by sha256."""
stats_db.upsert("abc123", {
"civitai_model_id": 1,
"civitai_version_id": 10,
"download_count": 5000,
"rating": 4.8,
"rating_count": 120,
"thumbs_up_count": 89,
})
result = stats_db.get_by_hashes(["abc123"])
assert "abc123" in result
assert result["abc123"]["download_count"] == 5000
assert result["abc123"]["rating"] == 4.8
assert result["abc123"]["thumbs_up_count"] == 89
def test_upsert_updates_existing(stats_db):
"""Upserting same sha256 updates values."""
stats_db.upsert("abc123", {"download_count": 100, "rating": 3.0,
"rating_count": 10, "thumbs_up_count": 5})
stats_db.upsert("abc123", {"download_count": 200, "rating": 4.0,
"rating_count": 20, "thumbs_up_count": 15})
result = stats_db.get_by_hashes(["abc123"])
assert result["abc123"]["download_count"] == 200
def test_get_by_hashes_batch(stats_db):
"""Batch retrieval returns only matching hashes."""
for i in range(5):
stats_db.upsert(f"hash_{i}", {"download_count": i * 100,
"rating": 0, "rating_count": 0,
"thumbs_up_count": 0})
result = stats_db.get_by_hashes(["hash_1", "hash_3", "nonexistent"])
assert len(result) == 2
assert "hash_1" in result
assert "hash_3" in result
assert "nonexistent" not in result
def test_get_by_hashes_empty_input(stats_db):
"""Empty input returns empty dict."""
assert stats_db.get_by_hashes([]) == {}
def test_upsert_batch(stats_db):
"""Batch upsert inserts multiple rows."""
rows = [
("h1", {"civitai_model_id": 1, "download_count": 100,
"rating": 4.0, "rating_count": 10, "thumbs_up_count": 5}),
("h2", {"civitai_model_id": 2, "download_count": 200,
"rating": 3.5, "rating_count": 20, "thumbs_up_count": 15}),
]
stats_db.upsert_batch(rows)
result = stats_db.get_by_hashes(["h1", "h2"])
assert len(result) == 2
assert result["h1"]["download_count"] == 100
assert result["h2"]["download_count"] == 200
def test_get_all_stats(stats_db):
"""get_all returns every row keyed by sha256."""
stats_db.upsert("a", {"download_count": 1, "rating": 0,
"rating_count": 0, "thumbs_up_count": 0})
stats_db.upsert("b", {"download_count": 2, "rating": 0,
"rating_count": 0, "thumbs_up_count": 0})
result = stats_db.get_all()
assert len(result) == 2
def test_stats_count(stats_db):
"""count returns number of rows."""
assert stats_db.count() == 0
stats_db.upsert("a", {"download_count": 1, "rating": 0,
"rating_count": 0, "thumbs_up_count": 0})
assert stats_db.count() == 1
+121
View File
@@ -0,0 +1,121 @@
"""Tests for the CivitAI stats fetch service."""
import pytest
from unittest.mock import patch
@pytest.fixture
def db(tmp_path):
from stats_db import StatsDB
db = StatsDB(tmp_path / "test.db")
db.init()
return db
@pytest.fixture
def service(db):
from stats_service import StatsFetchService
return StatsFetchService(db)
def test_extract_version_stats():
"""Extract stats from a CivitAI model API response."""
from stats_service import extract_version_stats
model_response = {
"id": 123,
"modelVersions": [
{
"id": 456,
"files": [{"hashes": {"SHA256": "AABB"}}],
"stats": {
"downloadCount": 5000,
"ratingCount": 120,
"rating": 4.8,
"thumbsUpCount": 89,
},
},
{
"id": 789,
"files": [{"hashes": {"SHA256": "CCDD"}}],
"stats": {
"downloadCount": 1000,
"ratingCount": 30,
"rating": 3.5,
"thumbsUpCount": 20,
},
},
],
}
result = extract_version_stats(model_response)
assert len(result) == 2
assert result[0][0] == "aabb" # sha256 lowercased
assert result[0][1]["download_count"] == 5000
assert result[0][1]["civitai_model_id"] == 123
assert result[0][1]["civitai_version_id"] == 456
assert result[1][0] == "ccdd"
def test_extract_version_stats_no_hash():
"""Versions without a SHA256 hash are skipped."""
from stats_service import extract_version_stats
model_response = {
"id": 1,
"modelVersions": [
{"id": 10, "files": [{"hashes": {}}], "stats": {"downloadCount": 1}},
],
}
assert extract_version_stats(model_response) == []
def test_extract_version_stats_no_stats():
"""Versions without stats get zeros."""
from stats_service import extract_version_stats
model_response = {
"id": 1,
"modelVersions": [
{"id": 10, "files": [{"hashes": {"SHA256": "AABB"}}]},
],
}
result = extract_version_stats(model_response)
assert len(result) == 1
assert result[0][1]["download_count"] == 0
@pytest.mark.asyncio
async def test_fetch_stats_for_models(service):
"""fetch_stats_for_models calls CivitAI API and stores results."""
models = [
{"sha256": "aabb", "civitai_model_id": 123},
{"sha256": "ccdd", "civitai_model_id": 456},
]
mock_response_123 = {
"id": 123,
"modelVersions": [{
"id": 10,
"files": [{"hashes": {"SHA256": "AABB"}}],
"stats": {"downloadCount": 500, "ratingCount": 10,
"rating": 4.0, "thumbsUpCount": 8},
}],
}
mock_response_456 = {
"id": 456,
"modelVersions": [{
"id": 20,
"files": [{"hashes": {"SHA256": "CCDD"}}],
"stats": {"downloadCount": 100, "ratingCount": 5,
"rating": 3.0, "thumbsUpCount": 2},
}],
}
async def mock_fetch(model_id):
return {123: mock_response_123, 456: mock_response_456}.get(model_id)
with patch.object(service, "_fetch_model", side_effect=mock_fetch):
count = await service.fetch_stats_for_models(models)
assert count == 2
result = service.db.get_by_hashes(["aabb", "ccdd"])
assert result["aabb"]["download_count"] == 500
assert result["ccdd"]["download_count"] == 100
+61
View File
@@ -0,0 +1,61 @@
"""Tests for stats-based sorting."""
import pytest
@pytest.fixture
def db(tmp_path):
from stats_db import StatsDB
db = StatsDB(tmp_path / "test.db")
db.init()
return db
def test_sort_by_downloads_desc(db):
"""Items sorted by download_count descending."""
from proxy import _sort_items_by_stats
db.upsert("h1", {"download_count": 100, "rating": 0, "rating_count": 0, "thumbs_up_count": 0})
db.upsert("h2", {"download_count": 5000, "rating": 0, "rating_count": 0, "thumbs_up_count": 0})
db.upsert("h3", {"download_count": 500, "rating": 0, "rating_count": 0, "thumbs_up_count": 0})
items = [
{"sha256": "h1", "file_name": "a"},
{"sha256": "h2", "file_name": "b"},
{"sha256": "h3", "file_name": "c"},
]
sorted_items = _sort_items_by_stats(items, "downloads", "desc", db)
assert [i["sha256"] for i in sorted_items] == ["h2", "h3", "h1"]
def test_sort_by_rating_desc(db):
"""Items sorted by rating descending."""
from proxy import _sort_items_by_stats
db.upsert("h1", {"download_count": 0, "rating": 3.0, "rating_count": 0, "thumbs_up_count": 0})
db.upsert("h2", {"download_count": 0, "rating": 4.8, "rating_count": 0, "thumbs_up_count": 0})
items = [{"sha256": "h1"}, {"sha256": "h2"}]
sorted_items = _sort_items_by_stats(items, "rating", "desc", db)
assert sorted_items[0]["sha256"] == "h2"
def test_sort_items_without_stats_go_last(db):
"""Items without stats sort to the end."""
from proxy import _sort_items_by_stats
db.upsert("h1", {"download_count": 100, "rating": 0, "rating_count": 0, "thumbs_up_count": 0})
items = [{"sha256": "no_stats"}, {"sha256": "h1"}]
sorted_items = _sort_items_by_stats(items, "downloads", "desc", db)
assert sorted_items[0]["sha256"] == "h1"
assert sorted_items[1]["sha256"] == "no_stats"
def test_is_stats_sort():
"""Identify stats-based sort keys."""
from proxy import _is_stats_sort
assert _is_stats_sort("downloads") is True
assert _is_stats_sort("rating") is True
assert _is_stats_sort("thumbsup") is True
assert _is_stats_sort("name") is False
assert _is_stats_sort("date") is False
-880
View File
@@ -1,880 +0,0 @@
.lmri-root {
--lmri-bg: var(--comfy-menu-bg, #18181b);
--lmri-panel: var(--comfy-input-bg, #242428);
--lmri-border: var(--border-color, #3a3a40);
--lmri-text: var(--fg-color, #f4f4f5);
--lmri-muted: var(--descrip-text, #a1a1aa);
--lmri-accent: var(--primary-color, #7c3aed);
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
overflow-y: auto;
background: var(--lmri-bg);
color: var(--lmri-text);
font: 13px/1.45 Inter, system-ui, sans-serif;
}
.lmri-root *,
.lmri-root *::before,
.lmri-root *::after {
box-sizing: border-box;
}
.lmri-header {
position: sticky;
z-index: 2;
top: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 14px 11px;
border-bottom: 1px solid var(--lmri-border);
background: color-mix(in srgb, var(--lmri-bg) 94%, transparent);
backdrop-filter: blur(10px);
}
.lmri-header h1,
.lmri-card h2,
.lmri-notice h3,
.lmri-empty h3 {
margin: 0;
color: var(--lmri-text);
}
.lmri-header h1 {
font-size: 15px;
font-weight: 650;
}
.lmri-header p {
max-width: 240px;
margin: 2px 0 0;
overflow: hidden;
color: var(--lmri-muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.lmri-header-actions,
.lmri-inline-actions {
display: flex;
align-items: center;
gap: 6px;
}
.lmri-inline-actions {
margin-top: 10px;
}
.lmri-icon-button,
.lmri-name,
.lmri-candidate,
.lmri-button {
border: 1px solid var(--lmri-border);
color: var(--lmri-text);
font: inherit;
cursor: pointer;
}
.lmri-icon-button {
display: grid;
flex: 0 0 30px;
width: 30px;
height: 30px;
place-items: center;
border-radius: 8px;
background: var(--lmri-panel);
}
.lmri-icon-button:disabled {
cursor: default;
opacity: 0.45;
}
.lmri-name-selector {
display: flex;
gap: 6px;
padding: 10px 12px 2px;
overflow-x: auto;
}
.lmri-name {
flex: 0 0 auto;
max-width: 190px;
padding: 6px 9px;
overflow: hidden;
border-radius: 999px;
background: transparent;
color: var(--lmri-muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.lmri-name.active {
border-color: color-mix(in srgb, var(--lmri-accent) 70%, white 10%);
background: color-mix(in srgb, var(--lmri-accent) 24%, transparent);
color: var(--lmri-text);
}
.lmri-content {
padding: 12px;
}
.lmri-state,
.lmri-empty {
display: flex;
min-height: 230px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
color: var(--lmri-muted);
text-align: center;
}
.lmri-empty-icon {
color: var(--lmri-accent);
font-size: 28px;
}
.lmri-empty p {
max-width: 280px;
margin: 0;
}
.lmri-card,
.lmri-notice {
overflow: hidden;
border: 1px solid var(--lmri-border);
border-radius: 12px;
background: var(--lmri-panel);
box-shadow: 0 8px 28px rgb(0 0 0 / 18%);
}
.lmri-preview {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
overflow: hidden;
background: #101012;
}
.lmri-preview img,
.lmri-preview video {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.lmri-preview video {
object-fit: contain;
}
.lmri-media-blurred {
filter: blur(22px) brightness(0.55);
transform: scale(1.08);
}
.lmri-media-reveal {
position: absolute;
z-index: 1;
top: 50%;
left: 50%;
padding: 7px 10px;
border: 1px solid rgb(255 255 255 / 45%);
border-radius: 7px;
background: rgb(10 10 12 / 82%);
color: white;
font: inherit;
cursor: pointer;
transform: translate(-50%, -50%);
}
.lmri-card-body,
.lmri-notice {
padding: 13px;
}
.lmri-card-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.lmri-card h2 {
font-size: 16px;
line-height: 1.25;
}
.lmri-file-name {
margin: 4px 0 0;
overflow-wrap: anywhere;
color: var(--lmri-muted);
font-size: 11px;
}
.lmri-flags {
display: flex;
align-items: center;
gap: 5px;
color: #facc15;
font-size: 15px;
}
.lmri-update {
padding: 2px 6px;
border-radius: 999px;
background: #166534;
color: #dcfce7;
font-size: 9px;
text-transform: uppercase;
}
.lmri-metadata {
margin-top: 12px;
padding: 8px 10px;
border: 1px solid var(--lmri-border);
border-radius: 9px;
background: color-mix(in srgb, var(--lmri-bg) 58%, transparent);
}
.lmri-usage-tips {
margin-top: 0;
}
.lmri-meta-row {
display: grid;
grid-template-columns: minmax(72px, 0.8fr) minmax(0, 1.5fr);
gap: 8px;
padding: 3px 0;
}
.lmri-meta-label {
color: var(--lmri-muted);
}
.lmri-meta-value {
overflow-wrap: anywhere;
text-align: right;
}
.lmri-section-title,
.lmri-copy h3 {
margin: 13px 0 6px;
color: var(--lmri-muted);
font-size: 10px;
font-weight: 650;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.lmri-pills {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.lmri-pill {
max-width: 100%;
padding: 4px 7px;
overflow: hidden;
border: 1px solid var(--lmri-border);
border-radius: 6px;
background: color-mix(in srgb, var(--lmri-bg) 56%, transparent);
color: var(--lmri-muted);
font-size: 10px;
text-overflow: ellipsis;
}
.lmri-triggers .lmri-pill {
border-color: color-mix(in srgb, var(--lmri-accent) 52%, var(--lmri-border));
color: var(--lmri-text);
}
.lmri-copy p {
margin: 0;
color: var(--lmri-text);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.lmri-shared {
margin-top: 14px;
overflow: hidden;
border: 1px solid var(--lmri-border);
border-radius: 10px;
background: color-mix(in srgb, var(--lmri-bg) 58%, transparent);
}
.lmri-shared-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 9px 10px;
}
.lmri-shared-header h3,
.lmri-shared-prompt h4 {
margin: 0;
color: var(--lmri-text);
font-size: 11px;
font-weight: 650;
}
.lmri-shared-source {
display: block;
margin-top: 1px;
color: var(--lmri-muted);
font-size: 9px;
}
.lmri-shared-navigation {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 5px;
}
.lmri-shared-nav,
.lmri-copy-button {
border: 1px solid var(--lmri-border);
border-radius: 6px;
background: var(--lmri-panel);
color: var(--lmri-text);
font: inherit;
cursor: pointer;
}
.lmri-shared-nav {
display: grid;
width: 24px;
height: 24px;
place-items: center;
padding: 0;
font-size: 17px;
line-height: 1;
}
.lmri-shared-position {
min-width: 29px;
color: var(--lmri-muted);
font-size: 9px;
text-align: center;
}
.lmri-shared-dots {
display: flex;
gap: 5px;
padding: 8px 9px 0;
overflow-x: auto;
}
.lmri-shared-dot {
flex: 0 0 8px;
width: 8px;
height: 8px;
padding: 0;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--lmri-muted) 52%, transparent);
cursor: pointer;
}
.lmri-shared-dot.active {
background: var(--lmri-accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--lmri-accent) 28%, transparent);
}
.lmri-shared-dot:focus-visible {
outline: 2px solid color-mix(in srgb, var(--lmri-accent) 75%, white 15%);
outline-offset: 2px;
}
.lmri-shared-viewer {
position: relative;
width: 100%;
min-height: 120px;
max-height: 360px;
aspect-ratio: 4 / 3;
overflow: hidden;
border-block: 1px solid var(--lmri-border);
background: #101012;
}
.lmri-shared-viewer img,
.lmri-shared-viewer video {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.lmri-shared-params {
padding: 8px 9px 0;
}
.lmri-shared-prompt {
padding: 9px 10px 10px;
}
.lmri-prompt-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.lmri-copy-button {
min-width: 48px;
padding: 3px 7px;
color: var(--lmri-muted);
font-size: 9px;
}
.lmri-copy-button:disabled {
cursor: wait;
opacity: 0.65;
}
.lmri-shared-prompt-text,
.lmri-negative-prompt p {
margin: 0;
overflow-wrap: anywhere;
color: var(--lmri-text);
font-size: 11px;
line-height: 1.45;
white-space: pre-wrap;
}
.lmri-shared-prompt-text {
max-height: 9em;
overflow-y: auto;
}
.lmri-negative-prompt {
padding: 0 10px 10px;
color: var(--lmri-muted);
font-size: 10px;
}
.lmri-negative-prompt summary {
cursor: pointer;
}
.lmri-negative-prompt p {
margin-top: 6px;
color: var(--lmri-muted);
}
.lmri-enrichment-state {
display: flex;
align-items: center;
gap: 7px;
margin-top: 12px;
color: var(--lmri-muted);
font-size: 10px;
}
.lmri-shared-nav:hover,
.lmri-copy-button:hover,
.lmri-media-reveal:hover {
border-color: color-mix(in srgb, var(--lmri-accent) 75%, var(--lmri-border));
}
.lmri-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
margin-top: 10px;
}
.lmri-primary-actions {
grid-template-columns: 1fr;
margin-top: 14px;
}
.lmri-button {
min-width: 0;
padding: 7px 8px;
border-radius: 7px;
background: color-mix(in srgb, var(--lmri-bg) 70%, transparent);
text-align: center;
text-decoration: none;
}
.lmri-button:hover,
.lmri-icon-button:hover:not(:disabled),
.lmri-candidate:hover {
border-color: color-mix(in srgb, var(--lmri-accent) 75%, var(--lmri-border));
background: color-mix(in srgb, var(--lmri-accent) 18%, var(--lmri-bg));
}
.lmri-manager {
border-color: color-mix(in srgb, var(--lmri-accent) 68%, var(--lmri-border));
background: color-mix(in srgb, var(--lmri-accent) 24%, var(--lmri-bg));
}
.lmri-civitai-red {
border-color: #7f1d1d;
color: #fecaca;
}
.lmri-notice h3 {
font-size: 14px;
}
.lmri-notice p {
margin: 7px 0 12px;
color: var(--lmri-muted);
}
.lmri-error {
border-color: #7f1d1d;
}
.lmri-candidates {
display: flex;
flex-direction: column;
gap: 6px;
}
.lmri-candidate {
display: flex;
width: 100%;
align-items: flex-start;
flex-direction: column;
padding: 8px 9px;
border-radius: 8px;
background: color-mix(in srgb, var(--lmri-bg) 65%, transparent);
text-align: left;
}
.lmri-candidate span {
color: var(--lmri-muted);
font-size: 10px;
overflow-wrap: anywhere;
}
@media (max-width: 360px) {
.lmri-actions {
grid-template-columns: 1fr;
}
}
.lmrc-overlay {
--lmrc-bg: var(--comfy-menu-bg, #18181b);
--lmrc-panel: var(--comfy-input-bg, #242428);
--lmrc-border: var(--border-color, #3a3a40);
--lmrc-text: var(--fg-color, #f4f4f5);
--lmrc-muted: var(--descrip-text, #a1a1aa);
--lmrc-accent: var(--primary-color, #7c3aed);
position: fixed;
z-index: 12000;
inset: 0;
display: grid;
padding: 20px;
overflow-y: auto;
place-items: center;
background: rgb(0 0 0 / 66%);
color: var(--lmrc-text);
font: 13px/1.45 Inter, system-ui, sans-serif;
backdrop-filter: blur(3px);
}
.lmrc-overlay *,
.lmrc-overlay *::before,
.lmrc-overlay *::after {
box-sizing: border-box;
}
.lmrc-dialog {
display: flex;
width: min(720px, calc(100vw - 32px));
max-height: min(820px, calc(100vh - 40px));
flex-direction: column;
overflow: hidden;
border: 1px solid var(--lmrc-border);
border-radius: 14px;
outline: none;
background: var(--lmrc-bg);
box-shadow: 0 24px 80px rgb(0 0 0 / 55%);
}
.lmrc-header {
display: flex;
flex: 0 0 auto;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 18px 20px 15px;
border-bottom: 1px solid var(--lmrc-border);
}
.lmrc-header h2,
.lmrc-section h3 {
margin: 0;
color: var(--lmrc-text);
}
.lmrc-header h2 {
font-size: 18px;
}
.lmrc-header p,
.lmrc-section-heading p {
margin: 4px 0 0;
color: var(--lmrc-muted);
}
.lmrc-form {
min-height: 0;
flex: 1 1 auto;
padding: 16px 20px 18px;
overflow-y: auto;
}
.lmrc-section {
margin-bottom: 18px;
}
.lmrc-section h3 {
font-size: 13px;
font-weight: 650;
}
.lmrc-connection-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(130px, 0.7fr);
gap: 12px;
margin-top: 9px;
}
.lmrc-field {
display: flex;
min-width: 0;
flex-direction: column;
gap: 5px;
}
.lmrc-label {
color: var(--lmrc-muted);
font-size: 11px;
font-weight: 600;
}
.lmrc-input {
width: 100%;
min-width: 0;
height: 36px;
padding: 7px 9px;
border: 1px solid var(--lmrc-border);
border-radius: 7px;
outline: none;
background: var(--lmrc-panel);
color: var(--lmrc-text);
font: inherit;
}
.lmrc-input:focus {
border-color: color-mix(in srgb, var(--lmrc-accent) 78%, white 8%);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--lmrc-accent) 22%, transparent);
}
.lmrc-input:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.lmrc-hint {
min-height: 16px;
color: var(--lmrc-muted);
font-size: 10px;
overflow-wrap: anywhere;
}
.lmrc-section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.lmrc-section-heading p {
max-width: 500px;
font-size: 11px;
}
.lmrc-mapping-rows {
display: flex;
flex-direction: column;
gap: 7px;
margin-top: 10px;
}
.lmrc-mapping-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 20px minmax(0, 1fr) 32px;
align-items: center;
gap: 7px;
}
.lmrc-mapping-arrow {
color: var(--lmrc-muted);
text-align: center;
}
.lmrc-icon-button,
.lmrc-button,
.lmrc-settings-button {
border: 1px solid var(--lmrc-border, var(--border-color, #3a3a40));
border-radius: 7px;
background: var(--lmrc-panel, var(--comfy-input-bg, #242428));
color: var(--lmrc-text, var(--fg-color, #f4f4f5));
font: inherit;
cursor: pointer;
}
.lmrc-icon-button {
display: grid;
flex: 0 0 32px;
width: 32px;
height: 32px;
padding: 0;
place-items: center;
}
.lmrc-button,
.lmrc-settings-button {
min-height: 34px;
padding: 7px 11px;
}
.lmrc-settings-button {
width: 100%;
text-align: left;
}
.lmrc-icon-button:hover,
.lmrc-button:hover:not(:disabled),
.lmrc-settings-button:hover {
border-color: color-mix(in srgb, var(--lmrc-accent, #7c3aed) 75%, var(--lmrc-border, #3a3a40));
}
.lmrc-button:disabled,
.lmrc-icon-button:disabled {
cursor: default;
opacity: 0.5;
}
.lmrc-primary {
border-color: color-mix(in srgb, var(--lmrc-accent) 72%, white 7%);
background: color-mix(in srgb, var(--lmrc-accent) 54%, var(--lmrc-panel));
}
.lmrc-notices {
display: flex;
flex-direction: column;
gap: 6px;
}
.lmrc-notice,
.lmrc-status {
padding: 8px 10px;
border: 1px solid var(--lmrc-border);
border-radius: 7px;
background: color-mix(in srgb, var(--lmrc-panel) 72%, transparent);
color: var(--lmrc-muted);
font-size: 11px;
}
.lmrc-notice.warning,
.lmrc-status.error {
border-color: #7f1d1d;
color: #fecaca;
}
.lmrc-status {
min-height: 35px;
margin-top: 10px;
}
.lmrc-status-action {
margin-left: 10px;
}
.lmrc-status:empty {
display: none;
}
.lmrc-status.success {
border-color: #166534;
color: #bbf7d0;
}
.lmrc-status.busy {
color: var(--lmrc-text);
}
.lmrc-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid var(--lmrc-border);
}
.lmrc-footer-group {
display: flex;
gap: 7px;
}
@media (max-width: 620px) {
.lmrc-overlay {
padding: 8px;
place-items: start center;
}
.lmrc-dialog {
width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
}
.lmrc-connection-grid,
.lmrc-mapping-row {
grid-template-columns: 1fr;
}
.lmrc-mapping-arrow {
transform: rotate(90deg);
}
.lmrc-mapping-row .lmrc-icon-button {
justify-self: end;
}
.lmrc-footer {
align-items: stretch;
flex-direction: column;
}
.lmrc-footer-group {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.lmrc-status-action {
display: block;
width: 100%;
margin: 8px 0 0;
}
}
File diff suppressed because it is too large Load Diff
-773
View File
@@ -1,773 +0,0 @@
const WEIGHT_EXTENSION = /\.(?:safetensors|ckpt|pt|pth|bin)$/i;
const LORA_SYNTAX = /<lora:([^:>]+)(?::[^>]*)?>/gi;
const VIDEO_EXTENSION = /\.(?:mp4|webm|mov|m4v)$/i;
const NSFW_LEVELS = {
pg: 1,
pg13: 2,
r: 4,
x: 8,
xxx: 16,
blocked: 32,
};
const DISABLED_VALUES = new Set([
"",
"none",
"null",
"disabled",
"select a lora",
"select lora",
]);
export function cleanLoraName(value) {
if (typeof value !== "string") return "";
const trimmed = value.trim().replace(/^["']|["']$/g, "");
const exactSyntax = /^<lora:([^:>]+)(?::[^>]*)?>$/i.exec(trimmed);
const name = (exactSyntax?.[1] || trimmed)
.replace(/\\/g, "/")
.replace(/\/{2,}/g, "/")
.replace(/^\.\//, "")
.trim();
if (DISABLED_VALUES.has(name.toLowerCase())) return "";
return name;
}
export function normalizeLoraIdentifier(value) {
return cleanLoraName(value)
.replace(WEIGHT_EXTENSION, "")
.replace(/^\/+|\/+$/g, "")
.toLowerCase();
}
export function loraSearchTerm(value) {
const clean = cleanLoraName(value);
const basename = clean.replace(/\\/g, "/").split("/").pop() || clean;
return basename.replace(WEIGHT_EXTENSION, "").trim();
}
function formatUsageTipLabel(value) {
return String(value)
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[_-]+/g, " ")
.replace(/^./, (letter) => letter.toUpperCase());
}
function formatUsageTipValue(value) {
if (value == null) return "";
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value);
}
export function normalizeUsageTips(value) {
let parsed = value;
if (typeof value === "string") {
const text = value.trim();
if (!text || text === "{}" || text === "[]" || text === "null") return [];
try {
parsed = JSON.parse(text);
} catch {
return [{ label: "Note", value: text }];
}
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
return Object.entries(parsed)
.map(([key, entry]) => ({
label: formatUsageTipLabel(key),
value: formatUsageTipValue(entry),
}))
.filter((entry) => entry.value !== "");
}
function decodeMediaPath(value) {
let decoded = String(value || "");
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const next = decodeURIComponent(decoded);
if (next === decoded) break;
decoded = next;
} catch {
break;
}
}
return decoded;
}
function mediaPath(value) {
const text = String(value || "").trim();
if (!text) return "";
try {
const parsed = new URL(text, "http://localhost/");
return decodeMediaPath(parsed.searchParams.get("path") || parsed.pathname)
.split(/[?#]/, 1)[0]
.toLowerCase();
} catch {
return decodeMediaPath(text).split(/[?#]/, 1)[0].toLowerCase();
}
}
export function isVideoMedia(value, declaredType = "") {
const type = String(declaredType || "").trim().toLowerCase();
if (type === "video" || type.startsWith("video/")) return true;
return VIDEO_EXTENSION.test(mediaPath(value));
}
function safeMediaReference(value) {
if (typeof value !== "string" || !value.trim()) return "";
try {
const parsed = new URL(value.trim(), "http://localhost/");
return !parsed.username &&
!parsed.password &&
(parsed.protocol === "http:" || parsed.protocol === "https:")
? value.trim()
: "";
} catch {
return "";
}
}
function stringValue(value) {
return typeof value === "string" ? value.trim() : "";
}
function firstString(...values) {
for (const value of values) {
const text = stringValue(value);
if (text) return text;
}
return "";
}
function objectValue(value) {
let parsed = value;
if (typeof value === "string") {
try {
parsed = JSON.parse(value);
} catch {
return {};
}
}
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: {};
}
function generationMetadata(item) {
const direct = objectValue(item?.meta);
return { ...direct, ...objectValue(direct.meta) };
}
function normalizeNsfwLevel(value) {
if (typeof value === "number" && Number.isFinite(value)) {
return value > 0 ? value : null;
}
if (typeof value !== "string" || !value.trim()) return null;
const normalized = value.trim().toLowerCase().replace(/[\s_-]+/g, "");
if (normalized === "unknown") return null;
if (normalized in NSFW_LEVELS) return NSFW_LEVELS[normalized];
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
}
function optimizeMediaUrl(value, mediaType) {
const safe = safeMediaReference(value);
if (!safe) return "";
try {
const parsed = new URL(safe, "http://localhost/");
if (
parsed.hostname !== "civitai.com" &&
parsed.hostname.endsWith(".civitai.com") &&
parsed.pathname.includes("/original=true")
) {
const replacement =
mediaType === "video"
? "/transcode=true,width=450,optimized=true"
: "/width=450,optimized=true";
parsed.pathname = parsed.pathname.replace("/original=true", replacement);
return parsed.href;
}
} catch {
return safe;
}
return safe;
}
function mediaIdentity(item, url) {
const id = item?.civitai_image_id ?? item?.id;
return id != null && String(id).trim()
? `id:${String(id).trim()}`
: `url:${url}`;
}
function localExampleFile(item, index, exampleFiles) {
if (!Array.isArray(exampleFiles) || !exampleFiles.length) return null;
if (typeof item?.id === "string" && item.id) {
const prefix = `custom_${item.id}`;
return exampleFiles.find((file) =>
String(file?.name || "").startsWith(prefix)
);
}
return exampleFiles.find((file) => {
const match = /(?:^|\/)image_(\d+)\./i.exec(String(file?.name || ""));
return match && Number(match[1]) === index;
});
}
function normalizeMediaItem(item, source, localFile = null) {
if (!item || typeof item !== "object" || item.downloadFailed) return null;
const meta = generationMetadata(item);
const rawUrl = firstString(
localFile?.path,
item.preview_url,
item.image_url,
item.url
);
const mediaType = isVideoMedia(
rawUrl,
localFile?.is_video ? "video" : item.media_type || item.type
)
? "video"
: "image";
const url = optimizeMediaUrl(rawUrl, mediaType);
if (!url) return null;
const prompt = firstString(item.prompt, meta.prompt);
const negativePrompt = firstString(
item.negative_prompt,
item.negativePrompt,
meta.negative_prompt,
meta.negativePrompt
);
const sizeLabel = firstString(item.size, meta.Size, meta.size);
const sizeMatch = /^(\d+)\s*[x×]\s*(\d+)$/i.exec(sizeLabel);
const width = Number(item.width || sizeMatch?.[1]);
const height = Number(item.height || sizeMatch?.[2]);
const reactionCount = [
item.like_count,
item.heart_count,
item.laugh_count,
item.comment_count,
].reduce((sum, value) => {
const count = Number(value);
return sum + (Number.isFinite(count) && count > 0 ? count : 0);
}, 0);
return {
id: mediaIdentity(item, url),
source,
url,
thumbnailUrl: safeMediaReference(item.thumbnail_url),
mediaType,
prompt,
negativePrompt,
username: firstString(item.username, item.creator?.username),
width: Number.isFinite(width) && width > 0 ? width : null,
height: Number.isFinite(height) && height > 0 ? height : null,
steps: item.steps ?? meta.steps ?? meta.Steps ?? null,
sampler: item.sampler ?? meta.sampler ?? meta.Sampler ?? "",
cfgScale:
item.cfg_scale ?? meta.cfg_scale ?? meta.cfgScale ?? meta.CFG ?? null,
seed: item.seed ?? meta.seed ?? meta.Seed ?? null,
denoise: item.denoise ?? meta.denoise ?? null,
clipSkip: meta.clip_skip ?? meta.clipSkip ?? null,
modelName: firstString(item.model_name, meta.Model, meta.model),
baseModel: firstString(
item.base_model,
item.baseModel,
meta.base_model,
meta.baseModel
),
nsfwLevel: normalizeNsfwLevel(
item.nsfwLevel ?? item.nsfw_level ?? item.nsfw
),
reactionCount,
civitaiImageId: item.civitai_image_id ?? item.id ?? null,
};
}
function missingMediaValue(value) {
return value == null || value === "";
}
function mergeMediaItem(primary, supplement) {
const merged = { ...primary };
const fillFields = [
"thumbnailUrl",
"prompt",
"negativePrompt",
"username",
"width",
"height",
"steps",
"sampler",
"cfgScale",
"seed",
"denoise",
"clipSkip",
"modelName",
"baseModel",
"civitaiImageId",
];
for (const field of fillFields) {
if (missingMediaValue(merged[field]) && !missingMediaValue(supplement[field])) {
merged[field] = supplement[field];
}
}
if (!String(merged.url).startsWith("/") && String(supplement.url).startsWith("/")) {
merged.url = supplement.url;
}
if (supplement.mediaType === "video") merged.mediaType = "video";
const levels = [merged.nsfwLevel, supplement.nsfwLevel].filter(
(value) => typeof value === "number" && Number.isFinite(value)
);
merged.nsfwLevel = levels.length ? Math.max(...levels) : null;
merged.reactionCount = Math.max(
Number(merged.reactionCount) || 0,
Number(supplement.reactionCount) || 0
);
return merged;
}
export function mergeCivitaiMetadata(summary, details) {
const merged = { ...objectValue(summary) };
for (const [key, value] of Object.entries(objectValue(details))) {
if (value !== undefined) merged[key] = value;
}
return merged;
}
export function normalizeSharedMedia(
communityImages,
civitaiMetadata,
exampleFiles = []
) {
const civitai = objectValue(civitaiMetadata);
const regularImages = Array.isArray(civitai.images) ? civitai.images : [];
const customImages = Array.isArray(civitai.customImages)
? civitai.customImages
: [];
const candidates = [
...(Array.isArray(communityImages)
? communityImages.map((item) => [item, "Community creation", null])
: []),
...regularImages.map((item, index) => [
item,
"Civitai example",
localExampleFile(item, index, exampleFiles),
]),
...customImages.map((item, index) => [
item,
"Custom example",
localExampleFile(item, regularImages.length + index, exampleFiles),
]),
];
const indexes = new Map();
const media = [];
for (const [item, source, localFile] of candidates) {
const normalized = normalizeMediaItem(item, source, localFile);
if (!normalized) continue;
const existingIndex = indexes.get(normalized.id);
if (existingIndex != null) {
media[existingIndex] = mergeMediaItem(media[existingIndex], normalized);
continue;
}
indexes.set(normalized.id, media.length);
media.push(normalized);
}
return media;
}
export function normalizeMediaSettings(value) {
const settings = objectValue(value);
return {
blurMatureContent: settings.blur_mature_content !== false,
matureBlurLevel:
normalizeNsfwLevel(settings.mature_blur_level) ?? NSFW_LEVELS.r,
showOnlySfw: settings.show_only_sfw === true,
};
}
function unwrapSidebarValue(value) {
return value && typeof value === "object" && "value" in value
? value.value
: value;
}
export function getActiveSidebarTabId(manager) {
if (!manager) return null;
const sidebar = manager.sidebarTab || manager;
return unwrapSidebarValue(
sidebar?.activeSidebarTabId ?? manager.activeSidebarTabId
);
}
export function closeActiveSidebarTab(manager, tabId) {
if (!manager || getActiveSidebarTabId(manager) !== tabId) return false;
const sidebar = manager.sidebarTab || manager;
if (typeof manager.setActiveSidebarTab === "function") {
try {
manager.setActiveSidebarTab(null);
if (getActiveSidebarTabId(manager) !== tabId) return true;
} catch {
// Older frontend wrappers may reject null.
}
}
if (
sidebar &&
(typeof sidebar === "object" || typeof sidebar === "function") &&
"activeSidebarTabId" in sidebar
) {
try {
const current = sidebar.activeSidebarTabId;
if (current && typeof current === "object" && "value" in current) {
current.value = null;
} else {
sidebar.activeSidebarTabId = null;
}
if (getActiveSidebarTabId(manager) !== tabId) return true;
} catch {
// Some frontend versions expose a readonly store property.
}
}
const toggleTargets = sidebar === manager ? [sidebar] : [sidebar, manager];
for (const target of toggleTargets) {
if (typeof target?.toggleSidebarTab !== "function") continue;
try {
target.toggleSidebarTab(tabId);
if (getActiveSidebarTabId(manager) !== tabId) return true;
} catch {
// Try the remaining compatibility paths.
}
}
if (typeof manager.command?.execute === "function") {
manager.command.execute(`Workspace.ToggleSidebarTab.${tabId}`);
return true;
}
return false;
}
export function extractLoraSyntax(value) {
if (typeof value !== "string") return [];
const names = [];
LORA_SYNTAX.lastIndex = 0;
for (const match of value.matchAll(LORA_SYNTAX)) {
const name = cleanLoraName(match[1]);
if (name) names.push(name);
}
return names;
}
function isEnabledEntry(entry) {
if (!entry || typeof entry !== "object") return true;
return entry.active !== false && entry.enabled !== false && entry.on !== false;
}
function collectStructuredNames(value, output, allowObjectKeys = false) {
if (typeof value === "string") {
const syntaxNames = extractLoraSyntax(value);
if (syntaxNames.length) {
output.push(...syntaxNames);
} else {
const name = cleanLoraName(value);
if (name) output.push(name);
}
return;
}
if (Array.isArray(value)) {
value.forEach((entry) => collectStructuredNames(entry, output, true));
return;
}
if (!value || typeof value !== "object" || !isEnabledEntry(value)) return;
const namedValue =
value.name ??
value.lora_name ??
value.loraName ??
value.lora ??
value.path ??
value.file;
if (typeof namedValue === "string") {
collectStructuredNames(namedValue, output);
return;
}
const nested = value.loras ?? value.items ?? value.values;
if (Array.isArray(nested) || (nested && typeof nested === "object")) {
collectStructuredNames(nested, output, true);
}
if (!allowObjectKeys) return;
for (const [key, entry] of Object.entries(value)) {
if (WEIGHT_EXTENSION.test(key) && entry !== false && entry !== 0) {
collectStructuredNames(key, output);
}
if (!entry || typeof entry !== "object" || !isEnabledEntry(entry)) continue;
const entryName =
entry.name ??
entry.lora_name ??
entry.loraName ??
entry.lora ??
entry.path ??
entry.file;
if (typeof entryName === "string") {
collectStructuredNames(entryName, output);
} else if (WEIGHT_EXTENSION.test(key)) {
collectStructuredNames(key, output);
}
}
}
function normalizeWidgetName(name) {
return String(name || "")
.trim()
.toLowerCase()
.replace(/[\s-]+/g, "_");
}
function loraSlotIndex(name) {
const normalized = normalizeWidgetName(name);
const patterns = [
/^lora_?(\d+)(?:_(?:name|path|file|text))?$/,
/^lora_(?:name|path|file)(?:_text)?_?(\d+)$/,
];
for (const pattern of patterns) {
const match = pattern.exec(normalized);
if (match) return String(Number(match[1]));
}
return null;
}
function isLoraSelectorName(name) {
const normalized = normalizeWidgetName(name);
return (
/^(?:lora|loras|lora_name|lora_path|lora_file)$/.test(normalized) ||
loraSlotIndex(normalized) !== null ||
/^lora.*_(?:name|path|file)$/.test(normalized)
);
}
function isFalseLike(value) {
return (
value === false ||
value === 0 ||
["0", "false", "off", "disabled", "no"].includes(
String(value || "").trim().toLowerCase()
)
);
}
function isLoraSlotEnabled(widgetName, widgetsByName) {
const slot = loraSlotIndex(widgetName);
if (slot === null) return true;
const companionNames = [
`enabled_${slot}`,
`enable_${slot}`,
`lora_enabled_${slot}`,
`lora_${slot}_enabled`,
];
for (const name of companionNames) {
if (widgetsByName.has(name)) {
return !isFalseLike(widgetsByName.get(name)?.value);
}
}
return true;
}
function shouldReadLoraSlot(widgetName, widgetsByName) {
const slot = loraSlotIndex(widgetName);
if (slot === null) return true;
const count = Number(widgetsByName.get("lora_count")?.value);
if (Number.isFinite(count) && Number(slot) > count) return false;
const normalized = normalizeWidgetName(widgetName);
const inputMode = String(widgetsByName.get("input_mode")?.value || "")
.trim()
.toLowerCase();
const isTextSelector = normalized === `lora_name_text_${slot}`;
const isDropdownSelector = normalized === `lora_name_${slot}`;
if (inputMode === "text" && isDropdownSelector) {
return !widgetsByName.has(`lora_name_text_${slot}`);
}
if (inputMode && inputMode !== "text" && isTextSelector) {
return !widgetsByName.has(`lora_name_${slot}`);
}
return true;
}
export function extractLoraNames(node) {
if (!node || typeof node !== "object") return [];
const output = [];
const descriptor = [
node.comfyClass,
node.type,
node.title,
node.constructor?.comfyClass,
]
.filter(Boolean)
.join(" ");
const isLoraNode = /lora/i.test(descriptor);
const hasManagerWidget = node.lorasWidget?.value != null;
if (hasManagerWidget) {
collectStructuredNames(node.lorasWidget.value, output, true);
}
const widgets = node.widgets || [];
const widgetsByName = new Map(
widgets.map((widget) => [normalizeWidgetName(widget?.name), widget])
);
for (const widget of hasManagerWidget ? [] : widgets) {
const widgetName = String(widget?.name || "");
const value = widget?.value;
const slotEnabled =
shouldReadLoraSlot(widgetName, widgetsByName) &&
isLoraSlotEnabled(widgetName, widgetsByName);
const syntaxNames = slotEnabled ? extractLoraSyntax(value) : [];
if (syntaxNames.length) output.push(...syntaxNames);
if (isLoraSelectorName(widgetName)) {
if (slotEnabled) collectStructuredNames(value, output, true);
} else if (
isLoraNode &&
/^(?:text|lora_syntax|lora_code)$/i.test(widgetName)
) {
output.push(...syntaxNames);
} else if (
isLoraNode &&
typeof value === "string" &&
WEIGHT_EXTENSION.test(cleanLoraName(value))
) {
collectStructuredNames(value, output);
}
}
const seen = new Set();
return output.filter((value) => {
const key = normalizeLoraIdentifier(value);
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}
export function getSelectedGraphNodes(canvas) {
if (!canvas) return [];
const selectedItems = canvas.selectedItems;
if (selectedItems && typeof selectedItems.values === "function") {
return Array.from(selectedItems.values()).filter(
(item) => item && (item.widgets || item.comfyClass || item.type)
);
}
return Object.values(canvas.selected_nodes || {}).filter(Boolean);
}
function aliasesForModel(model) {
const fileName = cleanLoraName(model?.file_name || "");
const modelName = cleanLoraName(model?.model_name || "");
const folder = String(model?.folder || "")
.replace(/\\/g, "/")
.replace(/^\/+|\/+$/g, "");
const relativePath = folder && fileName ? `${folder}/${fileName}` : fileName;
const filePath = cleanLoraName(model?.file_path || "");
return {
fileName: normalizeLoraIdentifier(fileName),
modelName: normalizeLoraIdentifier(modelName),
relativePath: normalizeLoraIdentifier(relativePath),
filePath: normalizeLoraIdentifier(filePath),
};
}
function matchScore(query, model) {
const normalized = normalizeLoraIdentifier(query);
if (!normalized) return 0;
const basename = normalized.split("/").pop();
const hasPath = normalized.includes("/");
const aliases = aliasesForModel(model);
if (hasPath) {
if (aliases.relativePath === normalized) return 100;
if (
aliases.filePath === normalized ||
aliases.filePath.endsWith(`/${normalized}`)
) {
return 95;
}
return 0;
}
if (aliases.fileName.split("/").pop() === basename) return 90;
if (aliases.modelName === normalized) return 85;
return 0;
}
export function matchModelItems(query, items) {
let bestScore = 0;
let candidates = [];
for (const item of Array.isArray(items) ? items : []) {
const score = matchScore(query, item);
if (!score) continue;
if (score > bestScore) {
bestScore = score;
candidates = [item];
} else if (score === bestScore) {
candidates.push(item);
}
}
return {
found: candidates.length === 1,
ambiguous: candidates.length > 1,
model: candidates.length === 1 ? candidates[0] : null,
candidates,
};
}
function exactCivitaiUrl(host, model) {
const modelId = model?.civitai?.modelId;
const versionId = model?.civitai?.id;
if (!modelId) return null;
const version = versionId
? `?modelVersionId=${encodeURIComponent(String(versionId))}`
: "";
return `https://${host}/models/${encodeURIComponent(String(modelId))}${version}`;
}
export function buildExternalLinks(query, model = null) {
const term =
loraSearchTerm(model?.model_name || query) || loraSearchTerm(query);
const encodedTerm = encodeURIComponent(term);
const archiveTerm = String(model?.sha256 || term);
return {
civitai:
exactCivitaiUrl("civitai.com", model) ||
`https://civitai.com/search/models?query=${encodedTerm}`,
civitaiRed:
exactCivitaiUrl("civitai.red", model) ||
`https://civitai.red/search/models?query=${encodedTerm}`,
civArchive: `https://civarchive.com/search?q=${encodeURIComponent(archiveTerm)}`,
};
}
-461
View File
@@ -1,461 +0,0 @@
import { api } from "../../scripts/api.js";
import {
buildConfigDraft,
buildConnectionDraft,
didEffectiveRemoteUrlChange,
isConfigWritable,
mappingsToRows,
} from "./remote_config_utils.js";
// Explicit /api paths work with both current and legacy ComfyUI apiURL helpers.
const CONFIG_ENDPOINT = "/api/lm-remote/config";
const TEST_ENDPOINT = "/api/lm-remote/test-connection";
let activeDialog = null;
let pageEffectiveRemoteUrl;
function element(tag, className, text) {
const value = document.createElement(tag);
if (className) value.className = className;
if (text != null) value.textContent = String(text);
return value;
}
function labeledInput(labelText, input) {
const label = element("label", "lmrc-field");
label.append(element("span", "lmrc-label", labelText), input);
return label;
}
function parseError(response, payload) {
const error = new Error(
payload?.error || `Request failed with HTTP ${response.status}.`
);
error.field = payload?.field || "";
error.status = response.status;
error.payload = payload;
return error;
}
async function requestJson(path, options = {}) {
const response = await api.fetchApi(path, options);
let payload = null;
try {
payload = await response.json();
} catch {
// The HTTP status below still gives the user a useful failure.
}
if (!response.ok || payload?.success === false) {
throw parseError(response, payload);
}
return payload;
}
function trapFocus(event, panel) {
if (event.key !== "Tab") return;
const focusable = Array.from(
panel.querySelectorAll(
'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
).filter((item) => item.offsetParent !== null);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
export function openRemoteConfigDialog({ onSaved } = {}) {
if (activeDialog) {
activeDialog.panel.focus();
return activeDialog;
}
const opener = document.activeElement;
const overlay = element("div", "lmrc-overlay");
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-labelledby", "lmrc-dialog-title");
const panel = element("section", "lmrc-dialog");
panel.tabIndex = -1;
overlay.appendChild(panel);
const header = element("header", "lmrc-header");
const heading = element("div");
const title = element("h2", "", "Configure LM Remote");
title.id = "lmrc-dialog-title";
heading.append(
title,
element(
"p",
"",
"Connect this ComfyUI instance to LoRA Manager. New requests use saved changes immediately."
)
);
const closeButton = element("button", "lmrc-icon-button");
closeButton.type = "button";
closeButton.title = "Close";
closeButton.setAttribute("aria-label", "Close configuration");
closeButton.appendChild(element("i", "pi pi-times"));
header.append(heading, closeButton);
const form = element("form", "lmrc-form");
form.noValidate = true;
const connectionSection = element("section", "lmrc-section");
connectionSection.appendChild(element("h3", "", "Connection"));
const connectionGrid = element("div", "lmrc-connection-grid");
const remoteUrlInput = element("input", "lmrc-input");
remoteUrlInput.type = "url";
remoteUrlInput.placeholder = "http://manager.local:8188";
remoteUrlInput.autocomplete = "url";
remoteUrlInput.spellcheck = false;
const remoteField = labeledInput("Remote URL", remoteUrlInput);
const remoteHint = element("small", "lmrc-hint");
remoteField.appendChild(remoteHint);
const timeoutInput = element("input", "lmrc-input");
timeoutInput.type = "number";
timeoutInput.min = "1";
timeoutInput.max = "300";
timeoutInput.step = "1";
const timeoutField = labeledInput("Timeout (seconds)", timeoutInput);
const timeoutHint = element("small", "lmrc-hint");
timeoutField.appendChild(timeoutHint);
connectionGrid.append(remoteField, timeoutField);
connectionSection.appendChild(connectionGrid);
const mappingSection = element("section", "lmrc-section");
const mappingHeading = element("div", "lmrc-section-heading");
const mappingCopy = element("div");
mappingCopy.append(
element("h3", "", "Path mappings"),
element(
"p",
"",
"Optional remote-to-local prefixes when both machines mount models at different paths."
)
);
const addMappingButton = element("button", "lmrc-button lmrc-secondary", "Add mapping");
addMappingButton.type = "button";
mappingHeading.append(mappingCopy, addMappingButton);
const mappingRows = element("div", "lmrc-mapping-rows");
mappingSection.append(mappingHeading, mappingRows);
const notices = element("div", "lmrc-notices");
const status = element("div", "lmrc-status");
status.setAttribute("role", "status");
status.setAttribute("aria-live", "polite");
const footer = element("footer", "lmrc-footer");
const leftActions = element("div", "lmrc-footer-group");
const testButton = element("button", "lmrc-button lmrc-secondary", "Test connection");
testButton.type = "button";
const reloadButton = element("button", "lmrc-button lmrc-secondary", "Reload saved");
reloadButton.type = "button";
leftActions.append(testButton, reloadButton);
const rightActions = element("div", "lmrc-footer-group");
const cancelButton = element("button", "lmrc-button lmrc-secondary", "Close");
cancelButton.type = "button";
const saveButton = element("button", "lmrc-button lmrc-primary", "Save");
saveButton.type = "submit";
rightActions.append(cancelButton, saveButton);
footer.append(leftActions, rightActions);
form.append(connectionSection, mappingSection, notices, status, footer);
panel.append(header, form);
document.body.appendChild(overlay);
let loaded = null;
let busy = false;
let formLocked = false;
let dismissLocked = false;
function updateDisabledState() {
const overrides = loaded?.overrides || {};
remoteUrlInput.disabled = formLocked || Boolean(overrides.remote_url);
timeoutInput.disabled = formLocked || Boolean(overrides.timeout);
for (const control of mappingRows.querySelectorAll("input, button")) {
control.disabled = formLocked;
}
closeButton.disabled = dismissLocked;
cancelButton.disabled = dismissLocked;
}
function close() {
if (dismissLocked) return;
if (activeDialog?.overlay !== overlay) return;
overlay.remove();
activeDialog = null;
if (opener?.isConnected && typeof opener.focus === "function") {
try {
opener.focus({ preventScroll: true });
} catch {
opener.focus();
}
}
}
function setStatus(message = "", kind = "", action = null) {
status.replaceChildren();
if (message) status.appendChild(element("span", "", message));
if (action) {
const actionButton = element(
"button",
"lmrc-button lmrc-secondary lmrc-status-action",
action.label
);
actionButton.type = "button";
actionButton.addEventListener("click", action.onClick);
status.appendChild(actionButton);
}
status.className = `lmrc-status${kind ? ` ${kind}` : ""}`;
}
function setBusy(
value,
message = "",
{ lockForm = false, lockDismiss = false } = {}
) {
busy = value;
formLocked = Boolean(value && lockForm);
dismissLocked = Boolean(value && lockDismiss);
testButton.disabled = value || !loaded;
reloadButton.disabled = value;
saveButton.disabled = value || !loaded || !isConfigWritable(loaded);
addMappingButton.disabled = value || !loaded;
form.setAttribute("aria-busy", String(value));
updateDisabledState();
if (message) setStatus(message, "busy");
}
function addMappingRow(remote = "", local = "") {
const row = element("div", "lmrc-mapping-row");
const remoteInput = element("input", "lmrc-input");
remoteInput.type = "text";
remoteInput.placeholder = "/data/models/loras";
remoteInput.value = remote;
remoteInput.setAttribute("aria-label", "Remote path prefix");
const arrow = element("i", "pi pi-arrow-right lmrc-mapping-arrow");
arrow.setAttribute("aria-hidden", "true");
const localInput = element("input", "lmrc-input");
localInput.type = "text";
localInput.placeholder = "/mnt/nas/models/loras";
localInput.value = local;
localInput.setAttribute("aria-label", "Local path prefix");
const remove = element("button", "lmrc-icon-button");
remove.type = "button";
remove.title = "Remove path mapping";
remove.setAttribute("aria-label", remove.title);
remove.appendChild(element("i", "pi pi-trash"));
remove.addEventListener("click", () => row.remove());
row.append(remoteInput, arrow, localInput, remove);
mappingRows.appendChild(row);
updateDisabledState();
return row;
}
function readMappingRows() {
return Array.from(mappingRows.querySelectorAll(".lmrc-mapping-row")).map(
(row) => {
const inputs = row.querySelectorAll("input");
return { remote: inputs[0]?.value || "", local: inputs[1]?.value || "" };
}
);
}
function renderNotices(payload) {
notices.replaceChildren();
const source = payload.storage?.source;
if (source === "legacy") {
notices.appendChild(
element(
"div",
"lmrc-notice",
"Loaded the package config. Saving migrates it to ComfyUI user data."
)
);
} else if (source === "user") {
notices.appendChild(
element("div", "lmrc-notice", "Stored in ComfyUI user data.")
);
} else if (source === "explicit") {
notices.appendChild(
element(
"div",
"lmrc-notice",
"Stored in the file selected by LM_REMOTE_CONFIG."
)
);
}
if (!isConfigWritable(payload)) {
notices.appendChild(
element(
"div",
"lmrc-notice warning",
"Configuration storage is not writable. Fix its file or directory permissions, then use Reload saved to check again."
)
);
}
for (const warning of payload.warnings || []) {
notices.appendChild(element("div", "lmrc-notice warning", warning));
}
}
function applyPayload(payload) {
loaded = payload;
const configured = payload.configured || {};
const effective = payload.effective || configured;
const overrides = payload.overrides || {};
remoteUrlInput.value = configured.remote_url || "";
timeoutInput.value = String(configured.timeout ?? 30);
remoteHint.textContent = overrides.remote_url
? `Managed by ${overrides.remote_url}. Effective: ${effective.remote_url}`
: "The base URL of the standalone LoRA Manager.";
timeoutHint.textContent = overrides.timeout
? `Managed by ${overrides.timeout}. Effective: ${effective.timeout} seconds.`
: "Used for Manager API and proxy requests.";
mappingRows.replaceChildren();
const rows = mappingsToRows(configured.path_mappings);
rows.forEach((row) => addMappingRow(row.remote, row.local));
renderNotices(payload);
setBusy(false);
}
async function load() {
setBusy(true, "Loading configuration…", { lockForm: true });
try {
const payload = await requestJson(CONFIG_ENDPOINT);
if (pageEffectiveRemoteUrl === undefined) {
pageEffectiveRemoteUrl = String(payload?.effective?.remote_url || "");
}
applyPayload(payload);
setStatus("");
(form.querySelector("input:not([disabled])") || panel).focus();
} catch (error) {
loaded = null;
setBusy(false);
setStatus(error.message || "Could not load LM Remote configuration.", "error");
}
}
function showFormError(error) {
setStatus(error.message, "error");
const target =
error.field === "remote_url"
? remoteUrlInput
: error.field === "timeout"
? timeoutInput
: null;
if (target) {
queueMicrotask(() => {
if (target.isConnected && !target.disabled) target.focus();
});
}
}
addMappingButton.addEventListener("click", () => {
addMappingRow().querySelector("input")?.focus();
});
reloadButton.addEventListener("click", () => {
if (!busy) load();
});
testButton.addEventListener("click", async () => {
if (busy || !loaded) return;
try {
const draft = buildConnectionDraft({
remoteUrl: remoteUrlInput.value,
timeout: timeoutInput.value,
effective: loaded.effective,
overrides: loaded.overrides,
});
setBusy(true, "Testing connection…", { lockForm: true });
const payload = await requestJson(TEST_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(draft),
});
setStatus(`Connected in ${payload.latency_ms} ms.`, "success");
} catch (error) {
showFormError(error);
} finally {
setBusy(false);
}
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (busy || !loaded || !isConfigWritable(loaded)) return;
try {
const config = buildConfigDraft({
remoteUrl: remoteUrlInput.value,
timeout: timeoutInput.value,
mappingRows: readMappingRows(),
});
setBusy(true, "Saving configuration…", {
lockForm: true,
lockDismiss: true,
});
const payload = await requestJson(CONFIG_ENDPOINT, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ revision: loaded.revision, config }),
});
applyPayload(payload);
if (
didEffectiveRemoteUrlChange(
{ effective: { remote_url: pageEffectiveRemoteUrl } },
payload
)
) {
setStatus(
"Saved. Reload the ComfyUI page to load Manager assets and reconnect live updates. Save workflow changes first.",
"success",
{
label: "Reload ComfyUI page",
onClick: () => window.location.reload(),
}
);
} else {
setStatus(
"Saved and applied to new requests. No ComfyUI restart required.",
"success"
);
}
onSaved?.(payload);
} catch (error) {
if (error.status === 409 && error.payload?.latest) {
setStatus(`${error.message} Use Reload saved to continue.`, "error");
} else {
showFormError(error);
}
} finally {
setBusy(false);
}
});
closeButton.addEventListener("click", close);
cancelButton.addEventListener("click", close);
overlay.addEventListener("mousedown", (event) => {
if (event.target === overlay) close();
});
overlay.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
close();
} else {
trapFocus(event, panel);
}
});
activeDialog = { overlay, panel, close };
panel.focus();
load();
return activeDialog;
}
-132
View File
@@ -1,132 +0,0 @@
export class RemoteConfigFormError extends Error {
constructor(field, message) {
super(message);
this.name = "RemoteConfigFormError";
this.field = field;
}
}
function normalizeTimeout(value) {
const text = String(value ?? "").trim();
if (!/^\d+$/.test(text)) {
throw new RemoteConfigFormError("timeout", "Timeout must be a whole number.");
}
const timeout = Number(text);
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 300) {
throw new RemoteConfigFormError(
"timeout",
"Timeout must be between 1 and 300 seconds."
);
}
return timeout;
}
function normalizeRemoteUrl(value, allowEmpty) {
const remoteUrl = String(value ?? "").trim();
if (!remoteUrl) {
if (allowEmpty) return "";
throw new RemoteConfigFormError(
"remote_url",
"Enter a remote LoRA Manager URL."
);
}
let parsed;
try {
parsed = new URL(remoteUrl);
} catch {
throw new RemoteConfigFormError("remote_url", "Enter a valid remote URL.");
}
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new RemoteConfigFormError(
"remote_url",
"Remote URL must use http:// or https://."
);
}
if (!parsed.hostname) {
throw new RemoteConfigFormError(
"remote_url",
"Remote URL must include a host name."
);
}
if (parsed.username || parsed.password) {
throw new RemoteConfigFormError(
"remote_url",
"Credentials are not allowed in the remote URL."
);
}
if (parsed.search || parsed.hash) {
throw new RemoteConfigFormError(
"remote_url",
"Remote URL cannot contain a query or fragment."
);
}
return remoteUrl.replace(/\/+$/, "");
}
function buildMappings(rows) {
const pathMappings = new Map();
for (const row of rows || []) {
const remote = String(row?.remote ?? "").trim();
const local = String(row?.local ?? "").trim();
if (!remote && !local) continue;
if (!remote || !local) {
throw new RemoteConfigFormError(
"path_mappings",
"Each path mapping needs both a remote and local path."
);
}
const normalizedRemote = remote.replace(/\\/g, "/").replace(/\/+$/, "") || "/";
if (pathMappings.has(normalizedRemote)) {
throw new RemoteConfigFormError(
"path_mappings",
`Duplicate remote path prefix: ${normalizedRemote}`
);
}
pathMappings.set(normalizedRemote, local);
}
return Object.fromEntries(pathMappings);
}
export function mappingsToRows(pathMappings) {
if (!pathMappings || typeof pathMappings !== "object") return [];
return Object.entries(pathMappings).map(([remote, local]) => ({
remote: String(remote),
local: String(local),
}));
}
export function buildConfigDraft({ remoteUrl, timeout, mappingRows }) {
return {
remote_url: normalizeRemoteUrl(remoteUrl, true),
timeout: normalizeTimeout(timeout),
path_mappings: buildMappings(mappingRows),
};
}
export function buildConnectionDraft({
remoteUrl,
timeout,
effective,
overrides,
}) {
return {
remote_url: normalizeRemoteUrl(
overrides?.remote_url ? effective?.remote_url : remoteUrl,
false
),
timeout: normalizeTimeout(
overrides?.timeout ? effective?.timeout : timeout
),
};
}
export function didEffectiveRemoteUrlChange(previousPayload, nextPayload) {
const previous = String(previousPayload?.effective?.remote_url || "");
const next = String(nextPayload?.effective?.remote_url || "");
return previous !== next;
}
export function isConfigWritable(payload) {
return payload?.storage?.writable !== false;
}