feat: add LoraDemoNode and LoraRandomizerNode with documentation

- Import and register two new nodes: LoraDemoNode and LoraRandomizerNode
- Update import exception handling for better readability with multi-line formatting
- Add comprehensive documentation file `docs/custom-node-ui-output.md` for UI output usage in custom nodes
- Ensure proper node registration in NODE_CLASS_MAPPINGS for ComfyUI integration
- Maintain backward compatibility with existing node structure and import fallbacks
This commit is contained in:
Will Miao
2026-01-12 15:06:38 +08:00
parent 65cede7335
commit 177b20263d
18 changed files with 2404 additions and 242 deletions

View File

@@ -0,0 +1,110 @@
<template>
<div class="lora-randomizer-widget">
<LoraRandomizerSettingsView
:count-mode="state.countMode.value"
:count-fixed="state.countFixed.value"
:count-min="state.countMin.value"
:count-max="state.countMax.value"
:model-strength-min="state.modelStrengthMin.value"
:model-strength-max="state.modelStrengthMax.value"
:use-same-clip-strength="state.useSameClipStrength.value"
:clip-strength-min="state.clipStrengthMin.value"
:clip-strength-max="state.clipStrengthMax.value"
:roll-mode="state.rollMode.value"
:is-rolling="state.isRolling.value"
:is-clip-strength-disabled="state.isClipStrengthDisabled.value"
@update:count-mode="state.countMode.value = $event"
@update:count-fixed="state.countFixed.value = $event"
@update:count-min="state.countMin.value = $event"
@update:count-max="state.countMax.value = $event"
@update:model-strength-min="state.modelStrengthMin.value = $event"
@update:model-strength-max="state.modelStrengthMax.value = $event"
@update:use-same-clip-strength="state.useSameClipStrength.value = $event"
@update:clip-strength-min="state.clipStrengthMin.value = $event"
@update:clip-strength-max="state.clipStrengthMax.value = $event"
@update:roll-mode="state.rollMode.value = $event"
@roll="handleRoll"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import LoraRandomizerSettingsView from './lora-randomizer/LoraRandomizerSettingsView.vue'
import { useLoraRandomizerState } from '../composables/useLoraRandomizerState'
import type { ComponentWidget, RandomizerConfig } from '../composables/types'
// Props
const props = defineProps<{
widget: ComponentWidget
node: { id: number }
}>()
// State management
const state = useLoraRandomizerState(props.widget)
// Handle roll button click
const handleRoll = async () => {
try {
console.log('[LoraRandomizerWidget] Roll button clicked')
// Get pool config from connected input (if any)
// This would need to be passed from the node's pool_config input
const poolConfig = null // TODO: Get from node input if connected
// Get locked loras from the loras widget
// This would need to be retrieved from the loras widget on the node
const lockedLoras: any[] = [] // TODO: Get from loras widget
// Call API to get random loras
const randomLoras = await state.rollLoras(poolConfig, lockedLoras)
console.log('[LoraRandomizerWidget] Got random LoRAs:', randomLoras)
// Update the loras widget with the new selection
// This will be handled by emitting an event or directly updating the loras widget
// For now, we'll emit a custom event that the parent widget handler can catch
if (typeof (props.widget as any).onRoll === 'function') {
(props.widget as any).onRoll(randomLoras)
}
} catch (error) {
console.error('[LoraRandomizerWidget] Error rolling LoRAs:', error)
alert('Failed to roll LoRAs: ' + (error as Error).message)
}
}
// Lifecycle
onMounted(async () => {
// Setup serialization
props.widget.serializeValue = async () => {
const config = state.buildConfig()
console.log('[LoraRandomizerWidget] Serializing config:', config)
return config
}
// Handle external value updates (e.g., loading workflow, paste)
props.widget.onSetValue = (v) => {
console.log('[LoraRandomizerWidget] Restoring from config:', v)
state.restoreFromConfig(v as RandomizerConfig)
}
// Restore from saved value
if (props.widget.value) {
console.log('[LoraRandomizerWidget] Restoring from saved value:', props.widget.value)
state.restoreFromConfig(props.widget.value as RandomizerConfig)
}
})
</script>
<style scoped>
.lora-randomizer-widget {
padding: 12px;
background: rgba(40, 44, 52, 0.6);
border-radius: 4px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,359 @@
<template>
<div class="randomizer-settings">
<div class="settings-header">
<h3 class="settings-title">RANDOMIZER SETTINGS</h3>
</div>
<!-- LoRA Count -->
<div class="setting-section">
<label class="setting-label">LoRA Count</label>
<div class="count-mode-selector">
<label class="radio-label">
<input
type="radio"
name="count-mode"
value="fixed"
:checked="countMode === 'fixed'"
@change="$emit('update:countMode', 'fixed')"
/>
<span>Fixed:</span>
<input
type="number"
class="number-input"
:value="countFixed"
:disabled="countMode !== 'fixed'"
min="1"
max="100"
@input="$emit('update:countFixed', parseInt(($event.target as HTMLInputElement).value))"
/>
</label>
</div>
<div class="count-mode-selector">
<label class="radio-label">
<input
type="radio"
name="count-mode"
value="range"
:checked="countMode === 'range'"
@change="$emit('update:countMode', 'range')"
/>
<span>Range:</span>
<input
type="number"
class="number-input"
:value="countMin"
:disabled="countMode !== 'range'"
min="1"
max="100"
@input="$emit('update:countMin', parseInt(($event.target as HTMLInputElement).value))"
/>
<span>to</span>
<input
type="number"
class="number-input"
:value="countMax"
:disabled="countMode !== 'range'"
min="1"
max="100"
@input="$emit('update:countMax', parseInt(($event.target as HTMLInputElement).value))"
/>
</label>
</div>
</div>
<!-- Model Strength Range -->
<div class="setting-section">
<label class="setting-label">Model Strength Range</label>
<div class="strength-inputs">
<div class="strength-input-group">
<label>Min:</label>
<input
type="number"
class="number-input"
:value="modelStrengthMin"
min="0"
max="10"
step="0.1"
@input="$emit('update:modelStrengthMin', parseFloat(($event.target as HTMLInputElement).value))"
/>
</div>
<div class="strength-input-group">
<label>Max:</label>
<input
type="number"
class="number-input"
:value="modelStrengthMax"
min="0"
max="10"
step="0.1"
@input="$emit('update:modelStrengthMax', parseFloat(($event.target as HTMLInputElement).value))"
/>
</div>
</div>
</div>
<!-- Clip Strength Range -->
<div class="setting-section">
<label class="setting-label">Clip Strength Range</label>
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
:checked="useSameClipStrength"
@change="$emit('update:useSameClipStrength', ($event.target as HTMLInputElement).checked)"
/>
<span>Same as model</span>
</label>
</div>
<div class="strength-inputs" :class="{ disabled: isClipStrengthDisabled }">
<div class="strength-input-group">
<label>Min:</label>
<input
type="number"
class="number-input"
:value="clipStrengthMin"
:disabled="isClipStrengthDisabled"
min="0"
max="10"
step="0.1"
@input="$emit('update:clipStrengthMin', parseFloat(($event.target as HTMLInputElement).value))"
/>
</div>
<div class="strength-input-group">
<label>Max:</label>
<input
type="number"
class="number-input"
:value="clipStrengthMax"
:disabled="isClipStrengthDisabled"
min="0"
max="10"
step="0.1"
@input="$emit('update:clipStrengthMax', parseFloat(($event.target as HTMLInputElement).value))"
/>
</div>
</div>
</div>
<!-- Roll Mode -->
<div class="setting-section">
<label class="setting-label">Roll Mode</label>
<div class="roll-mode-selector">
<label class="radio-label">
<input
type="radio"
name="roll-mode"
value="frontend"
:checked="rollMode === 'frontend'"
@change="$emit('update:rollMode', 'frontend')"
/>
<span>Frontend Roll (fixed until re-rolled)</span>
</label>
<button
class="roll-button"
:disabled="rollMode !== 'frontend' || isRolling"
@click="$emit('roll')"
>
<span v-if="!isRolling">🎲 Roll</span>
<span v-else>Rolling...</span>
</button>
</div>
<div class="roll-mode-selector">
<label class="radio-label">
<input
type="radio"
name="roll-mode"
value="backend"
:checked="rollMode === 'backend'"
@change="$emit('update:rollMode', 'backend')"
/>
<span>Backend Roll (randomizes each execution)</span>
</label>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
countMode: 'fixed' | 'range'
countFixed: number
countMin: number
countMax: number
modelStrengthMin: number
modelStrengthMax: number
useSameClipStrength: boolean
clipStrengthMin: number
clipStrengthMax: number
rollMode: 'frontend' | 'backend'
isRolling: boolean
isClipStrengthDisabled: boolean
}>()
defineEmits<{
'update:countMode': [value: 'fixed' | 'range']
'update:countFixed': [value: number]
'update:countMin': [value: number]
'update:countMax': [value: number]
'update:modelStrengthMin': [value: number]
'update:modelStrengthMax': [value: number]
'update:useSameClipStrength': [value: boolean]
'update:clipStrengthMin': [value: number]
'update:clipStrengthMax': [value: number]
'update:rollMode': [value: 'frontend' | 'backend']
roll: []
}>()
</script>
<style scoped>
.randomizer-settings {
display: flex;
flex-direction: column;
gap: 16px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: #e4e4e7;
}
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.settings-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
color: #a1a1aa;
margin: 0;
text-transform: uppercase;
}
.setting-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.setting-label {
font-size: 12px;
font-weight: 500;
color: #d4d4d8;
}
.count-mode-selector,
.roll-mode-selector {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: rgba(30, 30, 36, 0.5);
border-radius: 4px;
}
.radio-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #e4e4e7;
cursor: pointer;
flex: 1;
}
.radio-label input[type='radio'] {
cursor: pointer;
}
.radio-label input[type='radio']:disabled {
cursor: not-allowed;
}
.number-input {
width: 60px;
padding: 4px 8px;
background: rgba(20, 20, 24, 0.6);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 3px;
color: #e4e4e7;
font-size: 13px;
}
.number-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.strength-inputs {
display: flex;
gap: 12px;
padding: 6px 8px;
background: rgba(30, 30, 36, 0.5);
border-radius: 4px;
}
.strength-inputs.disabled {
opacity: 0.5;
}
.strength-input-group {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}
.strength-input-group label {
font-size: 12px;
color: #d4d4d8;
}
.checkbox-group {
padding: 6px 8px;
background: rgba(30, 30, 36, 0.5);
border-radius: 4px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #e4e4e7;
cursor: pointer;
}
.checkbox-label input[type='checkbox'] {
cursor: pointer;
}
.roll-button {
padding: 6px 16px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
border-radius: 4px;
color: white;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.roll-button:hover:not(:disabled) {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
}
.roll-button:active:not(:disabled) {
transform: translateY(0);
}
.roll-button:disabled {
opacity: 0.5;
cursor: not-allowed;
background: linear-gradient(135deg, #52525b 0%, #3f3f46 100%);
}
</style>

View File

@@ -37,13 +37,6 @@ export interface FolderTreeNode {
children?: FolderTreeNode[]
}
export interface ComponentWidget {
serializeValue?: () => Promise<LoraPoolConfig>
value?: LoraPoolConfig | LegacyLoraPoolConfig
onSetValue?: (v: LoraPoolConfig | LegacyLoraPoolConfig) => void
updateConfig?: (v: LoraPoolConfig) => void
}
// Legacy config for migration (v1)
export interface LegacyLoraPoolConfig {
version: 1
@@ -59,3 +52,33 @@ export interface LegacyLoraPoolConfig {
}
preview: { matchCount: number; lastUpdated: number }
}
// Randomizer config
export interface RandomizerConfig {
count_mode: 'fixed' | 'range'
count_fixed: number
count_min: number
count_max: number
model_strength_min: number
model_strength_max: number
use_same_clip_strength: boolean
clip_strength_min: number
clip_strength_max: number
roll_mode: 'frontend' | 'backend'
}
export interface LoraEntry {
name: string
strength: number
clipStrength: number
active: boolean
expanded: boolean
locked: boolean
}
export interface ComponentWidget {
serializeValue?: () => Promise<LoraPoolConfig | RandomizerConfig>
value?: LoraPoolConfig | LegacyLoraPoolConfig | RandomizerConfig
onSetValue?: (v: LoraPoolConfig | LegacyLoraPoolConfig | RandomizerConfig) => void
updateConfig?: (v: LoraPoolConfig | RandomizerConfig) => void
}

View File

@@ -0,0 +1,142 @@
import { ref, computed } from 'vue'
import type { ComponentWidget, RandomizerConfig, LoraEntry } from './types'
export function useLoraRandomizerState(widget: ComponentWidget) {
// State refs
const countMode = ref<'fixed' | 'range'>('range')
const countFixed = ref(5)
const countMin = ref(3)
const countMax = ref(7)
const modelStrengthMin = ref(0.0)
const modelStrengthMax = ref(1.0)
const useSameClipStrength = ref(true)
const clipStrengthMin = ref(0.0)
const clipStrengthMax = ref(1.0)
const rollMode = ref<'frontend' | 'backend'>('frontend')
const isRolling = ref(false)
// Build config object from current state
const buildConfig = (): RandomizerConfig => ({
count_mode: countMode.value,
count_fixed: countFixed.value,
count_min: countMin.value,
count_max: countMax.value,
model_strength_min: modelStrengthMin.value,
model_strength_max: modelStrengthMax.value,
use_same_clip_strength: useSameClipStrength.value,
clip_strength_min: clipStrengthMin.value,
clip_strength_max: clipStrengthMax.value,
roll_mode: rollMode.value,
})
// Restore state from config object
const restoreFromConfig = (config: RandomizerConfig) => {
countMode.value = config.count_mode || 'range'
countFixed.value = config.count_fixed || 5
countMin.value = config.count_min || 3
countMax.value = config.count_max || 7
modelStrengthMin.value = config.model_strength_min ?? 0.0
modelStrengthMax.value = config.model_strength_max ?? 1.0
useSameClipStrength.value = config.use_same_clip_strength ?? true
clipStrengthMin.value = config.clip_strength_min ?? 0.0
clipStrengthMax.value = config.clip_strength_max ?? 1.0
rollMode.value = config.roll_mode || 'frontend'
}
// Roll loras - call API to get random selection
const rollLoras = async (
poolConfig: any | null,
lockedLoras: LoraEntry[]
): Promise<LoraEntry[]> => {
try {
isRolling.value = true
const config = buildConfig()
// Build request body
const requestBody: any = {
model_strength_min: config.model_strength_min,
model_strength_max: config.model_strength_max,
use_same_clip_strength: config.use_same_clip_strength,
clip_strength_min: config.clip_strength_min,
clip_strength_max: config.clip_strength_max,
locked_loras: lockedLoras,
}
// Add count parameters
if (config.count_mode === 'fixed') {
requestBody.count = config.count_fixed
} else {
requestBody.count_min = config.count_min
requestBody.count_max = config.count_max
}
// Add pool config if provided
if (poolConfig) {
// Convert pool config to backend format
requestBody.pool_config = {
selected_base_models: poolConfig.filters?.baseModels || [],
include_tags: poolConfig.filters?.tags?.include || [],
exclude_tags: poolConfig.filters?.tags?.exclude || [],
include_folders: poolConfig.filters?.folders?.include || [],
exclude_folders: poolConfig.filters?.folders?.exclude || [],
no_credit_required: poolConfig.filters?.license?.noCreditRequired || false,
allow_selling: poolConfig.filters?.license?.allowSelling || false,
}
}
// Call API endpoint
const response = await fetch('/api/lm/loras/random-sample', {
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 random LoRAs')
}
const data = await response.json()
if (!data.success) {
throw new Error(data.error || 'Failed to get random LoRAs')
}
return data.loras || []
} catch (error) {
console.error('[LoraRandomizerState] Error rolling LoRAs:', error)
throw error
} finally {
isRolling.value = false
}
}
// Computed properties
const isClipStrengthDisabled = computed(() => useSameClipStrength.value)
return {
// State refs
countMode,
countFixed,
countMin,
countMax,
modelStrengthMin,
modelStrengthMax,
useSameClipStrength,
clipStrengthMin,
clipStrengthMax,
rollMode,
isRolling,
// Computed
isClipStrengthDisabled,
// Methods
buildConfig,
restoreFromConfig,
rollLoras,
}
}

View File

@@ -1,13 +1,17 @@
import { createApp, type App as VueApp } from 'vue'
import PrimeVue from 'primevue/config'
import LoraPoolWidget from '@/components/LoraPoolWidget.vue'
import type { LoraPoolConfig, LegacyLoraPoolConfig } from './composables/types'
import LoraRandomizerWidget from '@/components/LoraRandomizerWidget.vue'
import type { LoraPoolConfig, LegacyLoraPoolConfig, RandomizerConfig } from './composables/types'
// @ts-ignore - ComfyUI external module
import { app } from '../../../scripts/app.js'
const vueApps = new Map<number, VueApp>()
// Cache for dynamically loaded addLorasWidget module
let addLorasWidgetCache: any = null
// @ts-ignore
function createLoraPoolWidget(node) {
const container = document.createElement('div')
@@ -78,14 +82,109 @@ function createLoraPoolWidget(node) {
return { widget }
}
// @ts-ignore
function createLoraRandomizerWidget(node) {
const container = document.createElement('div')
container.id = `lora-randomizer-widget-${node.id}`
container.style.width = '100%'
container.style.height = '100%'
container.style.display = 'flex'
container.style.flexDirection = 'column'
container.style.overflow = 'hidden'
let internalValue: RandomizerConfig | undefined
const widget = node.addDOMWidget(
'randomizer_config',
'RANDOMIZER_CONFIG',
container,
{
getValue() {
return internalValue
},
setValue(v: RandomizerConfig) {
internalValue = v
if (typeof widget.onSetValue === 'function') {
widget.onSetValue(v)
}
},
serialize: true,
getMinHeight() {
return 500
}
}
)
widget.updateConfig = (v: RandomizerConfig) => {
internalValue = v
}
// Handle roll event from Vue component
widget.onRoll = (randomLoras: any[]) => {
console.log('[createLoraRandomizerWidget] Roll event received:', randomLoras)
// Find the loras widget on this node and update it
const lorasWidget = node.widgets.find((w: any) => w.name === 'loras')
if (lorasWidget) {
lorasWidget.value = randomLoras
console.log('[createLoraRandomizerWidget] Updated loras widget with rolled LoRAs')
} else {
console.warn('[createLoraRandomizerWidget] loras widget not found on node')
}
}
const vueApp = createApp(LoraRandomizerWidget, {
widget,
node
})
vueApp.use(PrimeVue, {
unstyled: true,
ripple: false
})
vueApp.mount(container)
vueApps.set(node.id + 10000, vueApp) // Offset to avoid collision with pool widget
widget.computeLayoutSize = () => {
const minWidth = 500
const minHeight = 500
const maxHeight = 500
return { minHeight, minWidth, maxHeight }
}
widget.onRemove = () => {
const vueApp = vueApps.get(node.id + 10000)
if (vueApp) {
vueApp.unmount()
vueApps.delete(node.id + 10000)
}
}
return { widget }
}
app.registerExtension({
name: 'LoraManager.VueWidgets',
getCustomWidgets() {
getCustomWidgets() {
return {
// @ts-ignore
LORA_POOL_CONFIG(node) {
return createLoraPoolWidget(node)
},
// @ts-ignore
RANDOMIZER_CONFIG(node) {
return createLoraRandomizerWidget(node)
},
// @ts-ignore
async LORAS(node: any) {
if (!addLorasWidgetCache) {
const module = await import(/* @vite-ignore */ '../loras_widget.js')
addLorasWidgetCache = module.addLorasWidget
}
return addLorasWidgetCache(node, 'loras', {}, null)
}
}
}