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,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,
}
}