mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
da71985c3e
The hidden __lm_autocomplete_meta_* widget persisted lastAccepted (insertedText/textSnapshot) into exported workflow JSON, leaking old prompt text even after the user deleted it. Patch app.graphToPrompt (shared by workflow export, Export API and queueing) to strip lastAccepted from the serialized result's widgets_values / widgets_values_named / output inputs. Only the exported artifact is touched; live node state, undo snapshots, copy/paste and local saves keep the boundary intact.
996 lines
30 KiB
TypeScript
996 lines
30 KiB
TypeScript
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 AutocompleteTextWidget from '@/components/AutocompleteTextWidget.vue'
|
|
import LoraInfoWidget from '@/components/LoraInfoWidget.vue'
|
|
import { createVueWidgetCleanup } from './vue-widget-cleanup'
|
|
import type { LoraPoolConfig, RandomizerConfig, CyclerConfig } from './composables/types'
|
|
import {
|
|
setupModeChangeHandler,
|
|
createModeChangeCallback,
|
|
LORA_CHAIN_NODE_TYPES
|
|
} from './mode-change-handler'
|
|
|
|
const LORA_POOL_WIDGET_MIN_WIDTH = 500
|
|
const LORA_POOL_WIDGET_MIN_HEIGHT = 520
|
|
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 = 408
|
|
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
|
|
const LORA_INFO_WIDGET_MIN_WIDTH = 300
|
|
const LORA_INFO_WIDGET_MIN_HEIGHT = 200
|
|
const AUTOCOMPLETE_TEXT_WIDGET_MIN_HEIGHT = 60
|
|
const AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT = 100
|
|
// Per-modelType min size hints for node initial sizing.
|
|
// These are returned from the factory so ComfyUI's _initialMinSize mechanism
|
|
// gives the node a sensible default width (and height for prompt/embeddings).
|
|
const AUTOCOMPLETE_TEXT_MIN_WIDTH_DEFAULT = 400
|
|
const AUTOCOMPLETE_TEXT_MIN_HEIGHT_DEFAULT = 300
|
|
const AUTOCOMPLETE_METADATA_VERSION = 1
|
|
const LORA_MANAGER_WIDGET_IDS_PROPERTY = '__lm_widget_ids'
|
|
|
|
// Access LiteGraph global for Vue DOM mode detection (matches AutocompleteTextWidget.vue)
|
|
declare const LiteGraph: { vueNodesMode?: boolean } | undefined
|
|
|
|
// @ts-ignore - ComfyUI external module
|
|
import { app } from '../../../scripts/app.js'
|
|
// @ts-ignore - ComfyUI external module
|
|
import { api } from '../../../scripts/api.js'
|
|
// @ts-ignore
|
|
import { getPoolConfigFromConnectedNode, getActiveLorasFromNode, updateConnectedTriggerWords, updateDownstreamLoaders } from '../../web/comfyui/utils.js'
|
|
// @ts-ignore
|
|
import { stripAutocompleteMetadataFromPromptResult } from '../../web/comfyui/autocomplete.js'
|
|
|
|
// Strip the autocomplete lastAccepted boundary from exported workflows.
|
|
// lastAccepted carries old prompt text (insertedText/textSnapshot) and is
|
|
// session-only state; it must not leak into exported JSON (#1093).
|
|
// graphToPrompt is shared by workflow export, Export API and queueing.
|
|
// Local saves go through the change-tracker snapshot (no graphToPrompt) and
|
|
// are intentionally left untouched so cross-session caret continuity is kept.
|
|
// Post-processing the resolved result avoids any window/race with
|
|
// change-tracker or copy/paste serialization of live state.
|
|
const originalGraphToPrompt = app.graphToPrompt.bind(app)
|
|
app.graphToPrompt = async (...args: unknown[]) => {
|
|
const result = await originalGraphToPrompt(...args)
|
|
stripAutocompleteMetadataFromPromptResult(result)
|
|
return result
|
|
}
|
|
|
|
function forwardMiddleMouseToCanvas(container: HTMLElement) {
|
|
if (!container) return
|
|
|
|
container.addEventListener('pointerdown', (event) => {
|
|
if (event.button === 1) {
|
|
const canvas = app.canvas
|
|
if (canvas && typeof canvas.processMouseDown === 'function') {
|
|
canvas.processMouseDown(event)
|
|
}
|
|
}
|
|
})
|
|
|
|
container.addEventListener('pointermove', (event) => {
|
|
if ((event.buttons & 4) === 4) {
|
|
const canvas = app.canvas
|
|
if (canvas && typeof canvas.processMouseMove === 'function') {
|
|
canvas.processMouseMove(event)
|
|
}
|
|
}
|
|
})
|
|
|
|
container.addEventListener('pointerup', (event) => {
|
|
if (event.button === 1) {
|
|
const canvas = app.canvas
|
|
if (canvas && typeof canvas.processMouseUp === 'function') {
|
|
canvas.processMouseUp(event)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
const vueApps = new Map<number | string, VueApp>()
|
|
let autocompleteTextWidgetInstanceId = 0
|
|
|
|
export function createAutocompleteTextWidgetInstanceId() {
|
|
autocompleteTextWidgetInstanceId += 1
|
|
return autocompleteTextWidgetInstanceId
|
|
}
|
|
|
|
// @ts-ignore
|
|
function createLoraPoolWidget(node) {
|
|
const container = document.createElement('div')
|
|
container.id = `lora-pool-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: LoraPoolConfig | undefined
|
|
|
|
const widget = node.addDOMWidget(
|
|
'pool_config',
|
|
'LORA_POOL_CONFIG',
|
|
container,
|
|
{
|
|
getValue() {
|
|
return internalValue
|
|
},
|
|
setValue(v: LoraPoolConfig) {
|
|
internalValue = v
|
|
// ComfyUI automatically calls widget.callback after setValue
|
|
// No need for custom onSetValue mechanism
|
|
},
|
|
serialize: true,
|
|
// Per dev guide: providing getMinHeight via options allows the system to
|
|
// skip expensive DOM measurements during rendering loop, improving performance
|
|
getMinHeight() {
|
|
return LORA_POOL_WIDGET_MIN_HEIGHT
|
|
}
|
|
}
|
|
)
|
|
|
|
const vueApp = createApp(LoraPoolWidget, {
|
|
widget,
|
|
node
|
|
})
|
|
|
|
vueApp.use(PrimeVue, {
|
|
unstyled: true,
|
|
ripple: false
|
|
})
|
|
|
|
vueApp.mount(container)
|
|
vueApps.set(node.id, vueApp)
|
|
|
|
widget.computeLayoutSize = () => {
|
|
const minWidth = LORA_POOL_WIDGET_MIN_WIDTH
|
|
const minHeight = LORA_POOL_WIDGET_MIN_HEIGHT
|
|
|
|
return { minHeight, minWidth }
|
|
}
|
|
|
|
widget.onRemove = () => {
|
|
const vueApp = vueApps.get(node.id)
|
|
if (vueApp) {
|
|
vueApp.unmount()
|
|
vueApps.delete(node.id)
|
|
}
|
|
}
|
|
|
|
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'
|
|
|
|
forwardMiddleMouseToCanvas(container)
|
|
|
|
// Initialize with default config to avoid sending undefined/empty string to backend
|
|
const defaultConfig: RandomizerConfig = {
|
|
count_mode: 'range',
|
|
count_fixed: 3,
|
|
count_min: 2,
|
|
count_max: 5,
|
|
model_strength_min: 0.0,
|
|
model_strength_max: 1.0,
|
|
use_same_clip_strength: true,
|
|
clip_strength_min: 0.0,
|
|
clip_strength_max: 1.0,
|
|
roll_mode: 'fixed',
|
|
use_recommended_strength: false,
|
|
recommended_strength_scale_min: 0.5,
|
|
recommended_strength_scale_max: 1.0,
|
|
}
|
|
let internalValue: RandomizerConfig = defaultConfig
|
|
|
|
const widget = node.addDOMWidget(
|
|
'randomizer_config',
|
|
'RANDOMIZER_CONFIG',
|
|
container,
|
|
{
|
|
getValue() {
|
|
return internalValue
|
|
},
|
|
setValue(v: RandomizerConfig) {
|
|
internalValue = v
|
|
// ComfyUI automatically calls widget.callback after setValue
|
|
// No need for custom onSetValue mechanism
|
|
},
|
|
serialize: true,
|
|
getMinHeight() {
|
|
return LORA_RANDOMIZER_WIDGET_MIN_HEIGHT
|
|
}
|
|
}
|
|
)
|
|
|
|
// Add method to get pool config from connected node
|
|
node.getPoolConfig = () => getPoolConfigFromConnectedNode(node)
|
|
|
|
// Handle roll event from Vue component
|
|
widget.onRoll = (randomLoras: any[]) => {
|
|
// 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
|
|
}
|
|
}
|
|
|
|
const vueApp = createApp(LoraRandomizerWidget, {
|
|
widget,
|
|
node,
|
|
api
|
|
})
|
|
|
|
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 = LORA_RANDOMIZER_WIDGET_MIN_WIDTH
|
|
const minHeight = LORA_RANDOMIZER_WIDGET_MIN_HEIGHT
|
|
const maxHeight = LORA_RANDOMIZER_WIDGET_MAX_HEIGHT
|
|
|
|
return { minHeight, minWidth, maxHeight }
|
|
}
|
|
|
|
widget.onRemove = () => {
|
|
const vueApp = vueApps.get(node.id + 10000)
|
|
if (vueApp) {
|
|
vueApp.unmount()
|
|
vueApps.delete(node.id + 10000)
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
const defaultConfig: CyclerConfig = {
|
|
current_index: 1,
|
|
total_count: 0,
|
|
pool_config_hash: '',
|
|
model_strength: 1.0,
|
|
clip_strength: 1.0,
|
|
use_same_clip_strength: true,
|
|
use_preset_strength: false,
|
|
preset_strength_scale: 1.0,
|
|
sort_by: 'filename',
|
|
current_lora_name: '',
|
|
current_lora_filename: '',
|
|
repeat_count: 1,
|
|
repeat_used: 0,
|
|
is_paused: false,
|
|
include_no_lora: false,
|
|
}
|
|
let internalValue: CyclerConfig | undefined = defaultConfig
|
|
|
|
const widget = node.addDOMWidget(
|
|
'cycler_config',
|
|
'CYCLER_CONFIG',
|
|
container,
|
|
{
|
|
getValue() {
|
|
return internalValue
|
|
},
|
|
setValue(v: CyclerConfig) {
|
|
const oldFilename = internalValue?.current_lora_filename
|
|
internalValue = v
|
|
// ComfyUI automatically calls widget.callback after setValue
|
|
// No need for custom onSetValue mechanism
|
|
// Update downstream loaders when the active LoRA filename changes
|
|
if (oldFilename !== v?.current_lora_filename) {
|
|
updateDownstreamLoaders(node)
|
|
}
|
|
},
|
|
serialize: true,
|
|
getMinHeight() {
|
|
return LORA_CYCLER_WIDGET_MIN_HEIGHT
|
|
}
|
|
}
|
|
)
|
|
|
|
// Add method to get pool config from connected node
|
|
node.getPoolConfig = () => getPoolConfigFromConnectedNode(node)
|
|
|
|
const vueApp = createApp(LoraCyclerWidget, {
|
|
widget,
|
|
node,
|
|
api
|
|
})
|
|
|
|
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')
|
|
container.id = `json-display-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: Record<string, unknown> | undefined
|
|
|
|
const widget = node.addDOMWidget(
|
|
'metadata',
|
|
'JSON_DISPLAY',
|
|
container,
|
|
{
|
|
getValue() {
|
|
return internalValue
|
|
},
|
|
setValue(v: Record<string, unknown>) {
|
|
internalValue = v
|
|
if (typeof widget.onSetValue === 'function') {
|
|
widget.onSetValue(v)
|
|
}
|
|
},
|
|
serialize: false, // Display-only widget - don't save metadata in workflows
|
|
getMinHeight() {
|
|
return JSON_DISPLAY_WIDGET_MIN_HEIGHT
|
|
}
|
|
}
|
|
)
|
|
|
|
const vueApp = createApp(JsonDisplayWidget, {
|
|
widget,
|
|
node
|
|
})
|
|
|
|
vueApp.use(PrimeVue, {
|
|
unstyled: true,
|
|
ripple: false
|
|
})
|
|
|
|
vueApp.mount(container)
|
|
vueApps.set(node.id + 20000, vueApp) // Offset to avoid collision with other widgets
|
|
|
|
widget.computeLayoutSize = () => {
|
|
const minWidth = JSON_DISPLAY_WIDGET_MIN_WIDTH
|
|
const minHeight = JSON_DISPLAY_WIDGET_MIN_HEIGHT
|
|
|
|
return { minHeight, minWidth }
|
|
}
|
|
|
|
widget.onRemove = () => {
|
|
const vueApp = vueApps.get(node.id + 20000)
|
|
if (vueApp) {
|
|
vueApp.unmount()
|
|
vueApps.delete(node.id + 20000)
|
|
}
|
|
}
|
|
|
|
return { widget }
|
|
}
|
|
|
|
const widgetInputOptions: Map<string, { placeholder?: string }> = new Map()
|
|
|
|
function getSerializableWidgetNames(node: any): string[] {
|
|
return (node.widgets || [])
|
|
.filter((widget: any) => widget && widget.serialize !== false)
|
|
.map((widget: any) => widget.name)
|
|
}
|
|
|
|
function createAutocompleteMetadataValue(textWidgetName = 'text') {
|
|
return {
|
|
version: AUTOCOMPLETE_METADATA_VERSION,
|
|
textWidgetName
|
|
}
|
|
}
|
|
|
|
function shouldBypassAutocompleteWidgetMigration(
|
|
node: any,
|
|
widgetValues: unknown[]
|
|
): boolean {
|
|
const inputDefs = node?.constructor?.nodeData?.inputs
|
|
if (!inputDefs || !Array.isArray(widgetValues)) {
|
|
return false
|
|
}
|
|
|
|
const widgetNames = new Set((node.widgets || []).map((widget: any) => widget?.name))
|
|
const hasAutocompleteMetadataWidget = Array.from(widgetNames).some((name) =>
|
|
typeof name === 'string' && name.startsWith('__lm_autocomplete_meta_')
|
|
)
|
|
|
|
if (!hasAutocompleteMetadataWidget) {
|
|
return false
|
|
}
|
|
|
|
const originalWidgetsInputs = Object.values(inputDefs).filter((input: any) =>
|
|
widgetNames.has(input.name) || input.forceInput
|
|
)
|
|
|
|
const widgetIndexHasForceInput = originalWidgetsInputs.flatMap((input: any) =>
|
|
input.control_after_generate
|
|
? [!!input.forceInput, false]
|
|
: [!!input.forceInput]
|
|
)
|
|
|
|
const result = (
|
|
widgetIndexHasForceInput.some(Boolean) &&
|
|
widgetIndexHasForceInput.length === widgetValues.length
|
|
)
|
|
|
|
return result
|
|
}
|
|
|
|
function remapWidgetValuesByName(
|
|
widgetValues: unknown[],
|
|
savedWidgetNames: string[],
|
|
currentWidgetNames: string[]
|
|
): unknown[] {
|
|
const valueByName = new Map<string, unknown>()
|
|
savedWidgetNames.forEach((name, index) => {
|
|
if (index < widgetValues.length) {
|
|
valueByName.set(name, widgetValues[index])
|
|
}
|
|
})
|
|
|
|
const currentWidgetNameSet = new Set(currentWidgetNames)
|
|
const remappedValues: unknown[] = []
|
|
for (const name of currentWidgetNames) {
|
|
if (valueByName.has(name)) {
|
|
remappedValues.push(valueByName.get(name))
|
|
}
|
|
}
|
|
|
|
// Append values for saved widget names that are NOT in the current widget
|
|
// list (e.g. forceInput widgets like "seed" that haven't been converted
|
|
// back to DOM widgets yet at configure time). Without these, the
|
|
// resulting array may accidentally match the length of ComfyUI's
|
|
// widgetIndexHasForceInput array, causing migrateWidgetsValues to
|
|
// incorrectly filter out the wrong values and drop real widget content.
|
|
for (const name of savedWidgetNames) {
|
|
if (!currentWidgetNameSet.has(name) && valueByName.has(name)) {
|
|
remappedValues.push(valueByName.get(name))
|
|
}
|
|
}
|
|
|
|
return remappedValues
|
|
}
|
|
|
|
function injectDefaultAutocompleteMetadataValues(
|
|
widgetValues: unknown[],
|
|
currentWidgetNames: string[]
|
|
): unknown[] {
|
|
const repairedValues: unknown[] = []
|
|
let legacyValueIndex = 0
|
|
|
|
for (const widgetName of currentWidgetNames) {
|
|
if (widgetName.startsWith('__lm_autocomplete_meta_')) {
|
|
const textWidgetName = widgetName.replace('__lm_autocomplete_meta_', '') || 'text'
|
|
repairedValues.push(createAutocompleteMetadataValue(textWidgetName))
|
|
continue
|
|
}
|
|
|
|
if (legacyValueIndex < widgetValues.length) {
|
|
repairedValues.push(widgetValues[legacyValueIndex])
|
|
legacyValueIndex++
|
|
}
|
|
}
|
|
|
|
return repairedValues
|
|
}
|
|
|
|
function normalizeAutocompleteWidgetValues(node: any, info: any) {
|
|
if (!info || !Array.isArray(info.widgets_values)) {
|
|
return
|
|
}
|
|
|
|
const currentWidgetNames = getSerializableWidgetNames(node)
|
|
|
|
if (currentWidgetNames.length === 0) {
|
|
return
|
|
}
|
|
|
|
const savedWidgetNames = info.properties?.[LORA_MANAGER_WIDGET_IDS_PROPERTY]
|
|
|
|
if (Array.isArray(savedWidgetNames) && savedWidgetNames.length > 0) {
|
|
const remappedValues = remapWidgetValuesByName(
|
|
info.widgets_values,
|
|
savedWidgetNames,
|
|
currentWidgetNames
|
|
)
|
|
info.widgets_values = remappedValues
|
|
return
|
|
}
|
|
|
|
const metadataWidgetCount = currentWidgetNames.filter((name) =>
|
|
name.startsWith('__lm_autocomplete_meta_')
|
|
).length
|
|
|
|
if (
|
|
metadataWidgetCount > 0 &&
|
|
info.widgets_values.length === currentWidgetNames.length - metadataWidgetCount
|
|
) {
|
|
const repairedValues = injectDefaultAutocompleteMetadataValues(
|
|
info.widgets_values,
|
|
currentWidgetNames
|
|
)
|
|
info.widgets_values = repairedValues
|
|
}
|
|
}
|
|
|
|
function applyAutocompleteTextLayoutFix(
|
|
widget: any,
|
|
_container: HTMLElement | undefined,
|
|
isVueMode: boolean
|
|
): void {
|
|
// In Vue rendering mode the WidgetDOM wrapper handles sizing, so we
|
|
// only provide a computeSize hint and leave the container unconstrained.
|
|
// In canvas mode we clear all custom sizing so LiteGraph's default
|
|
// widget-area layout takes over. Neither path sets a hard max-height;
|
|
// the textarea can grow freely (e.g. in app mode where
|
|
// [&_textarea]:resize-y applies).
|
|
if (isVueMode) {
|
|
;(widget as any).computeLayoutSize = undefined
|
|
widget.computeSize = (width?: number) =>
|
|
[width ?? 200, AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT - 4]
|
|
} else {
|
|
delete (widget as any).computeLayoutSize
|
|
delete (widget as any).computeSize
|
|
}
|
|
}
|
|
|
|
// Listen for Vue DOM mode setting changes and dispatch custom event
|
|
const initVueDomModeListener = () => {
|
|
if (app.ui?.settings?.addEventListener) {
|
|
app.ui.settings.addEventListener('Comfy.VueNodes.Enabled.change', () => {
|
|
// Use requestAnimationFrame to ensure the setting value has been updated
|
|
// before we read it (the event may fire before internal state updates)
|
|
requestAnimationFrame(() => {
|
|
const isVueDomMode = app.ui?.settings?.getSettingValue?.('Comfy.VueNodes.Enabled') ?? false
|
|
|
|
if (app.graph?.nodes) {
|
|
for (const node of app.graph.nodes) {
|
|
const textWidget = node.widgets?.find(
|
|
(w: any) => w.type === 'AUTOCOMPLETE_TEXT_LORAS'
|
|
)
|
|
if (!textWidget) continue
|
|
const container = (textWidget as any).element as HTMLElement | undefined
|
|
applyAutocompleteTextLayoutFix(textWidget, container, isVueDomMode)
|
|
}
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
for (const nodeEl of document.querySelectorAll('[data-node-id]')) {
|
|
const grid = nodeEl.querySelector('[data-testid="node-widgets"]') as HTMLElement | null
|
|
if (!grid) continue
|
|
const nodeId = nodeEl.getAttribute('data-node-id')
|
|
const node = app.graph?.getNodeById(nodeId as any)
|
|
if (!node) continue
|
|
const rows: string[] = []
|
|
let needsFix = false
|
|
for (const w of node.widgets ?? []) {
|
|
if (w.type === 'LORA_MANAGER_AUTOCOMPLETE_METADATA') {
|
|
rows.push('min-content')
|
|
} else if (w.name === 'loras') {
|
|
rows.push('auto')
|
|
} else if (w.name === 'text' && w.type === 'AUTOCOMPLETE_TEXT_LORAS') {
|
|
rows.push(isVueDomMode ? 'min-content' : 'auto')
|
|
needsFix = true
|
|
} else {
|
|
rows.push('auto')
|
|
}
|
|
}
|
|
if (needsFix) {
|
|
grid.style.gridTemplateRows = rows.join(' ')
|
|
}
|
|
}
|
|
})
|
|
|
|
app.canvas?.setDirty(true, true)
|
|
|
|
document.dispatchEvent(new CustomEvent('lora-manager:vue-mode-change', {
|
|
detail: { isVueDomMode }
|
|
}))
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
// Initialize listener when app is ready
|
|
if (app.ui?.settings) {
|
|
initVueDomModeListener()
|
|
} else {
|
|
// Defer until app is ready
|
|
const checkAppReady = setInterval(() => {
|
|
if (app.ui?.settings) {
|
|
initVueDomModeListener()
|
|
clearInterval(checkAppReady)
|
|
}
|
|
}, 100)
|
|
}
|
|
|
|
// @ts-ignore
|
|
function createLoraInfoWidget(node: any) {
|
|
const container = document.createElement('div')
|
|
container.id = `lora-info-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: { name?: string; notes?: string; filePath?: string; activeTab?: string } | undefined
|
|
|
|
const widget = node.addDOMWidget(
|
|
'lora_info_display',
|
|
'LORA_INFO_DISPLAY',
|
|
container,
|
|
{
|
|
getValue() {
|
|
return internalValue
|
|
},
|
|
setValue(v: { name?: string; notes?: string; filePath?: string; activeTab?: string }) {
|
|
internalValue = v
|
|
if (typeof widget.onSetValue === 'function') {
|
|
widget.onSetValue(v)
|
|
}
|
|
},
|
|
serialize: true,
|
|
getMinHeight() {
|
|
return LORA_INFO_WIDGET_MIN_HEIGHT
|
|
}
|
|
}
|
|
)
|
|
|
|
const vueApp = createApp(LoraInfoWidget, {
|
|
widget,
|
|
node,
|
|
api,
|
|
app,
|
|
isVueMode: typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode,
|
|
})
|
|
|
|
vueApp.use(PrimeVue, {
|
|
unstyled: true,
|
|
ripple: false
|
|
})
|
|
|
|
vueApp.mount(container)
|
|
vueApps.set(node.id + 40000, vueApp) // Offset to avoid collision
|
|
|
|
widget.computeLayoutSize = () => {
|
|
const minWidth = LORA_INFO_WIDGET_MIN_WIDTH
|
|
const minHeight = LORA_INFO_WIDGET_MIN_HEIGHT
|
|
|
|
return { minHeight, minWidth }
|
|
}
|
|
|
|
widget.onRemove = () => {
|
|
const vueApp = vueApps.get(node.id + 40000)
|
|
if (vueApp) {
|
|
vueApp.unmount()
|
|
vueApps.delete(node.id + 40000)
|
|
}
|
|
}
|
|
|
|
return { widget }
|
|
}
|
|
|
|
// Factory function for creating autocomplete text widgets
|
|
// @ts-ignore
|
|
function createAutocompleteTextWidgetFactory(
|
|
node: any,
|
|
widgetName: string,
|
|
modelType: 'loras' | 'prompt',
|
|
inputOptions: { placeholder?: string } = {}
|
|
) {
|
|
const metadataWidgetName = `__lm_autocomplete_meta_${widgetName}`
|
|
|
|
let container: HTMLElement | null = null
|
|
|
|
const existingContainers = document.querySelectorAll<HTMLElement>(
|
|
'[id^="autocomplete-text-widget-"]'
|
|
)
|
|
for (const el of existingContainers) {
|
|
if (el.children.length === 0) {
|
|
container = el
|
|
break
|
|
}
|
|
}
|
|
|
|
if (!container) {
|
|
const instanceId = String(createAutocompleteTextWidgetInstanceId())
|
|
container = document.createElement('div')
|
|
container.id = `autocomplete-text-widget-${instanceId}`
|
|
container.style.width = '100%'
|
|
container.style.height = '100%'
|
|
container.style.display = 'flex'
|
|
container.style.flexDirection = 'column'
|
|
container.style.overflow = 'hidden'
|
|
forwardMiddleMouseToCanvas(container)
|
|
}
|
|
|
|
// Store textarea reference on the container element so cloned widgets can access it
|
|
// This is necessary because when widgets are promoted to subgraph nodes,
|
|
// the cloned widget shares the same element but needs access to inputEl
|
|
const widgetElementRef = { inputEl: undefined as HTMLTextAreaElement | undefined }
|
|
;(container as any).__widgetInputEl = widgetElementRef
|
|
|
|
const metadataWidget = node.addWidget('text', metadataWidgetName, {
|
|
version: AUTOCOMPLETE_METADATA_VERSION,
|
|
textWidgetName: widgetName
|
|
})
|
|
metadataWidget.value = createAutocompleteMetadataValue(widgetName)
|
|
metadataWidget.type = 'LORA_MANAGER_AUTOCOMPLETE_METADATA'
|
|
metadataWidget.hidden = true
|
|
metadataWidget.computeSize = () => [0, -4]
|
|
metadataWidget.serializeValue = () => metadataWidget.value
|
|
|
|
const widget = node.addDOMWidget(
|
|
widgetName,
|
|
`AUTOCOMPLETE_TEXT_${modelType.toUpperCase()}`,
|
|
container,
|
|
{
|
|
getValue() {
|
|
// Access inputEl from widget or from the shared element reference
|
|
const inputEl = widget.inputEl ?? (container as any).__widgetInputEl?.inputEl
|
|
return inputEl?.value ?? ''
|
|
},
|
|
setValue(v: string) {
|
|
// Access inputEl from widget or from the shared element reference
|
|
const inputEl = widget.inputEl ?? (container as any).__widgetInputEl?.inputEl
|
|
if (inputEl) {
|
|
inputEl.value = v ?? ''
|
|
// Notify Vue component of value change via custom event
|
|
inputEl.dispatchEvent(new CustomEvent('lora-manager:autocomplete-value-changed', {
|
|
detail: { value: v ?? '' }
|
|
}))
|
|
} else {
|
|
;(widget as any)._pendingValue = v ?? ''
|
|
}
|
|
// Also call onSetValue if defined (for Vue component integration)
|
|
if (typeof widget.onSetValue === 'function') {
|
|
widget.onSetValue(v ?? '')
|
|
}
|
|
},
|
|
serialize: true,
|
|
getMinHeight() {
|
|
return AUTOCOMPLETE_TEXT_WIDGET_MIN_HEIGHT
|
|
},
|
|
...(modelType === 'loras' && {
|
|
getMaxHeight() {
|
|
return AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT
|
|
}
|
|
})
|
|
}
|
|
)
|
|
widget.metadataWidget = metadataWidget
|
|
|
|
// Get spellcheck setting from ComfyUI settings (default: false)
|
|
const spellcheck = app.ui?.settings?.getSettingValue?.('Comfy.TextareaWidget.Spellcheck') ?? false
|
|
|
|
const maxHeight = modelType === 'loras' ? AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT : undefined
|
|
|
|
const vueApp = createApp(AutocompleteTextWidget, {
|
|
widget,
|
|
node,
|
|
modelType,
|
|
placeholder: inputOptions.placeholder || widgetName,
|
|
showPreview: true,
|
|
spellcheck,
|
|
maxHeight
|
|
})
|
|
|
|
vueApp.use(PrimeVue, {
|
|
unstyled: true,
|
|
ripple: false
|
|
})
|
|
|
|
vueApp.mount(container)
|
|
const appKey = container.id
|
|
vueApps.set(appKey, vueApp)
|
|
|
|
if (maxHeight) {
|
|
container.style.minHeight = `${AUTOCOMPLETE_TEXT_WIDGET_MIN_HEIGHT}px`
|
|
}
|
|
|
|
if (modelType === 'loras') {
|
|
applyAutocompleteTextLayoutFix(
|
|
widget,
|
|
container,
|
|
typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true
|
|
)
|
|
}
|
|
|
|
const vueCleanup = createVueWidgetCleanup(vueApp, () => {
|
|
vueApps.delete(appKey)
|
|
})
|
|
|
|
widget.onRemove = () => {
|
|
vueCleanup()
|
|
}
|
|
|
|
// Return minWidth/minHeight hints so ComfyUI's _initialMinSize mechanism
|
|
// sets a sensible initial node width (and height for prompt/embeddings).
|
|
// loras modelType retains its existing height constraints (getMaxHeight: 100).
|
|
const minWidth = AUTOCOMPLETE_TEXT_MIN_WIDTH_DEFAULT
|
|
const minHeight = modelType === 'loras' ? undefined : AUTOCOMPLETE_TEXT_MIN_HEIGHT_DEFAULT
|
|
|
|
return { widget, minWidth, minHeight }
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: 'LoraManager.VueWidgets',
|
|
|
|
getCustomWidgets() {
|
|
return {
|
|
// @ts-ignore
|
|
LORA_POOL_CONFIG(node) {
|
|
return createLoraPoolWidget(node)
|
|
},
|
|
// @ts-ignore
|
|
RANDOMIZER_CONFIG(node) {
|
|
return createLoraRandomizerWidget(node)
|
|
},
|
|
// @ts-ignore
|
|
CYCLER_CONFIG(node) {
|
|
return createLoraCyclerWidget(node)
|
|
},
|
|
// Autocomplete text widget for LoRAs (used by Lora Loader, Lora Stacker, WanVideo Lora Select)
|
|
// @ts-ignore
|
|
AUTOCOMPLETE_TEXT_LORAS(node) {
|
|
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {}
|
|
return createAutocompleteTextWidgetFactory(node, 'text', 'loras', options)
|
|
},
|
|
// Autocomplete text widget for prompt (used by Prompt and Text nodes)
|
|
// @ts-ignore
|
|
AUTOCOMPLETE_TEXT_PROMPT(node) {
|
|
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {}
|
|
return createAutocompleteTextWidgetFactory(node, 'text', 'prompt', options)
|
|
},
|
|
}
|
|
},
|
|
|
|
// Add display-only widget to Debug Metadata node
|
|
// Register mode change handlers for LoRA provider nodes
|
|
// Extract and store input options for autocomplete widgets
|
|
// @ts-ignore
|
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
|
const comfyClass = nodeType.comfyClass
|
|
const inputs = { ...nodeData.input?.required, ...nodeData.input?.optional }
|
|
let hasAutocompleteWidget = false
|
|
|
|
// Extract and store input options for autocomplete widgets
|
|
for (const [inputName, inputDef] of Object.entries(inputs)) {
|
|
// @ts-ignore
|
|
if (Array.isArray(inputDef) && typeof inputDef[0] === 'string' && inputDef[0].startsWith('AUTOCOMPLETE_TEXT_')) {
|
|
// @ts-ignore
|
|
const options = inputDef[1] || {}
|
|
widgetInputOptions.set(`${nodeData.name}:${inputName}`, options)
|
|
hasAutocompleteWidget = true
|
|
}
|
|
}
|
|
|
|
if (hasAutocompleteWidget) {
|
|
const originalOnSerialize = nodeType.prototype.onSerialize
|
|
const originalConfigure = nodeType.prototype.configure
|
|
|
|
nodeType.prototype.onSerialize = function (serialized: any) {
|
|
originalOnSerialize?.apply(this, arguments)
|
|
|
|
serialized.properties = serialized.properties || {}
|
|
const widgetIds = getSerializableWidgetNames(this)
|
|
serialized.properties[LORA_MANAGER_WIDGET_IDS_PROPERTY] = widgetIds
|
|
}
|
|
|
|
nodeType.prototype.configure = function (info: any) {
|
|
normalizeAutocompleteWidgetValues(this, info)
|
|
|
|
const bypassResult = shouldBypassAutocompleteWidgetMigration(this, info?.widgets_values ?? [])
|
|
|
|
if (bypassResult) {
|
|
info.widgets_values = [...(info.widgets_values ?? []), null]
|
|
}
|
|
|
|
return originalConfigure?.apply(this, arguments)
|
|
}
|
|
}
|
|
|
|
// Register mode change handlers for LORA_STACK chain nodes
|
|
if (LORA_CHAIN_NODE_TYPES.includes(comfyClass)) {
|
|
const originalOnNodeCreated = nodeType.prototype.onNodeCreated
|
|
|
|
nodeType.prototype.onNodeCreated = function () {
|
|
originalOnNodeCreated?.apply(this, arguments)
|
|
|
|
// Create node-specific callback for Lora Stacker (updates direct trigger toggles)
|
|
const nodeSpecificCallback = comfyClass === "Lora Stacker (LoraManager)"
|
|
? (activeLoraNames: Set<string>) => updateConnectedTriggerWords(this, activeLoraNames)
|
|
: undefined
|
|
|
|
// Create and set up the mode change handler
|
|
const onModeChange = createModeChangeCallback(this, updateDownstreamLoaders, nodeSpecificCallback)
|
|
setupModeChangeHandler(this, onModeChange)
|
|
}
|
|
}
|
|
|
|
// Add the JSON display widget to Debug Metadata node
|
|
if (nodeData.name === 'Debug Metadata (LoraManager)') {
|
|
const onNodeCreated = nodeType.prototype.onNodeCreated
|
|
|
|
nodeType.prototype.onNodeCreated = function () {
|
|
onNodeCreated?.apply(this, [])
|
|
|
|
// Add the JSON display widget
|
|
createJsonDisplayWidget(this)
|
|
}
|
|
}
|
|
|
|
// Add the Lora Info display widget
|
|
if (nodeData.name === 'Lora Info (LoraManager)') {
|
|
const onNodeCreated = nodeType.prototype.onNodeCreated
|
|
|
|
nodeType.prototype.onNodeCreated = function () {
|
|
onNodeCreated?.apply(this, [])
|
|
|
|
// Create the lora info display widget
|
|
createLoraInfoWidget(this)
|
|
}
|
|
}
|
|
}
|
|
})
|