360 lines
11 KiB
Vue
360 lines
11 KiB
Vue
<template>
|
|
<div class="block min-w-0" :class="compact ? 'text-xs' : 'text-sm'">
|
|
<span v-if="label" class="mb-1 block text-zinc-400">{{ label }}</span>
|
|
<p v-if="model.length > 1" class="mb-1 text-[11px] text-zinc-500">Drag to set chain order. Top applies first.</p>
|
|
<ul v-if="model.length" class="space-y-2">
|
|
<li
|
|
v-for="(item, index) in model"
|
|
:key="item.name"
|
|
class="rounded-lg border border-[#2A2A2E] bg-[#1A1A1D] p-2.5"
|
|
:class="[
|
|
dragOverIndex === index ? 'border-amber-300/50' : '',
|
|
draggingIndex === index ? 'opacity-60' : ''
|
|
]"
|
|
@dragover.prevent="onDragOver(index)"
|
|
@drop.prevent="onDrop(index)"
|
|
@dragleave="onDragLeave(index)"
|
|
>
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="flex min-w-0 flex-1 items-start gap-2">
|
|
<button
|
|
v-if="model.length > 1"
|
|
type="button"
|
|
class="mt-0.5 shrink-0 cursor-grab touch-none text-zinc-500 hover:text-zinc-300 active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-40"
|
|
:disabled="disabled"
|
|
draggable="true"
|
|
title="Drag to reorder"
|
|
aria-label="Drag to reorder"
|
|
@dragstart="onDragStart(index, $event)"
|
|
@dragend="onDragEnd"
|
|
>
|
|
<span class="block leading-none" aria-hidden="true">⋮⋮</span>
|
|
</button>
|
|
<span class="min-w-0 flex-1">
|
|
<span class="flex min-w-0 items-center gap-1.5">
|
|
<span class="block truncate font-medium text-[#F3F1EA]" :title="labelFor(item.name)">{{ labelFor(item.name) }}</span>
|
|
<LoraTriggerTip
|
|
:text="triggerFor(item.name)"
|
|
:aria-label="'Activation for ' + labelFor(item.name)"
|
|
@insert="emit('insert-trigger', $event)"
|
|
@save="saveTrigger(item.name, $event)"
|
|
/>
|
|
</span>
|
|
<span v-if="filenameSubtitle" class="mt-0.5 block truncate text-[11px] text-[#9A9588]" :title="item.name">{{ stemFor(item.name) }}</span>
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="shrink-0 text-[11px] text-[#9A9588] hover:text-red-300 disabled:opacity-50"
|
|
:disabled="disabled"
|
|
@click="removeAt(index)"
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
|
|
<div class="mt-2 space-y-1.5">
|
|
<label class="grid grid-cols-[3.25rem_minmax(0,1fr)_2.75rem] items-center gap-2 text-[11px] text-[#9A9588]">
|
|
<span>{{ clipStrength ? 'Model' : 'Strength' }}</span>
|
|
<input
|
|
type="range"
|
|
class="lora-strength-range min-w-0"
|
|
:min="0"
|
|
:max="2"
|
|
:step="LORA_STRENGTH_STEP"
|
|
:disabled="disabled"
|
|
:value="displayStrength(item.strengthModel)"
|
|
:aria-label="(clipStrength ? 'Model' : 'Strength') + ' for ' + labelFor(item.name)"
|
|
@input="onRange(index, 'strengthModel', $event)"
|
|
>
|
|
<input
|
|
type="number"
|
|
class="lora-strength-num w-full rounded border border-[#2A2A2E] bg-[#0B0B0D] px-1 py-0.5 text-right font-mono text-[11px] text-[#F3F1EA] outline-none focus:border-[#F5A524]"
|
|
:min="0"
|
|
:max="2"
|
|
:step="LORA_STRENGTH_STEP"
|
|
:disabled="disabled"
|
|
:value="displayStrength(item.strengthModel).toFixed(2)"
|
|
:aria-label="(clipStrength ? 'Model' : 'Strength') + ' value'"
|
|
@change="onNumber(index, 'strengthModel', $event)"
|
|
>
|
|
</label>
|
|
<label
|
|
v-if="clipStrength"
|
|
class="grid grid-cols-[3.25rem_minmax(0,1fr)_2.75rem] items-center gap-2 text-[11px] text-[#9A9588]"
|
|
>
|
|
<span>CLIP</span>
|
|
<input
|
|
type="range"
|
|
class="lora-strength-range min-w-0"
|
|
:min="0"
|
|
:max="2"
|
|
:step="LORA_STRENGTH_STEP"
|
|
:disabled="disabled"
|
|
:value="displayStrength(item.strengthClip)"
|
|
:aria-label="'CLIP for ' + labelFor(item.name)"
|
|
@input="onRange(index, 'strengthClip', $event)"
|
|
>
|
|
<input
|
|
type="number"
|
|
class="lora-strength-num w-full rounded border border-[#2A2A2E] bg-[#0B0B0D] px-1 py-0.5 text-right font-mono text-[11px] text-[#F3F1EA] outline-none focus:border-[#F5A524]"
|
|
:min="0"
|
|
:max="2"
|
|
:step="LORA_STRENGTH_STEP"
|
|
:disabled="disabled"
|
|
:value="displayStrength(item.strengthClip).toFixed(2)"
|
|
:aria-label="'CLIP value'"
|
|
@change="onNumber(index, 'strengthClip', $event)"
|
|
>
|
|
</label>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
<select
|
|
v-if="model.length < LORA_STACK_MAX"
|
|
class="w-full rounded-lg border border-[#2A2A2E] bg-[#0B0B0D] text-[#F3F1EA] outline-none ring-[#F5A524]/40 focus:ring-2 disabled:opacity-50"
|
|
:class="[
|
|
compact ? 'px-2 py-1.5 text-xs' : 'px-3 py-2 text-sm',
|
|
model.length ? 'mt-2' : ''
|
|
]"
|
|
:disabled="disabled || !availableNames.length"
|
|
:value="''"
|
|
@change="onAdd"
|
|
>
|
|
<option value="">{{ selectPlaceholder }}</option>
|
|
<option
|
|
v-for="name in availableNames"
|
|
:key="name"
|
|
:value="name"
|
|
:title="name"
|
|
>{{ labelFor(name) }}</option>
|
|
</select>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.lora-strength-range {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
height: 6px;
|
|
border-radius: 999px;
|
|
background: #2a2a2e;
|
|
outline: none;
|
|
}
|
|
.lora-strength-range::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
width: 14px;
|
|
height: 14px;
|
|
border-radius: 999px;
|
|
background: #f5a524;
|
|
border: 0;
|
|
cursor: pointer;
|
|
}
|
|
.lora-strength-range::-moz-range-track {
|
|
height: 6px;
|
|
border-radius: 999px;
|
|
background: #2a2a2e;
|
|
}
|
|
.lora-strength-range::-moz-range-thumb {
|
|
width: 14px;
|
|
height: 14px;
|
|
border-radius: 999px;
|
|
background: #f5a524;
|
|
border: 0;
|
|
cursor: pointer;
|
|
}
|
|
.lora-strength-range:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
.lora-strength-range::-webkit-slider-runnable-track {
|
|
height: 6px;
|
|
border-radius: 999px;
|
|
background: #2a2a2e;
|
|
}
|
|
</style>
|
|
|
|
<script setup lang="ts">
|
|
import {
|
|
LORA_STACK_MAX,
|
|
LORA_STRENGTH_DEFAULT,
|
|
LORA_STRENGTH_STEP,
|
|
loraDisplayName,
|
|
loraFriendlyName,
|
|
loraIdentityKey,
|
|
loraMatchesImageEngine,
|
|
loraMatchesUse,
|
|
type LoraUse,
|
|
loraTriggerFor,
|
|
makeLoraStackItem,
|
|
type LoraStackItem
|
|
} from '~/utils/loras'
|
|
|
|
const UI_MIN = 0
|
|
const UI_MAX = 2
|
|
|
|
const props = withDefaults(defineProps<{
|
|
names: string[]
|
|
filenameSubtitle?: boolean
|
|
label?: string
|
|
emptyLabel?: string
|
|
addLabel?: string
|
|
missingLabel?: string
|
|
compact?: boolean
|
|
disabled?: boolean
|
|
clipStrength?: boolean
|
|
triggers?: Record<string, string>
|
|
aliases?: Record<string, string>
|
|
engine?: 'flux' | 'krea' | ''
|
|
use?: LoraUse
|
|
}>(), {
|
|
label: 'LoRA',
|
|
filenameSubtitle: false,
|
|
emptyLabel: 'None',
|
|
addLabel: 'Add LoRA',
|
|
missingLabel: 'Wake GPU to load LoRAs',
|
|
compact: false,
|
|
disabled: false,
|
|
clipStrength: true,
|
|
triggers: () => ({}),
|
|
aliases: () => ({}),
|
|
engine: ''
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
'insert-trigger': [text: string]
|
|
'update-trigger': [name: string, trigger: string]
|
|
}>()
|
|
|
|
const model = defineModel<LoraStackItem[]>({ default: () => [] })
|
|
|
|
const draggingIndex = ref<number | null>(null)
|
|
const dragOverIndex = ref<number | null>(null)
|
|
|
|
const availableNames = computed(() => {
|
|
const taken = new Set(model.value.map(item => loraIdentityKey(item.name)))
|
|
const engine = props.engine === 'flux' || props.engine === 'krea' ? props.engine : null
|
|
return props.names
|
|
.filter(name => !taken.has(loraIdentityKey(name)))
|
|
.filter(name => props.use ? loraMatchesUse(name, props.use) : !engine || loraMatchesImageEngine(name, engine))
|
|
.slice()
|
|
.sort((a, b) => labelFor(a).localeCompare(labelFor(b), undefined, { sensitivity: 'base' }))
|
|
})
|
|
|
|
const selectPlaceholder = computed(() => {
|
|
if (!availableNames.value.length) {
|
|
return props.names.length ? 'All listed LoRAs are in the chain' : props.missingLabel
|
|
}
|
|
return model.value.length ? props.addLabel : props.emptyLabel
|
|
})
|
|
|
|
function clampUi(raw: unknown) {
|
|
const value = Number(raw)
|
|
if (!Number.isFinite(value)) return LORA_STRENGTH_DEFAULT
|
|
const snapped = Math.round(value / LORA_STRENGTH_STEP) * LORA_STRENGTH_STEP
|
|
return Math.min(UI_MAX, Math.max(UI_MIN, Math.round(snapped * 100) / 100))
|
|
}
|
|
|
|
function displayStrength(value: unknown) {
|
|
return clampUi(value ?? LORA_STRENGTH_DEFAULT)
|
|
}
|
|
|
|
function onAdd(event: Event) {
|
|
const name = (event.target as HTMLSelectElement).value
|
|
;(event.target as HTMLSelectElement).value = ''
|
|
addLora(name)
|
|
}
|
|
|
|
function addLora(name: string) {
|
|
const item = makeLoraStackItem(name)
|
|
if (!item || model.value.length >= LORA_STACK_MAX) return
|
|
const taken = new Set(model.value.map(entry => loraIdentityKey(entry.name)))
|
|
if (taken.has(loraIdentityKey(item.name))) return
|
|
model.value = [...model.value, {
|
|
...item,
|
|
strengthModel: clampUi(item.strengthModel ?? LORA_STRENGTH_DEFAULT),
|
|
strengthClip: clampUi(item.strengthClip ?? LORA_STRENGTH_DEFAULT)
|
|
}]
|
|
}
|
|
|
|
function removeAt(index: number) {
|
|
model.value = model.value.filter((_, i) => i !== index)
|
|
}
|
|
|
|
function moveItem(from: number, to: number) {
|
|
if (from === to || from < 0 || to < 0 || to >= model.value.length) return
|
|
const next = [...model.value]
|
|
const [item] = next.splice(from, 1)
|
|
next.splice(to, 0, item)
|
|
model.value = next
|
|
}
|
|
|
|
function onDragStart(index: number, event: DragEvent) {
|
|
if (props.disabled) {
|
|
event.preventDefault()
|
|
return
|
|
}
|
|
draggingIndex.value = index
|
|
dragOverIndex.value = index
|
|
event.dataTransfer?.setData('text/plain', String(index))
|
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
|
|
}
|
|
|
|
function onDragOver(index: number) {
|
|
if (draggingIndex.value === null || props.disabled) return
|
|
dragOverIndex.value = index
|
|
}
|
|
|
|
function onDragLeave(index: number) {
|
|
if (dragOverIndex.value === index) dragOverIndex.value = null
|
|
}
|
|
|
|
function onDrop(index: number) {
|
|
if (draggingIndex.value === null || props.disabled) return
|
|
moveItem(draggingIndex.value, index)
|
|
draggingIndex.value = null
|
|
dragOverIndex.value = null
|
|
}
|
|
|
|
function onDragEnd() {
|
|
draggingIndex.value = null
|
|
dragOverIndex.value = null
|
|
}
|
|
|
|
function setStrength(index: number, key: 'strengthModel' | 'strengthClip', value: number) {
|
|
const next = clampUi(value)
|
|
model.value = model.value.map((item, i) => {
|
|
if (i !== index) return item
|
|
if (!props.clipStrength) {
|
|
return { ...item, strengthModel: next, strengthClip: next }
|
|
}
|
|
return { ...item, [key]: next }
|
|
})
|
|
}
|
|
|
|
function onRange(index: number, key: 'strengthModel' | 'strengthClip', event: Event) {
|
|
setStrength(index, key, Number((event.target as HTMLInputElement).value))
|
|
}
|
|
|
|
function onNumber(index: number, key: 'strengthModel' | 'strengthClip', event: Event) {
|
|
setStrength(index, key, Number((event.target as HTMLInputElement).value))
|
|
}
|
|
|
|
function triggerFor(name: string) {
|
|
return loraTriggerFor(name, props.triggers)
|
|
}
|
|
|
|
function labelFor(name: string) {
|
|
return loraFriendlyName(name, props.aliases)
|
|
}
|
|
|
|
function stemFor(name: string) {
|
|
return loraDisplayName(name)
|
|
}
|
|
|
|
function saveTrigger(name: string, trigger: string) {
|
|
emit('update-trigger', name, trigger)
|
|
}
|
|
</script>
|