feat(lora-cycler): add sequential LoRA cycling through filtered pool

Add Lora Cycler node that cycles through LoRAs sequentially from a filtered pool. Supports configurable sort order, strength settings, and persists cycle progress across workflow save/load.

Backend:
- New LoraCyclerNode with cycle() method
- New /api/lm/loras/cycler-list endpoint
- LoraService.get_cycler_list() for filtered/sorted list

Frontend:
- LoraCyclerWidget with Vue.js component
- useLoraCyclerState composable
- LoraCyclerSettingsView for UI display
This commit is contained in:
Will Miao
2026-01-22 15:36:32 +08:00
parent 17c5583297
commit 6fbea77137
11 changed files with 2329 additions and 262 deletions

View File

@@ -10,6 +10,7 @@ try: # pragma: no cover - import fallback for pytest collection
from .py.nodes.wanvideo_lora_select_from_text import WanVideoLoraSelectFromText
from .py.nodes.lora_pool import LoraPoolNode
from .py.nodes.lora_randomizer import LoraRandomizerNode
from .py.nodes.lora_cycler import LoraCyclerNode
from .py.metadata_collector import init as init_metadata_collector
except (
ImportError
@@ -46,6 +47,9 @@ except (
LoraRandomizerNode = importlib.import_module(
"py.nodes.lora_randomizer"
).LoraRandomizerNode
LoraCyclerNode = importlib.import_module(
"py.nodes.lora_cycler"
).LoraCyclerNode
init_metadata_collector = importlib.import_module("py.metadata_collector").init
NODE_CLASS_MAPPINGS = {
@@ -60,6 +64,7 @@ NODE_CLASS_MAPPINGS = {
WanVideoLoraSelectFromText.NAME: WanVideoLoraSelectFromText,
LoraPoolNode.NAME: LoraPoolNode,
LoraRandomizerNode.NAME: LoraRandomizerNode,
LoraCyclerNode.NAME: LoraCyclerNode,
}
WEB_DIRECTORY = "./web/comfyui"

130
py/nodes/lora_cycler.py Normal file
View File

@@ -0,0 +1,130 @@
"""
Lora Cycler Node - Sequentially cycles through LoRAs from a pool.
This node accepts optional pool_config input to filter available LoRAs, and outputs
a LORA_STACK with one LoRA at a time. Returns UI updates with current/next LoRA info
and tracks the cycle progress which persists across workflow save/load.
"""
import logging
import os
from ..utils.utils import get_lora_info
logger = logging.getLogger(__name__)
class LoraCyclerNode:
"""Node that sequentially cycles through LoRAs from a pool"""
NAME = "Lora Cycler (LoraManager)"
CATEGORY = "Lora Manager/randomizer"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"cycler_config": ("CYCLER_CONFIG", {}),
},
"optional": {
"pool_config": ("POOL_CONFIG", {}),
},
}
RETURN_TYPES = ("LORA_STACK",)
RETURN_NAMES = ("LORA_STACK",)
FUNCTION = "cycle"
OUTPUT_NODE = False
async def cycle(self, cycler_config, pool_config=None):
"""
Cycle through LoRAs based on configuration and pool filters.
Args:
cycler_config: Dict with cycler settings (current_index, model_strength, clip_strength, sort_by)
pool_config: Optional config from LoRA Pool node for filtering
Returns:
Dictionary with 'result' (LORA_STACK tuple) and 'ui' (for widget display)
"""
from ..services.service_registry import ServiceRegistry
from ..services.lora_service import LoraService
# Extract settings from cycler_config
current_index = cycler_config.get("current_index", 1) # 1-based
model_strength = float(cycler_config.get("model_strength", 1.0))
clip_strength = float(cycler_config.get("clip_strength", 1.0))
sort_by = cycler_config.get("sort_by", "filename")
# Get scanner and service
scanner = await ServiceRegistry.get_lora_scanner()
lora_service = LoraService(scanner)
# Get filtered and sorted LoRA list
lora_list = await lora_service.get_cycler_list(
pool_config=pool_config, sort_by=sort_by
)
total_count = len(lora_list)
if total_count == 0:
logger.warning("[LoraCyclerNode] No LoRAs available in pool")
return {
"result": ([],),
"ui": {
"current_index": [1],
"next_index": [1],
"total_count": [0],
"current_lora_name": [""],
"current_lora_filename": [""],
"error": ["No LoRAs available in pool"],
},
}
# Clamp index to valid range (1-based)
clamped_index = max(1, min(current_index, total_count))
# Get LoRA at current index (convert to 0-based for list access)
current_lora = lora_list[clamped_index - 1]
# Build LORA_STACK with single LoRA
lora_path, _ = get_lora_info(current_lora["file_name"])
if not lora_path:
logger.warning(
f"[LoraCyclerNode] Could not find path for LoRA: {current_lora['file_name']}"
)
lora_stack = []
else:
# Normalize path separators
lora_path = lora_path.replace("/", os.sep)
lora_stack = [(lora_path, model_strength, clip_strength)]
# Calculate next index (wrap to 1 if at end)
next_index = clamped_index + 1
if next_index > total_count:
next_index = 1
# Get next LoRA for UI display (what will be used next generation)
next_lora = lora_list[next_index - 1]
# Determine display name based on sort_by setting
if sort_by == "filename":
next_display_name = next_lora["file_name"]
else:
next_display_name = next_lora.get("model_name", next_lora["file_name"])
return {
"result": (lora_stack,),
"ui": {
"current_index": [clamped_index],
"next_index": [next_index],
"total_count": [total_count],
"current_lora_name": [
current_lora.get("model_name", current_lora["file_name"])
],
"current_lora_filename": [current_lora["file_name"]],
"next_lora_name": [next_display_name],
"next_lora_filename": [next_lora["file_name"]],
"sort_by": [sort_by],
},
}

View File

@@ -63,6 +63,11 @@ class LoraRoutes(BaseModelRoutes):
"POST", "/api/lm/{prefix}/random-sample", prefix, self.get_random_loras
)
# Cycler routes
registrar.add_prefixed_route(
"POST", "/api/lm/{prefix}/cycler-list", prefix, self.get_cycler_list
)
# ComfyUI integration
registrar.add_prefixed_route(
"POST", "/api/lm/{prefix}/get_trigger_words", prefix, self.get_trigger_words
@@ -283,6 +288,29 @@ class LoraRoutes(BaseModelRoutes):
logger.error(f"Error getting random LoRAs: {e}", exc_info=True)
return web.json_response({"success": False, "error": str(e)}, status=500)
async def get_cycler_list(self, request: web.Request) -> web.Response:
"""Get filtered and sorted LoRA list for cycler widget"""
try:
json_data = await request.json()
# Parse parameters
pool_config = json_data.get("pool_config")
sort_by = json_data.get("sort_by", "filename")
# Get cycler list from service
lora_list = await self.service.get_cycler_list(
pool_config=pool_config,
sort_by=sort_by
)
return web.json_response(
{"success": True, "loras": lora_list, "count": len(lora_list)}
)
except Exception as e:
logger.error(f"Error getting cycler list: {e}", exc_info=True)
return web.json_response({"success": False, "error": str(e)}, status=500)
async def get_trigger_words(self, request: web.Request) -> web.Response:
"""Get trigger words for specified LoRA models"""
try:

View File

@@ -479,3 +479,49 @@ class LoraService(BaseModelService):
]
return available_loras
async def get_cycler_list(
self,
pool_config: Optional[Dict] = None,
sort_by: str = "filename"
) -> List[Dict]:
"""
Get filtered and sorted LoRA list for cycling.
Args:
pool_config: Optional pool config for filtering (filters dict)
sort_by: Sort field - 'filename' or 'model_name'
Returns:
List of LoRA dicts with file_name and model_name
"""
# Get cached data
cache = await self.scanner.get_cached_data(force_refresh=False)
available_loras = cache.raw_data if cache else []
# Apply pool filters if provided
if pool_config:
available_loras = await self._apply_pool_filters(
available_loras, pool_config
)
# Sort by specified field
if sort_by == "model_name":
available_loras = sorted(
available_loras,
key=lambda x: (x.get("model_name") or x.get("file_name", "")).lower()
)
else: # Default to filename
available_loras = sorted(
available_loras,
key=lambda x: x.get("file_name", "").lower()
)
# Return minimal data needed for cycling
return [
{
"file_name": lora["file_name"],
"model_name": lora.get("model_name", lora["file_name"]),
}
for lora in available_loras
]

