refactor: move No LoRA feature from LoRA Pool to Lora Cycler widget

Move the 'empty/no LoRA' cycling functionality from the LoRA Pool node
to the Lora Cycler widget for cleaner architecture:

Frontend changes:
- Add include_no_lora field to CyclerConfig interface
- Add includeNoLora state and logic to useLoraCyclerState composable
- Add toggle UI in LoraCyclerSettingsView with special styling
- Show 'No LoRA' entry in LoraListModal when enabled
- Update LoraCyclerWidget to integrate new logic

Backend changes:
- lora_cycler.py reads include_no_lora from config
- Calculate effective_total_count (actual count + 1 when enabled)
- Return empty lora_stack when on No LoRA position
- Return actual LoRA count in total_count (not effective count)

Reverted files to pre-PR state:
- lora_loader.py, lora_pool.py, lora_randomizer.py, lora_stacker.py
- lora_routes.py, lora_service.py
- LoraPoolWidget.vue and related files

Related to PR #861

Co-authored-by: dogatech <dogatech@dogatech.home>
This commit is contained in:
Will Miao
2026-03-19 14:19:49 +08:00
parent 8dd849892d
commit 1ae1b0d607
22 changed files with 459 additions and 316 deletions

View File

@@ -2,8 +2,8 @@
<div class="lora-cycler-widget">
<LoraCyclerSettingsView
:current-index="state.currentIndex.value"
:total-count="state.totalCount.value"
:current-lora-name="state.currentLoraName.value"
:total-count="displayTotalCount"
:current-lora-name="displayLoraName"
:current-lora-filename="state.currentLoraFilename.value"
:model-strength="state.modelStrength.value"
:clip-strength="state.clipStrength.value"
@@ -16,11 +16,14 @@
:is-pause-disabled="hasQueuedPrompts"
:is-workflow-executing="state.isWorkflowExecuting.value"
:executing-repeat-step="state.executingRepeatStep.value"
:include-no-lora="state.includeNoLora.value"
:is-no-lora="isNoLora"
@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:repeat-count="handleRepeatCountChange"
@update:include-no-lora="handleIncludeNoLoraChange"
@toggle-pause="handleTogglePause"
@reset-index="handleResetIndex"
@open-lora-selector="isModalOpen = true"
@@ -30,6 +33,7 @@
:visible="isModalOpen"
:lora-list="cachedLoraList"
:current-index="state.currentIndex.value"
:include-no-lora="state.includeNoLora.value"
@close="isModalOpen = false"
@select="handleModalSelect"
/>
@@ -37,7 +41,7 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, ref, computed } from 'vue'
import LoraCyclerSettingsView from './lora-cycler/LoraCyclerSettingsView.vue'
import LoraListModal from './lora-cycler/LoraListModal.vue'
import { useLoraCyclerState } from '../composables/useLoraCyclerState'
@@ -102,6 +106,31 @@ const isModalOpen = ref(false)
// Cache for LoRA list (used by modal)
const cachedLoraList = ref<LoraItem[]>([])
// Computed: display total count (includes no lora option if enabled)
const displayTotalCount = computed(() => {
const baseCount = state.totalCount.value
return state.includeNoLora.value ? baseCount + 1 : baseCount
})
// Computed: display LoRA name (shows "No LoRA" if on the last index and includeNoLora is enabled)
const displayLoraName = computed(() => {
const currentIndex = state.currentIndex.value
const totalCount = state.totalCount.value
// If includeNoLora is enabled and we're on the last position (no lora slot)
if (state.includeNoLora.value && currentIndex === totalCount + 1) {
return 'No LoRA'
}
// Otherwise show the normal LoRA name
return state.currentLoraName.value
})
// Computed: check if currently on "No LoRA" option
const isNoLora = computed(() => {
return state.includeNoLora.value && state.currentIndex.value === state.totalCount.value + 1
})
// Get pool config from connected node
const getPoolConfig = (): LoraPoolConfig | null => {
// Check if getPoolConfig method exists on node (added by main.ts)
@@ -113,7 +142,17 @@ const getPoolConfig = (): LoraPoolConfig | null => {
// Update display from LoRA list and index
const updateDisplayFromLoraList = (loraList: LoraItem[], index: number) => {
if (loraList.length > 0 && index > 0 && index <= loraList.length) {
const actualLoraCount = loraList.length
// If index is beyond actual LoRA count, it means we're on the "no lora" option
if (state.includeNoLora.value && index === actualLoraCount + 1) {
state.currentLoraName.value = 'No LoRA'
state.currentLoraFilename.value = 'No LoRA'
return
}
// Otherwise, show normal LoRA info
if (actualLoraCount > 0 && index > 0 && index <= actualLoraCount) {
const currentLora = loraList[index - 1]
if (currentLora) {
state.currentLoraName.value = currentLora.file_name
@@ -124,6 +163,14 @@ const updateDisplayFromLoraList = (loraList: LoraItem[], index: number) => {
// Handle index update from user
const handleIndexUpdate = async (newIndex: number) => {
// Calculate max valid index (includes no lora slot if enabled)
const maxIndex = state.includeNoLora.value
? state.totalCount.value + 1
: state.totalCount.value
// Clamp index to valid range
const clampedIndex = Math.max(1, Math.min(newIndex, maxIndex || 1))
// Reset execution state when user manually changes index
// This ensures the next execution starts from the user-set index
;(props.widget as any)[HAS_EXECUTED] = false
@@ -134,14 +181,14 @@ const handleIndexUpdate = async (newIndex: number) => {
executionQueue.length = 0
hasQueuedPrompts.value = false
state.setIndex(newIndex)
state.setIndex(clampedIndex)
// Refresh list to update current LoRA display
try {
const poolConfig = getPoolConfig()
const loraList = await state.fetchCyclerList(poolConfig)
cachedLoraList.value = loraList
updateDisplayFromLoraList(loraList, newIndex)
updateDisplayFromLoraList(loraList, clampedIndex)
} catch (error) {
console.error('[LoraCyclerWidget] Error updating index:', error)
}
@@ -169,6 +216,17 @@ const handleRepeatCountChange = (newValue: number) => {
state.displayRepeatUsed.value = 0
}
// Handle include no lora toggle
const handleIncludeNoLoraChange = (newValue: boolean) => {
state.includeNoLora.value = newValue
// If turning off and current index is beyond the actual LoRA count,
// clamp it to the last valid LoRA index
if (!newValue && state.currentIndex.value > state.totalCount.value) {
state.currentIndex.value = Math.max(1, state.totalCount.value)
}
}
// Handle pause toggle
const handleTogglePause = () => {
state.togglePause()

View File

@@ -10,7 +10,6 @@
:exclude-folders="state.excludeFolders.value"
:no-credit-required="state.noCreditRequired.value"
:allow-selling="state.allowSelling.value"
:include-empty-lora="state.includeEmptyLora.value"
:preview-items="state.previewItems.value"
:match-count="state.matchCount.value"
:is-loading="state.isLoading.value"
@@ -19,7 +18,6 @@
@update:exclude-folders="state.excludeFolders.value = $event"
@update:no-credit-required="state.noCreditRequired.value = $event"
@update:allow-selling="state.allowSelling.value = $event"
@update:include-empty-lora="state.includeEmptyLora.value = $event"
@refresh="state.refreshPreview"
/>

View File

@@ -13,7 +13,9 @@
@click="handleOpenSelector"
>
<span class="progress-label">{{ isWorkflowExecuting ? 'Using LoRA:' : 'Next LoRA:' }}</span>
<span class="progress-name clickable" :class="{ disabled: isPauseDisabled }" :title="currentLoraFilename">
<span class="progress-name clickable"
:class="{ disabled: isPauseDisabled, 'no-lora': isNoLora }"
:title="currentLoraFilename">
{{ currentLoraName || 'None' }}
<svg class="selector-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 10l5 5 5-5z"/>
@@ -160,6 +162,27 @@
/>
</div>
</div>
<!-- Include No LoRA Toggle -->
<div class="setting-section">
<div class="section-header-with-toggle">
<label class="setting-label">
Add "No LoRA" step
</label>
<button
type="button"
class="toggle-switch"
:class="{ 'toggle-switch--active': includeNoLora }"
@click="$emit('update:includeNoLora', !includeNoLora)"
role="switch"
:aria-checked="includeNoLora"
title="Add an iteration without LoRA for comparison"
>
<span class="toggle-switch__track"></span>
<span class="toggle-switch__thumb"></span>
</button>
</div>
</div>
</div>
</template>
@@ -182,6 +205,8 @@ const props = defineProps<{
isPauseDisabled: boolean
isWorkflowExecuting: boolean
executingRepeatStep: number
includeNoLora: boolean
isNoLora?: boolean
}>()
const emit = defineEmits<{
@@ -190,6 +215,7 @@ const emit = defineEmits<{
'update:clipStrength': [value: number]
'update:useCustomClipRange': [value: boolean]
'update:repeatCount': [value: number]
'update:includeNoLora': [value: boolean]
'toggle-pause': []
'reset-index': []
'open-lora-selector': []
@@ -346,6 +372,16 @@ const onRepeatBlur = (event: Event) => {
color: rgba(191, 219, 254, 1);
}
.progress-name.no-lora {
font-style: italic;
color: rgba(226, 232, 240, 0.6);
}
.progress-name.clickable.no-lora:hover:not(.disabled) {
background: rgba(160, 174, 192, 0.2);
color: rgba(226, 232, 240, 0.8);
}
.progress-name.clickable.disabled {
cursor: not-allowed;
opacity: 0.5;

View File

@@ -35,7 +35,10 @@
v-for="item in filteredList"
:key="item.index"
class="lora-item"
:class="{ active: currentIndex === item.index }"
:class="{
active: currentIndex === item.index,
'no-lora-item': item.lora.file_name === 'No LoRA'
}"
@mouseenter="showPreview(item.lora.file_name, $event)"
@mouseleave="hidePreview"
@click="selectLora(item.index)"
@@ -65,6 +68,7 @@ const props = defineProps<{
visible: boolean
loraList: LoraItem[]
currentIndex: number
includeNoLora?: boolean
}>()
const emit = defineEmits<{
@@ -79,7 +83,8 @@ const searchInputRef = ref<HTMLInputElement | null>(null)
let previewTooltip: any = null
const subtitleText = computed(() => {
const total = props.loraList.length
const baseTotal = props.loraList.length
const total = props.includeNoLora ? baseTotal + 1 : baseTotal
const filtered = filteredList.value.length
if (filtered === total) {
return `Total: ${total} LoRA${total !== 1 ? 's' : ''}`
@@ -88,11 +93,19 @@ const subtitleText = computed(() => {
})
const filteredList = computed<LoraListItem[]>(() => {
const list = props.loraList.map((lora, idx) => ({
const list: LoraListItem[] = props.loraList.map((lora, idx) => ({
index: idx + 1,
lora
}))
// Add "No LoRA" option at the end if includeNoLora is enabled
if (props.includeNoLora) {
list.push({
index: list.length + 1,
lora: { file_name: 'No LoRA' } as LoraItem
})
}
if (!searchQuery.value.trim()) {
return list
}
@@ -303,6 +316,15 @@ onUnmounted(() => {
font-weight: 500;
}
.lora-item.no-lora-item .lora-name {
font-style: italic;
color: rgba(226, 232, 240, 0.6);
}
.lora-item.no-lora-item:hover .lora-name {
color: rgba(226, 232, 240, 0.8);
}
.no-results {
padding: 32px 20px;
text-align: center;

View File

@@ -27,10 +27,8 @@
<LicenseSection
:no-credit-required="noCreditRequired"
:allow-selling="allowSelling"
:include-empty-lora="includeEmptyLora"
@update:no-credit-required="$emit('update:noCreditRequired', $event)"
@update:allow-selling="$emit('update:allowSelling', $event)"
@update:include-empty-lora="$emit('update:includeEmptyLora', $event)"
/>
</div>
@@ -63,10 +61,9 @@ defineProps<{
// Folders
includeFolders: string[]
excludeFolders: string[]
// License & Misc
// License
noCreditRequired: boolean
allowSelling: boolean
includeEmptyLora: boolean
// Preview
previewItems: LoraItem[]
matchCount: number
@@ -79,7 +76,6 @@ defineEmits<{
'update:excludeFolders': [value: string[]]
'update:noCreditRequired': [value: boolean]
'update:allowSelling': [value: boolean]
'update:includeEmptyLora': [value: boolean]
refresh: []
}>()
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div class="section">
<div class="section__header">
<span class="section__title">LICENSE & OPTIONS</span>
<span class="section__title">LICENSE</span>
</div>
<div class="section__toggles">
<label class="toggle-item">
@@ -33,22 +33,6 @@
<span class="toggle-switch__thumb"></span>
</button>
</label>
<label class="toggle-item">
<span class="toggle-item__label">Include No LoRAs</span>
<button
type="button"
class="toggle-switch"
:class="{ 'toggle-switch--active': includeEmptyLora }"
@click="$emit('update:includeEmptyLora', !includeEmptyLora)"
role="switch"
:aria-checked="includeEmptyLora"
title="Include an empty/blank LoRA option in the pool results"
>
<span class="toggle-switch__track"></span>
<span class="toggle-switch__thumb"></span>
</button>
</label>
</div>
</div>
</template>
@@ -57,13 +41,11 @@
defineProps<{
noCreditRequired: boolean
allowSelling: boolean
includeEmptyLora: boolean
}>()
defineEmits<{
'update:noCreditRequired': [value: boolean]
'update:allowSelling': [value: boolean]
'update:includeEmptyLora': [value: boolean]
}>()
</script>
@@ -87,7 +69,6 @@ defineEmits<{
.section__toggles {
display: flex;
flex-wrap: wrap;
gap: 16px;
}