diff --git a/AGENTS.md b/AGENTS.md
index b0b76b75..ae651b90 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -161,3 +161,32 @@ npm run test:coverage
- Symlink handling requires normalized paths
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*` patterns
- Run `python scripts/sync_translation_keys.py` after UI string updates
+
+## Frontend UI Architecture
+
+This project has two distinct UI systems:
+
+### 1. Standalone Lora Manager Web UI
+- Location: `./static/` and `./templates/`
+- Purpose: Full-featured web application for managing LoRA models
+- Tech stack: Vanilla JS + CSS, served by the standalone server
+- Development: Uses npm for frontend testing (`npm test`, `npm run test:watch`, etc.)
+
+### 2. ComfyUI Custom Node Widgets
+- Location: `./web/comfyui/`
+- Purpose: Widgets and UI logic that ComfyUI loads as custom node extensions
+- Tech stack: Vanilla JS + Vue.js widgets (in `./vue-widgets/` and built to `./web/comfyui/vue-widgets/`)
+- Widget styling: Primary styles in `./web/comfyui/lm_styles.css` (NOT `./static/css/`)
+- Development: No npm build step for these widgets (Vue widgets use build system)
+
+### Widget Development Guidelines
+- Use `app.registerExtension()` to register ComfyUI extensions (ComfyUI integration layer)
+- Use `node.addDOMWidget()` for custom DOM widgets
+- Widget styles should follow the patterns in `./web/comfyui/lm_styles.css`
+ - Selected state: `rgba(66, 153, 225, 0.3)` background, `rgba(66, 153, 225, 0.6)` border
+ - Hover state: `rgba(66, 153, 225, 0.2)` background
+ - Color palette matches the Lora Manager accent color (blue #4299e1)
+ - Use oklch() for color values when possible (defined in `./static/css/base.css`)
+- Vue widget components are in `./vue-widgets/src/components/` and built to `./web/comfyui/vue-widgets/`
+- When modifying widget styles, check `./web/comfyui/lm_styles.css` for consistency with other ComfyUI widgets
+
diff --git a/docs/dom_widget_dev_guide.md b/docs/dom_widget_dev_guide.md
index 718a5880..e2cd6890 100644
--- a/docs/dom_widget_dev_guide.md
+++ b/docs/dom_widget_dev_guide.md
@@ -274,6 +274,34 @@ When importing `app`, adjust the path based on your extension's folder depth. Ty
### 7.3 Security
If setting `innerHTML` dynamically, ensure the content is sanitized or trusted to prevent XSS attacks.
+### 7.4 UI Constraints for ComfyUI Custom Node Widgets
+
+When developing DOMWidgets as internal UI widgets for ComfyUI custom nodes, keep the following constraints in mind:
+
+#### 7.4.1 Minimize Vertical Space
+
+ComfyUI nodes are often displayed in a compact graph view with many nodes visible simultaneously. Avoid excessive vertical spacing that could clutter the workspace.
+
+- Keep layouts compact and efficient
+- Use appropriate padding and margins (4-8px typically)
+- Stack related controls vertically but avoid unnecessary spacing
+
+#### 7.4.2 Avoid Dynamic Height Changes
+
+Dynamic height changes (expand/collapse sections, showing/hiding content) can cause node layout recalculations and affect connection wire positioning.
+
+- Prefer static layouts over expandable/collapsible sections
+- Use tooltips or overlays for additional information instead
+- If dynamic height is unavoidable, manually trigger layout updates (see Section 4.4)
+
+#### 7.4.3 Keep UI Simple and Intuitive
+
+As internal widgets for ComfyUI custom nodes, the UI should be accessible to users without technical implementation details.
+
+- Use clear, user-friendly terminology (avoid "frontend/backend roll" in favor of "fixed/always randomize")
+- Focus on user intent rather than implementation details
+- Avoid complex interactions that may confuse users
+
---
## 8. Complete Example: Text Counter
diff --git a/py/nodes/lora_randomizer.py b/py/nodes/lora_randomizer.py
index 83990410..9e610776 100644
--- a/py/nodes/lora_randomizer.py
+++ b/py/nodes/lora_randomizer.py
@@ -2,8 +2,8 @@
Lora Randomizer Node - Randomly selects LoRAs from a pool with configurable settings.
This node accepts optional pool_config input to filter available LoRAs, and outputs
-a LORA_STACK with randomly selected LoRAs. Supports both frontend roll (fixed selection)
-and backend roll (randomizes each execution).
+a LORA_STACK with randomly selected LoRAs. Returns UI updates with new random LoRAs
+and tracks the last used combination for reuse.
"""
import logging
@@ -44,7 +44,7 @@ class LoraRandomizerNode:
Randomize LoRAs based on configuration and pool filters.
Args:
- randomizer_config: Dict with randomizer settings (count, strength ranges, roll mode)
+ randomizer_config: Dict with randomizer settings (count, strength ranges, roll_mode)
loras: List of LoRA dicts from LORAS widget (includes locked state)
pool_config: Optional config from LoRA Pool node for filtering
@@ -53,41 +53,23 @@ class LoraRandomizerNode:
"""
from ..services.service_registry import ServiceRegistry
- # Parse randomizer settings
- count_mode = randomizer_config.get("count_mode", "range")
- count_fixed = randomizer_config.get("count_fixed", 5)
- count_min = randomizer_config.get("count_min", 3)
- count_max = randomizer_config.get("count_max", 7)
- model_strength_min = randomizer_config.get("model_strength_min", 0.0)
- model_strength_max = randomizer_config.get("model_strength_max", 1.0)
- use_same_clip_strength = randomizer_config.get("use_same_clip_strength", True)
- clip_strength_min = randomizer_config.get("clip_strength_min", 0.0)
- clip_strength_max = randomizer_config.get("clip_strength_max", 1.0)
- roll_mode = randomizer_config.get("roll_mode", "frontend")
+ roll_mode = randomizer_config.get("roll_mode", "always")
+ logger.debug(f"[LoraRandomizerNode] roll_mode: {roll_mode}")
- # Get lora scanner to access available loras
- scanner = await ServiceRegistry.get_lora_scanner()
-
- # Backend roll mode: execute with input loras, return new random to UI
- if roll_mode == "backend":
- execution_stack = self._build_execution_stack_from_input(loras)
+ if roll_mode == "fixed":
+ ui_loras = loras
+ else:
+ scanner = await ServiceRegistry.get_lora_scanner()
ui_loras = await self._generate_random_loras_for_ui(
scanner, randomizer_config, loras, pool_config
)
- logger.info(
- f"[LoraRandomizerNode] Backend roll: executing with input, returning new random to UI"
- )
- return {"result": (execution_stack,), "ui": {"loras": ui_loras}}
- # Frontend roll mode: use current behavior (random selection for both)
- ui_loras = await self._generate_random_loras_for_ui(
- scanner, randomizer_config, loras, pool_config
- )
- execution_stack = self._build_execution_stack_from_input(ui_loras)
- logger.info(
- f"[LoraRandomizerNode] Frontend roll: executing with random selection"
- )
- return {"result": (execution_stack,), "ui": {"loras": ui_loras}}
+ execution_stack = self._build_execution_stack_from_input(loras)
+
+ return {
+ "result": (execution_stack,),
+ "ui": {"loras": ui_loras, "last_used": loras},
+ }
def _build_execution_stack_from_input(self, loras):
"""
@@ -121,9 +103,6 @@ class LoraRandomizerNode:
lora_stack.append((lora_path, model_strength, clip_strength))
- logger.info(
- f"[LoraRandomizerNode] Built execution stack with {len(lora_stack)} LoRAs"
- )
return lora_stack
async def _generate_random_loras_for_ui(
@@ -158,16 +137,10 @@ class LoraRandomizerNode:
else:
target_count = random.randint(count_min, count_max)
- logger.info(
- f"[LoraRandomizerNode] Generating random LoRAs, target count: {target_count}"
- )
-
# Extract locked LoRAs from input
locked_loras = [lora for lora in input_loras if lora.get("locked", False)]
locked_count = len(locked_loras)
- logger.info(f"[LoraRandomizerNode] Locked LoRAs: {locked_count}")
-
# Get available loras from cache
try:
cache_data = await scanner.get_cached_data(force_refresh=False)
@@ -185,10 +158,6 @@ class LoraRandomizerNode:
available_loras, pool_config, scanner
)
- logger.info(
- f"[LoraRandomizerNode] Available LoRAs after filtering: {len(available_loras)}"
- )
-
# Calculate how many new LoRAs to select
slots_needed = target_count - locked_count
@@ -208,10 +177,6 @@ class LoraRandomizerNode:
if slots_needed > len(available_pool):
slots_needed = len(available_pool)
- logger.info(
- f"[LoraRandomizerNode] Selecting {slots_needed} new LoRAs from {len(available_pool)} available"
- )
-
# Random sample
selected = []
if slots_needed > 0:
@@ -243,9 +208,6 @@ class LoraRandomizerNode:
# Merge with locked LoRAs
result_loras.extend(locked_loras)
- logger.info(
- f"[LoraRandomizerNode] Final random LoRA count: {len(result_loras)}"
- )
return result_loras
async def _apply_pool_filters(self, available_loras, pool_config, scanner):
@@ -331,19 +293,21 @@ class LoraRandomizerNode:
available_loras = lora_service.filter_set.apply(available_loras, criteria)
# Apply license filters
+ # Note: no_credit_required=True means filter out models where credit is NOT required
+ # (i.e., keep only models where credit IS required)
if no_credit_required:
available_loras = [
lora
for lora in available_loras
- if not lora.get("civitai", {}).get("allowNoCredit", True)
+ if not (lora.get("license_flags", 127) & (1 << 0))
]
+ # allow_selling=True means keep only models where selling generated content is allowed
if allow_selling:
available_loras = [
lora
for lora in available_loras
- if lora.get("civitai", {}).get("allowCommercialUse", ["None"])[0]
- != "None"
+ if bool(lora.get("license_flags", 127) & (1 << 1))
]
return available_loras
diff --git a/py/services/lora_service.py b/py/services/lora_service.py
index 7beffecd..d5940389 100644
--- a/py/services/lora_service.py
+++ b/py/services/lora_service.py
@@ -384,19 +384,21 @@ class LoraService(BaseModelService):
available_loras = self.filter_set.apply(available_loras, criteria)
# Apply license filters
+ # Note: no_credit_required=True means filter out models where credit is NOT required
+ # (i.e., keep only models where credit IS required)
if no_credit_required:
available_loras = [
lora
for lora in available_loras
- if not lora.get("civitai", {}).get("allowNoCredit", True)
+ if not (lora.get("license_flags", 127) & (1 << 0))
]
+ # allow_selling=True means keep only models where selling generated content is allowed
if allow_selling:
available_loras = [
lora
for lora in available_loras
- if lora.get("civitai", {}).get("allowCommercialUse", ["None"])[0]
- != "None"
+ if bool(lora.get("license_flags", 127) & (1 << 1))
]
return available_loras
diff --git a/tests/nodes/test_lora_randomizer.py b/tests/nodes/test_lora_randomizer.py
new file mode 100644
index 00000000..d22c58a8
--- /dev/null
+++ b/tests/nodes/test_lora_randomizer.py
@@ -0,0 +1,321 @@
+"""Tests for LoraRandomizerNode roll_mode functionality"""
+
+from unittest.mock import AsyncMock
+
+import pytest
+
+from py.nodes.lora_randomizer import LoraRandomizerNode
+from py.services import service_registry
+
+
+@pytest.fixture
+def randomizer_node():
+ """Create a LoraRandomizerNode instance for testing"""
+ return LoraRandomizerNode()
+
+
+@pytest.fixture
+def sample_loras():
+ """Sample loras input"""
+ return [
+ {
+ "name": "lora1.safetensors",
+ "strength": 0.8,
+ "clipStrength": 0.8,
+ "active": True,
+ "expanded": False,
+ "locked": True,
+ },
+ {
+ "name": "lora2.safetensors",
+ "strength": 0.6,
+ "clipStrength": 0.6,
+ "active": True,
+ "expanded": False,
+ "locked": False,
+ },
+ ]
+
+
+@pytest.fixture
+def randomizer_config_fixed():
+ """Randomizer config with roll_mode='fixed'"""
+ return {
+ "count_mode": "fixed",
+ "count_fixed": 3,
+ "count_min": 2,
+ "count_max": 5,
+ "model_strength_min": 0.5,
+ "model_strength_max": 1.0,
+ "use_same_clip_strength": True,
+ "clip_strength_min": 0.5,
+ "clip_strength_max": 1.0,
+ "roll_mode": "fixed",
+ }
+
+
+@pytest.fixture
+def randomizer_config_always():
+ """Randomizer config with roll_mode='always'"""
+ return {
+ "count_mode": "fixed",
+ "count_fixed": 3,
+ "count_min": 2,
+ "count_max": 5,
+ "model_strength_min": 0.5,
+ "model_strength_max": 1.0,
+ "use_same_clip_strength": True,
+ "clip_strength_min": 0.5,
+ "clip_strength_max": 1.0,
+ "roll_mode": "always",
+ }
+
+
+@pytest.mark.asyncio
+async def test_roll_mode_fixed_returns_input_loras(
+ randomizer_node, sample_loras, randomizer_config_fixed, mock_scanner, monkeypatch
+):
+ """Test that fixed mode returns input loras as ui_loras"""
+ monkeypatch.setattr(
+ service_registry.ServiceRegistry,
+ "get_lora_scanner",
+ AsyncMock(return_value=mock_scanner),
+ )
+
+ mock_scanner._cache.raw_data = [
+ {
+ "file_name": "new_lora.safetensors",
+ "file_path": "/path/to/new_lora.safetensors",
+ "folder": "",
+ }
+ ]
+
+ result = await randomizer_node.randomize(
+ randomizer_config_fixed, sample_loras, pool_config=None
+ )
+
+ assert "result" in result
+ assert "ui" in result
+ assert "loras" in result["ui"]
+ assert "last_used" in result["ui"]
+
+ ui_loras = result["ui"]["loras"]
+ last_used = result["ui"]["last_used"]
+
+ assert ui_loras == sample_loras
+ assert last_used == sample_loras
+
+
+@pytest.mark.asyncio
+async def test_roll_mode_always_generates_new_loras(
+ randomizer_node, sample_loras, randomizer_config_always, mock_scanner, monkeypatch
+):
+ """Test that always mode generates new random loras"""
+ monkeypatch.setattr(
+ service_registry.ServiceRegistry,
+ "get_lora_scanner",
+ AsyncMock(return_value=mock_scanner),
+ )
+
+ mock_scanner._cache.raw_data = [
+ {
+ "file_name": "random_lora1.safetensors",
+ "file_path": "/path/to/random_lora1.safetensors",
+ "folder": "",
+ },
+ {
+ "file_name": "random_lora2.safetensors",
+ "file_path": "/path/to/random_lora2.safetensors",
+ "folder": "",
+ },
+ ]
+
+ result = await randomizer_node.randomize(
+ randomizer_config_always, sample_loras, pool_config=None
+ )
+
+ ui_loras = result["ui"]["loras"]
+ last_used = result["ui"]["last_used"]
+
+ assert last_used == sample_loras
+ assert ui_loras != sample_loras
+
+
+@pytest.mark.asyncio
+async def test_roll_mode_always_preserves_locked_loras(
+ randomizer_node, sample_loras, randomizer_config_always, mock_scanner, monkeypatch
+):
+ """Test that always mode preserves locked loras from input"""
+ monkeypatch.setattr(
+ service_registry.ServiceRegistry,
+ "get_lora_scanner",
+ AsyncMock(return_value=mock_scanner),
+ )
+
+ mock_scanner._cache.raw_data = [
+ {
+ "file_name": "random_lora.safetensors",
+ "file_path": "/path/to/random_lora.safetensors",
+ "folder": "",
+ }
+ ]
+
+ result = await randomizer_node.randomize(
+ randomizer_config_always, sample_loras, pool_config=None
+ )
+
+ ui_loras = result["ui"]["loras"]
+
+ locked_lora = next((l for l in ui_loras if l.get("locked")), None)
+ assert locked_lora is not None
+ assert locked_lora["name"] == "lora1.safetensors"
+ assert locked_lora["strength"] == 0.8
+
+
+@pytest.mark.asyncio
+async def test_last_used_always_input_loras(
+ randomizer_node, sample_loras, randomizer_config_fixed, mock_scanner, monkeypatch
+):
+ """Test that last_used is always set to input loras"""
+ monkeypatch.setattr(
+ service_registry.ServiceRegistry,
+ "get_lora_scanner",
+ AsyncMock(return_value=mock_scanner),
+ )
+
+ mock_scanner._cache.raw_data = [
+ {
+ "file_name": "new_lora.safetensors",
+ "file_path": "/path/to/new_lora.safetensors",
+ "folder": "",
+ }
+ ]
+
+ result = await randomizer_node.randomize(
+ randomizer_config_fixed, sample_loras, pool_config=None
+ )
+
+ last_used = result["ui"]["last_used"]
+ assert last_used == sample_loras
+
+
+@pytest.mark.asyncio
+async def test_execution_stack_built_from_input_loras(
+ randomizer_node, sample_loras, randomizer_config_fixed, mock_scanner, monkeypatch
+):
+ """Test that execution stack is always built from input loras (current user selection)"""
+ monkeypatch.setattr(
+ service_registry.ServiceRegistry,
+ "get_lora_scanner",
+ AsyncMock(return_value=mock_scanner),
+ )
+
+ mock_scanner._cache.raw_data = [
+ {
+ "file_name": "lora1.safetensors",
+ "file_path": "/path/to/lora1.safetensors",
+ "folder": "",
+ },
+ {
+ "file_name": "lora2.safetensors",
+ "file_path": "/path/to/lora2.safetensors",
+ "folder": "",
+ },
+ ]
+
+ result = await randomizer_node.randomize(
+ randomizer_config_fixed, sample_loras, pool_config=None
+ )
+
+ execution_stack = result["result"][0]
+ ui_loras = result["ui"]["loras"]
+
+ # execution_stack should be built from input loras (sample_loras)
+ assert len(execution_stack) == 2
+ assert execution_stack[0][1] == 0.8
+ assert execution_stack[0][2] == 0.8
+ assert execution_stack[1][1] == 0.6
+ assert execution_stack[1][2] == 0.6
+
+ # ui_loras matches input loras in fixed mode
+ assert ui_loras == sample_loras
+
+
+@pytest.mark.asyncio
+async def test_roll_mode_default_always(
+ randomizer_node, sample_loras, mock_scanner, monkeypatch
+):
+ """Test that default roll_mode is 'always'"""
+ monkeypatch.setattr(
+ service_registry.ServiceRegistry,
+ "get_lora_scanner",
+ AsyncMock(return_value=mock_scanner),
+ )
+
+ config_without_roll_mode = {
+ "count_mode": "fixed",
+ "count_fixed": 3,
+ }
+
+ mock_scanner._cache.raw_data = [
+ {
+ "file_name": "random_lora.safetensors",
+ "file_path": "/path/to/random_lora.safetensors",
+ "folder": "",
+ }
+ ]
+
+ result = await randomizer_node.randomize(
+ config_without_roll_mode, sample_loras, pool_config=None
+ )
+
+ ui_loras = result["ui"]["loras"]
+ last_used = result["ui"]["last_used"]
+ execution_stack = result["result"][0]
+
+ # last_used should always be input loras
+ assert last_used == sample_loras
+ # ui_loras should be different (new random loras generated)
+ assert ui_loras != sample_loras
+ # execution_stack should be built from input loras, not ui_loras
+ assert len(execution_stack) == 2
+ assert execution_stack[0][1] == 0.8
+ assert execution_stack[0][2] == 0.8
+ assert execution_stack[1][1] == 0.6
+ assert execution_stack[1][2] == 0.6
+
+
+@pytest.mark.asyncio
+async def test_execution_stack_always_from_input_loras_not_ui_loras(
+ randomizer_node, sample_loras, randomizer_config_always, mock_scanner, monkeypatch
+):
+ """Test that execution_stack is always built from input loras, even when ui_loras is different"""
+ monkeypatch.setattr(
+ service_registry.ServiceRegistry,
+ "get_lora_scanner",
+ AsyncMock(return_value=mock_scanner),
+ )
+
+ mock_scanner._cache.raw_data = [
+ {
+ "file_name": "new_random_lora.safetensors",
+ "file_path": "/path/to/new_random_lora.safetensors",
+ "folder": "",
+ }
+ ]
+
+ result = await randomizer_node.randomize(
+ randomizer_config_always, sample_loras, pool_config=None
+ )
+
+ execution_stack = result["result"][0]
+ ui_loras = result["ui"]["loras"]
+
+ # ui_loras should be new random loras
+ assert ui_loras != sample_loras
+ # execution_stack should be built from input loras (sample_loras), not ui_loras
+ assert len(execution_stack) == 2
+ assert execution_stack[0][1] == 0.8
+ assert execution_stack[0][2] == 0.8
+ assert execution_stack[1][1] == 0.6
+ assert execution_stack[1][2] == 0.6
diff --git a/vue-widgets/src/components/LoraRandomizerWidget.vue b/vue-widgets/src/components/LoraRandomizerWidget.vue
index f31132d1..4a48fbf5 100644
--- a/vue-widgets/src/components/LoraRandomizerWidget.vue
+++ b/vue-widgets/src/components/LoraRandomizerWidget.vue
@@ -13,6 +13,9 @@
:roll-mode="state.rollMode.value"
:is-rolling="state.isRolling.value"
:is-clip-strength-disabled="state.isClipStrengthDisabled.value"
+ :last-used="state.lastUsed.value"
+ :current-loras="currentLoras"
+ :can-reuse-last="canReuseLast"
@update:count-mode="state.countMode.value = $event"
@update:count-fixed="state.countFixed.value = $event"
@update:count-min="state.countMin.value = $event"
@@ -23,13 +26,15 @@
@update:clip-strength-min="state.clipStrengthMin.value = $event"
@update:clip-strength-max="state.clipStrengthMax.value = $event"
@update:roll-mode="state.rollMode.value = $event"
- @roll="handleRoll"
+ @generate-fixed="handleGenerateFixed"
+ @always-randomize="handleAlwaysRandomize"
+ @reuse-last="handleReuseLast"
/>
diff --git a/vue-widgets/src/components/lora-randomizer/LastUsedPreview.vue b/vue-widgets/src/components/lora-randomizer/LastUsedPreview.vue
new file mode 100644
index 00000000..720e06ec
--- /dev/null
+++ b/vue-widgets/src/components/lora-randomizer/LastUsedPreview.vue
@@ -0,0 +1,155 @@
+
+
+