mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 07:05:43 -03:00
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:
155
vue-widgets/src/components/lora-randomizer/LastUsedPreview.vue
Normal file
155
vue-widgets/src/components/lora-randomizer/LastUsedPreview.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user