mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
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:
219
vue-widgets/src/components/LoraCyclerWidget.vue
Normal file
219
vue-widgets/src/components/LoraCyclerWidget.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
208
vue-widgets/src/composables/useLoraCyclerState.ts
Normal file
208
vue-widgets/src/composables/useLoraCyclerState.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user