Compare commits
4
Commits
7886ae85d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6778110c50 | ||
|
|
932272806c | ||
|
|
5bb3b3a349 | ||
|
|
de13f8dd8d |
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
node_modules/
|
||||
config.json
|
||||
@@ -40,15 +40,13 @@ git clone https://github.com/ethanfel/ComfyUI-LM-Remote.git
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `config.json` in the package directory:
|
||||
Open the configuration panel from any of these places:
|
||||
|
||||
```json
|
||||
{
|
||||
"remote_url": "http://192.168.1.3:8188",
|
||||
"timeout": 30,
|
||||
"path_mappings": {}
|
||||
}
|
||||
```
|
||||
- Click the gear in the **LoRA Info** sidebar
|
||||
- Open **Settings**, select **LM Remote**, then click **Configure LM Remote**
|
||||
- Run **Configure LM Remote** from the command palette
|
||||
|
||||
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.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
@@ -56,14 +54,19 @@ Edit `config.json` in the package directory:
|
||||
| `timeout` | int | `30` | HTTP request timeout in seconds |
|
||||
| `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 variables take priority over `config.json`:
|
||||
Environment variables take priority over the stored configuration without rewriting it. Overridden fields are shown as managed in the panel.
|
||||
|
||||
| Variable | Overrides |
|
||||
|----------|-----------|
|
||||
| `LM_REMOTE_URL` | `remote_url` |
|
||||
| `LM_REMOTE_TIMEOUT` | `timeout` |
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `LM_REMOTE_URL` | Overrides `remote_url`; the field is shown as managed in the panel |
|
||||
| `LM_REMOTE_TIMEOUT` | Overrides `timeout`; the field is shown as managed in the panel |
|
||||
| `LM_REMOTE_CONFIG` | Uses an explicit configuration file instead of the ComfyUI user-data path |
|
||||
|
||||
### Path Mappings
|
||||
|
||||
@@ -95,6 +98,23 @@ 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 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
|
||||
|
||||
### Reverse Proxy
|
||||
@@ -105,7 +125,7 @@ An aiohttp middleware is registered at startup that intercepts requests to LoRA
|
||||
- `/api/lm/*` -- all REST API endpoints (except send_sync routes below)
|
||||
- `/extensions/ComfyUI-Lora-Manager/*` -- widget JS files and Vue widget bundle
|
||||
- `/loras_static/*`, `/locales/*`, `/example_images_static/*` -- static assets
|
||||
- `/loras`, `/checkpoints`, `/embeddings`, `/loras/recipes`, `/statistics` -- web UI pages
|
||||
- `/loras`, `/checkpoints`, `/embeddings`, `/loras/recipes`, `/community`, `/statistics` -- web UI pages
|
||||
- `/ws/fetch-progress`, `/ws/download-progress`, `/ws/init-progress` -- WebSocket connections
|
||||
|
||||
**Handled locally** (events broadcast to local browser via `send_sync`):
|
||||
@@ -132,12 +152,14 @@ After fetching the relative path from the remote metadata, LoRA files are loaded
|
||||
|
||||
After installation and configuration:
|
||||
|
||||
1. Restart ComfyUI
|
||||
2. Check logs for: `[LM-Remote] Proxy routes registered -> http://192.168.1.3:8188`
|
||||
3. Open ComfyUI -- the LoRA Manager web UI should load (proxied from remote)
|
||||
4. Add a "Lora Loader (Remote, LoraManager)" node to a workflow
|
||||
5. Select a LoRA -- trigger words should populate from remote metadata
|
||||
6. Run the workflow -- the LoRA loads from local shared storage
|
||||
1. Restart ComfyUI once after installing the custom node
|
||||
2. Open **Configure LM Remote**, enter the URL, and run **Test connection**
|
||||
3. Save the configuration; no ComfyUI process restart is required
|
||||
4. If prompted after a remote URL change, save workflow edits and reload the browser page
|
||||
5. Open the LoRA Manager web UI -- it should load through the remote proxy
|
||||
6. Add a stock or remote LoRA loader and click the node -- **LoRA Info** should open
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"remote_url": "",
|
||||
"timeout": 30,
|
||||
"path_mappings": {}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"remote_url": "http://192.168.1.3:8188",
|
||||
"timeout": 30,
|
||||
"path_mappings": {}
|
||||
}
|
||||
@@ -1,62 +1,535 @@
|
||||
"""Configuration for ComfyUI-LM-Remote."""
|
||||
"""Validated, reloadable configuration for ComfyUI-LM-Remote."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PACKAGE_DIR = Path(__file__).resolve().parent
|
||||
_CONFIG_FILE = _PACKAGE_DIR / "config.json"
|
||||
_LEGACY_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 RemoteConfig:
|
||||
"""Holds remote LoRA Manager connection settings."""
|
||||
class ConfigValidationError(ValueError):
|
||||
"""Raised when a proposed configuration value is invalid."""
|
||||
|
||||
def __init__(self):
|
||||
self.remote_url: str = ""
|
||||
self.timeout: int = 30
|
||||
self.path_mappings: dict[str, str] = {}
|
||||
self._load()
|
||||
def __init__(self, field: str, message: str):
|
||||
super().__init__(message)
|
||||
self.field = field
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _load(self):
|
||||
# Environment variable takes priority
|
||||
env_url = os.environ.get("LM_REMOTE_URL", "")
|
||||
env_timeout = os.environ.get("LM_REMOTE_TIMEOUT", "")
|
||||
|
||||
# Load config.json defaults
|
||||
if _CONFIG_FILE.exists():
|
||||
try:
|
||||
with open(_CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
self.remote_url = data.get("remote_url", "")
|
||||
self.timeout = int(data.get("timeout", 30))
|
||||
self.path_mappings = data.get("path_mappings", {})
|
||||
except Exception as exc:
|
||||
logger.warning("[LM-Remote] Failed to read config.json: %s", exc)
|
||||
class ConfigConflictError(RuntimeError):
|
||||
"""Raised when a browser attempts to replace a stale configuration."""
|
||||
|
||||
# Env overrides
|
||||
if env_url:
|
||||
self.remote_url = env_url
|
||||
if env_timeout:
|
||||
self.timeout = int(env_timeout)
|
||||
|
||||
# Strip trailing slash
|
||||
self.remote_url = self.remote_url.rstrip("/")
|
||||
@dataclass(frozen=True)
|
||||
class ConfigSnapshot:
|
||||
"""One coherent set of effective runtime values."""
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.remote_url)
|
||||
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:
|
||||
"""Apply remote->local path prefix mappings."""
|
||||
for remote_prefix, local_prefix in self.path_mappings.items():
|
||||
if remote_path.startswith(remote_prefix):
|
||||
return local_prefix + remote_path[len(remote_prefix):]
|
||||
"""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:
|
||||
"""Thread-safe configuration with legacy fallback and atomic persistence."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_file: str | Path | None = None,
|
||||
legacy_config_file: str | Path | None = None,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
) -> None:
|
||||
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():
|
||||
path = self._config_file
|
||||
source = "explicit" if self._explicit_target else "user"
|
||||
elif self._explicit_target:
|
||||
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:
|
||||
data = json.load(handle)
|
||||
if not isinstance(data, dict):
|
||||
raise ConfigValidationError(
|
||||
"config", "Configuration file must contain an object."
|
||||
)
|
||||
return data, source
|
||||
|
||||
def _source_location(self) -> tuple[Path | None, str]:
|
||||
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:
|
||||
try:
|
||||
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:
|
||||
try:
|
||||
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
|
||||
|
||||
def reload(self) -> ConfigSnapshot:
|
||||
"""Reload persisted and environment-managed values without partial mutation."""
|
||||
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
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.snapshot.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:
|
||||
"""Apply the longest boundary-aware remote-to-local path mapping."""
|
||||
return self.snapshot.map_path(remote_path)
|
||||
|
||||
|
||||
remote_config = RemoteConfig()
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "comfyui-lm-remote",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test tests/frontend/*.test.js"
|
||||
}
|
||||
}
|
||||
@@ -8,19 +8,37 @@ Docker instance. Non-matching requests fall through to the regular ComfyUI rout
|
||||
Routes that use ``send_sync`` are handled locally so that events are broadcast
|
||||
to the local ComfyUI frontend (the remote instance has no connected browsers).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web, WSMsgType
|
||||
|
||||
from .config import remote_config
|
||||
from .config import (
|
||||
ConfigConflictError,
|
||||
ConfigSnapshot,
|
||||
ConfigValidationError,
|
||||
remote_config,
|
||||
)
|
||||
from .remote_client import RemoteLoraClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CONFIG_ROUTE = "/api/lm-remote/config"
|
||||
_TEST_CONNECTION_ROUTE = "/api/lm-remote/test-connection"
|
||||
_PROXY_HOP_HEADER = "X-LM-Remote-Proxy"
|
||||
_MAX_CONFIG_BODY = 64 * 1024
|
||||
_MAX_TEST_RESPONSE = 64 * 1024
|
||||
_MEDIA_STREAM_CHUNK_SIZE = 64 * 1024
|
||||
_MEDIA_IDLE_TIMEOUT = 300
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# URL prefixes that should be forwarded to the remote LoRA Manager
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -38,6 +56,7 @@ _PROXY_PAGE_ROUTES = {
|
||||
"/checkpoints",
|
||||
"/embeddings",
|
||||
"/loras/recipes",
|
||||
"/community",
|
||||
"/statistics",
|
||||
}
|
||||
|
||||
@@ -58,6 +77,7 @@ _WS_ROUTES = {
|
||||
def _get_prompt_server():
|
||||
"""Lazily import PromptServer to avoid circular imports at module level."""
|
||||
from server import PromptServer # type: ignore
|
||||
|
||||
return PromptServer.instance
|
||||
|
||||
|
||||
@@ -109,9 +129,7 @@ async def _handle_get_trigger_words(request: web.Request) -> web.Response:
|
||||
return web.json_response({"success": True})
|
||||
except Exception as exc:
|
||||
logger.error("[LM-Remote] Error getting trigger words: %s", exc)
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(exc)}, status=500
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
async def _handle_update_lora_code(request: web.Request) -> web.Response:
|
||||
@@ -178,17 +196,413 @@ _SEND_SYNC_HANDLERS = {
|
||||
"/api/lm/register-nodes": _handle_register_nodes,
|
||||
}
|
||||
|
||||
# Shared HTTP session for proxied requests (connection pooling)
|
||||
_proxy_session: aiohttp.ClientSession | None = None
|
||||
|
||||
def _config_response() -> web.Response:
|
||||
return web.json_response({"success": True, **remote_config.as_dict()})
|
||||
|
||||
|
||||
async def _get_proxy_session() -> aiohttp.ClientSession:
|
||||
"""Return a shared aiohttp session for HTTP proxy requests."""
|
||||
global _proxy_session
|
||||
if _proxy_session is None or _proxy_session.closed:
|
||||
timeout = aiohttp.ClientTimeout(total=remote_config.timeout)
|
||||
_proxy_session = aiohttp.ClientSession(timeout=timeout)
|
||||
return _proxy_session
|
||||
class _RequestBodyTooLarge(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
async def _read_limited_json(request: web.Request) -> object:
|
||||
"""Read JSON without ever buffering more than the configuration limit."""
|
||||
if request.content_length is not None and request.content_length > _MAX_CONFIG_BODY:
|
||||
raise _RequestBodyTooLarge
|
||||
|
||||
body = bytearray()
|
||||
while True:
|
||||
remaining = _MAX_CONFIG_BODY + 1 - len(body)
|
||||
chunk = await request.content.read(min(8192, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
body.extend(chunk)
|
||||
if len(body) > _MAX_CONFIG_BODY:
|
||||
raise _RequestBodyTooLarge
|
||||
return json.loads(body)
|
||||
|
||||
|
||||
async def _activate_runtime_generation() -> None:
|
||||
"""Invalidate and rotate all resources tied to the previous snapshot."""
|
||||
RemoteLoraClient.get_instance().invalidate_caches()
|
||||
await _retire_proxy_sessions(remote_config.generation)
|
||||
await _rotate_active_websockets(remote_config.generation)
|
||||
|
||||
|
||||
async def _handle_config(request: web.Request) -> web.Response:
|
||||
"""Read or atomically replace LM Remote's server-side configuration."""
|
||||
if request.method == "GET":
|
||||
return _config_response()
|
||||
if request.method != "PUT":
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Method not allowed."},
|
||||
status=405,
|
||||
headers={"Allow": "GET, PUT"},
|
||||
)
|
||||
if request.content_type != "application/json":
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Content-Type must be application/json."},
|
||||
status=415,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = await _read_limited_json(request)
|
||||
except (_RequestBodyTooLarge, web.HTTPRequestEntityTooLarge):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Configuration request is too large."},
|
||||
status=413,
|
||||
)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Request body is not valid JSON."}, status=400
|
||||
)
|
||||
if not isinstance(payload, dict):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Request body must be an object."}, status=400
|
||||
)
|
||||
unknown = set(payload) - {"revision", "config"}
|
||||
if unknown:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Unknown request field(s): {', '.join(sorted(unknown))}",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
revision = payload.get("revision")
|
||||
proposed = payload.get("config")
|
||||
if not isinstance(revision, str) or not revision:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"field": "revision",
|
||||
"error": "Reload configuration before saving.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
if not isinstance(proposed, dict):
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"field": "config",
|
||||
"error": "Configuration must be an object.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
current = remote_config.as_dict()
|
||||
configured = current["configured"]
|
||||
overrides = current["overrides"]
|
||||
for field in ("remote_url", "timeout"):
|
||||
if overrides.get(field) and proposed.get(field) != configured.get(field):
|
||||
variable = overrides[field]
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"field": field,
|
||||
"error": f"{field.replace('_', ' ').title()} is managed by {variable}.",
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
|
||||
try:
|
||||
remote_config.save(proposed, expected_revision=revision)
|
||||
await _activate_runtime_generation()
|
||||
except ConfigValidationError as exc:
|
||||
return web.json_response(
|
||||
{"success": False, "field": exc.field, "error": str(exc)}, status=400
|
||||
)
|
||||
except ConfigConflictError as exc:
|
||||
remote_config.reload()
|
||||
await _activate_runtime_generation()
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(exc), "latest": remote_config.as_dict()},
|
||||
status=409,
|
||||
)
|
||||
except OSError:
|
||||
logger.exception("[LM-Remote] Failed to persist configuration")
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Could not save LM Remote configuration."},
|
||||
status=500,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[LM-Remote] Configuration applied without restart (generation %s)",
|
||||
remote_config.generation,
|
||||
)
|
||||
return _config_response()
|
||||
|
||||
|
||||
class _ConnectionTestError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
async def _read_small_json(response: aiohttp.ClientResponse) -> object:
|
||||
body = bytearray()
|
||||
while True:
|
||||
remaining = _MAX_TEST_RESPONSE + 1 - len(body)
|
||||
chunk = await response.content.read(min(8192, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
body.extend(chunk)
|
||||
if len(body) > _MAX_TEST_RESPONSE:
|
||||
raise _ConnectionTestError("Remote response was unexpectedly large.")
|
||||
try:
|
||||
return json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise _ConnectionTestError("Remote response was not valid JSON.") from exc
|
||||
|
||||
|
||||
async def _perform_connection_test(remote_url: str, timeout_seconds: int) -> int:
|
||||
"""Probe a fixed Manager endpoint without changing the active configuration."""
|
||||
started = time.monotonic()
|
||||
bounded_timeout = min(timeout_seconds, 30)
|
||||
timeout = aiohttp.ClientTimeout(
|
||||
total=bounded_timeout,
|
||||
connect=min(bounded_timeout, 10),
|
||||
)
|
||||
headers = {"Accept": "application/json", _PROXY_HOP_HEADER: "probe"}
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
health_url = f"{remote_url}/api/lm/health-check"
|
||||
async with session.get(
|
||||
health_url, headers=headers, allow_redirects=False
|
||||
) as response:
|
||||
if response.status == 404:
|
||||
payload = None
|
||||
elif not 200 <= response.status < 300:
|
||||
raise _ConnectionTestError(
|
||||
f"Remote LoRA Manager returned HTTP {response.status}."
|
||||
)
|
||||
else:
|
||||
payload = await _read_small_json(response)
|
||||
if not isinstance(payload, dict) or payload.get("status") != "ok":
|
||||
raise _ConnectionTestError(
|
||||
"The server answered, but it is not a compatible LoRA Manager."
|
||||
)
|
||||
|
||||
# Older Manager releases may not expose health-check.
|
||||
if payload is None:
|
||||
list_url = f"{remote_url}/api/lm/loras/list?page=1&page_size=1"
|
||||
async with session.get(
|
||||
list_url, headers=headers, allow_redirects=False
|
||||
) as response:
|
||||
if not 200 <= response.status < 300:
|
||||
raise _ConnectionTestError(
|
||||
f"Remote LoRA Manager returned HTTP {response.status}."
|
||||
)
|
||||
list_payload = await _read_small_json(response)
|
||||
if not isinstance(list_payload, dict) or "items" not in list_payload:
|
||||
raise _ConnectionTestError(
|
||||
"The server answered, but it is not a compatible LoRA Manager."
|
||||
)
|
||||
|
||||
return max(1, round((time.monotonic() - started) * 1000))
|
||||
|
||||
|
||||
async def _handle_test_connection(request: web.Request) -> web.Response:
|
||||
if request.method != "POST":
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Method not allowed."},
|
||||
status=405,
|
||||
headers={"Allow": "POST"},
|
||||
)
|
||||
if request.content_type != "application/json":
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Content-Type must be application/json."},
|
||||
status=415,
|
||||
)
|
||||
try:
|
||||
payload = await _read_limited_json(request)
|
||||
if not isinstance(payload, dict):
|
||||
raise ConfigValidationError("config", "Request body must be an object.")
|
||||
unknown = set(payload) - {"remote_url", "timeout"}
|
||||
if unknown:
|
||||
raise ConfigValidationError(
|
||||
"config", f"Unknown request field(s): {', '.join(sorted(unknown))}"
|
||||
)
|
||||
from .config import _normalize_timeout, _normalize_url
|
||||
|
||||
remote_url = _normalize_url(payload.get("remote_url", ""), allow_empty=False)
|
||||
timeout_seconds = _normalize_timeout(payload.get("timeout", 30))
|
||||
latency_ms = await _perform_connection_test(remote_url, timeout_seconds)
|
||||
except (_RequestBodyTooLarge, web.HTTPRequestEntityTooLarge):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Connection test request is too large."},
|
||||
status=413,
|
||||
)
|
||||
except ConfigValidationError as exc:
|
||||
return web.json_response(
|
||||
{"success": False, "field": exc.field, "error": str(exc)}, status=400
|
||||
)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Request body is not valid JSON."}, status=400
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Connection test timed out."}, status=504
|
||||
)
|
||||
except aiohttp.ClientError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Could not reach the remote LoRA Manager."},
|
||||
status=502,
|
||||
)
|
||||
except _ConnectionTestError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=502)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Connected to LoRA Manager.",
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Generation-scoped HTTP sessions allow an old request to finish while a newly
|
||||
# saved configuration starts using its own connection pool immediately.
|
||||
@dataclass(eq=False)
|
||||
class _ProxySessionState:
|
||||
session: aiohttp.ClientSession
|
||||
active_requests: int = 0
|
||||
retired: bool = False
|
||||
|
||||
|
||||
_proxy_sessions: dict[int, _ProxySessionState] = {}
|
||||
_proxy_session_lock: asyncio.Lock | None = None
|
||||
|
||||
|
||||
def _get_proxy_session_lock() -> asyncio.Lock:
|
||||
global _proxy_session_lock
|
||||
if _proxy_session_lock is None:
|
||||
_proxy_session_lock = asyncio.Lock()
|
||||
return _proxy_session_lock
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _proxy_session_lease(snapshot: ConfigSnapshot):
|
||||
"""Lease the pool for one generation without closing active requests."""
|
||||
async with _get_proxy_session_lock():
|
||||
state = _proxy_sessions.get(snapshot.generation)
|
||||
if state is None or state.session.closed:
|
||||
if state is not None:
|
||||
state.retired = True
|
||||
state = _ProxySessionState(
|
||||
session=aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=snapshot.timeout),
|
||||
cookie_jar=aiohttp.DummyCookieJar(),
|
||||
),
|
||||
retired=snapshot.generation != remote_config.generation,
|
||||
)
|
||||
_proxy_sessions[snapshot.generation] = state
|
||||
state.active_requests += 1
|
||||
|
||||
try:
|
||||
yield state.session
|
||||
finally:
|
||||
close_session: aiohttp.ClientSession | None = None
|
||||
async with _get_proxy_session_lock():
|
||||
state.active_requests -= 1
|
||||
if state.retired and state.active_requests == 0:
|
||||
if _proxy_sessions.get(snapshot.generation) is state:
|
||||
_proxy_sessions.pop(snapshot.generation, None)
|
||||
close_session = state.session
|
||||
if close_session is not None and not close_session.closed:
|
||||
await close_session.close()
|
||||
|
||||
|
||||
async def _retire_proxy_sessions(active_generation: int) -> None:
|
||||
"""Retire old pools and close only those with no requests in flight."""
|
||||
close_sessions: list[aiohttp.ClientSession] = []
|
||||
async with _get_proxy_session_lock():
|
||||
for generation, state in list(_proxy_sessions.items()):
|
||||
if generation == active_generation:
|
||||
continue
|
||||
state.retired = True
|
||||
if state.active_requests == 0:
|
||||
_proxy_sessions.pop(generation, None)
|
||||
close_sessions.append(state.session)
|
||||
for session in close_sessions:
|
||||
if not session.closed:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def _close_all_proxy_sessions() -> None:
|
||||
"""Force-close every pool during application shutdown."""
|
||||
async with _get_proxy_session_lock():
|
||||
sessions = [state.session for state in _proxy_sessions.values()]
|
||||
_proxy_sessions.clear()
|
||||
for session in sessions:
|
||||
if not session.closed:
|
||||
await session.close()
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class _ActiveWebSocket:
|
||||
generation: int
|
||||
local_ws: web.WebSocketResponse
|
||||
session: aiohttp.ClientSession
|
||||
remote_ws: aiohttp.ClientWebSocketResponse | None = None
|
||||
retired: bool = False
|
||||
|
||||
|
||||
_active_proxy_websockets: set[_ActiveWebSocket] = set()
|
||||
_active_proxy_websockets_lock: asyncio.Lock | None = None
|
||||
|
||||
|
||||
def _get_active_websockets_lock() -> asyncio.Lock:
|
||||
global _active_proxy_websockets_lock
|
||||
if _active_proxy_websockets_lock is None:
|
||||
_active_proxy_websockets_lock = asyncio.Lock()
|
||||
return _active_proxy_websockets_lock
|
||||
|
||||
|
||||
async def _register_active_websocket(bridge: _ActiveWebSocket) -> bool:
|
||||
async with _get_active_websockets_lock():
|
||||
if bridge.generation != remote_config.generation:
|
||||
bridge.retired = True
|
||||
return False
|
||||
_active_proxy_websockets.add(bridge)
|
||||
return True
|
||||
|
||||
|
||||
async def _unregister_active_websocket(bridge: _ActiveWebSocket) -> None:
|
||||
async with _get_active_websockets_lock():
|
||||
_active_proxy_websockets.discard(bridge)
|
||||
|
||||
|
||||
async def _close_websocket_bridge(bridge: _ActiveWebSocket) -> None:
|
||||
bridge.retired = True
|
||||
try:
|
||||
if bridge.remote_ws is not None and not bridge.remote_ws.closed:
|
||||
await bridge.remote_ws.close(
|
||||
code=1012, message=b"LM Remote configuration changed"
|
||||
)
|
||||
if not bridge.local_ws.closed:
|
||||
await bridge.local_ws.close(
|
||||
code=1012, message=b"LM Remote configuration changed"
|
||||
)
|
||||
finally:
|
||||
if not bridge.session.closed:
|
||||
await bridge.session.close()
|
||||
|
||||
|
||||
async def _rotate_active_websockets(active_generation: int | None) -> None:
|
||||
"""Close bridges for retired generations so browsers reconnect."""
|
||||
async with _get_active_websockets_lock():
|
||||
bridges = [
|
||||
bridge
|
||||
for bridge in _active_proxy_websockets
|
||||
if active_generation is None or bridge.generation != active_generation
|
||||
]
|
||||
for bridge in bridges:
|
||||
_active_proxy_websockets.discard(bridge)
|
||||
for bridge in bridges:
|
||||
try:
|
||||
await _close_websocket_bridge(bridge)
|
||||
except Exception:
|
||||
logger.exception("[LM-Remote] Failed to rotate a proxied WebSocket")
|
||||
|
||||
|
||||
def _should_proxy(path: str) -> bool:
|
||||
@@ -204,9 +618,29 @@ def _is_ws_route(path: str) -> bool:
|
||||
return path in _WS_ROUTES
|
||||
|
||||
|
||||
async def _proxy_ws(request: web.Request) -> web.WebSocketResponse:
|
||||
def _is_streaming_media_route(path: str) -> bool:
|
||||
"""Return whether a proxied response must be relayed incrementally."""
|
||||
return path.startswith("/api/lm/previews") or path.startswith(
|
||||
"/example_images_static/"
|
||||
)
|
||||
|
||||
|
||||
def _media_request_timeout(snapshot: ConfigSnapshot) -> aiohttp.ClientTimeout:
|
||||
"""Allow long playback while still bounding connect and idle stalls."""
|
||||
return aiohttp.ClientTimeout(
|
||||
total=None,
|
||||
sock_connect=min(snapshot.timeout, 30),
|
||||
sock_read=max(snapshot.timeout, _MEDIA_IDLE_TIMEOUT),
|
||||
)
|
||||
|
||||
|
||||
async def _proxy_ws(
|
||||
request: web.Request, snapshot: ConfigSnapshot
|
||||
) -> web.WebSocketResponse:
|
||||
"""Proxy a WebSocket connection to the remote LoRA Manager."""
|
||||
remote_url = remote_config.remote_url.replace("http://", "ws://").replace("https://", "wss://")
|
||||
remote_url = snapshot.remote_url.replace("http://", "ws://", 1).replace(
|
||||
"https://", "wss://", 1
|
||||
)
|
||||
remote_ws_url = f"{remote_url}{request.path}"
|
||||
if request.query_string:
|
||||
remote_ws_url += f"?{request.query_string}"
|
||||
@@ -214,10 +648,21 @@ async def _proxy_ws(request: web.Request) -> web.WebSocketResponse:
|
||||
local_ws = web.WebSocketResponse()
|
||||
await local_ws.prepare(request)
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=None)
|
||||
timeout = aiohttp.ClientTimeout(
|
||||
total=None,
|
||||
sock_connect=min(snapshot.timeout, 30),
|
||||
)
|
||||
session = aiohttp.ClientSession(timeout=timeout)
|
||||
bridge = _ActiveWebSocket(snapshot.generation, local_ws, session)
|
||||
if not await _register_active_websocket(bridge):
|
||||
await _close_websocket_bridge(bridge)
|
||||
return local_ws
|
||||
|
||||
try:
|
||||
async with session.ws_connect(remote_ws_url) as remote_ws:
|
||||
async with session.ws_connect(
|
||||
remote_ws_url, headers={_PROXY_HOP_HEADER: "1"}
|
||||
) as remote_ws:
|
||||
bridge.remote_ws = remote_ws
|
||||
|
||||
async def forward_local_to_remote():
|
||||
async for msg in local_ws:
|
||||
@@ -225,7 +670,11 @@ async def _proxy_ws(request: web.Request) -> web.WebSocketResponse:
|
||||
await remote_ws.send_str(msg.data)
|
||||
elif msg.type == WSMsgType.BINARY:
|
||||
await remote_ws.send_bytes(msg.data)
|
||||
elif msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED):
|
||||
elif msg.type in (
|
||||
WSMsgType.CLOSE,
|
||||
WSMsgType.CLOSING,
|
||||
WSMsgType.CLOSED,
|
||||
):
|
||||
return
|
||||
|
||||
async def forward_remote_to_local():
|
||||
@@ -234,7 +683,11 @@ async def _proxy_ws(request: web.Request) -> web.WebSocketResponse:
|
||||
await local_ws.send_str(msg.data)
|
||||
elif msg.type == WSMsgType.BINARY:
|
||||
await local_ws.send_bytes(msg.data)
|
||||
elif msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED):
|
||||
elif msg.type in (
|
||||
WSMsgType.CLOSE,
|
||||
WSMsgType.CLOSING,
|
||||
WSMsgType.CLOSED,
|
||||
):
|
||||
return
|
||||
|
||||
# Run both directions concurrently. When either side closes,
|
||||
@@ -259,16 +712,24 @@ async def _proxy_ws(request: web.Request) -> web.WebSocketResponse:
|
||||
await local_ws.close()
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("[LM-Remote] WebSocket proxy error for %s: %s", request.path, exc)
|
||||
if not bridge.retired:
|
||||
logger.warning(
|
||||
"[LM-Remote] WebSocket proxy error for %s: %s", request.path, exc
|
||||
)
|
||||
finally:
|
||||
await _unregister_active_websocket(bridge)
|
||||
if not local_ws.closed:
|
||||
await local_ws.close()
|
||||
await session.close()
|
||||
|
||||
return local_ws
|
||||
|
||||
|
||||
async def _proxy_http(request: web.Request) -> web.Response:
|
||||
async def _proxy_http(
|
||||
request: web.Request, snapshot: ConfigSnapshot
|
||||
) -> web.StreamResponse:
|
||||
"""Forward an HTTP request to the remote LoRA Manager and return its response."""
|
||||
remote_url = f"{remote_config.remote_url}{request.path}"
|
||||
remote_url = f"{snapshot.remote_url}{request.path}"
|
||||
if request.query_string:
|
||||
remote_url += f"?{request.query_string}"
|
||||
|
||||
@@ -277,33 +738,94 @@ async def _proxy_http(request: web.Request) -> web.Response:
|
||||
|
||||
# Filter hop-by-hop headers
|
||||
headers = {}
|
||||
skip = {"host", "transfer-encoding", "connection", "keep-alive", "upgrade"}
|
||||
skip = {
|
||||
"host",
|
||||
"transfer-encoding",
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"upgrade",
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
"cookie",
|
||||
"origin",
|
||||
"referer",
|
||||
}
|
||||
for k, v in request.headers.items():
|
||||
if k.lower() not in skip:
|
||||
headers[k] = v
|
||||
headers[_PROXY_HOP_HEADER] = "1"
|
||||
|
||||
session = await _get_proxy_session()
|
||||
streaming_media = _is_streaming_media_route(request.path)
|
||||
downstream: web.StreamResponse | None = None
|
||||
try:
|
||||
async with session.request(
|
||||
method=request.method,
|
||||
url=remote_url,
|
||||
headers=headers,
|
||||
data=body,
|
||||
) as resp:
|
||||
resp_body = await resp.read()
|
||||
resp_headers = {}
|
||||
for k, v in resp.headers.items():
|
||||
if k.lower() not in ("transfer-encoding", "content-encoding", "content-length"):
|
||||
resp_headers[k] = v
|
||||
return web.Response(
|
||||
status=resp.status,
|
||||
body=resp_body,
|
||||
headers=resp_headers,
|
||||
)
|
||||
async with _proxy_session_lease(snapshot) as session:
|
||||
request_options = {
|
||||
"method": request.method,
|
||||
"url": remote_url,
|
||||
"headers": headers,
|
||||
"data": body,
|
||||
}
|
||||
if streaming_media:
|
||||
request_options.update(
|
||||
{
|
||||
"timeout": _media_request_timeout(snapshot),
|
||||
"auto_decompress": False,
|
||||
}
|
||||
)
|
||||
async with session.request(
|
||||
**request_options,
|
||||
) as resp:
|
||||
if streaming_media:
|
||||
resp_headers = {}
|
||||
for k, v in resp.headers.items():
|
||||
if k.lower() not in (
|
||||
"transfer-encoding",
|
||||
"connection",
|
||||
"set-cookie",
|
||||
):
|
||||
resp_headers[k] = v
|
||||
downstream = web.StreamResponse(
|
||||
status=resp.status,
|
||||
headers=resp_headers,
|
||||
)
|
||||
await downstream.prepare(request)
|
||||
if request.method != "HEAD":
|
||||
async for chunk in resp.content.iter_chunked(
|
||||
_MEDIA_STREAM_CHUNK_SIZE
|
||||
):
|
||||
await downstream.write(chunk)
|
||||
await downstream.write_eof()
|
||||
return downstream
|
||||
|
||||
resp_body = await resp.read()
|
||||
resp_headers = {}
|
||||
for k, v in resp.headers.items():
|
||||
if k.lower() not in (
|
||||
"transfer-encoding",
|
||||
"content-encoding",
|
||||
"content-length",
|
||||
"set-cookie",
|
||||
):
|
||||
resp_headers[k] = v
|
||||
return web.Response(
|
||||
status=resp.status,
|
||||
body=resp_body,
|
||||
headers=resp_headers,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("[LM-Remote] Proxy error for %s %s: %s", request.method, request.path, exc)
|
||||
if downstream is not None and downstream.prepared:
|
||||
logger.debug(
|
||||
"[LM-Remote] Media stream ended for %s %s: %s",
|
||||
request.method,
|
||||
request.path,
|
||||
exc,
|
||||
)
|
||||
return downstream
|
||||
logger.error(
|
||||
"[LM-Remote] Proxy error for %s %s: %s", request.method, request.path, exc
|
||||
)
|
||||
return web.json_response(
|
||||
{"error": f"Remote LoRA Manager unavailable: {exc}"},
|
||||
{"error": "Remote LoRA Manager unavailable."},
|
||||
status=502,
|
||||
)
|
||||
|
||||
@@ -312,13 +834,28 @@ async def _proxy_http(request: web.Request) -> web.Response:
|
||||
# Middleware factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def lm_remote_proxy_middleware(request: web.Request, handler):
|
||||
"""aiohttp middleware that intercepts LoRA Manager requests."""
|
||||
if not remote_config.is_configured:
|
||||
path = request.path
|
||||
|
||||
# Configuration remains local and available even before a remote is set.
|
||||
if path == _CONFIG_ROUTE:
|
||||
return await _handle_config(request)
|
||||
if path == _TEST_CONNECTION_ROUTE:
|
||||
return await _handle_test_connection(request)
|
||||
|
||||
snapshot = remote_config.snapshot
|
||||
if not snapshot.remote_url:
|
||||
return await handler(request)
|
||||
|
||||
path = request.path
|
||||
if request.headers.get(_PROXY_HOP_HEADER) and (
|
||||
_should_proxy(path) or _is_ws_route(path)
|
||||
):
|
||||
return web.json_response(
|
||||
{"error": "LM Remote proxy loop detected."}, status=508
|
||||
)
|
||||
|
||||
# Routes that need send_sync are handled locally so events reach
|
||||
# the local browser (the remote instance has no connected browsers).
|
||||
@@ -328,31 +865,32 @@ async def lm_remote_proxy_middleware(request: web.Request, handler):
|
||||
|
||||
# WebSocket routes
|
||||
if _is_ws_route(path):
|
||||
return await _proxy_ws(request)
|
||||
return await _proxy_ws(request, snapshot)
|
||||
|
||||
# Regular proxy routes
|
||||
if _should_proxy(path):
|
||||
return await _proxy_http(request)
|
||||
return await _proxy_http(request, snapshot)
|
||||
|
||||
# Not a LoRA Manager route — fall through
|
||||
return await handler(request)
|
||||
|
||||
|
||||
async def _cleanup_proxy_session(app) -> None:
|
||||
"""Shutdown hook to close the shared proxy session."""
|
||||
global _proxy_session
|
||||
if _proxy_session and not _proxy_session.closed:
|
||||
await _proxy_session.close()
|
||||
_proxy_session = None
|
||||
"""Shutdown hook to close every HTTP pool and WebSocket bridge."""
|
||||
await _rotate_active_websockets(None)
|
||||
await _close_all_proxy_sessions()
|
||||
await RemoteLoraClient.get_instance().close()
|
||||
|
||||
|
||||
def register_proxy(app) -> None:
|
||||
"""Insert the proxy middleware into the aiohttp app."""
|
||||
if not remote_config.is_configured:
|
||||
logger.warning("[LM-Remote] No remote_url configured — proxy disabled")
|
||||
return
|
||||
|
||||
# Insert at position 0 so we run before the original LoRA Manager routes
|
||||
app.middlewares.insert(0, lm_remote_proxy_middleware)
|
||||
app.on_shutdown.append(_cleanup_proxy_session)
|
||||
logger.info("[LM-Remote] Proxy routes registered -> %s", remote_config.remote_url)
|
||||
"""Append the proxy after ComfyUI's origin and security guards."""
|
||||
if lm_remote_proxy_middleware not in app.middlewares:
|
||||
app.middlewares.append(lm_remote_proxy_middleware)
|
||||
if _cleanup_proxy_session not in app.on_shutdown:
|
||||
app.on_shutdown.append(_cleanup_proxy_session)
|
||||
if remote_config.is_configured:
|
||||
logger.info(
|
||||
"[LM-Remote] Proxy routes registered -> %s", remote_config.remote_url
|
||||
)
|
||||
else:
|
||||
logger.info("[LM-Remote] Configuration API ready; remote URL is not set yet")
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
addopts = --import-mode=importlib --confcutdir=tests
|
||||
testpaths = tests
|
||||
+201
-69
@@ -1,26 +1,35 @@
|
||||
"""HTTP client for the remote LoRA Manager instance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .config import remote_config
|
||||
from .config import ConfigSnapshot, remote_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache TTL in seconds — how long before we re-fetch the full LoRA list
|
||||
_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:
|
||||
"""Singleton HTTP client that talks to the remote LoRA Manager.
|
||||
|
||||
Uses the actual LoRA Manager REST API endpoints:
|
||||
- ``GET /api/lm/loras/list?page_size=9999`` — paginated LoRA list
|
||||
- ``GET /api/lm/loras/list?page=N&page_size=100`` — paginated LoRA list
|
||||
- ``GET /api/lm/loras/get-trigger-words?name=X`` — trigger words
|
||||
- ``POST /api/lm/loras/random-sample`` — random LoRA selection
|
||||
- ``POST /api/lm/loras/cycler-list`` — sorted LoRA list for cycler
|
||||
@@ -30,13 +39,15 @@ class RemoteLoraClient:
|
||||
"""
|
||||
|
||||
_instance: RemoteLoraClient | None = None
|
||||
_session: aiohttp.ClientSession | None = None
|
||||
|
||||
def __init__(self):
|
||||
self._lora_cache: list[dict] = []
|
||||
self._lora_cache_ts: float = 0
|
||||
self._lora_cache_generation: int = -1
|
||||
self._checkpoint_cache: list[dict] = []
|
||||
self._checkpoint_cache_ts: float = 0
|
||||
self._checkpoint_cache_generation: int = -1
|
||||
self._cache_lock = threading.RLock()
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> RemoteLoraClient:
|
||||
@@ -44,71 +55,176 @@ class RemoteLoraClient:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
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
|
||||
|
||||
async def close(self):
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
"""Compatibility hook; requests use loop-safe, short-lived sessions."""
|
||||
|
||||
def invalidate_caches(self) -> None:
|
||||
"""Forget results associated with a previous remote configuration."""
|
||||
with self._cache_lock:
|
||||
self._lora_cache = []
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _get_json(self, path: str, params: dict | None = None) -> Any:
|
||||
url = f"{remote_config.remote_url}{path}"
|
||||
session = await self._get_session()
|
||||
async with session.get(url, params=params) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
async def _get_json(
|
||||
self,
|
||||
path: str,
|
||||
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:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
|
||||
async def _post_json(self, path: str, json_body: dict | None = None) -> Any:
|
||||
url = f"{remote_config.remote_url}{path}"
|
||||
session = await self._get_session()
|
||||
async with session.post(url, json=json_body) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
async def _post_json(
|
||||
self,
|
||||
path: str,
|
||||
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:
|
||||
resp.raise_for_status()
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _get_lora_list_cached(self) -> list[dict]:
|
||||
async def _get_lora_list_cached(
|
||||
self, *, snapshot: ConfigSnapshot | None = None
|
||||
) -> list[dict]:
|
||||
"""Return the full LoRA list, using a short-lived cache."""
|
||||
now = time.monotonic()
|
||||
if self._lora_cache and (now - self._lora_cache_ts) < _CACHE_TTL:
|
||||
return self._lora_cache
|
||||
snapshot = snapshot or remote_config.snapshot
|
||||
with self._cache_lock:
|
||||
if (
|
||||
self._lora_cache_generation == snapshot.generation
|
||||
and (now - self._lora_cache_ts) < _CACHE_TTL
|
||||
):
|
||||
return list(self._lora_cache)
|
||||
|
||||
try:
|
||||
data = await self._get_json(
|
||||
"/api/lm/loras/list", params={"page_size": "9999"}
|
||||
)
|
||||
self._lora_cache = data.get("items", [])
|
||||
self._lora_cache_ts = now
|
||||
items = await self._get_all_pages("/api/lm/loras/list", snapshot=snapshot)
|
||||
if remote_config.generation == snapshot.generation:
|
||||
with self._cache_lock:
|
||||
self._lora_cache = list(items)
|
||||
self._lora_cache_ts = now
|
||||
self._lora_cache_generation = snapshot.generation
|
||||
return list(items)
|
||||
except _ConfigurationChanged:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.warning("[LM-Remote] Failed to fetch LoRA list: %s", exc)
|
||||
# Return stale cache on error, or empty list
|
||||
return self._lora_cache
|
||||
with self._cache_lock:
|
||||
if self._lora_cache_generation == snapshot.generation:
|
||||
return list(self._lora_cache)
|
||||
return []
|
||||
|
||||
async def _get_checkpoint_list_cached(self) -> list[dict]:
|
||||
async def _get_checkpoint_list_cached(
|
||||
self, *, snapshot: ConfigSnapshot | None = None
|
||||
) -> list[dict]:
|
||||
"""Return the full checkpoint list, using a short-lived cache."""
|
||||
now = time.monotonic()
|
||||
if self._checkpoint_cache and (now - self._checkpoint_cache_ts) < _CACHE_TTL:
|
||||
return self._checkpoint_cache
|
||||
snapshot = snapshot or remote_config.snapshot
|
||||
with self._cache_lock:
|
||||
if (
|
||||
self._checkpoint_cache_generation == snapshot.generation
|
||||
and (now - self._checkpoint_cache_ts) < _CACHE_TTL
|
||||
):
|
||||
return list(self._checkpoint_cache)
|
||||
|
||||
try:
|
||||
data = await self._get_json(
|
||||
"/api/lm/checkpoints/list", params={"page_size": "9999"}
|
||||
items = await self._get_all_pages(
|
||||
"/api/lm/checkpoints/list", snapshot=snapshot
|
||||
)
|
||||
self._checkpoint_cache = data.get("items", [])
|
||||
self._checkpoint_cache_ts = now
|
||||
if remote_config.generation == snapshot.generation:
|
||||
with self._cache_lock:
|
||||
self._checkpoint_cache = list(items)
|
||||
self._checkpoint_cache_ts = now
|
||||
self._checkpoint_cache_generation = snapshot.generation
|
||||
return list(items)
|
||||
except _ConfigurationChanged:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.warning("[LM-Remote] Failed to fetch checkpoint list: %s", exc)
|
||||
return self._checkpoint_cache
|
||||
with self._cache_lock:
|
||||
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:
|
||||
"""Find an item in a list by file_name."""
|
||||
@@ -117,6 +233,31 @@ class RemoteLoraClient:
|
||||
return item
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
@@ -127,35 +268,17 @@ class RemoteLoraClient:
|
||||
Uses the cached ``/api/lm/loras/list`` data. Falls back to the
|
||||
per-LoRA ``get-trigger-words`` endpoint if the list lookup fails.
|
||||
"""
|
||||
import posixpath
|
||||
|
||||
try:
|
||||
items = await self._get_lora_list_cached()
|
||||
snapshot = remote_config.snapshot
|
||||
items = await self._get_lora_list_cached(snapshot=snapshot)
|
||||
item = self._find_item_by_name(items, lora_name)
|
||||
|
||||
if item:
|
||||
file_path = item.get("file_path", "")
|
||||
file_path = remote_config.map_path(file_path)
|
||||
file_path = snapshot.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", "")
|
||||
basename = posixpath.basename(file_path) # "test.safetensors"
|
||||
|
||||
if folder:
|
||||
relative = f"{folder}/{basename}"
|
||||
else:
|
||||
relative = basename
|
||||
relative = self._relative_lora_path(file_path, folder)
|
||||
|
||||
civitai = item.get("civitai") or {}
|
||||
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||
@@ -165,6 +288,7 @@ class RemoteLoraClient:
|
||||
tw_data = await self._get_json(
|
||||
"/api/lm/loras/get-trigger-words",
|
||||
params={"name": lora_name},
|
||||
snapshot=snapshot,
|
||||
)
|
||||
trigger_words = tw_data.get("trigger_words", [])
|
||||
return lora_name, trigger_words
|
||||
@@ -176,7 +300,8 @@ class RemoteLoraClient:
|
||||
async def get_lora_hash(self, lora_name: str) -> str | None:
|
||||
"""Return the SHA-256 hash for a LoRA by display name."""
|
||||
try:
|
||||
items = await self._get_lora_list_cached()
|
||||
snapshot = remote_config.snapshot
|
||||
items = await self._get_lora_list_cached(snapshot=snapshot)
|
||||
item = self._find_item_by_name(items, lora_name)
|
||||
if item:
|
||||
return item.get("sha256") or item.get("hash")
|
||||
@@ -187,18 +312,23 @@ class RemoteLoraClient:
|
||||
async def get_checkpoint_hash(self, checkpoint_name: str) -> str | None:
|
||||
"""Return the SHA-256 hash for a checkpoint by display name."""
|
||||
try:
|
||||
items = await self._get_checkpoint_list_cached()
|
||||
snapshot = remote_config.snapshot
|
||||
items = await self._get_checkpoint_list_cached(snapshot=snapshot)
|
||||
item = self._find_item_by_name(items, checkpoint_name)
|
||||
if item:
|
||||
return item.get("sha256") or item.get("hash")
|
||||
except Exception as exc:
|
||||
logger.warning("[LM-Remote] get_checkpoint_hash(%s) failed: %s", checkpoint_name, exc)
|
||||
logger.warning(
|
||||
"[LM-Remote] get_checkpoint_hash(%s) failed: %s", checkpoint_name, exc
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_random_loras(self, **kwargs) -> list[dict]:
|
||||
"""Ask the remote to generate random LoRAs (for Randomizer node)."""
|
||||
try:
|
||||
result = await self._post_json("/api/lm/loras/random-sample", json_body=kwargs)
|
||||
result = await self._post_json(
|
||||
"/api/lm/loras/random-sample", json_body=kwargs
|
||||
)
|
||||
return result if isinstance(result, list) else result.get("loras", [])
|
||||
except Exception as exc:
|
||||
logger.warning("[LM-Remote] get_random_loras failed: %s", exc)
|
||||
@@ -207,7 +337,9 @@ class RemoteLoraClient:
|
||||
async def get_cycler_list(self, **kwargs) -> list[dict]:
|
||||
"""Ask the remote for a sorted LoRA list (for Cycler node)."""
|
||||
try:
|
||||
result = await self._post_json("/api/lm/loras/cycler-list", json_body=kwargs)
|
||||
result = await self._post_json(
|
||||
"/api/lm/loras/cycler-list", json_body=kwargs
|
||||
)
|
||||
return result if isinstance(result, list) else result.get("loras", [])
|
||||
except Exception as exc:
|
||||
logger.warning("[LM-Remote] get_cycler_list failed: %s", exc)
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
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"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
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"
|
||||
)
|
||||
@@ -0,0 +1,715 @@
|
||||
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"
|
||||
@@ -0,0 +1,880 @@
|
||||
.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
@@ -0,0 +1,773 @@
|
||||
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)}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user