docs: add frontend UI architecture and ComfyUI widget guidelines

- Document dual UI systems: standalone web UI and ComfyUI custom node widgets
- Add ComfyUI widget development guidelines including styling and constraints
- Update terminology in LoraRandomizerNode from 'frontend/backend' to 'fixed/always' for clarity
- Include UI constraints for ComfyUI widgets: minimize vertical space, avoid dynamic height changes, keep UI simple
This commit is contained in:
Will Miao
2026-01-13 11:20:50 +08:00
parent bce6b0e610
commit 6a17e75782
16 changed files with 877 additions and 244 deletions

View File

@@ -13,6 +13,9 @@
:roll-mode="state.rollMode.value"
:is-rolling="state.isRolling.value"
:is-clip-strength-disabled="state.isClipStrengthDisabled.value"
:last-used="state.lastUsed.value"
:current-loras="currentLoras"
:can-reuse-last="canReuseLast"
@update:count-mode="state.countMode.value = $event"
@update:count-fixed="state.countFixed.value = $event"
@update:count-min="state.countMin.value = $event"
@@ -23,13 +26,15 @@
@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"
@generate-fixed="handleGenerateFixed"
@always-randomize="handleAlwaysRandomize"
@reuse-last="handleReuseLast"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { onMounted, computed, ref, watch } from 'vue'
import LoraRandomizerSettingsView from './lora-randomizer/LoraRandomizerSettingsView.vue'
import { useLoraRandomizerState } from '../composables/useLoraRandomizerState'
import type { ComponentWidget, RandomizerConfig, LoraEntry } from '../composables/types'
@@ -43,11 +48,31 @@ const props = defineProps<{
// State management
const state = useLoraRandomizerState(props.widget)
// Handle roll button click
const handleRoll = async () => {
try {
console.log('[LoraRandomizerWidget] Roll button clicked')
// Track current loras from the loras widget
const currentLoras = ref<LoraEntry[]>([])
// Computed property to check if we can reuse last
const canReuseLast = computed(() => {
const lastUsed = state.lastUsed.value
if (!lastUsed || lastUsed.length === 0) return false
return !areLorasEqual(currentLoras.value, lastUsed)
})
// Helper function to compare two lora lists
const areLorasEqual = (a: LoraEntry[], b: LoraEntry[]): boolean => {
if (a.length !== b.length) return false
const sortedA = [...a].sort((x, y) => x.name.localeCompare(y.name))
const sortedB = [...b].sort((x, y) => x.name.localeCompare(y.name))
return sortedA.every((lora, i) =>
lora.name === sortedB[i].name &&
lora.strength === sortedB[i].strength &&
lora.clipStrength === sortedB[i].clipStrength
)
}
// Handle "Generate Fixed" button click
const handleGenerateFixed = async () => {
try {
// Get pool config from connected pool_config input
const poolConfig = (props.node as any).getPoolConfig?.() || null
@@ -55,46 +80,116 @@ const handleRoll = async () => {
const lorasWidget = props.node.widgets?.find((w: any) => w.name === "loras")
const lockedLoras: LoraEntry[] = (lorasWidget?.value || []).filter((lora: LoraEntry) => lora.locked === true)
console.log('[LoraRandomizerWidget] Pool config:', poolConfig)
console.log('[LoraRandomizerWidget] Locked loras:', lockedLoras)
// Call API to get random loras
const randomLoras = await state.rollLoras(poolConfig, lockedLoras)
// Update the loras widget with the new selection
if (lorasWidget) {
lorasWidget.value = randomLoras
currentLoras.value = randomLoras
}
// Set roll mode to fixed
state.rollMode.value = 'fixed'
} catch (error) {
console.error('[LoraRandomizerWidget] Error generating fixed LoRAs:', error)
alert('Failed to generate LoRAs: ' + (error as Error).message)
}
}
// Handle "Always Randomize" button click
const handleAlwaysRandomize = async () => {
try {
// Get pool config from connected pool_config input
const poolConfig = (props.node as any).getPoolConfig?.() || null
// Get locked loras from the loras widget
const lorasWidget = props.node.widgets?.find((w: any) => w.name === "loras")
const lockedLoras: LoraEntry[] = (lorasWidget?.value || []).filter((lora: LoraEntry) => lora.locked === true)
// 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)
if (lorasWidget) {
lorasWidget.value = randomLoras
currentLoras.value = randomLoras
}
// Set roll mode to always
state.rollMode.value = 'always'
} catch (error) {
console.error('[LoraRandomizerWidget] Error rolling LoRAs:', error)
alert('Failed to roll LoRAs: ' + (error as Error).message)
console.error('[LoraRandomizerWidget] Error generating random LoRAs:', error)
alert('Failed to generate LoRAs: ' + (error as Error).message)
}
}
// Handle "Reuse Last" button click
const handleReuseLast = () => {
const lastUsedLoras = state.useLastUsed()
if (lastUsedLoras) {
// Update the loras widget with the last used combination
const lorasWidget = props.node.widgets?.find((w: any) => w.name === 'loras')
if (lorasWidget) {
lorasWidget.value = lastUsedLoras
currentLoras.value = lastUsedLoras
}
// Switch to fixed mode
state.rollMode.value = 'fixed'
}
}
// Watch for changes to the loras widget to track current loras
watch(() => props.node.widgets?.find((w: any) => w.name === 'loras')?.value, (newVal) => {
if (newVal && Array.isArray(newVal)) {
currentLoras.value = newVal
}
}, { immediate: true, deep: true })
// 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)
}
// 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("[LoraRandomizerWidget] Node executed with output:", output)
// Update last_used from backend
if (output?.last_used !== undefined) {
state.lastUsed.value = output.last_used
console.log(`[LoraRandomizerWidget] Updated last_used: ${output.last_used ? output.last_used.length : 0} LoRAs`)
}
// Update loras widget if backend provided new loras
const lorasWidget = props.node.widgets?.find((w: any) => w.name === 'loras')
if (lorasWidget && output?.loras && Array.isArray(output.loras)) {
console.log("[LoraRandomizerWidget] Received loras data from backend:", output.loras)
lorasWidget.value = output.loras
currentLoras.value = output.loras
}
// Call original onExecuted if it exists
if (originalOnExecuted) {
return originalOnExecuted(output)
}
}
})
</script>