View File

@@ -0,0 +1,219 @@
<template>
<div class="lora-cycler-widget">
<LoraCyclerSettingsView
:current-index="state.currentIndex.value"
:total-count="state.totalCount.value"
:current-lora-name="state.currentLoraName.value"
:current-lora-filename="state.currentLoraFilename.value"
:model-strength="state.modelStrength.value"
:clip-strength="state.clipStrength.value"
:use-custom-clip-range="state.useCustomClipRange.value"
:is-clip-strength-disabled="state.isClipStrengthDisabled.value"
:sort-by="state.sortBy.value"
:is-loading="state.isLoading.value"
@update:current-index="handleIndexUpdate"
@update:model-strength="state.modelStrength.value = $event"
@update:clip-strength="state.clipStrength.value = $event"
@update:use-custom-clip-range="handleUseCustomClipRangeChange"
@update:sort-by="handleSortByChange"
@refresh="handleRefresh"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import LoraCyclerSettingsView from './lora-cycler/LoraCyclerSettingsView.vue'
import { useLoraCyclerState } from '../composables/useLoraCyclerState'
import type { ComponentWidget, CyclerConfig, LoraPoolConfig } from '../composables/types'
// Props
const props = defineProps<{
widget: ComponentWidget
node: { id: number; inputs?: any[]; widgets?: any[]; graph?: any }
}>()
// State management
const state = useLoraCyclerState(props.widget)
// Track last known pool config hash
const lastPoolConfigHash = ref('')
// Track if component is mounted
const isMounted = ref(false)
// Get pool config from connected node
const getPoolConfig = (): LoraPoolConfig | null => {
// Check if getPoolConfig method exists on node (added by main.ts)
if ((props.node as any).getPoolConfig) {
return (props.node as any).getPoolConfig()
}
return null
}
// Handle index update from user
const handleIndexUpdate = async (newIndex: number) => {
state.setIndex(newIndex)
// Refresh list to update current LoRA display
try {
const poolConfig = getPoolConfig()
const loraList = await state.fetchCyclerList(poolConfig)
if (loraList.length > 0 && newIndex > 0 && newIndex <= loraList.length) {
const currentLora = loraList[newIndex - 1]
if (currentLora) {
state.currentLoraName.value = state.sortBy.value === 'filename'
? currentLora.file_name
: (currentLora.model_name || currentLora.file_name)
state.currentLoraFilename.value = currentLora.file_name
}
}
} catch (error) {
console.error('[LoraCyclerWidget] Error updating index:', error)
}
}
// Handle sort by change
const handleSortByChange = async (newSortBy: 'filename' | 'model_name') => {
state.sortBy.value = newSortBy
// Refresh list with new sort order
try {
const poolConfig = getPoolConfig()
await state.refreshList(poolConfig)
} catch (error) {
console.error('[LoraCyclerWidget] Error changing sort:', error)
}
}
// Handle use custom clip range toggle
const handleUseCustomClipRangeChange = (newValue: boolean) => {
state.useCustomClipRange.value = newValue
// When toggling off, sync clip strength to model strength
if (!newValue) {
state.clipStrength.value = state.modelStrength.value
}
}
// Handle refresh button click
const handleRefresh = async () => {
try {
const poolConfig = getPoolConfig()
await state.refreshList(poolConfig)
} catch (error) {
console.error('[LoraCyclerWidget] Error refreshing:', error)
}
}
// Check for pool config changes
const checkPoolConfigChanges = async () => {
if (!isMounted.value) return
const poolConfig = getPoolConfig()
const newHash = state.hashPoolConfig(poolConfig)
if (newHash !== lastPoolConfigHash.value) {
console.log('[LoraCyclerWidget] Pool config changed, refreshing list')
lastPoolConfigHash.value = newHash
try {
await state.refreshList(poolConfig)
} catch (error) {
console.error('[LoraCyclerWidget] Error on pool config change:', error)
}
}
}
// Lifecycle
onMounted(async () => {
// Setup serialization
props.widget.serializeValue = async () => {
return state.buildConfig()
}
// Handle external value updates (e.g., loading workflow, paste)
props.widget.onSetValue = (v) => {
state.restoreFromConfig(v as CyclerConfig)
}
// Restore from saved value
if (props.widget.value) {
state.restoreFromConfig(props.widget.value as CyclerConfig)
}
// Mark component as mounted
isMounted.value = true
// Initial load
try {
const poolConfig = getPoolConfig()
lastPoolConfigHash.value = state.hashPoolConfig(poolConfig)
await state.refreshList(poolConfig)
} catch (error) {
console.error('[LoraCyclerWidget] Error on initial load:', error)
}
// 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("[LoraCyclerWidget] Node executed with output:", output)
// Update state from backend response (values are wrapped in arrays)
if (output?.next_index !== undefined) {
const val = Array.isArray(output.next_index) ? output.next_index[0] : output.next_index
state.currentIndex.value = val
}
if (output?.total_count !== undefined) {
const val = Array.isArray(output.total_count) ? output.total_count[0] : output.total_count
state.totalCount.value = val
}
if (output?.current_lora_name !== undefined) {
const val = Array.isArray(output.current_lora_name) ? output.current_lora_name[0] : output.current_lora_name
state.currentLoraName.value = val
}
if (output?.current_lora_filename !== undefined) {
const val = Array.isArray(output.current_lora_filename) ? output.current_lora_filename[0] : output.current_lora_filename
state.currentLoraFilename.value = val
}
if (output?.next_lora_name !== undefined) {
const val = Array.isArray(output.next_lora_name) ? output.next_lora_name[0] : output.next_lora_name
state.currentLoraName.value = val
}
if (output?.next_lora_filename !== undefined) {
const val = Array.isArray(output.next_lora_filename) ? output.next_lora_filename[0] : output.next_lora_filename
state.currentLoraFilename.value = val
}
if (output?.sort_by !== undefined) {
const val = Array.isArray(output.sort_by) ? output.sort_by[0] : output.sort_by
state.sortBy.value = val
}
// Call original onExecuted if it exists
if (originalOnExecuted) {
return originalOnExecuted(output)
}
}
// Watch for connection changes by polling (since ComfyUI doesn't provide connection events)
const checkInterval = setInterval(checkPoolConfigChanges, 1000)
// Cleanup on unmount (handled by Vue's effect scope)
;(props.widget as any).onRemoveCleanup = () => {
clearInterval(checkInterval)
}
})
</script>
<style scoped>
.lora-cycler-widget {
padding: 6px;
background: rgba(40, 44, 52, 0.6);
border-radius: 6px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,490 @@
<template>
<div class="cycler-settings">
<div class="settings-header">
<h3 class="settings-title">CYCLER SETTINGS</h3>
</div>
<!-- Progress Display -->
<div class="setting-section progress-section">
<div class="progress-display">
<div class="progress-info">
<span class="progress-label">Next LoRA:</span>
<span class="progress-name" :title="currentLoraFilename">{{ currentLoraName || 'None' }}</span>
</div>
<div class="progress-counter">
<span class="progress-index">{{ currentIndex }}</span>
<span class="progress-separator">/</span>
<span class="progress-total">{{ totalCount }}</span>
<button
class="refresh-button"
:disabled="isLoading"
@click="$emit('refresh')"
title="Refresh list"
>
<svg
class="refresh-icon"
:class="{ spinning: isLoading }"
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"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Starting Index -->
<div class="setting-section">
<label class="setting-label">Starting Index</label>
<div class="index-input-container">
<input
type="number"
class="index-input"
:min="1"
:max="totalCount || 1"
:value="currentIndex"
:disabled="totalCount === 0"
@input="onIndexInput"
@blur="onIndexBlur"
/>
<span class="index-hint">1 - {{ totalCount || 1 }}</span>
</div>
</div>
<!-- Model Strength -->
<div class="setting-section">
<label class="setting-label">Model Strength</label>
<div class="slider-container">
<SingleSlider
:min="-10"
:max="10"
:value="modelStrength"
:step="0.1"
:default-range="{ min: 0.5, max: 1.5 }"
@update:value="$emit('update:modelStrength', $event)"
/>
</div>
</div>
<!-- Clip Strength -->
<div class="setting-section">
<div class="section-header-with-toggle">
<label class="setting-label">
Clip Strength - {{ useCustomClipRange ? 'Custom Value' : 'Use Model Strength' }}
</label>
<button
type="button"
class="toggle-switch"
:class="{ 'toggle-switch--active': useCustomClipRange }"
@click="$emit('update:useCustomClipRange', !useCustomClipRange)"
role="switch"
:aria-checked="useCustomClipRange"
title="Use custom clip strength when enabled, otherwise use model strength"
>
<span class="toggle-switch__track"></span>
<span class="toggle-switch__thumb"></span>
</button>
</div>
<div class="slider-container" :class="{ 'slider-container--disabled': isClipStrengthDisabled }">
<SingleSlider
:min="-10"
:max="10"
:value="clipStrength"
:step="0.1"
:default-range="{ min: 0.5, max: 1.5 }"
:disabled="isClipStrengthDisabled"
@update:value="$emit('update:clipStrength', $event)"
/>
</div>
</div>
<!-- Sort By -->
<div class="setting-section">
<label class="setting-label">Sort By</label>
<div class="sort-tabs">
<label class="sort-tab" :class="{ active: sortBy === 'filename' }">
<input
type="radio"
name="sort-by"
value="filename"
:checked="sortBy === 'filename'"
@change="$emit('update:sortBy', 'filename')"
/>
<span class="sort-tab-label">Filename</span>
</label>
<label class="sort-tab" :class="{ active: sortBy === 'model_name' }">
<input
type="radio"
name="sort-by"
value="model_name"
:checked="sortBy === 'model_name'"
@change="$emit('update:sortBy', 'model_name')"
/>
<span class="sort-tab-label">Model Name</span>
</label>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import SingleSlider from '../shared/SingleSlider.vue'
const props = defineProps<{
currentIndex: number
totalCount: number
currentLoraName: string
currentLoraFilename: string
modelStrength: number
clipStrength: number
useCustomClipRange: boolean
isClipStrengthDisabled: boolean
sortBy: 'filename' | 'model_name'
isLoading: boolean
}>()
const emit = defineEmits<{
'update:currentIndex': [value: number]
'update:modelStrength': [value: number]
'update:clipStrength': [value: number]
'update:useCustomClipRange': [value: boolean]
'update:sortBy': [value: 'filename' | 'model_name']
'refresh': []
}>()
// Temporary value for input while typing
const tempIndex = ref<string>('')
const onIndexInput = (event: Event) => {
const input = event.target as HTMLInputElement
tempIndex.value = input.value
}
const onIndexBlur = (event: Event) => {
const input = event.target as HTMLInputElement
const value = parseInt(input.value, 10)
if (!isNaN(value)) {
const clampedValue = Math.max(1, Math.min(value, props.totalCount || 1))
emit('update:currentIndex', clampedValue)
input.value = clampedValue.toString()
} else {
input.value = props.currentIndex.toString()
}
tempIndex.value = ''
}
</script>
<style scoped>
.cycler-settings {
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: #e4e4e7;
}
.settings-header {
margin-bottom: 8px;
}
.settings-title {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.05em;
color: var(--fg-color, #fff);
opacity: 0.6;
margin: 0;
text-transform: uppercase;
}
.setting-section {
margin-bottom: 8px;
}
.setting-label {
font-size: 13px;
font-weight: 500;
color: rgba(226, 232, 240, 0.8);
display: block;
margin-bottom: 6px;
}
/* Progress Display */
.progress-section {
margin-bottom: 12px;
}
.progress-display {
background: rgba(26, 32, 44, 0.9);
border: 1px solid rgba(226, 232, 240, 0.2);
border-radius: 6px;
padding: 8px 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.progress-label {
font-size: 10px;
font-weight: 500;
color: rgba(226, 232, 240, 0.5);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.progress-name {
font-size: 13px;
font-weight: 500;
color: rgba(191, 219, 254, 1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-counter {
display: flex;
align-items: center;
gap: 4px;
padding-left: 12px;
flex-shrink: 0;
}
.progress-index {
font-size: 18px;
font-weight: 600;
color: rgba(66, 153, 225, 1);
font-family: 'SF Mono', 'Roboto Mono', monospace;
}
.progress-separator {
font-size: 14px;
color: rgba(226, 232, 240, 0.4);
margin: 0 2px;
}
.progress-total {
font-size: 14px;
font-weight: 500;
color: rgba(226, 232, 240, 0.6);
font-family: 'SF Mono', 'Roboto Mono', monospace;
}
.refresh-button {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin-left: 8px;
padding: 0;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: rgba(226, 232, 240, 0.6);
cursor: pointer;
transition: all 0.2s;
}
.refresh-button:hover:not(:disabled) {
background: rgba(66, 153, 225, 0.2);
border-color: rgba(66, 153, 225, 0.4);
color: rgba(191, 219, 254, 1);
}
.refresh-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.refresh-icon {
width: 14px;
height: 14px;
}
.refresh-icon.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Index Input */
.index-input-container {
display: flex;
align-items: center;
gap: 8px;
}
.index-input {
width: 80px;
padding: 6px 10px;
background: rgba(26, 32, 44, 0.9);
border: 1px solid rgba(226, 232, 240, 0.2);
border-radius: 6px;
color: #e4e4e7;
font-size: 13px;
font-family: 'SF Mono', 'Roboto Mono', monospace;
}
.index-input:focus {
outline: none;
border-color: rgba(66, 153, 225, 0.6);
}
.index-input:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.index-hint {
font-size: 11px;
color: rgba(226, 232, 240, 0.4);
}
/* Slider Container */
.slider-container {
background: rgba(26, 32, 44, 0.9);
border: 1px solid rgba(226, 232, 240, 0.2);
border-radius: 6px;
padding: 6px;
}
.slider-container--disabled {
opacity: 0.5;
pointer-events: none;
}
.section-header-with-toggle {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.section-header-with-toggle .setting-label {
margin-bottom: 4px;
}
/* Toggle Switch */
.toggle-switch {
position: relative;
width: 36px;
height: 20px;
padding: 0;
background: transparent;
border: none;
cursor: pointer;
}
.toggle-switch__track {
position: absolute;
inset: 0;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--border-color, #444);
border-radius: 10px;
transition: all 0.2s;
}
.toggle-switch--active .toggle-switch__track {
background: rgba(66, 153, 225, 0.3);
border-color: rgba(66, 153, 225, 0.6);
}
.toggle-switch__thumb {
position: absolute;
top: 3px;
left: 2px;
width: 14px;
height: 14px;
background: var(--fg-color, #fff);
border-radius: 50%;
transition: all 0.2s;
opacity: 0.6;
}
.toggle-switch--active .toggle-switch__thumb {
transform: translateX(16px);
background: #4299e1;
opacity: 1;
}
.toggle-switch:hover .toggle-switch__thumb {
opacity: 1;
}
/* Sort Tabs */
.sort-tabs {
display: flex;
background: rgba(26, 32, 44, 0.9);
border: 1px solid rgba(226, 232, 240, 0.2);
border-radius: 6px;
overflow: hidden;
}
.sort-tab {
flex: 1;
position: relative;
padding: 8px 12px;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
}
.sort-tab input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.sort-tab-label {
font-size: 13px;
font-weight: 500;
color: rgba(226, 232, 240, 0.7);
transition: all 0.2s ease;
}
.sort-tab:hover .sort-tab-label {
color: rgba(226, 232, 240, 0.9);
}
.sort-tab.active .sort-tab-label {
color: rgba(191, 219, 254, 1);
font-weight: 600;
}
.sort-tab.active {
background: rgba(66, 153, 225, 0.2);
}
.sort-tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: rgba(66, 153, 225, 0.9);
}
</style>

View File

@@ -82,9 +82,22 @@ export interface LoraEntry {
locked: boolean
}
export interface ComponentWidget {
serializeValue?: () => Promise<LoraPoolConfig | RandomizerConfig>
value?: LoraPoolConfig | LegacyLoraPoolConfig | RandomizerConfig
onSetValue?: (v: LoraPoolConfig | LegacyLoraPoolConfig | RandomizerConfig) => void
updateConfig?: (v: LoraPoolConfig | RandomizerConfig) => void
// Cycler config
export interface CyclerConfig {
current_index: number // 1-based index
total_count: number // Cached for display
pool_config_hash: string // For change detection
model_strength: number
clip_strength: number
use_same_clip_strength: boolean
sort_by: 'filename' | 'model_name'
current_lora_name: string // For display
current_lora_filename: string
}
export interface ComponentWidget {
serializeValue?: () => Promise<LoraPoolConfig | RandomizerConfig | CyclerConfig>
value?: LoraPoolConfig | LegacyLoraPoolConfig | RandomizerConfig | CyclerConfig
onSetValue?: (v: LoraPoolConfig | LegacyLoraPoolConfig | RandomizerConfig | CyclerConfig) => void
updateConfig?: (v: LoraPoolConfig | RandomizerConfig | CyclerConfig) => void
}

View File

@@ -0,0 +1,208 @@
import { ref, watch, computed } from 'vue'
import type { ComponentWidget, CyclerConfig, LoraPoolConfig } from './types'
export interface CyclerLoraItem {
file_name: string
model_name: string
}
export function useLoraCyclerState(widget: ComponentWidget) {
// State refs
const currentIndex = ref(1) // 1-based
const totalCount = ref(0)
const poolConfigHash = ref('')
const modelStrength = ref(1.0)
const clipStrength = ref(1.0)
const useCustomClipRange = ref(false)
const sortBy = ref<'filename' | 'model_name'>('filename')
const currentLoraName = ref('')
const currentLoraFilename = ref('')
const isLoading = ref(false)
// Build config object from current state
const buildConfig = (): CyclerConfig => ({
current_index: currentIndex.value,
total_count: totalCount.value,
pool_config_hash: poolConfigHash.value,
model_strength: modelStrength.value,
clip_strength: clipStrength.value,
use_same_clip_strength: !useCustomClipRange.value,
sort_by: sortBy.value,
current_lora_name: currentLoraName.value,
current_lora_filename: currentLoraFilename.value,
})
// Restore state from config object
const restoreFromConfig = (config: CyclerConfig) => {
currentIndex.value = config.current_index || 1
totalCount.value = config.total_count || 0
poolConfigHash.value = config.pool_config_hash || ''
modelStrength.value = config.model_strength ?? 1.0
clipStrength.value = config.clip_strength ?? 1.0
useCustomClipRange.value = !(config.use_same_clip_strength ?? true)
sortBy.value = config.sort_by || 'filename'
currentLoraName.value = config.current_lora_name || ''
currentLoraFilename.value = config.current_lora_filename || ''
}
// Generate hash from pool config for change detection
const hashPoolConfig = (poolConfig: LoraPoolConfig | null): string => {
if (!poolConfig || !poolConfig.filters) {
return ''
}
try {
return btoa(JSON.stringify(poolConfig.filters))
} catch {
return ''
}
}
// Fetch cycler list from API
const fetchCyclerList = async (
poolConfig: LoraPoolConfig | null
): Promise<CyclerLoraItem[]> => {
try {
isLoading.value = true
const requestBody: Record<string, unknown> = {
sort_by: sortBy.value,
}
if (poolConfig?.filters) {
requestBody.pool_config = poolConfig.filters
}
const response = await fetch('/api/lm/loras/cycler-list', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to fetch cycler list')
}
const data = await response.json()
if (!data.success) {
throw new Error(data.error || 'Failed to get cycler list')
}
return data.loras || []
} catch (error) {
console.error('[LoraCyclerState] Error fetching cycler list:', error)
throw error
} finally {
isLoading.value = false
}
}
// Refresh list and update state
const refreshList = async (poolConfig: LoraPoolConfig | null) => {
try {
const newHash = hashPoolConfig(poolConfig)
const hashChanged = newHash !== poolConfigHash.value
// Fetch the list
const loraList = await fetchCyclerList(poolConfig)
// Update total count
totalCount.value = loraList.length
// If pool config changed, reset index to 1
if (hashChanged) {
currentIndex.value = 1
poolConfigHash.value = newHash
}
// Clamp index to valid range
if (currentIndex.value > totalCount.value) {
currentIndex.value = Math.max(1, totalCount.value)
}
// Update current LoRA info
if (loraList.length > 0 && currentIndex.value > 0) {
const currentLora = loraList[currentIndex.value - 1]
if (currentLora) {
currentLoraName.value = sortBy.value === 'filename'
? currentLora.file_name
: (currentLora.model_name || currentLora.file_name)
currentLoraFilename.value = currentLora.file_name
}
} else {
currentLoraName.value = ''
currentLoraFilename.value = ''
}
return loraList
} catch (error) {
console.error('[LoraCyclerState] Error refreshing list:', error)
throw error
}
}
// Set index manually
const setIndex = (index: number) => {
if (index >= 1 && index <= totalCount.value) {
currentIndex.value = index
}
}
// Computed property to check if clip strength is disabled
const isClipStrengthDisabled = computed(() => !useCustomClipRange.value)
// Watch model strength changes to sync with clip strength when not using custom range
watch(modelStrength, (newValue) => {
if (!useCustomClipRange.value) {
clipStrength.value = newValue
}
})
// Watch all state changes and update widget value
watch([
currentIndex,
totalCount,
poolConfigHash,
modelStrength,
clipStrength,
useCustomClipRange,
sortBy,
currentLoraName,
currentLoraFilename,
], () => {
const config = buildConfig()
if (widget.updateConfig) {
widget.updateConfig(config)
} else {
widget.value = config
}
}, { deep: true })
return {
// State refs
currentIndex,
totalCount,
poolConfigHash,
modelStrength,
clipStrength,
useCustomClipRange,
sortBy,
currentLoraName,
currentLoraFilename,
isLoading,
// Computed
isClipStrengthDisabled,
// Methods
buildConfig,
restoreFromConfig,
hashPoolConfig,
fetchCyclerList,
refreshList,
setIndex,
}
}

View File

@@ -2,14 +2,18 @@ import { createApp, type App as VueApp } from 'vue'
import PrimeVue from 'primevue/config'
import LoraPoolWidget from '@/components/LoraPoolWidget.vue'
import LoraRandomizerWidget from '@/components/LoraRandomizerWidget.vue'
import LoraCyclerWidget from '@/components/LoraCyclerWidget.vue'
import JsonDisplayWidget from '@/components/JsonDisplayWidget.vue'
import type { LoraPoolConfig, LegacyLoraPoolConfig, RandomizerConfig } from './composables/types'
import type { LoraPoolConfig, LegacyLoraPoolConfig, RandomizerConfig, CyclerConfig } 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 = 448
const LORA_RANDOMIZER_WIDGET_MAX_HEIGHT = LORA_RANDOMIZER_WIDGET_MIN_HEIGHT
const LORA_CYCLER_WIDGET_MIN_WIDTH = 380
const LORA_CYCLER_WIDGET_MIN_HEIGHT = 410
const LORA_CYCLER_WIDGET_MAX_HEIGHT = LORA_CYCLER_WIDGET_MIN_HEIGHT
const JSON_DISPLAY_WIDGET_MIN_WIDTH = 300
const JSON_DISPLAY_WIDGET_MIN_HEIGHT = 200
@@ -210,6 +214,80 @@ function createLoraRandomizerWidget(node) {
return { widget }
}
// @ts-ignore
function createLoraCyclerWidget(node) {
const container = document.createElement('div')
container.id = `lora-cycler-widget-${node.id}`
container.style.width = '100%'
container.style.height = '100%'
container.style.display = 'flex'
container.style.flexDirection = 'column'
container.style.overflow = 'hidden'
forwardMiddleMouseToCanvas(container)
let internalValue: CyclerConfig | undefined
const widget = node.addDOMWidget(
'cycler_config',
'CYCLER_CONFIG',
container,
{
getValue() {
return internalValue
},
setValue(v: CyclerConfig) {
internalValue = v
if (typeof widget.onSetValue === 'function') {
widget.onSetValue(v)
}
},
serialize: true,
getMinHeight() {
return LORA_CYCLER_WIDGET_MIN_HEIGHT
}
}
)
widget.updateConfig = (v: CyclerConfig) => {
internalValue = v
}
// Add method to get pool config from connected node
node.getPoolConfig = () => getPoolConfigFromConnectedNode(node)
const vueApp = createApp(LoraCyclerWidget, {
widget,
node
})
vueApp.use(PrimeVue, {
unstyled: true,
ripple: false
})
vueApp.mount(container)
vueApps.set(node.id + 30000, vueApp) // Offset to avoid collision with other widgets
widget.computeLayoutSize = () => {
const minWidth = LORA_CYCLER_WIDGET_MIN_WIDTH
const minHeight = LORA_CYCLER_WIDGET_MIN_HEIGHT
const maxHeight = LORA_CYCLER_WIDGET_MAX_HEIGHT
return { minHeight, minWidth, maxHeight }
}
widget.onRemove = () => {
const vueApp = vueApps.get(node.id + 30000)
if (vueApp) {
vueApp.unmount()
vueApps.delete(node.id + 30000)
}
}
return { widget }
}
// @ts-ignore
function createJsonDisplayWidget(node) {
const container = document.createElement('div')
@@ -290,6 +368,10 @@ app.registerExtension({
return createLoraRandomizerWidget(node)
},
// @ts-ignore
CYCLER_CONFIG(node) {
return createLoraCyclerWidget(node)
},
// @ts-ignore
async LORAS(node: any) {
if (!addLorasWidgetCache) {
// @ts-ignore

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long