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