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

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