View File

@@ -0,0 +1,155 @@
<template>
<div class="last-used-preview">
<div class="last-used-preview__content">
<div
v-for="lora in displayLoras"
:key="lora.name"
class="last-used-preview__item"
>
<img
v-if="previewUrls[lora.name]"
:src="previewUrls[lora.name]"
class="last-used-preview__thumb"
@error="onImageError(lora.name)"
/>
<div v-else class="last-used-preview__thumb last-used-preview__thumb--placeholder">
<svg viewBox="0 0 16 16" fill="currentColor">
<path d="M6.002 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>
<path d="M2.002 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2h-12zm12 1a1 1 0 0 1 1 1v6.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12V3a1 1 0 0 1 1-1h12z"/>
</svg>
</div>
<div class="last-used-preview__info">
<span class="last-used-preview__name">{{ lora.name }}</span>
<span class="last-used-preview__strength">
M: {{ lora.strength }}{{ lora.clipStrength !== undefined ? ` / C: ${lora.clipStrength}` : '' }}
</span>
</div>
</div>
<div v-if="loras.length > 5" class="last-used-preview__more">
+{{ (loras.length - 5).toLocaleString() }} more LoRAs
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { LoraEntry } from '../../composables/types'
const props = defineProps<{
loras: LoraEntry[]
}>()
const displayLoras = computed(() => props.loras.slice(0, 5))
// Preview URLs cache
const previewUrls = ref<Record<string, string>>({})
// Fetch preview URL for a lora using API
const fetchPreviewUrl = async (loraName: string) => {
try {
const response = await fetch(`/api/lm/loras/preview-url?name=${encodeURIComponent(loraName)}`)
if (response.ok) {
const data = await response.json()
if (data.preview_url) {
previewUrls.value[loraName] = data.preview_url
}
}
} catch (error) {
// Silent fail, just use placeholder
}
}
// Load preview URLs on mount
props.loras.forEach(lora => {
fetchPreviewUrl(lora.name)
})
const onImageError = (loraName: string) => {
previewUrls.value[loraName] = ''
}
</script>
<style scoped>
.last-used-preview {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
z-index: 100;
width: 280px;
}
.last-used-preview__content {
background: var(--comfy-menu-bg, #1a1a1a);
border: 1px solid var(--border-color, #444);
border-radius: 6px;
padding: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
gap: 4px;
}
.last-used-preview__item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
background: var(--comfy-input-bg, #333);
border-radius: 4px;
}
.last-used-preview__thumb {
width: 28px;
height: 28px;
object-fit: cover;
border-radius: 3px;
flex-shrink: 0;
background: rgba(0, 0, 0, 0.2);
}
.last-used-preview__thumb--placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--fg-color, #fff);
opacity: 0.2;
}
.last-used-preview__thumb--placeholder svg {
width: 14px;
height: 14px;
}
.last-used-preview__info {
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.last-used-preview__name {
font-size: 11px;
color: var(--fg-color, #fff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.last-used-preview__strength {
font-size: 10px;
color: var(--fg-color, #fff);
opacity: 0.5;
}
.last-used-preview__more {
font-size: 11px;
color: var(--fg-color, #fff);
opacity: 0.5;
text-align: center;
padding: 4px;
}
</style>

View File

@@ -135,48 +135,73 @@
</div>
</div>
<!-- Roll Mode -->
<!-- Roll Mode - New 3-button design -->
<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')"
<div class="roll-buttons-with-tooltip">
<div class="roll-buttons">
<button
class="roll-button"
:class="{ selected: rollMode === 'fixed' }"
:disabled="isRolling"
@click="$emit('generate-fixed')"
>
<svg class="roll-button__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="20" rx="5"/>
<circle cx="12" cy="12" r="3"/>
<circle cx="6" cy="8" r="1.5"/>
<circle cx="18" cy="16" r="1.5"/>
</svg>
<span class="roll-button__text">Generate Fixed</span>
</button>
<button
class="roll-button"
:class="{ selected: rollMode === 'always' }"
:disabled="isRolling"
@click="$emit('always-randomize')"
>
<svg class="roll-button__icon" 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"/>
<circle cx="12" cy="12" r="3"/>
<circle cx="6" cy="8" r="1.5"/>
<circle cx="18" cy="16" r="1.5"/>
</svg>
<span class="roll-button__text">Always Randomize</span>
</button>
<button
class="roll-button"
:class="{ selected: rollMode === 'fixed' && canReuseLast && areLorasEqual(currentLoras, lastUsed) }"
:disabled="!canReuseLast"
@mouseenter="showTooltip = true"
@mouseleave="showTooltip = false"
@click="$emit('reuse-last')"
>
<svg class="roll-button__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 14 4 9l5-5"/>
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/>
</svg>
<span class="roll-button__text">Reuse Last</span>
</button>
</div>
<!-- Last Used Preview Tooltip -->
<Transition name="tooltip">
<LastUsedPreview
v-if="showTooltip && lastUsed && lastUsed.length > 0"
:loras="lastUsed"
/>
<span>Frontend Roll (fixed until re-rolled)</span>
</label>
<button
class="roll-button"
:disabled="rollMode !== 'frontend' || isRolling"
@click="$emit('roll')"
>
<span class="roll-button__content">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect><path d="M8 8h.01"></path><path d="M16 16h.01"></path><path d="M16 8h.01"></path><path d="M8 16h.01"></path></svg>
Roll
</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>
</Transition>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import LastUsedPreview from './LastUsedPreview.vue'
import type { LoraEntry } from '../../composables/types'
defineProps<{
countMode: 'fixed' | 'range'
countFixed: number
@@ -187,9 +212,12 @@ defineProps<{
useSameClipStrength: boolean
clipStrengthMin: number
clipStrengthMax: number
rollMode: 'frontend' | 'backend'
rollMode: 'fixed' | 'always'
isRolling: boolean
isClipStrengthDisabled: boolean
lastUsed: LoraEntry[] | null
currentLoras: LoraEntry[]
canReuseLast: boolean
}>()
defineEmits<{
@@ -202,9 +230,25 @@ defineEmits<{
'update:useSameClipStrength': [value: boolean]
'update:clipStrengthMin': [value: number]
'update:clipStrengthMax': [value: number]
'update:rollMode': [value: 'frontend' | 'backend']
roll: []
'update:rollMode': [value: 'fixed' | 'always']
'generate-fixed': []
'always-randomize': []
'reuse-last': []
}>()
const showTooltip = ref(false)
const areLorasEqual = (a: LoraEntry[] | null, b: LoraEntry[] | null): boolean => {
if (!a || !b) return false
if (a.length !== b.length) return false
const sortedA = [...a].sort((x, y) => x.name.localeCompare(y.name))
const sortedB = [...b].sort((x, y) => x.name.localeCompare(y.name))
return sortedA.every((lora, i) =>
lora.name === sortedB[i].name &&
lora.strength === sortedB[i].strength &&
lora.clipStrength === sortedB[i].clipStrength
)
}
</script>
<style scoped>
@@ -330,41 +374,75 @@ defineEmits<{
cursor: pointer;
}
/* Roll buttons with tooltip container */
.roll-buttons-with-tooltip {
position: relative;
}
/* Roll buttons container */
.roll-buttons {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
}
.roll-button {
padding: 8px 16px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
padding: 8px 10px;
background: rgba(30, 30, 36, 0.6);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: white;
font-size: 13px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
color: #e4e4e7;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
}
.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);
background: rgba(66, 153, 225, 0.2);
border-color: rgba(66, 153, 225, 0.4);
color: #bfdbfe;
}
.roll-button:active:not(:disabled) {
transform: translateY(0);
.roll-button.selected {
background: rgba(66, 153, 225, 0.3);
border-color: rgba(66, 153, 225, 0.6);
color: #e4e4e7;
box-shadow: 0 0 0 1px rgba(66, 153, 225, 0.3);
}
.roll-button:disabled {
opacity: 0.5;
opacity: 0.4;
cursor: not-allowed;
background: linear-gradient(135deg, #52525b 0%, #3f3f46 100%);
}
.roll-button__content {
display: inline-flex;
align-items: center;
gap: 6px;
.roll-button__icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.roll-button__text {
font-size: 11px;
text-align: center;
line-height: 1.2;
}
/* Tooltip transitions */
.tooltip-enter-active,
.tooltip-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.tooltip-enter-from,
.tooltip-leave-to {
opacity: 0;
transform: translateY(4px);
}
</style>