docs: add frontend UI architecture and ComfyUI widget guidelines

- Document dual UI systems: standalone web UI and ComfyUI custom node widgets
- Add ComfyUI widget development guidelines including styling and constraints
- Update terminology in LoraRandomizerNode from 'frontend/backend' to 'fixed/always' for clarity
- Include UI constraints for ComfyUI widgets: minimize vertical space, avoid dynamic height changes, keep UI simple
This commit is contained in:
Will Miao
2026-01-13 11:20:50 +08:00
parent bce6b0e610
commit 6a17e75782
16 changed files with 877 additions and 244 deletions

View File

@@ -161,3 +161,32 @@ npm run test:coverage
- Symlink handling requires normalized paths - Symlink handling requires normalized paths
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*` patterns - API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*` patterns
- Run `python scripts/sync_translation_keys.py` after UI string updates - 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

View File

@@ -274,6 +274,34 @@ When importing `app`, adjust the path based on your extension's folder depth. Ty
### 7.3 Security ### 7.3 Security
If setting `innerHTML` dynamically, ensure the content is sanitized or trusted to prevent XSS attacks. 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 ## 8. Complete Example: Text Counter

View File

@@ -2,8 +2,8 @@
Lora Randomizer Node - Randomly selects LoRAs from a pool with configurable settings. 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 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) a LORA_STACK with randomly selected LoRAs. Returns UI updates with new random LoRAs
and backend roll (randomizes each execution). and tracks the last used combination for reuse.
""" """
import logging import logging
@@ -44,7 +44,7 @@ class LoraRandomizerNode:
Randomize LoRAs based on configuration and pool filters. Randomize LoRAs based on configuration and pool filters.
Args: 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) loras: List of LoRA dicts from LORAS widget (includes locked state)
pool_config: Optional config from LoRA Pool node for filtering pool_config: Optional config from LoRA Pool node for filtering
@@ -53,41 +53,23 @@ class LoraRandomizerNode:
""" """
from ..services.service_registry import ServiceRegistry from ..services.service_registry import ServiceRegistry
# Parse randomizer settings roll_mode = randomizer_config.get("roll_mode", "always")
count_mode = randomizer_config.get("count_mode", "range") logger.debug(f"[LoraRandomizerNode] roll_mode: {roll_mode}")
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")
# Get lora scanner to access available loras if roll_mode == "fixed":
scanner = await ServiceRegistry.get_lora_scanner() ui_loras = loras
else:
# Backend roll mode: execute with input loras, return new random to UI scanner = await ServiceRegistry.get_lora_scanner()
if roll_mode == "backend":
execution_stack = self._build_execution_stack_from_input(loras)
ui_loras = await self._generate_random_loras_for_ui( ui_loras = await self._generate_random_loras_for_ui(
scanner, randomizer_config, loras, pool_config 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) execution_stack = self._build_execution_stack_from_input(loras)
ui_loras = await self._generate_random_loras_for_ui(
scanner, randomizer_config, loras, pool_config return {
) "result": (execution_stack,),
execution_stack = self._build_execution_stack_from_input(ui_loras) "ui": {"loras": ui_loras, "last_used": loras},
logger.info( }
f"[LoraRandomizerNode] Frontend roll: executing with random selection"
)
return {"result": (execution_stack,), "ui": {"loras": ui_loras}}
def _build_execution_stack_from_input(self, loras): def _build_execution_stack_from_input(self, loras):
""" """
@@ -121,9 +103,6 @@ class LoraRandomizerNode:
lora_stack.append((lora_path, model_strength, clip_strength)) lora_stack.append((lora_path, model_strength, clip_strength))
logger.info(
f"[LoraRandomizerNode] Built execution stack with {len(lora_stack)} LoRAs"
)
return lora_stack return lora_stack
async def _generate_random_loras_for_ui( async def _generate_random_loras_for_ui(
@@ -158,16 +137,10 @@ class LoraRandomizerNode:
else: else:
target_count = random.randint(count_min, count_max) target_count = random.randint(count_min, count_max)
logger.info(
f"[LoraRandomizerNode] Generating random LoRAs, target count: {target_count}"
)
# Extract locked LoRAs from input # Extract locked LoRAs from input
locked_loras = [lora for lora in input_loras if lora.get("locked", False)] locked_loras = [lora for lora in input_loras if lora.get("locked", False)]
locked_count = len(locked_loras) locked_count = len(locked_loras)
logger.info(f"[LoraRandomizerNode] Locked LoRAs: {locked_count}")
# Get available loras from cache # Get available loras from cache
try: try:
cache_data = await scanner.get_cached_data(force_refresh=False) cache_data = await scanner.get_cached_data(force_refresh=False)
@@ -185,10 +158,6 @@ class LoraRandomizerNode:
available_loras, pool_config, scanner available_loras, pool_config, scanner
) )
logger.info(
f"[LoraRandomizerNode] Available LoRAs after filtering: {len(available_loras)}"
)
# Calculate how many new LoRAs to select # Calculate how many new LoRAs to select
slots_needed = target_count - locked_count slots_needed = target_count - locked_count
@@ -208,10 +177,6 @@ class LoraRandomizerNode:
if slots_needed > len(available_pool): if slots_needed > len(available_pool):
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 # Random sample
selected = [] selected = []
if slots_needed > 0: if slots_needed > 0:
@@ -243,9 +208,6 @@ class LoraRandomizerNode:
# Merge with locked LoRAs # Merge with locked LoRAs
result_loras.extend(locked_loras) result_loras.extend(locked_loras)
logger.info(
f"[LoraRandomizerNode] Final random LoRA count: {len(result_loras)}"
)
return result_loras return result_loras
async def _apply_pool_filters(self, available_loras, pool_config, scanner): 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) available_loras = lora_service.filter_set.apply(available_loras, criteria)
# Apply license filters # 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: if no_credit_required:
available_loras = [ available_loras = [
lora lora
for lora in available_loras 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: if allow_selling:
available_loras = [ available_loras = [
lora lora
for lora in available_loras for lora in available_loras
if lora.get("civitai", {}).get("allowCommercialUse", ["None"])[0] if bool(lora.get("license_flags", 127) & (1 << 1))
!= "None"
] ]
return available_loras return available_loras

View File

@@ -384,19 +384,21 @@ class LoraService(BaseModelService):
available_loras = self.filter_set.apply(available_loras, criteria) available_loras = self.filter_set.apply(available_loras, criteria)
# Apply license filters # 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: if no_credit_required:
available_loras = [ available_loras = [
lora lora
for lora in available_loras 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: if allow_selling:
available_loras = [ available_loras = [
lora lora
for lora in available_loras for lora in available_loras
if lora.get("civitai", {}).get("allowCommercialUse", ["None"])[0] if bool(lora.get("license_flags", 127) & (1 << 1))
!= "None"
] ]
return available_loras return available_loras

View File

@@ -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

View File

@@ -13,6 +13,9 @@
:roll-mode="state.rollMode.value" :roll-mode="state.rollMode.value"
:is-rolling="state.isRolling.value" :is-rolling="state.isRolling.value"
:is-clip-strength-disabled="state.isClipStrengthDisabled.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-mode="state.countMode.value = $event"
@update:count-fixed="state.countFixed.value = $event" @update:count-fixed="state.countFixed.value = $event"
@update:count-min="state.countMin.value = $event" @update:count-min="state.countMin.value = $event"
@@ -23,13 +26,15 @@
@update:clip-strength-min="state.clipStrengthMin.value = $event" @update:clip-strength-min="state.clipStrengthMin.value = $event"
@update:clip-strength-max="state.clipStrengthMax.value = $event" @update:clip-strength-max="state.clipStrengthMax.value = $event"
@update:roll-mode="state.rollMode.value = $event" @update:roll-mode="state.rollMode.value = $event"
@roll="handleRoll" @generate-fixed="handleGenerateFixed"
@always-randomize="handleAlwaysRandomize"
@reuse-last="handleReuseLast"
/> />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { onMounted, computed, ref, watch } from 'vue'
import LoraRandomizerSettingsView from './lora-randomizer/LoraRandomizerSettingsView.vue' import LoraRandomizerSettingsView from './lora-randomizer/LoraRandomizerSettingsView.vue'
import { useLoraRandomizerState } from '../composables/useLoraRandomizerState' import { useLoraRandomizerState } from '../composables/useLoraRandomizerState'
import type { ComponentWidget, RandomizerConfig, LoraEntry } from '../composables/types' import type { ComponentWidget, RandomizerConfig, LoraEntry } from '../composables/types'
@@ -43,11 +48,31 @@ const props = defineProps<{
// State management // State management
const state = useLoraRandomizerState(props.widget) const state = useLoraRandomizerState(props.widget)
// Handle roll button click // Track current loras from the loras widget
const handleRoll = async () => { const currentLoras = ref<LoraEntry[]>([])
try {
console.log('[LoraRandomizerWidget] Roll button clicked')
// Computed property to check if we can reuse last
const canReuseLast = computed(() => {
const lastUsed = state.lastUsed.value
if (!lastUsed || lastUsed.length === 0) return false
return !areLorasEqual(currentLoras.value, lastUsed)
})
// Helper function to compare two lora lists
const areLorasEqual = (a: LoraEntry[], b: LoraEntry[]): boolean => {
if (a.length !== b.length) return false
const sortedA = [...a].sort((x, y) => x.name.localeCompare(y.name))
const sortedB = [...b].sort((x, y) => x.name.localeCompare(y.name))
return sortedA.every((lora, i) =>
lora.name === sortedB[i].name &&
lora.strength === sortedB[i].strength &&
lora.clipStrength === sortedB[i].clipStrength
)
}
// Handle "Generate Fixed" button click
const handleGenerateFixed = async () => {
try {
// Get pool config from connected pool_config input // Get pool config from connected pool_config input
const poolConfig = (props.node as any).getPoolConfig?.() || null const poolConfig = (props.node as any).getPoolConfig?.() || null
@@ -55,46 +80,116 @@ const handleRoll = async () => {
const lorasWidget = props.node.widgets?.find((w: any) => w.name === "loras") const lorasWidget = props.node.widgets?.find((w: any) => w.name === "loras")
const lockedLoras: LoraEntry[] = (lorasWidget?.value || []).filter((lora: LoraEntry) => lora.locked === true) const lockedLoras: LoraEntry[] = (lorasWidget?.value || []).filter((lora: LoraEntry) => lora.locked === true)
console.log('[LoraRandomizerWidget] Pool config:', poolConfig) // Call API to get random loras
console.log('[LoraRandomizerWidget] Locked loras:', lockedLoras) const randomLoras = await state.rollLoras(poolConfig, lockedLoras)
// Update the loras widget with the new selection
if (lorasWidget) {
lorasWidget.value = randomLoras
currentLoras.value = randomLoras
}
// Set roll mode to fixed
state.rollMode.value = 'fixed'
} catch (error) {
console.error('[LoraRandomizerWidget] Error generating fixed LoRAs:', error)
alert('Failed to generate LoRAs: ' + (error as Error).message)
}
}
// Handle "Always Randomize" button click
const handleAlwaysRandomize = async () => {
try {
// Get pool config from connected pool_config input
const poolConfig = (props.node as any).getPoolConfig?.() || null
// Get locked loras from the loras widget
const lorasWidget = props.node.widgets?.find((w: any) => w.name === "loras")
const lockedLoras: LoraEntry[] = (lorasWidget?.value || []).filter((lora: LoraEntry) => lora.locked === true)
// Call API to get random loras // Call API to get random loras
const randomLoras = await state.rollLoras(poolConfig, lockedLoras) const randomLoras = await state.rollLoras(poolConfig, lockedLoras)
console.log('[LoraRandomizerWidget] Got random LoRAs:', randomLoras)
// Update the loras widget with the new selection // Update the loras widget with the new selection
// This will be handled by emitting an event or directly updating the loras widget if (lorasWidget) {
// For now, we'll emit a custom event that the parent widget handler can catch lorasWidget.value = randomLoras
if (typeof (props.widget as any).onRoll === 'function') { currentLoras.value = randomLoras
(props.widget as any).onRoll(randomLoras)
} }
// Set roll mode to always
state.rollMode.value = 'always'
} catch (error) { } catch (error) {
console.error('[LoraRandomizerWidget] Error rolling LoRAs:', error) console.error('[LoraRandomizerWidget] Error generating random LoRAs:', error)
alert('Failed to roll LoRAs: ' + (error as Error).message) alert('Failed to generate LoRAs: ' + (error as Error).message)
} }
} }
// Handle "Reuse Last" button click
const handleReuseLast = () => {
const lastUsedLoras = state.useLastUsed()
if (lastUsedLoras) {
// Update the loras widget with the last used combination
const lorasWidget = props.node.widgets?.find((w: any) => w.name === 'loras')
if (lorasWidget) {
lorasWidget.value = lastUsedLoras
currentLoras.value = lastUsedLoras
}
// Switch to fixed mode
state.rollMode.value = 'fixed'
}
}
// Watch for changes to the loras widget to track current loras
watch(() => props.node.widgets?.find((w: any) => w.name === 'loras')?.value, (newVal) => {
if (newVal && Array.isArray(newVal)) {
currentLoras.value = newVal
}
}, { immediate: true, deep: true })
// Lifecycle // Lifecycle
onMounted(async () => { onMounted(async () => {
// Setup serialization // Setup serialization
props.widget.serializeValue = async () => { props.widget.serializeValue = async () => {
const config = state.buildConfig() const config = state.buildConfig()
console.log('[LoraRandomizerWidget] Serializing config:', config)
return config return config
} }
// Handle external value updates (e.g., loading workflow, paste) // Handle external value updates (e.g., loading workflow, paste)
props.widget.onSetValue = (v) => { props.widget.onSetValue = (v) => {
console.log('[LoraRandomizerWidget] Restoring from config:', v)
state.restoreFromConfig(v as RandomizerConfig) state.restoreFromConfig(v as RandomizerConfig)
} }
// Restore from saved value // Restore from saved value
if (props.widget.value) { if (props.widget.value) {
console.log('[LoraRandomizerWidget] Restoring from saved value:', props.widget.value)
state.restoreFromConfig(props.widget.value as RandomizerConfig) state.restoreFromConfig(props.widget.value as RandomizerConfig)
} }
// Override onExecuted to handle backend UI updates
const originalOnExecuted = (props.node as any).onExecuted?.bind(props.node)
;(props.node as any).onExecuted = function(output: any) {
console.log("[LoraRandomizerWidget] Node executed with output:", output)
// Update last_used from backend
if (output?.last_used !== undefined) {
state.lastUsed.value = output.last_used
console.log(`[LoraRandomizerWidget] Updated last_used: ${output.last_used ? output.last_used.length : 0} LoRAs`)
}
// Update loras widget if backend provided new loras
const lorasWidget = props.node.widgets?.find((w: any) => w.name === 'loras')
if (lorasWidget && output?.loras && Array.isArray(output.loras)) {
console.log("[LoraRandomizerWidget] Received loras data from backend:", output.loras)
lorasWidget.value = output.loras
currentLoras.value = output.loras
}
// Call original onExecuted if it exists
if (originalOnExecuted) {
return originalOnExecuted(output)
}
}
}) })
</script> </script>

View File

@@ -0,0 +1,155 @@
<template>
<div class="last-used-preview">
<div class="last-used-preview__content">
<div
v-for="lora in displayLoras"
:key="lora.name"
class="last-used-preview__item"
>
<img
v-if="previewUrls[lora.name]"
:src="previewUrls[lora.name]"
class="last-used-preview__thumb"
@error="onImageError(lora.name)"
/>
<div v-else class="last-used-preview__thumb last-used-preview__thumb--placeholder">
<svg viewBox="0 0 16 16" fill="currentColor">
<path d="M6.002 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>
<path d="M2.002 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2h-12zm12 1a1 1 0 0 1 1 1v6.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12V3a1 1 0 0 1 1-1h12z"/>
</svg>
</div>
<div class="last-used-preview__info">
<span class="last-used-preview__name">{{ lora.name }}</span>
<span class="last-used-preview__strength">
M: {{ lora.strength }}{{ lora.clipStrength !== undefined ? ` / C: ${lora.clipStrength}` : '' }}
</span>
</div>
</div>
<div v-if="loras.length > 5" class="last-used-preview__more">
+{{ (loras.length - 5).toLocaleString() }} more LoRAs
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { LoraEntry } from '../../composables/types'
const props = defineProps<{
loras: LoraEntry[]
}>()
const displayLoras = computed(() => props.loras.slice(0, 5))
// Preview URLs cache
const previewUrls = ref<Record<string, string>>({})
// Fetch preview URL for a lora using API
const fetchPreviewUrl = async (loraName: string) => {
try {
const response = await fetch(`/api/lm/loras/preview-url?name=${encodeURIComponent(loraName)}`)
if (response.ok) {
const data = await response.json()
if (data.preview_url) {
previewUrls.value[loraName] = data.preview_url
}
}
} catch (error) {
// Silent fail, just use placeholder
}
}
// Load preview URLs on mount
props.loras.forEach(lora => {
fetchPreviewUrl(lora.name)
})
const onImageError = (loraName: string) => {
previewUrls.value[loraName] = ''
}
</script>
<style scoped>
.last-used-preview {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
z-index: 100;
width: 280px;
}
.last-used-preview__content {
background: var(--comfy-menu-bg, #1a1a1a);
border: 1px solid var(--border-color, #444);
border-radius: 6px;
padding: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
gap: 4px;
}
.last-used-preview__item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
background: var(--comfy-input-bg, #333);
border-radius: 4px;
}
.last-used-preview__thumb {
width: 28px;
height: 28px;
object-fit: cover;
border-radius: 3px;
flex-shrink: 0;
background: rgba(0, 0, 0, 0.2);
}
.last-used-preview__thumb--placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--fg-color, #fff);
opacity: 0.2;
}
.last-used-preview__thumb--placeholder svg {
width: 14px;
height: 14px;
}
.last-used-preview__info {
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.last-used-preview__name {
font-size: 11px;
color: var(--fg-color, #fff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.last-used-preview__strength {
font-size: 10px;
color: var(--fg-color, #fff);
opacity: 0.5;
}
.last-used-preview__more {
font-size: 11px;
color: var(--fg-color, #fff);
opacity: 0.5;
text-align: center;
padding: 4px;
}
</style>

View File

@@ -135,48 +135,73 @@
</div> </div>
</div> </div>
<!-- Roll Mode --> <!-- Roll Mode - New 3-button design -->
<div class="setting-section"> <div class="setting-section">
<label class="setting-label">Roll Mode</label> <label class="setting-label">Roll Mode</label>
<div class="roll-mode-selector"> <div class="roll-buttons-with-tooltip">
<label class="radio-label"> <div class="roll-buttons">
<input <button
type="radio" class="roll-button"
name="roll-mode" :class="{ selected: rollMode === 'fixed' }"
value="frontend" :disabled="isRolling"
:checked="rollMode === 'frontend'" @click="$emit('generate-fixed')"
@change="$emit('update:rollMode', 'frontend')" >
<svg class="roll-button__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="20" rx="5"/>
<circle cx="12" cy="12" r="3"/>
<circle cx="6" cy="8" r="1.5"/>
<circle cx="18" cy="16" r="1.5"/>
</svg>
<span class="roll-button__text">Generate Fixed</span>
</button>
<button
class="roll-button"
:class="{ selected: rollMode === 'always' }"
:disabled="isRolling"
@click="$emit('always-randomize')"
>
<svg class="roll-button__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
<path d="M21 3v5h-5"/>
<circle cx="12" cy="12" r="3"/>
<circle cx="6" cy="8" r="1.5"/>
<circle cx="18" cy="16" r="1.5"/>
</svg>
<span class="roll-button__text">Always Randomize</span>
</button>
<button
class="roll-button"
:class="{ selected: rollMode === 'fixed' && canReuseLast && areLorasEqual(currentLoras, lastUsed) }"
:disabled="!canReuseLast"
@mouseenter="showTooltip = true"
@mouseleave="showTooltip = false"
@click="$emit('reuse-last')"
>
<svg class="roll-button__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 14 4 9l5-5"/>
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/>
</svg>
<span class="roll-button__text">Reuse Last</span>
</button>
</div>
<!-- Last Used Preview Tooltip -->
<Transition name="tooltip">
<LastUsedPreview
v-if="showTooltip && lastUsed && lastUsed.length > 0"
:loras="lastUsed"
/> />
<span>Frontend Roll (fixed until re-rolled)</span> </Transition>
</label>
<button
class="roll-button"
:disabled="rollMode !== 'frontend' || isRolling"
@click="$emit('roll')"
>
<span class="roll-button__content">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect><path d="M8 8h.01"></path><path d="M16 16h.01"></path><path d="M16 8h.01"></path><path d="M8 16h.01"></path></svg>
Roll
</span>
</button>
</div>
<div class="roll-mode-selector">
<label class="radio-label">
<input
type="radio"
name="roll-mode"
value="backend"
:checked="rollMode === 'backend'"
@change="$emit('update:rollMode', 'backend')"
/>
<span>Backend Roll (randomizes each execution)</span>
</label>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
import LastUsedPreview from './LastUsedPreview.vue'
import type { LoraEntry } from '../../composables/types'
defineProps<{ defineProps<{
countMode: 'fixed' | 'range' countMode: 'fixed' | 'range'
countFixed: number countFixed: number
@@ -187,9 +212,12 @@ defineProps<{
useSameClipStrength: boolean useSameClipStrength: boolean
clipStrengthMin: number clipStrengthMin: number
clipStrengthMax: number clipStrengthMax: number
rollMode: 'frontend' | 'backend' rollMode: 'fixed' | 'always'
isRolling: boolean isRolling: boolean
isClipStrengthDisabled: boolean isClipStrengthDisabled: boolean
lastUsed: LoraEntry[] | null
currentLoras: LoraEntry[]
canReuseLast: boolean
}>() }>()
defineEmits<{ defineEmits<{
@@ -202,9 +230,25 @@ defineEmits<{
'update:useSameClipStrength': [value: boolean] 'update:useSameClipStrength': [value: boolean]
'update:clipStrengthMin': [value: number] 'update:clipStrengthMin': [value: number]
'update:clipStrengthMax': [value: number] 'update:clipStrengthMax': [value: number]
'update:rollMode': [value: 'frontend' | 'backend'] 'update:rollMode': [value: 'fixed' | 'always']
roll: [] 'generate-fixed': []
'always-randomize': []
'reuse-last': []
}>() }>()
const showTooltip = ref(false)
const areLorasEqual = (a: LoraEntry[] | null, b: LoraEntry[] | null): boolean => {
if (!a || !b) return false
if (a.length !== b.length) return false
const sortedA = [...a].sort((x, y) => x.name.localeCompare(y.name))
const sortedB = [...b].sort((x, y) => x.name.localeCompare(y.name))
return sortedA.every((lora, i) =>
lora.name === sortedB[i].name &&
lora.strength === sortedB[i].strength &&
lora.clipStrength === sortedB[i].clipStrength
)
}
</script> </script>
<style scoped> <style scoped>
@@ -330,41 +374,75 @@ defineEmits<{
cursor: pointer; cursor: pointer;
} }
/* Roll buttons with tooltip container */
.roll-buttons-with-tooltip {
position: relative;
}
/* Roll buttons container */
.roll-buttons {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
}
.roll-button { .roll-button {
padding: 8px 16px; padding: 8px 10px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); background: rgba(30, 30, 36, 0.6);
border: none; border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px; border-radius: 4px;
color: white; display: flex;
font-size: 13px; flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
color: #e4e4e7;
font-size: 11px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
white-space: nowrap; white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
} }
.roll-button:hover:not(:disabled) { .roll-button:hover:not(:disabled) {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); background: rgba(66, 153, 225, 0.2);
transform: translateY(-1px); border-color: rgba(66, 153, 225, 0.4);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); color: #bfdbfe;
} }
.roll-button:active:not(:disabled) { .roll-button.selected {
transform: translateY(0); background: rgba(66, 153, 225, 0.3);
border-color: rgba(66, 153, 225, 0.6);
color: #e4e4e7;
box-shadow: 0 0 0 1px rgba(66, 153, 225, 0.3);
} }
.roll-button:disabled { .roll-button:disabled {
opacity: 0.5; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
background: linear-gradient(135deg, #52525b 0%, #3f3f46 100%);
} }
.roll-button__content { .roll-button__icon {
display: inline-flex; width: 20px;
align-items: center; height: 20px;
gap: 6px; flex-shrink: 0;
}
.roll-button__text {
font-size: 11px;
text-align: center;
line-height: 1.2;
}
/* Tooltip transitions */
.tooltip-enter-active,
.tooltip-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.tooltip-enter-from,
.tooltip-leave-to {
opacity: 0;
transform: translateY(4px);
} }
</style> </style>

View File

@@ -64,7 +64,8 @@ export interface RandomizerConfig {
use_same_clip_strength: boolean use_same_clip_strength: boolean
clip_strength_min: number clip_strength_min: number
clip_strength_max: number clip_strength_max: number
roll_mode: 'frontend' | 'backend' roll_mode: 'fixed' | 'always'
last_used?: LoraEntry[] | null
} }
export interface LoraEntry { export interface LoraEntry {

View File

@@ -1,20 +1,23 @@
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import type { ComponentWidget, RandomizerConfig, LoraEntry } from './types' import type { ComponentWidget, RandomizerConfig, LoraEntry } from './types'
export function useLoraRandomizerState(widget: ComponentWidget) { export function useLoraRandomizerState(widget: ComponentWidget) {
// State refs // State refs
const countMode = ref<'fixed' | 'range'>('range') const countMode = ref<'fixed' | 'range'>('range')
const countFixed = ref(5) const countFixed = ref(3)
const countMin = ref(3) const countMin = ref(2)
const countMax = ref(7) const countMax = ref(5)
const modelStrengthMin = ref(0.0) const modelStrengthMin = ref(0.0)
const modelStrengthMax = ref(1.0) const modelStrengthMax = ref(1.0)
const useSameClipStrength = ref(true) const useSameClipStrength = ref(true)
const clipStrengthMin = ref(0.0) const clipStrengthMin = ref(0.0)
const clipStrengthMax = ref(1.0) const clipStrengthMax = ref(1.0)
const rollMode = ref<'frontend' | 'backend'>('frontend') const rollMode = ref<'fixed' | 'always'>('fixed')
const isRolling = ref(false) const isRolling = ref(false)
// Track last used combination (for backend roll mode)
const lastUsed = ref<LoraEntry[] | null>(null)
// Build config object from current state // Build config object from current state
const buildConfig = (): RandomizerConfig => ({ const buildConfig = (): RandomizerConfig => ({
count_mode: countMode.value, count_mode: countMode.value,
@@ -27,20 +30,32 @@ export function useLoraRandomizerState(widget: ComponentWidget) {
clip_strength_min: clipStrengthMin.value, clip_strength_min: clipStrengthMin.value,
clip_strength_max: clipStrengthMax.value, clip_strength_max: clipStrengthMax.value,
roll_mode: rollMode.value, roll_mode: rollMode.value,
last_used: lastUsed.value,
}) })
// Restore state from config object // Restore state from config object
const restoreFromConfig = (config: RandomizerConfig) => { const restoreFromConfig = (config: RandomizerConfig) => {
countMode.value = config.count_mode || 'range' countMode.value = config.count_mode || 'range'
countFixed.value = config.count_fixed || 5 countFixed.value = config.count_fixed || 3
countMin.value = config.count_min || 3 countMin.value = config.count_min || 2
countMax.value = config.count_max || 7 countMax.value = config.count_max || 5
modelStrengthMin.value = config.model_strength_min ?? 0.0 modelStrengthMin.value = config.model_strength_min ?? 0.0
modelStrengthMax.value = config.model_strength_max ?? 1.0 modelStrengthMax.value = config.model_strength_max ?? 1.0
useSameClipStrength.value = config.use_same_clip_strength ?? true useSameClipStrength.value = config.use_same_clip_strength ?? true
clipStrengthMin.value = config.clip_strength_min ?? 0.0 clipStrengthMin.value = config.clip_strength_min ?? 0.0
clipStrengthMax.value = config.clip_strength_max ?? 1.0 clipStrengthMax.value = config.clip_strength_max ?? 1.0
rollMode.value = config.roll_mode || 'frontend' // Migrate old roll_mode values to new ones
const rawRollMode = (config as any).roll_mode as string
if (rawRollMode === 'frontend') {
rollMode.value = 'fixed'
} else if (rawRollMode === 'backend') {
rollMode.value = 'always'
} else if (rawRollMode === 'fixed' || rawRollMode === 'always') {
rollMode.value = rawRollMode as 'fixed' | 'always'
} else {
rollMode.value = 'fixed'
}
lastUsed.value = config.last_used || null
} }
// Roll loras - call API to get random selection // Roll loras - call API to get random selection
@@ -114,9 +129,38 @@ export function useLoraRandomizerState(widget: ComponentWidget) {
} }
} }
// Restore last used and return it
const useLastUsed = () => {
if (lastUsed.value && lastUsed.value.length > 0) {
return lastUsed.value
}
return null
}
// Computed properties // Computed properties
const isClipStrengthDisabled = computed(() => useSameClipStrength.value) const isClipStrengthDisabled = computed(() => useSameClipStrength.value)
// Watch all state changes and update widget value
watch([
countMode,
countFixed,
countMin,
countMax,
modelStrengthMin,
modelStrengthMax,
useSameClipStrength,
clipStrengthMin,
clipStrengthMax,
rollMode,
], () => {
const config = buildConfig()
if (widget.updateConfig) {
widget.updateConfig(config)
} else {
widget.value = config
}
}, { deep: true })
return { return {
// State refs // State refs
countMode, countMode,
@@ -130,6 +174,7 @@ export function useLoraRandomizerState(widget: ComponentWidget) {
clipStrengthMax, clipStrengthMax,
rollMode, rollMode,
isRolling, isRolling,
lastUsed,
// Computed // Computed
isClipStrengthDisabled, isClipStrengthDisabled,
@@ -138,5 +183,6 @@ export function useLoraRandomizerState(widget: ComponentWidget) {
buildConfig, buildConfig,
restoreFromConfig, restoreFromConfig,
rollLoras, rollLoras,
useLastUsed,
} }
} }

View File

@@ -4,6 +4,12 @@ import LoraPoolWidget from '@/components/LoraPoolWidget.vue'
import LoraRandomizerWidget from '@/components/LoraRandomizerWidget.vue' import LoraRandomizerWidget from '@/components/LoraRandomizerWidget.vue'
import type { LoraPoolConfig, LegacyLoraPoolConfig, RandomizerConfig } from './composables/types' import type { LoraPoolConfig, LegacyLoraPoolConfig, RandomizerConfig } from './composables/types'
const LORA_POOL_WIDGET_MIN_WIDTH = 500
const LORA_POOL_WIDGET_MIN_HEIGHT = 400
const LORA_RANDOMIZER_WIDGET_MIN_WIDTH = 500
const LORA_RANDOMIZER_WIDGET_MIN_HEIGHT = 462
const LORA_RANDOMIZER_WIDGET_MAX_HEIGHT = 462
// @ts-ignore - ComfyUI external module // @ts-ignore - ComfyUI external module
import { app } from '../../../scripts/app.js' import { app } from '../../../scripts/app.js'
// @ts-ignore // @ts-ignore
@@ -44,7 +50,7 @@ function createLoraPoolWidget(node) {
// Per dev guide: providing getMinHeight via options allows the system to // Per dev guide: providing getMinHeight via options allows the system to
// skip expensive DOM measurements during rendering loop, improving performance // skip expensive DOM measurements during rendering loop, improving performance
getMinHeight() { getMinHeight() {
return 400 return LORA_POOL_WIDGET_MIN_HEIGHT
} }
} }
) )
@@ -67,8 +73,8 @@ function createLoraPoolWidget(node) {
vueApps.set(node.id, vueApp) vueApps.set(node.id, vueApp)
widget.computeLayoutSize = () => { widget.computeLayoutSize = () => {
const minWidth = 500 const minWidth = LORA_POOL_WIDGET_MIN_WIDTH
const minHeight = 400 const minHeight = LORA_POOL_WIDGET_MIN_HEIGHT
return { minHeight, minWidth } return { minHeight, minWidth }
} }
@@ -106,13 +112,14 @@ function createLoraRandomizerWidget(node) {
}, },
setValue(v: RandomizerConfig) { setValue(v: RandomizerConfig) {
internalValue = v internalValue = v
console.log('randomizer widget value update: ', internalValue)
if (typeof widget.onSetValue === 'function') { if (typeof widget.onSetValue === 'function') {
widget.onSetValue(v) widget.onSetValue(v)
} }
}, },
serialize: true, serialize: true,
getMinHeight() { getMinHeight() {
return 500 return LORA_RANDOMIZER_WIDGET_MIN_HEIGHT
} }
} }
) )
@@ -126,15 +133,10 @@ function createLoraRandomizerWidget(node) {
// Handle roll event from Vue component // Handle roll event from Vue component
widget.onRoll = (randomLoras: any[]) => { widget.onRoll = (randomLoras: any[]) => {
console.log('[createLoraRandomizerWidget] Roll event received:', randomLoras)
// Find the loras widget on this node and update it // Find the loras widget on this node and update it
const lorasWidget = node.widgets.find((w: any) => w.name === 'loras') const lorasWidget = node.widgets.find((w: any) => w.name === 'loras')
if (lorasWidget) { if (lorasWidget) {
lorasWidget.value = randomLoras lorasWidget.value = randomLoras
console.log('[createLoraRandomizerWidget] Updated loras widget with rolled LoRAs')
} else {
console.warn('[createLoraRandomizerWidget] loras widget not found on node')
} }
} }
@@ -152,9 +154,9 @@ function createLoraRandomizerWidget(node) {
vueApps.set(node.id + 10000, vueApp) // Offset to avoid collision with pool widget vueApps.set(node.id + 10000, vueApp) // Offset to avoid collision with pool widget
widget.computeLayoutSize = () => { widget.computeLayoutSize = () => {
const minWidth = 500 const minWidth = LORA_RANDOMIZER_WIDGET_MIN_WIDTH
const minHeight = 500 const minHeight = LORA_RANDOMIZER_WIDGET_MIN_HEIGHT
const maxHeight = 500 const maxHeight = LORA_RANDOMIZER_WIDGET_MAX_HEIGHT
return { minHeight, minWidth, maxHeight } return { minHeight, minWidth, maxHeight }
} }
@@ -193,8 +195,6 @@ app.registerExtension({
// Check if this is a randomizer node to enable lock buttons // Check if this is a randomizer node to enable lock buttons
const isRandomizerNode = node.comfyClass === 'Lora Randomizer (LoraManager)' const isRandomizerNode = node.comfyClass === 'Lora Randomizer (LoraManager)'
console.log(node)
// For randomizer nodes, add a callback to update connected trigger words // For randomizer nodes, add a callback to update connected trigger words
const callback = isRandomizerNode ? (value: any) => { const callback = isRandomizerNode ? (value: any) => {
updateDownstreamLoaders(node) updateDownstreamLoaders(node)

View File

@@ -1,40 +0,0 @@
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "LoraManager.LoraDemo",
// Hook into node creation
async nodeCreated(node) {
if (node.comfyClass !== "Lora Demo (LoraManager)") {
return;
}
// Store original onExecuted
const originalOnExecuted = node.onExecuted?.bind(node);
// Override onExecuted to handle UI updates
node.onExecuted = function(output) {
// Check if output has loras data
if (output?.loras && Array.isArray(output.loras)) {
console.log("[LoraDemoNode] Received loras data from backend:", output.loras);
// Find the loras widget on this node
const lorasWidget = node.widgets.find(w => w.name === 'loras');
if (lorasWidget) {
// Update widget value with backend data
lorasWidget.value = output.loras;
console.log(`[LoraDemoNode] Updated widget with ${output.loras.length} loras`);
} else {
console.warn("[LoraDemoNode] loras widget not found on node");
}
}
// Call original onExecuted if it exists
if (originalOnExecuted) {
return originalOnExecuted(output);
}
};
}
});

View File

@@ -1,44 +0,0 @@
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "LoraManager.LoraRandomizer",
// Hook into node creation
async nodeCreated(node) {
if (node.comfyClass !== "Lora Randomizer (LoraManager)") {
return;
}
console.log("[LoraRandomizerWidget] Node created:", node.id);
// Store original onExecuted
const originalOnExecuted = node.onExecuted?.bind(node);
// Override onExecuted to handle UI updates
node.onExecuted = function(output) {
console.log("[LoraRandomizerWidget] Node executed with output:", output);
// Check if output has loras data
if (output?.loras && Array.isArray(output.loras)) {
console.log("[LoraRandomizerWidget] Received loras data from backend:", output.loras);
// Find the loras widget on this node
const lorasWidget = node.widgets.find(w => w.name === 'loras');
if (lorasWidget) {
// Update widget value with backend data
lorasWidget.value = output.loras;
console.log(`[LoraRandomizerWidget] Updated widget with ${output.loras.length} loras`);
} else {
console.warn("[LoraRandomizerWidget] loras widget not found on node");
}
}
// Call original onExecuted if it exists
if (originalOnExecuted) {
return originalOnExecuted(output);
}
};
}
});

View File

@@ -699,6 +699,7 @@ export function addLorasWidget(node, name, opts, callback) {
return widgetValue; return widgetValue;
}, },
setValue: function(v) { setValue: function(v) {
console.log('loras widget value update: ', v);
// Remove duplicates by keeping the last occurrence of each lora name // Remove duplicates by keeping the last occurrence of each lora name
const uniqueValue = (v || []).reduce((acc, lora) => { const uniqueValue = (v || []).reduce((acc, lora) => {
// Remove any existing lora with the same name // Remove any existing lora with the same name

View File

@@ -239,7 +239,6 @@ app.registerExtension({
// Handle trigger word updates from Python // Handle trigger word updates from Python
handleTriggerWordUpdate(id, graphId, message) { handleTriggerWordUpdate(id, graphId, message) {
console.log('trigger word update: ', id, graphId, message);
const node = getNodeFromGraph(graphId, id); const node = getNodeFromGraph(graphId, id);
if (!node || node.comfyClass !== "TriggerWord Toggle (LoraManager)") { if (!node || node.comfyClass !== "TriggerWord Toggle (LoraManager)") {
console.warn("Node not found or not a TriggerWordToggle:", id); console.warn("Node not found or not a TriggerWordToggle:", id);

View File

@@ -328,8 +328,6 @@ export function updateConnectedTriggerWords(node, loraNames) {
.map((connectedNode) => getNodeReference(connectedNode)) .map((connectedNode) => getNodeReference(connectedNode))
.filter((reference) => reference !== null); .filter((reference) => reference !== null);
console.log('node ids: ', nodeIds, loraNames);
if (nodeIds.length === 0) { if (nodeIds.length === 0) {
return; return;
} }