Files

5464 lines
237 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { $el } from "../../scripts/ui.js";
import LoraSmartInfo from './smart_info.js';
var debug = {
enabled: true,
log: function(...args) {
if (this.enabled) {
console.log(...args);
}
}
};
class LoraSidebar {
constructor(app) {
const stores = app.getAppStores?.() || {};
this.canvasStore = stores.canvasStore;
this.settingStore = stores.settingStore;
this.app = app;
//session state changes
this.sessionKey = 'loraSidebarSession';
this.stateKey = 'loraSidebarState';
this.loadingKey = 'loraSidebarContinuousLoadComplete';
sessionStorage.setItem(this.loadingKey, 'false');
this.isNewSession = this.checkNewSession(); ;
this.state = this.isNewSession ? this.getDefaultState() : this.loadState();
this.isFirstOpen = true; //session hack
this.defaultCatState = app.extensionManager.setting.get('LoRA Sidebar.General.catState', "Expanded") === "Expanded";
this.favState = app.extensionManager.setting.get('LoRA Sidebar.General.favState', true);
this.loraData = [];
this.filteredData = [];
this.SorthMethod = app.extensionManager.setting.get('LoRA Sidebar.General.sortMethod', 'AlphaAsc');
this.minSize = 100;
this.maxSize = 400;
this.loadingDelay = 800; // continuous loading delay
this.initialIndex = 500;
this.searchDelay = 150;
this.searchTimeout = null;
this.batchSize = app.extensionManager.setting.get('LoRA Sidebar.General.batchSize', 500);
this.nextStartIndex = this.initialIndex + 1;
this.savedElementSize = app.extensionManager.setting.get('LoRA Sidebar.General.thumbnailSize', 125);
this.elementSize = this.state.elementSize || this.savedElementSize;
this.loadingIndicator = this.createLoadingIndicator();
this.galleryContainer = this.createGalleryContainer();
this.sizeSlider = this.createSizeSlider();
this.progressBar = this.createProgressBar(); // Initialize progressBar
this.loraSidebarWidth = 0;
this.searchInput = this.createSearchInput();
this.initialRenderComplete = false;
this.savedModelFilter = app.extensionManager.setting.get('LoRA Sidebar.General.showModels', "All");
this.sortModels = app.extensionManager.setting.get('LoRA Sidebar.General.sortModels', 'None');
this.modelFilter = this.state.modelFilter || this.savedModelFilter;
this.modelFilterDropdown = this.createModelFilterDropdown();
this.currentSearchInput = this.state.searchTerm ? this.state.searchTerm.split(/\s+/) : [];
this.PREDEFINED_TAGS = [
"character", "style", "celebrity", "concept", "clothing", "poses",
"background", "tool", "buildings", "vehicle", "objects", "animal", "assets", "action"
];
this.CUSTOM_TAGS = (app.extensionManager.setting.get('LoRA Sidebar.General.customTags') || "").split(',').map(tag => tag.trim().toLowerCase());
debug.log("After loading CUSTOM_TAGS:", this.CUSTOM_TAGS);
this.tagSource = app.extensionManager.setting.get('LoRA Sidebar.General.tagSource', "CivitAI");
debug.log("Tag source:", this.tagSource);
this.a1111Style = app.extensionManager.setting.get('LoRA Sidebar.General.a1111Style', false);
this.UseRG3 = app.extensionManager.setting.get('LoRA Sidebar.General.useRG3', false);
this.infoPersist = app.extensionManager.setting.get('LoRA Sidebar.General.infoPersist', true);
this.loadingOverlay = this.createLoadingOverlay();
this.lastLoadOffset = 0;
this.searchCount = 0;
this.element = $el("div.lora-sidebar", {
draggable: false,
}, [
$el("div.lora-sidebar-content", {
draggable: false,
}, [
$el("h3", "LoRA Sidebar"),
this.searchInput,
this.modelFilterDropdown,
this.sizeSlider,
this.loadingIndicator,
this.galleryContainer,
this.progressBar,
this.loadingOverlay
])
]);
this.preventDragging();
this.placeholderUrl = "/lora_sidebar/placeholder";
this.CatNew = app.extensionManager.setting.get('LoRA Sidebar.General.catNew', false);
this.FavOpen = app.extensionManager.setting.get('LoRA Sidebar.General.favState', true);
this.smartEnabled = app.extensionManager.setting.get('LoRA Sidebar.General.smartEnabled', true)
this.showNSFW = app.extensionManager.setting.get('LoRA Sidebar.NSFW.hideNSFW', true);
this.nsfwThreshold = app.extensionManager.setting.get('LoRA Sidebar.NSFW.nsfwLevel', 25);
this.nsfwFolder = app.extensionManager.setting.get('LoRA Sidebar.NSFW.nsfwFolder', true);
this.nsfwString = app.extensionManager.setting.get('LoRA Sidebar.NSFW.nsfwString', "NSFW");
this.addStyles().catch(console.error);
//this.lastProcessed = { newVal: undefined, oldVal: undefined }; // I NEED TO REFACTOR THIS
api.addEventListener("lora_process_progress", this.updateProgress.bind(this));
}
async addStyles() {
try {
const response = await fetch('./extensions/ComfyUI_LoRA_Sidebar/css/lora-sidebar.css');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const css = await response.text();
const styleElement = document.createElement('style');
styleElement.textContent = css;
document.head.appendChild(styleElement);
} catch (error) {
console.error('Error loading LoRA Sidebar styles:', error);
}
}
// session state stuff
checkNewSession() {
const lastSession = sessionStorage.getItem(this.sessionKey);
const currentSession = Date.now().toString();
sessionStorage.setItem(this.sessionKey, currentSession);
return !lastSession || lastSession !== currentSession;
}
getDefaultState() {
return {
searchTerm: '',
modelFilter: this.savedModelFilter,
elementSize: this.savedElementSize,
categoryStates: {},
scrollPosition: 0,
totalLoaded: 0
};
}
saveState() {
sessionStorage.setItem(this.stateKey, JSON.stringify(this.state));
}
loadState() {
const savedState = sessionStorage.getItem(this.stateKey);
return savedState ? JSON.parse(savedState) : this.getDefaultState();
}
getTotalLoadedItems() {
const containers = this.galleryContainer.querySelectorAll('.lora-items-container');
let total = 0;
containers.forEach(container => {
total += container.children.length;
});
return total;
}
///////
getActiveTags() {
return this.tagSource === 'CivitAI' ? this.PREDEFINED_TAGS : this.CUSTOM_TAGS;
}
setNSFWThreshold(threshold) {
this.nsfwThreshold = threshold;
}
isNSFW(lora) {
return (lora.nsfw && this.nsfwThreshold < 50) || (lora.nsfwLevel && lora.nsfwLevel > this.nsfwThreshold);
}
preventDragging() {
const makeNotDraggable = (element) => {
// Skip elements with class lora-overlay
if (!element.classList?.contains('lora-overlay')) {
element.draggable = false;
// Recursively process all children
Array.from(element.children).forEach(child => makeNotDraggable(child));
}
};
// Start with the sidebar element
makeNotDraggable(this.element);
}
createSizeSlider() {
this.sizeSlider = $el("input.lora-size-slider", {
type: "range",
min: this.minSize,
max: this.maxSize,
value: this.elementSize,
draggable: false,
oninput: (e) => {
this.elementSize = parseInt(e.target.value);
this.updateGalleryLayout();
}
});
return this.sizeSlider;
}
createModelFilterDropdown() {
const filterOptions = ['All', 'Pony', 'Flux', 'SD 3.5', 'Illustrious', 'NoobAI', 'SDXL', 'SD 1.5', 'Wan Video', 'Hunyuan Video', 'Custom + Other'];
const dropdown = $el("select.model-filter-dropdown", {
onchange: (e) => this.handleModelFilter(e.target.value)
});
filterOptions.forEach(option => {
const optionElement = $el("option", {
value: option,
textContent: option,
selected: option === this.state.modelFilter
});
dropdown.appendChild(optionElement);
});
return dropdown;
}
updateGalleryLayout() {
const itemWidth = `${this.elementSize}px`;
this.element.querySelectorAll('.lora-item').forEach(item => {
item.style.width = itemWidth;
// The height will be automatically set by the aspect-ratio in CSS
});
// Update the grid-template-columns for all lora-items-container
this.element.querySelectorAll('.lora-items-container').forEach(container => {
container.style.gridTemplateColumns = `repeat(auto-fill, minmax(${itemWidth}, 1fr))`;
});
}
createSearchInput() {
const searchContainer = $el("div.search-container", [
$el("input.search-input", {
type: "text",
placeholder: "Search name, tags, model, etc.",
value: this.state.searchTerm,
oninput: (e) => this.debouncedSearch(e.target.value)
}),
$el("button.search-clear", {
innerHTML: "&#x2715;",
title: "Clear search",
onclick: () => {
const searchInput = this.element.querySelector('.search-input');
searchInput.value = '';
this.debouncedSearch('');
}
})
]);
return searchContainer;
}
debounce(func, delay) {
let debounceTimer;
return function (...args) {
const context = this;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => func.apply(context, args), delay);
};
}
debouncedSearch(searchInput) {
// Clear any existing timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
// Set a new timeout
this.searchTimeout = setTimeout(() => {
this.handleSearch(searchInput);
}, this.searchDelay);
}
createLoadingIndicator() {
return $el("div.loading-indicator", {
style: {
display: "none",
textAlign: "center",
padding: "20px"
}
}, [
$el("p", "Processing LoRAs..."),
$el("progress")
]);
}
createGalleryContainer() {
this.galleryContainer = $el("div.lora-gallery-container", {
draggable: false,
});
return this.galleryContainer;
}
saveScrollPosition() {
const sidebarContentContainer = document.querySelector('.sidebar-content-container');
if (sidebarContentContainer) {
this.state.scrollPosition = sidebarContentContainer.scrollTop;
this.saveState();
}
}
handleSearch(searchInput) {
const newSearchInput = searchInput.toLowerCase().split(/\s+/).filter(term => term.length > 0);
if (JSON.stringify(this.currentSearchInput) !== JSON.stringify(newSearchInput) ||
searchInput === this.currentSearchInput.join(' ')) {
this.currentSearchInput = newSearchInput;
// Use your existing fast filtering logic
this.filteredData = this.loraData.filter(lora => {
// NSFW filter - always apply if showNSFW is false
if (!this.showNSFW && this.isNSFW(lora)) {
return false;
}
// Search term filter
const matchesSearch = this.currentSearchInput.length === 0 || this.currentSearchInput.every(term => {
const nameMatch = lora.name && lora.name.toLowerCase().includes(term);
const tagMatch = Array.isArray(lora.tags) && lora.tags.some(tag =>
tag.toLowerCase().includes(term)
);
const trainedWordsMatch = Array.isArray(lora.trained_words) &&
lora.trained_words.some(word => word.toLowerCase().includes(term));
const baseModelMatch = lora.baseModel && lora.baseModel.toLowerCase().includes(term);
const subdirMatch = lora.subdir && lora.subdir.toLowerCase().includes(term);
const typeMatch = lora.type && lora.type.toLowerCase().includes(term);
return nameMatch || tagMatch || trainedWordsMatch ||
baseModelMatch || subdirMatch || typeMatch;
});
// Model filter
const matchesModelFilter = this.modelFilter === 'All' ||
this.matchesModelFilter(lora, this.modelFilter);
return matchesSearch && matchesModelFilter;
});
// Reset gallery and render filtered results
this.galleryContainer.innerHTML = '';
this.renderLoraGallery(0, this.batchSize);
// If we're searching and have less than 500 items showing, load more from expanded categories
if (this.currentSearchInput.length > 0) {
const visibleCount = this.galleryContainer.querySelectorAll('.lora-item').length;
if (visibleCount < 500) {
// Find expanded categories that need more items
const expandedCategories = Array.from(this.galleryContainer.querySelectorAll('.lora-category'))
.filter(category => {
const container = category.querySelector('.lora-items-container');
return container &&
window.getComputedStyle(container).display !== 'none' &&
container.dataset.pendingLorasData;
});
// Load more from each category until we hit 500
for (const category of expandedCategories) {
if (this.galleryContainer.querySelectorAll('.lora-item').length >= 500) break;
const container = category.querySelector('.lora-items-container');
const categoryName = category.getAttribute('data-category');
const loadedCount = container.children.length;
// Get filtered items for this category
const categoryItems = this.filteredData.filter(lora =>
!lora.favorite &&
(!lora.is_new || !this.CatNew) &&
this.getCategoryForLora(lora) === categoryName
);
// Calculate remaining items to load
const remainingItems = categoryItems.slice(loadedCount);
if (remainingItems.length > 0) {
this.createLoraElementsForCategory(container, remainingItems);
// Update pending data
if (remainingItems.length > container.children.length - loadedCount) {
const stillPending = remainingItems.slice(container.children.length - loadedCount);
container.dataset.pendingLorasData = JSON.stringify(stillPending);
} else {
delete container.dataset.pendingLorasData;
}
}
}
}
}
this.state.searchTerm = searchInput;
this.saveState();
}
}
countItemsInClosedCategories() {
let count = 0;
const categories = this.galleryContainer.querySelectorAll('.lora-category');
categories.forEach(category => {
const lorasContainer = category.querySelector('.lora-items-container');
if (lorasContainer.style.display === 'none') {
// For pending loras, get count from dataset
if (lorasContainer.dataset.pendingLoras) {
count += JSON.parse(lorasContainer.dataset.pendingLoras).length;
} else {
count += lorasContainer.children.length;
}
}
});
return count;
}
loraPathCalc(loraData) {
return `${loraData.path}${loraData.path ? "/" : ""}${loraData.filename.split("/").pop()}`;
}
loraPathCalcUpdate(loraData) {
debug.log("loraPathCalcUpdate", loraData);
return `${loraData.subdir}/${loraData.filename}`;
}
sanitizeHTML(html) {
// Create a temporary element
const temp = document.createElement('div');
temp.innerHTML = html;
// Remove any script tags
const scripts = temp.getElementsByTagName('script');
while (scripts[0]) {
scripts[0].parentNode.removeChild(scripts[0]);
}
// Remove any style tags
const styles = temp.getElementsByTagName('style');
while (styles[0]) {
styles[0].parentNode.removeChild(styles[0]);
}
// Remove any on* attributes (onclick, onmouseover, etc.)
const all = temp.getElementsByTagName('*');
for (let i = 0; i < all.length; i++) {
const attrs = all[i].attributes;
for (let j = attrs.length - 1; j >= 0; j--) {
if (attrs[j].name.startsWith('on')) {
all[i].removeAttribute(attrs[j].name);
}
}
}
// Return the sanitized HTML
return temp.innerHTML;
}
matchesModelFilter(lora, filter) {
const baseModel = (lora.baseModel || '').toLowerCase();
switch (filter) {
case 'Pony':
return baseModel.includes('pony');
case 'Flux':
return baseModel.includes('flux');
case 'SD 3.5':
return baseModel.includes('sd') && baseModel.includes('3.5');
case 'Illustrious':
return baseModel.includes('illustrious');
case 'SDXL':
return baseModel.includes('sdxl');
case 'SD 1.5':
return baseModel.includes('sd') && baseModel.includes('1.5');
case 'NoobAI':
return baseModel.includes('noobai');
case 'Wan Video':
return baseModel.includes('wan video');
case 'Hunyuan Video':
return baseModel.includes('hunyuan video');
case 'Custom + Other':
return !['pony', 'flux', 'sdxl', 'illustrious', 'noobai', 'wan video', 'hunyuan video'].some(model => baseModel.includes(model)) &&
!(baseModel.includes('sd') && (baseModel.includes('1.5') || baseModel.includes('3.5')));
default:
return true;
}
}
handleModelFilter(filterValue) {
this.modelFilter = filterValue;
this.state.modelFilter = filterValue;
this.handleSearch(this.state.searchTerm);
this.saveState();
}
async checkUnprocessedLoras() {
debug.log("Checking for unprocessed LoRAs...");
try {
const response = await api.fetchApi('/lora_sidebar/unprocessed_count');
debug.log("Response received:", response);
if (response.ok) {
const data = await response.json();
debug.log("Unprocessed LoRAs data:", data);
return {
unprocessedCount: data.unprocessed_count,
newCount: data.new_loras.length,
movedCount: data.moved_loras.length,
missingCount: data.missing_loras.length,
localMetadata: data.local_metadata,
remoteMetadata: data.remote_metadata
};
}
console.error("Error response:", response.status, response.statusText);
} catch (error) {
console.error("Error checking unprocessed LoRAs:", error);
}
return {
unprocessedCount: 0,
newCount: 0,
movedCount: 0,
missingCount: 0,
localMetadata: 0,
remoteMetadata: 0
};
}
showUnprocessedLorasPopup(counts) {
return new Promise(async (resolve) => {
// First, check if processing is already running
let isProcessing = false;
try {
const processingResponse = await fetch('/lora_sidebar/is_processing');
if (processingResponse.ok) {
const processingData = await processingResponse.json();
isProcessing = processingData.is_processing;
}
} catch (error) {
console.error("Error checking processing status:", error);
}
// If processing is already in progress, don't show the popup
if (isProcessing) {
debug.log("Processing already in progress, skipping prompt.");
resolve(false); // Automatically resolve as "no processing"
return;
}
// Fetch the estimate from the backend
let estimatedTimeStr = "Calculating...";
let unprocessedLoras = 0;
let newLoras = 0;
let movedLoras = 0;
let missingLoras = 0;
let localLoras = 0;
let remoteLoras = 0;
try {
const response = await fetch(`/lora_sidebar/estimate?new=${counts.new}&moved=${counts.moved}&missing=${counts.missing}&local=${counts.local}&remote=${counts.remote}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
const { total_unprocessed_loras, new_loras, moved_loras, missing_loras, local_loras, remote_loras, estimated_time_minutes } = data;
// Use the estimated_time_minutes directly as it already includes the units
estimatedTimeStr = `${estimated_time_minutes}`;
unprocessedLoras = `${total_unprocessed_loras}`;
newLoras = `${new_loras}`;
movedLoras = `${moved_loras}`;
missingLoras = `${missing_loras}`;
localLoras = `${local_loras}`;
remoteLoras = `${remote_loras}`;
} catch (error) {
console.error("Error fetching estimate:", error);
estimatedTimeStr = "Unavailable";
}
const popup = $el("div.lora-popup", {
style: {
position: "fixed",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
backgroundColor: "#333",
padding: "20px",
borderRadius: "10px",
boxShadow: "0 0 10px rgba(0,0,0,0.5)",
zIndex: "1000",
color: "#ccc",
maxWidth: "400px",
textAlign: "center"
}
}, [
$el("h3", "Process LoRAs"),
$el("p", `Found ${unprocessedLoras} LoRAs to process.`),
$el("p", `Of these detected ${newLoras} are New, ${movedLoras} have Moved, and ${missingLoras} are Missing. ${localLoras} LoRAs have local data we can use and ${remoteLoras} will require remote API calls.`),
$el("p", `Estimated processing time: ${estimatedTimeStr}.`),
$el("p", "This process can take a lot of time with large amounts of remote LoRAs. (This is for your protection so CivitAI doesn't ban your IP!) Local metadata, speeds things up considerably."),
$el("p", "You are free to close the sidebar and use Comfy while it runs, but WFs might run very slowly. If you need to quit, progress is saved and can be resumed later."),
$el("p", "If this is asking to process older LoRA files again, it means I had to make a data change. (Sorry) You can Cancel this process for now but you won't have new features (or LoRAs) until you hit OK."),
$el("div", {
style: {
display: "flex",
justifyContent: "space-around",
marginTop: "20px"
}
}, [
$el("button", {
textContent: "OK",
onclick: () => {
document.body.removeChild(popup);
resolve(true);
},
style: {
padding: "10px 20px",
cursor: "pointer",
backgroundColor: "#4CAF50",
color: "white",
border: "none",
borderRadius: "5px"
}
}),
$el("button", {
textContent: "Cancel",
onclick: () => {
document.body.removeChild(popup);
resolve(false);
},
style: {
padding: "10px 20px",
cursor: "pointer",
backgroundColor: "#f44336",
color: "white",
border: "none",
borderRadius: "5px"
}
})
])
]);
document.body.appendChild(popup);
});
}
createProgressBar() {
const container = document.createElement('div');
container.style.display = 'none';
container.style.width = '100%';
container.style.marginTop = '10px';
const progress = document.createElement('progress');
progress.style.width = '100%';
progress.max = 100;
progress.value = 0;
// counter
const progressText = document.createElement('div');
progressText.style.textAlign = 'center';
progressText.style.marginTop = '5px';
progressText.style.fontSize = '12px';
progressText.style.color = '#666';
container.appendChild(progress);
container.appendChild(progressText);
return container;
}
updateProgress(event) {
const { progress, completed, total } = event.detail;
this.progressBar.style.display = 'block';
const progressBar = this.progressBar.querySelector('progress');
const progressText = this.progressBar.querySelector('div');
progressBar.value = progress;
progressText.textContent = `Processing: ${completed}/${total}`;
}
async processLoras() {
this.progressBar.style.display = 'block';
try {
const response = await api.fetchApi('/lora_sidebar/process');
if (response.ok) {
const result = await response.json();
debug.log("LoRAs processed successfully", result);
} else {
console.error("Error processing LoRAs:", response.status, response.statusText);
}
} catch (error) {
console.error("Error processing LoRAs:", error);
} finally {
this.progressBar.style.display = 'none';
}
}
createLoadingOverlay() {
const overlay = $el("div.loading-overlay", {
style: {
display: "none"
}
}, [
$el("div.spinner-container", [
$el("div.spinner"),
$el("h2", {
textContent: "Loading LoRAs...",
className: "loading-text"
}),
$el("p", {
textContent: "This can take some time if you're generating or just processed new LoRAs.",
className: "loading-text"
}),
$el("p", {
textContent: "If it always takes a long time, you should play with the LoRA Loading Batch Size in the sidebar settings.",
className: "loading-text"
})
])
]);
return overlay;
}
async toggleFavorite(lora) {
try {
const response = await api.fetchApi('/lora_sidebar/toggle_favorite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: lora.id })
});
if (response.ok) {
// Update in-memory data
const loraInData = this.loraData.find(l => l.id === lora.id);
if (loraInData) {
const wasInFavorites = loraInData.favorite;
loraInData.favorite = !loraInData.favorite;
// Update filtered data if it exists
const loraInFiltered = this.filteredData.find(l => l.id === lora.id);
if (loraInFiltered) {
loraInFiltered.favorite = loraInData.favorite;
}
// Get category info update from BE
const result = await response.json();
if (result.categoryInfo) {
this.categoryInfo = result.categoryInfo;
}
// Update just the affected categories
const favoritesCategory = this.galleryContainer.querySelector(
'.lora-category[data-category="Favorites"]'
);
const originalCategory = this.galleryContainer.querySelector(
`.lora-category[data-category="${this.getCategoryForLora(lora)}"]`
);
// Update Favorites category
if (favoritesCategory) {
const favorites = this.filteredData.filter(l => l.favorite);
const container = favoritesCategory.querySelector('.lora-items-container');
const isExpanded = container &&
window.getComputedStyle(container).display !== 'none';
if (isExpanded) {
container.innerHTML = '';
this.createLoraElementsForCategory(container, favorites);
} else {
// update pending data when minimized
container.dataset.pendingLorasData = JSON.stringify(favorites);
}
// Update pending data if any
const currentCount = container.children.length;
if (favorites.length > currentCount) {
const pendingItems = favorites.slice(currentCount);
container.dataset.pendingLoras = JSON.stringify(
pendingItems.map(i => i.id)
);
container.dataset.pendingLorasData = JSON.stringify(pendingItems);
}
// Update count
const countDisplay = favoritesCategory.querySelector('.category-count');
if (countDisplay) {
countDisplay.textContent = `${container.children.length}/${favorites.length}`;
}
}
// Update original category if it exists
if (originalCategory) {
const container = originalCategory.querySelector('.lora-items-container');
if (container) {
const categoryName = originalCategory.getAttribute('data-category');
const categoryLoras = this.filteredData.filter(l =>
!l.favorite &&
(!l.is_new || !this.CatNew) &&
this.getCategoryForLora(l) === this.getCategoryForLora(lora)
);
const isExpanded = window.getComputedStyle(container).display !== 'none';
if (isExpanded) {
// Only reload the currently loaded items
const currentCount = container.children.length;
container.innerHTML = '';
this.createLoraElementsForCategory(
container,
categoryLoras.slice(0, currentCount)
);
// Update pending data if any
if (categoryLoras.length > currentCount) {
const pendingItems = categoryLoras.slice(currentCount);
container.dataset.pendingLoras = JSON.stringify(
pendingItems.map(i => i.id)
);
container.dataset.pendingLorasData = JSON.stringify(pendingItems);
}
}
// Update count
// Get the original total from categoryInfo
const totalInCategory = this.categoryInfo?.[categoryName]?.total || categoryLoras.length;
const countDisplay = originalCategory.querySelector('.category-count');
if (countDisplay) {
countDisplay.textContent =
`${container.children.length}/${totalInCategory}`;
}
}
}
this.showToast("success", "Favorite Updated",
`${lora.name} has been ${loraInData.favorite ? 'added to' : 'removed from'} favorites.`);
}
}
} catch (error) {
console.error("Error toggling favorite:", error);
this.showToast("error", "Update Failed",
`Failed to update favorite status for ${lora.name}.`);
}
}
// Helper function for category determination
getCategoryForLora(lora) {
if (this.sortModels === 'Tags') {
if (lora.tags) {
const activeTags = this.getActiveTags();
const matchingTag = activeTags.find(tag => lora.tags.includes(tag));
if (matchingTag) return matchingTag;
}
return 'Unsorted';
} else if (this.sortModels === 'Subdir') {
return lora.subdir ? lora.subdir.split(/[/\\]/).pop() : 'Unsorted';
}
return 'All LoRAs';
}
async loadLoraData(offset = 0, limit = this.batchSize) {
try {
const sortPreference = this.SorthMethod || 'AlphaAsc';
const url = `/lora_sidebar/data?` + new URLSearchParams({
offset: offset,
limit: limit,
sort: sortPreference,
nsfw_folder: this.nsfwFolder,
nsfw_string: this.nsfwString
});
const response = await api.fetchApi(url);
if (response.ok) {
const data = await response.json();
if (data && data.loras) {
// msfw check
if (this.nsfwFolder) {
data.loras = data.loras.map(lora => {
if (lora.subdir && lora.subdir.toLowerCase().includes('nsfw')) {
return {
...lora,
nsfw: true,
nsfwLevel: 100
};
}
return lora;
});
}
// Initial load
if (offset === 0) {
this.loraData = data.loras;
this.filteredData = this.loraData;
// Store category info on initial load
if (data.categoryInfo) {
this.categoryInfo = data.categoryInfo;
debug.log("Updated category info:", this.categoryInfo);
}
// Initial render with proper limit
const initialRenderCount = Math.min(500, data.loras.length);
this.renderLoraGallery(0, initialRenderCount);
// Update favorites if provided (first batch only)
if (data.favorites) {
debug.log("Updated favorites from backend");
this.favorites = data.favorites;
}
// Only continue loading if there's more data
if (data.hasMore) {
let currentOffset = limit;
let hasMore = data.hasMore;
while (hasMore) {
const nextUrl = `/lora_sidebar/data?` + new URLSearchParams({
offset: currentOffset,
limit: limit,
sort: sortPreference,
nsfw_folder: this.nsfwFolder
});
const nextResponse = await api.fetchApi(nextUrl);
if (nextResponse.ok) {
const nextData = await nextResponse.json();
if (nextData && nextData.loras) {
// nsfw check 2
if (this.nsfwFolder) {
nextData.loras = nextData.loras.map(lora => {
if (lora.subdir && lora.subdir.toLowerCase().includes('nsfw')) {
return {
...lora,
nsfw: true,
nsfwLevel: 100
};
}
return lora;
});
}
// Carefully append new data
this.loraData = [...this.loraData, ...nextData.loras];
// Update filtered data only if we're not currently searching
if (!this.currentSearchInput.length) {
this.filteredData = this.loraData;
}
if (nextData.categoryInfo) {
this.categoryInfo = {
...this.categoryInfo,
...nextData.categoryInfo
};
debug.log("Updated category info:", this.categoryInfo);
}
currentOffset += limit;
hasMore = nextData.hasMore;
}
}
}
}
} else {
// Handle subsequent batch loads (scrolling)
this.loraData = [...this.loraData, ...data.loras];
// Only update filtered data if not searching
if (!this.currentSearchInput.length) {
this.filteredData = this.loraData;
this.renderLoraGallery(this.loraData.length - data.loras.length, data.loras.length);
}
// Update category info if provided
if (data.categoryInfo) {
this.categoryInfo = {
...this.categoryInfo,
...data.categoryInfo
};
debug.log("Updated category info:", this.categoryInfo);
}
}
// Store pagination info
this.hasMore = data.hasMore;
this.totalCount = data.totalCount;
this.favoritesManuallyClosed = false; // Reset on full refresh
debug.log(`Loaded ${data.loras.length} LoRAs, total: ${this.loraData.length}, hasMore: ${this.hasMore}`);
}
}
} catch (error) {
console.error("Error loading LoRA data:", error);
}
}
renderLoraGallery(startIndex = 0, count = 500) {
try {
// Reduce count to total loras we're dealing with
let totalLoras = this.filteredData.length
debug.log("Total Loras:",totalLoras);
debug.log("Count:",count);
if (count > totalLoras) {
count = totalLoras;
debug.log("Batch larger than LoRA list, setting size to",count);
}
if (startIndex === 0) {
this.galleryContainer.innerHTML = '';
const favorites = this.filteredData.filter(lora => lora.favorite);
let remainingItems = Math.min(count, 250 + favorites.length); // TODO change this hard set to something that uses batch size like 10%?
debug.log("We've got this many loras to work with",remainingItems);
// Render Favorites
if (favorites.length > 0) {
const favoritesCategory = this.createCategoryElement("Favorites", favorites);
if (favoritesCategory) {
this.galleryContainer.appendChild(favoritesCategory);
// Only count against budget if expanded
const favsContainer = favoritesCategory.querySelector('.lora-items-container');
if (favsContainer && window.getComputedStyle(favsContainer).display !== 'none') {
remainingItems -= favorites.length;
}
}
}
// Render New Items
if (remainingItems > 0) {
const newItems = this.filteredData.filter(lora =>
!lora.favorite && lora.is_new && this.CatNew
);
if (newItems.length > 0) {
const newCategory = this.createCategoryElement("New",
newItems.slice(0, remainingItems));
if (newCategory) {
this.galleryContainer.appendChild(newCategory);
remainingItems -= Math.min(newItems.length, remainingItems);
}
}
}
// Get remaining items excluding favorites and new
const remainingLoras = this.filteredData.filter(lora =>
!lora.favorite && (!lora.is_new || !this.CatNew)
);
// Handle the rest based on sort mode
if (this.sortModels === 'None') {
// Simple rendering without categories
const allLorasCategory = this.createCategoryElement("All LoRAs",
remainingLoras.slice(0, Math.min(500, remainingItems)));
if (allLorasCategory) {
this.galleryContainer.appendChild(allLorasCategory);
}
} else {
// Create categories with minimum 1 item if possible
let categorizedLoras = new Map();
// First, categorize all loras
remainingLoras.forEach(lora => {
if (!this.showNSFW && this.isNSFW(lora)) return;
let category = 'Unsorted';
if (this.sortModels === 'Tags') {
const activeTags = this.getActiveTags();
if (lora.tags) {
const matchingTag = activeTags.find(tag => lora.tags.includes(tag));
if (matchingTag) category = matchingTag;
}
} else if (this.sortModels === 'Subdir') {
// Replace slashes with ' > ' for display, remove trailing slashes first
category = lora.subdir ? lora.subdir.replace(/[/\\]+$/, '').replace(/[/\\]/g, ' > ') : 'Unsorted';
}
if (!categorizedLoras.has(category)) {
categorizedLoras.set(category, []);
}
categorizedLoras.get(category).push(lora);
});
categorizedLoras = new Map(
Array.from(categorizedLoras.entries())
.sort((a, b) => {
if (a[0] === 'Unsorted') return 1;
if (b[0] === 'Unsorted') return -1;
return a[0].localeCompare(b[0]);
})
);
// First pass - create all categories with 1 item each
for (const [category, loras] of categorizedLoras) {
debug.log("Initial category load for", category, ":", {
totalLoras: loras.length,
firstItem: loras[0],
pendingCount: loras.length > 1 ? loras.slice(1).length : 0
});
this.searchCount = totalLoras;
const initialItems = loras.slice(0, 1);
const categoryElement = this.createCategoryElement(category, initialItems);
if (categoryElement) {
this.galleryContainer.appendChild(categoryElement);
remainingItems--;
// Store pending items
const lorasContainer = categoryElement.querySelector('.lora-items-container');
if (lorasContainer && loras.length > 1) {
lorasContainer.dataset.pendingLorasData = JSON.stringify(loras.slice(1));
lorasContainer.dataset.totalItems = loras.length;
}
// Update the count display
const countDisplay = categoryElement.querySelector('.category-count');
if (countDisplay) {
// Use "broken" count display for special categories TODO - THIS IS RIDICULOUS
if (category === "Favorites") {
const favoritesCategory = this.galleryContainer.querySelector(
'.lora-category[data-category="Favorites"]'
);
const favContainer = favoritesCategory.querySelector('.lora-items-container');
const totalFavorites = favContainer ? favContainer.children.length : loras.length;
countDisplay.textContent = `totalBULLSHIT/${loras.length}`;
} else {
const totalFromInfo = this.categoryInfo?.[category]?.total;
countDisplay.textContent = `${loras.length}/${totalFromInfo}`;
}
}
}
}
// Second pass - distribute remaining budget to expanded categories
if (remainingItems > 0) {
for (const [category, loras] of categorizedLoras) {
debug.log(`Processing category: ${category}`);
const categoryElement = this.galleryContainer.querySelector(
`.lora-category[data-category="${category}"]`
);
if (!categoryElement) continue;
const container = categoryElement.querySelector('.lora-items-container');
if (!container || window.getComputedStyle(container).display === 'none') {
debug.log(`Skipping ${category}, container:${!!container}, display:${container ? window.getComputedStyle(container).display : 'no-container'}`);
continue;
}
// Load up to remaining budget
const pendingItems = JSON.parse(container.dataset.pendingLorasData || '[]');
const itemsToLoad = pendingItems.slice(0, remainingItems);
if (itemsToLoad.length > 0) {
this.createLoraElementsForCategory(container, itemsToLoad);
remainingItems -= itemsToLoad.length;
// Update pending data
const newPending = pendingItems.slice(itemsToLoad.length);
if (newPending.length > 0) {
container.dataset.pendingLorasData = JSON.stringify(newPending);
} else {
delete container.dataset.pendingLorasData;
}
// Update the count display
const countDisplay = categoryElement.querySelector('.category-count');
if (countDisplay) {
const total = parseInt(container.dataset.totalItems || 0);
countDisplay.textContent = `${container.children.length}/${total}`;
}
}
if (remainingItems <= 0) break;
}
}
}
this.sortCategories();
this.updateGalleryLayout();
this.initialRenderComplete = true;
} else {
// Handle subsequent batch loads (scrolling)
const lorasToRender = this.filteredData.slice(startIndex, startIndex + count);
lorasToRender.forEach(lora => {
if (!lora.favorite && (!lora.is_new || !this.CatNew)) {
const categoryName = this.getCategoryForLora(lora);
const categoryElement = this.galleryContainer.querySelector(
`.lora-category[data-category="${categoryName}"]`
);
if (categoryElement) {
const lorasContainer = categoryElement.querySelector('.lora-items-container');
const isExpanded = window.getComputedStyle(lorasContainer).display !== 'none';
if (isExpanded) {
this.createLoraElementsForCategory(lorasContainer, [lora]);
}
}
}
});
}
} catch (error) {
console.error('Critical error in renderLoraGallery:', error);
}
}
sortCategories() {
const categories = Array.from(this.galleryContainer.querySelectorAll('.lora-category'));
categories.sort((a, b) => {
// Get category names
const aName = a.getAttribute('data-category');
const bName = b.getAttribute('data-category');
// Special handling for fixed-position categories
if (aName === 'Favorites') return -1; // Favorites always first
if (bName === 'Favorites') return 1;
if (aName === 'New') return -1; // New always second
if (bName === 'New') return 1;
// All other categories sort alphabetically
return aName.localeCompare(bName);
});
// Reappend in sorted order
categories.forEach(category => this.galleryContainer.appendChild(category));
}
getCategoryItems(categoryName) {
const activeTags = this.getActiveTags();
let items;
const NEW_CATEGORY_LIMIT = 100;
if (categoryName === "Favorites") {
items = this.filteredData.filter(lora => lora.favorite);
} else if (categoryName === "New") {
// Only process if the setting is enabled
if (!this.CatNew) {
return [];
}
// Get new items excluding favorites
let newItems = this.filteredData.filter(lora => lora.is_new && !lora.favorite);
// If we have more than limit items, check if they all have the same date
if (newItems.length > NEW_CATEGORY_LIMIT) {
// Get unique creation times
const uniqueDates = new Set(newItems.map(lora => lora.created_time));
// If all items have the same date (likely initial import)
// and we're over the limit, disable the category
if (uniqueDates.size === 1) {
return [];
}
// Otherwise, just limit to the most recent items
newItems = newItems.slice(0, NEW_CATEGORY_LIMIT);
}
items = newItems;
} else if (this.sortModels === 'Subdir') {
items = this.filteredData.filter(lora => {
const parts = lora.subdir ? lora.subdir.split('\\') : [];
return parts[parts.length - 1] === categoryName;
});
} else if (this.sortModels === 'Tags') {
if (activeTags.includes(categoryName)) {
items = this.filteredData.filter(lora =>
lora.tags && lora.tags.includes(categoryName)
);
} else if (categoryName === 'Unsorted') {
items = this.filteredData.filter(lora =>
!lora.tags || !activeTags.some(tag => lora.tags.includes(tag))
);
}
}
return items || [];
}
setupScrollHandler() {
debug.log("Setting up scroll handler");
const observer = new MutationObserver(() => {
const sidebarContentContainer = document.querySelector('.sidebar-content-container');
if (sidebarContentContainer) {
debug.log("Sidebar content container found, attaching scroll handler");
let isLoading = false; // Track actual load state
let scrollTimeout;
sidebarContentContainer.addEventListener('scroll', async () => {
// Clear any existing timeout
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
// Set a new timeout to save scroll position after scrolling stops
scrollTimeout = setTimeout(() => {
this.saveScrollPosition();
}, 100);
// Regular processing for normal category modes
if (this.sortModels !== 'None') {
if (!isLoading) {
isLoading = true;
this.handleScrollStart();
isLoading = false;
}
return;
}
// Special case for "All LoRAs" mode - only near bottom
if (!isLoading && this.isNearBottom(sidebarContentContainer)) {
const currentItemCount = this.galleryContainer.querySelectorAll('.lora-item').length;
// Skip if we're trying to load the same offset
if (currentItemCount === this.lastOffset) {
debug.log("Skipping duplicate load at offset:", this.lastOffset);
return;
}
debug.log("Near bottom in All LoRAs mode - starting load at offset:", currentItemCount);
this.lastOffset = currentItemCount;
isLoading = true;
this.handleScrollStart();
isLoading = false;
}
}, { passive: true });
// Restore scroll position after content is loaded
if (this.state.scrollPosition) {
const restoreScroll = () => {
sidebarContentContainer.scrollTop = this.state.scrollPosition;
};
// Try immediately and after a short delay to ensure content is loaded
restoreScroll();
setTimeout(restoreScroll, 100);
}
observer.disconnect();
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
async loadCategoryBatch(categoryName, loadedCount, isFromMinimize = false) {
if (this.isLoading) return;
this.isLoading = true;
try {
const response = await api.fetchApi(
`/lora_sidebar/category/${encodeURIComponent(categoryName)}`
);
if (response.ok) {
const data = await response.json();
if (data.category_ids?.length) {
debug.log("Loading category", categoryName, ":", {
totalIds: data.category_ids.length,
firstId: data.category_ids[0],
loadedCount
});
const category = this.galleryContainer.querySelector(
`.lora-category[data-category="${categoryName}"]`
);
if (category) {
const container = category.querySelector('.lora-items-container');
if (container) {
// If this is from minimize and we're already at 250, skip loading more
if (isFromMinimize && container.children.length >= 250) { //TODO - can't assume this is enough for everyone
return;
}
// Get currently loaded ids from container
const existingIds = Array.from(container.children)
.map(child => {
const img = child.querySelector('img');
if (!img) return null;
const src = img.src;
const encodedFilename = src.split('/').pop();
const filename = decodeURIComponent(encodedFilename);
// Only compare against the IDs in this category
return data.category_ids.find(id =>
filename === id ||
encodedFilename === id
);
})
.filter(Boolean);
debug.log("Category IDs:", data.category_ids);
debug.log("Found existing IDs:", existingIds);
// Filter out already loaded ids from the full set
const validIds = data.category_ids.filter(id =>
!existingIds.includes(id)
// && this.filteredData.some(lora => lora.id === id) - can remove?
);
/*
// respect search
const ids = this.currentSearchInput.length > 0
? data.category_ids.filter(id =>
this.filteredData.some(lora => lora.id === id))
: data.category_ids; */
// Special case: If all ids are "valid" but we know we have one loaded,
// we need to slice off the first one to avoid duplication
// If the first ID from category_ids is in our valid list, remove it
if (validIds.includes(data.category_ids[0])) {
const firstIdIndex = validIds.indexOf(data.category_ids[0]);
validIds.splice(firstIdIndex, 1);
}
// const remainingIds = (validIds.length === data.category_ids.length && loadedCount === 1)
//? validIds.slice(loadedCount)
//: validIds;
const remainingIds = validIds
debug.log("Category:", category, "Remaining IDs:", remainingIds);
// If from minimize, limit how many we load
const idsToLoad = isFromMinimize
? remainingIds.slice(0, 250 - container.children.length)
: remainingIds;
const remainingLoras = idsToLoad
.map(id => this.filteredData.find(lora => lora.id === id))
.filter(Boolean)
.filter(lora => this.showNSFW || !this.isNSFW(lora));
this.createLoraElementsForCategory(container, remainingLoras);
// Update count
const countDisplay = category.querySelector('.category-count');
if (countDisplay) {
const total = countDisplay.textContent.split('/')[1]; // Get the current 'total' part
countDisplay.textContent = `${container.children.length}/${total}`;
}
}
}
}
}
} catch (error) {
console.error(`Error loading batch for category ${categoryName}:`, error);
} finally {
this.isLoading = false;
}
}
handleScrollStart() {
if (this.isLoading) return;
const sidebarContentContainer = document.querySelector('.sidebar-content-container');
if (this.currentSearchInput.length > 0) {
debug.log("Scrolling during search:", this.currentSearchInput);
}
// Do the initial calculations
const containerRect = sidebarContentContainer.getBoundingClientRect();
const buffer = containerRect.height * 0.5;
// Find categories that need processing
const categories = Array.from(this.galleryContainer.querySelectorAll('.lora-category'));
// Keep your debug logging
categories.forEach(category => {
const container = category.querySelector('.lora-items-container');
if (container && window.getComputedStyle(container).display !== 'none') {
const categoryName = category.getAttribute('data-category');
debug.log(`Category ${categoryName}:`, {
loadedItems: container.children.length,
totalItems: parseInt(container.dataset.totalItems || 0),
pendingData: container.dataset.pendingLorasData ?
JSON.parse(container.dataset.pendingLorasData).length : 0,
filtered: this.currentSearchInput.length > 0 ?
this.filteredData.filter(l => this.getCategoryForLora(l) === categoryName).length : 'n/a'
});
}
});
const approachingCategories = categories.filter(category => {
// Skip "All LoRAs" category when using None sorting
if (this.sortModels === 'None' && category.getAttribute('data-category') === "All LoRAs") {
return false;
}
const container = category.querySelector('.lora-items-container');
if (!container || window.getComputedStyle(container).display === 'none') {
return false;
}
const rect = category.getBoundingClientRect();
const currentCount = container.children.length;
const totalItems = parseInt(container.dataset.totalItems || 0);
const needsLoading = (currentCount < 500 && (container.dataset.pendingLorasData ||
currentCount < totalItems)) ||
(currentCount >= 500 && totalItems > currentCount &&
this.isNearCategoryEnd(category));
return needsLoading &&
rect.top <= containerRect.bottom + buffer &&
rect.bottom >= containerRect.top - buffer;
});
if (approachingCategories.length > 0) {
// Process categories in chunks
this.processCategories(approachingCategories, 0, 100); // Process 100 items at a time
}
// Handle non-categorized view
if (this.sortModels === 'None' && this.isNearBottom(sidebarContentContainer)) {
let currentItemCount = this.galleryContainer.querySelectorAll('.lora-item').length;
const allLorasCategory = this.galleryContainer.querySelector('.lora-category[data-category="All LoRAs"]');
// Check for minimized favorites and adjust count using actual favorites data
const favoriteCount = this.filteredData.filter(lora => lora.favorite).length;
const favoritesCategory = this.galleryContainer.querySelector('.lora-category[data-category="Favorites"]');
const favoritesMinimized = favoritesCategory &&
window.getComputedStyle(favoritesCategory.querySelector('.lora-items-container')).display === 'none';
// Use filteredData.length as total during search
let effectiveTotal = this.currentSearchInput.length > 0 ? this.filteredData.length : this.totalCount;
if (favoritesMinimized) {
effectiveTotal -= favoriteCount;
}
debug.log("All LoRAs scroll check:", {
currentItemCount,
effectiveTotal,
totalCount: this.totalCount,
isFiltered: this.currentSearchInput.length > 0,
isProcessing: this.isLoading
});
if (currentItemCount < effectiveTotal && !this.isLoading) {
this.isLoading = true;
if (this.currentSearchInput.length > 0) {
const nextBatch = this.filteredData.slice(
currentItemCount,
currentItemCount + Math.min(100, this.batchSize)
);
if (nextBatch.length) {
this.createLoraElementsForCategory(this.galleryContainer, nextBatch);
}
} else {
this.loadLoraData(currentItemCount, Math.min(100, this.batchSize));
}
this.isLoading = false;
}
}
}
// process categories in chunks
processCategories(categories, startIndex, chunkSize) {
if (startIndex >= categories.length) return;
const category = categories[startIndex];
const container = category.querySelector('.lora-items-container');
const categoryName = category.getAttribute('data-category');
let itemsProcessed = 0;
const pendingItems = container.dataset.pendingLorasData ?
JSON.parse(container.dataset.pendingLorasData) : [];
if (pendingItems.length > 0) {
const itemsToLoad = pendingItems.slice(0, chunkSize);
this.createLoraElementsForCategory(container, itemsToLoad);
if (itemsToLoad.length < pendingItems.length) {
const remainingItems = pendingItems.slice(itemsToLoad.length);
container.dataset.pendingLorasData = JSON.stringify(remainingItems);
} else {
delete container.dataset.pendingLorasData;
}
// Update count display
const countDisplay = category.querySelector('.category-count');
if (countDisplay) {
const total = countDisplay.textContent.split('/')[1];
countDisplay.textContent = `${container.children.length}/${total}`;
}
itemsProcessed = itemsToLoad.length;
}
// Process next chunk after a small delay
if (startIndex + 1 < categories.length || itemsProcessed >= chunkSize) {
setTimeout(() => {
this.processCategories(categories, startIndex + 1, chunkSize);
}, 16);
}
}
isNearCategoryEnd(category) {
const container = category.querySelector('.lora-items-container');
if (!container) return false;
const lastItem = container.lastElementChild;
if (!lastItem) return false;
const viewportHeight = window.innerHeight;
const lastItemRect = lastItem.getBoundingClientRect();
// Check if last item is visible in viewport with a small buffer
const buffer = 200; // pixels
return lastItemRect.top <= viewportHeight + buffer && lastItemRect.bottom >= -buffer;
}
isNearBottom(container) {
const scrollPosition = container.scrollTop + container.clientHeight;
const totalHeight = container.scrollHeight;
const scrollThreshold = 0.9; // 90% of the way down
return scrollPosition / totalHeight > scrollThreshold;
}
createCategoryElement(categoryName, loras, appendToExisting = false) {
let categoryContainer;
if (appendToExisting) {
categoryContainer = this.galleryContainer.querySelector(`.lora-category[data-category="${categoryName}"]`);
if (!categoryContainer) {
categoryContainer = $el("div.lora-category", {
draggable: false
});
categoryContainer.setAttribute('data-category', categoryName);
}
} else {
categoryContainer = $el("div.lora-category", {
draggable: false
});
categoryContainer.setAttribute('data-category', categoryName);
}
let header = categoryContainer.querySelector('.category-header');
if (!header) {
// Get expanded state based on if it's Favorites
const isExpanded = categoryName === "Favorites" && this.FavOpen ? true :
this.state.categoryStates[categoryName] !== undefined ?
this.state.categoryStates[categoryName] :
this.defaultCatState;
// Get counts from category info
let totalCount = this.categoryInfo?.[categoryName]?.total || loras.length;
let loadedCount = loras.length;
// Add debug logs to see what we're getting
debug.log(`Creating category ${categoryName}:`, {
categoryInfo: this.categoryInfo?.[categoryName],
lorasLength: loras.length,
totalFromInfo: this.categoryInfo?.[categoryName]?.total,
totalCount: totalCount,
});
header = $el("div.category-header", {
draggable: false
}, [
$el("h3.category-title", {}, [categoryName]),
$el("span.category-count", {}, [
this.currentSearchInput.length && !isExpanded ?
`${loras.length}/${totalCount}` :
`${loras.length}/${totalCount}`
]),
$el("span.category-toggle", {}, [isExpanded ? "▼" : "▶"])
]);
header.addEventListener('click', () => this.toggleCategory(categoryName));
categoryContainer.appendChild(header);
}
let lorasContainer = categoryContainer.querySelector('.lora-items-container');
if (!lorasContainer) {
lorasContainer = $el("div.lora-items-container", {
draggable: false
});
const isExpanded = categoryName === "Favorites" && this.FavOpen ? true :
this.state.categoryStates[categoryName] !== undefined ?
this.state.categoryStates[categoryName] :
this.defaultCatState;
lorasContainer.style.display = isExpanded ? 'grid' : 'none';
// Store category info
lorasContainer.dataset.totalItems = this.categoryInfo?.[categoryName]?.total || loras.length;
// Only create lora elements if expanded, otherwise store for later
if (!isExpanded) {
// Special case: Always create the first item
if (loras.length === 1) {
this.createLoraElementsForCategory(lorasContainer, loras);
}
lorasContainer.dataset.pendingLoras = JSON.stringify(loras.map(lora => lora.id));
lorasContainer.dataset.pendingLorasData = JSON.stringify(loras);
} else {
this.createLoraElementsForCategory(lorasContainer, loras);
}
categoryContainer.appendChild(lorasContainer);
} else if (appendToExisting) {
const isExpanded = window.getComputedStyle(lorasContainer).display !== 'none';
if (isExpanded) {
this.createLoraElementsForCategory(lorasContainer, loras);
// Update loaded count
lorasContainer.dataset.loadedItems =
parseInt(lorasContainer.dataset.loadedItems || 0) + loras.length;
} else {
// Update pending loras data
const pendingLoras = JSON.parse(lorasContainer.dataset.pendingLoras || '[]');
const pendingLorasData = JSON.parse(lorasContainer.dataset.pendingLorasData || '[]');
pendingLoras.push(...loras.map(lora => lora.id));
pendingLorasData.push(...loras);
lorasContainer.dataset.pendingLoras = JSON.stringify(pendingLoras);
lorasContainer.dataset.pendingLorasData = JSON.stringify(pendingLorasData);
}
}
return categoryContainer;
}
createLoraElementsForCategory(container, loras) {
//loras.forEach(lora => {
// const loraElement = this.createLoraElement(lora);
/* const categoryElement = container.closest('.lora-category');
const categoryName = categoryElement?.getAttribute('data-category');
const catName = "jarfu"
if (categoryName === catName) {
debug.log("Creating lora elements for test category:",
loras.map(l => ({
id: l.id,
name: l.name,
favorite: l.favorite,
is_new: l.is_new,
caller: new Error().stack // This will show us where this was called from
}))
);
} */
// Get existing IDs from the data attributes on the lora-items
const existingIds = Array.from(container.querySelectorAll('.lora-item'))
.map(item => item.querySelector('[draggable="true"]')?.dataset?.loraId)
.filter(Boolean);
loras.forEach(lora => {
if (existingIds.includes(lora.id)) return;
const loraElement = this.createLoraElement(lora);
/* if (loraElement && categoryName === catName) {
debug.log("Created individual lora element:", {
id: lora.id,
name: lora.name,
favorite: lora.favorite,
is_new: lora.is_new,
category: lora.category,
caller: new Error().stack
});
} */
if (loraElement) {
container.appendChild(loraElement);
}
});
this.updateGalleryLayout();
}
async toggleCategory(categoryName) {
// hack for a hack of a hack, TODO CLEAN THIS UP OMG
if (this.currentSearchInput.length) {
const category = this.galleryContainer.querySelector(
`.lora-category[data-category="${categoryName}"]`
);
if (category) {
const container = category.querySelector('.lora-items-container');
if (container) {
const categoryLoras = this.filteredData.filter(lora =>
!lora.favorite &&
(!lora.is_new || !this.CatNew) &&
this.getCategoryForLora(lora) === categoryName
);
// If we have exactly 1 filtered result and pending data, clear pending
if (categoryLoras.length === 1 && container.dataset.pendingLorasData) {
delete container.dataset.pendingLorasData;
delete container.dataset.pendingLoras;
}
}
}
}
const category = this.galleryContainer.querySelector(
`.lora-category[data-category="${categoryName}"]`
);
if (!category) return;
const content = category.querySelector('.lora-items-container');
const toggle = category.querySelector('.category-toggle');
if (!content || !toggle) return;
const isVisible = window.getComputedStyle(content).display !== 'none';
// Track if Favorites was manually closed
if (categoryName === "Favorites") {
this.FavOpen = !isVisible;
}
if (isVisible) {
content.style.display = 'none';
toggle.textContent = '▶';
this.state.categoryStates[categoryName] = false;
// Skip preload logic if this is the "All LoRAs" category
if (categoryName === "All LoRAs") return;
// When minimizing, check next categories
const categories = Array.from(this.galleryContainer.querySelectorAll('.lora-category'));
const currentIndex = categories.findIndex(cat => cat === category);
let remainingBudget = 250; // Load up to 250 items
let checkedCount = 0;
// Start from next category
for (let i = currentIndex + 1; i < categories.length && checkedCount < 3; i++) {
const nextCat = categories[i];
const nextContainer = nextCat.querySelector('.lora-items-container');
// Check if this category is already well-loaded
if (nextContainer && nextContainer.children.length >= 200) {
break; // Stop loading if we hit a category with plenty of items
}
// Only process if category is expanded and has pending items
if (nextContainer &&
window.getComputedStyle(nextContainer).display !== 'none' &&
nextContainer.dataset.pendingLorasData) {
checkedCount++;
const nextCategoryName = nextCat.getAttribute('data-category');
const loadedCount = nextContainer.children.length;
const pendingItems = JSON.parse(nextContainer.dataset.pendingLorasData);
// Calculate how many items we can load with remaining budget
const itemsToLoad = Math.min(remainingBudget, pendingItems.length);
if (itemsToLoad > 0) {
await this.loadCategoryBatch(nextCategoryName, loadedCount, true);
// Update pending data
const newLoadedCount = nextContainer.children.length;
const itemsLoaded = newLoadedCount - loadedCount;
if (itemsLoaded > 0) {
// Update pending data to remove loaded items
const remainingPendingItems = pendingItems.slice(itemsLoaded);
if (remainingPendingItems.length > 0) {
nextContainer.dataset.pendingLorasData = JSON.stringify(remainingPendingItems);
} else {
delete nextContainer.dataset.pendingLorasData;
}
remainingBudget -= itemsLoaded;
if (remainingBudget <= 0) break;
}
}
}
}
} else {
content.style.display = 'grid';
toggle.textContent = '▼';
this.state.categoryStates[categoryName] = true;
if (content.dataset.pendingLorasData) {
const pendingItems = JSON.parse(content.dataset.pendingLorasData);
const loadedCount = content.children.length;
const toLoad = pendingItems.slice(0, Math.max(0, 500 - loadedCount));
this.createLoraElementsForCategory(content, toLoad);
// Update pending data if there are more items
const remainingItems = pendingItems.slice(Math.max(0, 500 - loadedCount));
if (remainingItems.length > 0) {
content.dataset.pendingLorasData = JSON.stringify(remainingItems);
} else {
delete content.dataset.pendingLorasData;
}
} else if (categoryName === "All LoRAs") {
const currentCount = content.children.length;
const remainingLoras = this.filteredData.filter(lora =>
!lora.favorite && (!lora.is_new || !this.CatNew)
);
// Only load up to 500 items initially
const initialLoadCount = Math.min(500 - currentCount, remainingLoras.length - currentCount);
if (initialLoadCount > 0) {
const lorasToAdd = remainingLoras.slice(currentCount, currentCount + initialLoadCount);
this.createLoraElementsForCategory(content, lorasToAdd);
}
// Store remaining items as pending if there are more
if (remainingLoras.length > content.children.length) {
content.dataset.pendingLorasData = JSON.stringify(
remainingLoras.slice(content.children.length)
);
}
}
const loadedCount = content.children.length;
debug.log("Toggle expand - current children count:", loadedCount);
debug.log("Current children:", content.children);
const totalCount = this.categoryInfo?.[categoryName]?.total || loras.length;
content.dataset.totalItems = totalCount.toString();
if (loadedCount < 500 && loadedCount < totalCount) {
await this.loadCategoryBatch(categoryName, loadedCount);
}
// We need to update the count display here after loading items
const countDisplay = category.querySelector('.category-count');
if (countDisplay) {
countDisplay.textContent = `${content.children.length}/${totalCount}`;
}
}
this.saveState();
this.updateGalleryLayout();
}
getFontSizeBasedOnLength(text) {
const length = text.length;
if (length <= 10) {
return "16px"; // Largest font size for short text
} else if (length <= 20) {
return "14px";
} else if (length <= 30) {
return "12px";
} else {
return "10px"; // Minimum font size with ellipsis
}
}
createLoraElement(lora, forceRefresh = false) {
try {
const container = $el("div.lora-item");
const previewUrl = forceRefresh
? `/lora_sidebar/preview/${encodeURIComponent(lora.id)}?cb=${Date.now()}`
: `/lora_sidebar/preview/${encodeURIComponent(lora.id)}`;
let isVideo = false;
let previewElement;
const createPreviewElement = (tryVideo = false) => {
isVideo = tryVideo;
const elementType = isVideo ? "video" : "img";
const element = $el(elementType, {
src: previewUrl,
alt: lora.name,
draggable: true,
...(isVideo && { loop: true, muted: true }),
ondragstart: (e) => {
const dragData = JSON.stringify({
type: "comfy-lora",
id: lora.id
});
e.dataTransfer.setData("application/json", dragData);
this.handleDragStart(e);
},
onerror: (e) => {
if (!e.target.hasAttribute('data-error-handled')) {
e.target.setAttribute('data-error-handled', 'true');
if (!isVideo) {
const videoElement = createPreviewElement(true);
container.replaceChild(videoElement, previewElement);
previewElement = videoElement;
} else {
debug.log(`Using placeholder for ${lora.name}`);
const imgElement = $el("img", {
src: this.placeholderUrl,
alt: lora.name,
draggable: true
});
container.replaceChild(imgElement, previewElement);
previewElement = imgElement;
isVideo = false;
}
}
},
onloadedmetadata: isVideo ? (e) => {
if (e.target.videoWidth === 0 && e.target.videoHeight === 0) {
debug.log(`Invalid video for ${lora.name}, using placeholder`);
const imgElement = $el("img", {
src: this.placeholderUrl,
alt: lora.name,
draggable: true
});
container.replaceChild(imgElement, previewElement);
previewElement = imgElement;
isVideo = false;
}
} : null
});
return element;
};
previewElement = createPreviewElement();
const overlay = $el("div.lora-overlay", {
draggable: true,
ondragstart: (e) => {
e.stopPropagation();
const dragData = JSON.stringify({
type: "comfy-lora",
id: lora.id
});
// Debug logs for element finding
const loraItem = e.target.parentElement;
debug.log("LoRA Item found:", loraItem);
const img = loraItem.querySelector('img');
debug.log("Image element found:", img);
if (img) {
debug.log("Attempting to set drag image:", {
width: img.width,
height: img.height,
src: img.src,
complete: img.complete
});
e.dataTransfer.setDragImage(img, img.width / 2, img.height / 2);
} else {
console.error("No image element found in LoRA item");
}
debug.log("Setting drag data from overlay:", dragData);
e.dataTransfer.setData("application/json", dragData);
this.handleDragStart(e);
}
});
const buttonContainer = $el("div.lora-buttons", [
this.createButton("📋", "Copy", () => this.copyTrainedWords(lora)),
this.createButton("🔄", "Refresh", () => this.refreshLora(lora)),
this.createButton(lora.favorite ? "★" : "☆", "Favorite", () => this.toggleFavorite(lora)),
this.createButton("ℹ️", "Info", (e) => this.showLoraInfo(lora, e.target)),
]);
const fontSize = this.getFontSizeBasedOnLength(lora.name);
const titleContainer = $el("div.lora-title", [
$el("h4", {
textContent: lora.name,
style: { fontSize: fontSize }
})
]);
overlay.appendChild(buttonContainer);
overlay.appendChild(titleContainer);
if (this.isNSFW(lora) && this.nsfwThreshold < 50) {
container.classList.add('nsfw');
const nsfwLabel = $el("span.nsfw-label", {
textContent: lora.nsfw ? "NSFW" : `NSFW Level: ${lora.nsfwLevel}`
});
container.appendChild(nsfwLabel);
}
// Add hover events to the overlay for video control
overlay.addEventListener('mouseenter', () => {
if (isVideo && previewElement instanceof HTMLVideoElement) {
previewElement.play().then(() => {
}).catch(error => {
console.error(`Error playing video for ${lora.name}:`, error);
});
}
});
overlay.addEventListener('mouseleave', () => {
if (isVideo && previewElement instanceof HTMLVideoElement) {
previewElement.pause();
previewElement.currentTime = 0;
}
});
container.appendChild(previewElement);
container.appendChild(overlay);
return container;
} catch (error) {
console.error('Failed to create LoRA element:', lora?.name || 'unknown', error);
return null;
}
}
createButton(icon, tooltip, onClick) {
return $el("button", {
textContent: icon,
title: tooltip,
onclick: onClick
});
}
showToast(severity, summary, detail, life = 3000) {
this.app.extensionManager.toast.add({ severity, summary, detail, life });
}
copyTrainedWords(lora) {
if (lora.trained_words && lora.trained_words.length > 0) {
const trainedWordsText = lora.trained_words.join(', ');
navigator.clipboard.writeText(trainedWordsText).then(() => {
this.showToast("success", "Copied", "Trained words copied to clipboard.");
}).catch(err => {
console.error('Failed to copy trained words:', err);
this.showToast("error", "Copy Failed", "Failed to copy trained words to clipboard.");
});
} else {
this.showToast("info", "No Trained Words", "No trained words exist for this LoRA.");
}
}
async refreshLora(lora) {
if (!lora || !lora.id) {
console.error("Invalid LoRA object or missing ID:", lora);
app.extensionManager.toast.add({
severity: "error",
summary: "Refresh Failed",
detail: "Invalid LoRA data. Please try reloading the page.",
life: 5000
});
return;
}
try {
// First, call /refresh/{lora_id} to get versionId if it doesn't exist
let versionId = lora.versionId;
if (!versionId) {
debug.log(`No versionId for LoRA ${lora.name}, performing hash lookup.`);
const idResponse = await api.fetchApi(`/lora_sidebar/refresh/${lora.id}`, {
method: 'POST'
});
if (!idResponse.ok) {
// Check local_metadata flag to determine if this is a custom LoRA
if (lora.local_metadata) {
this.showToast("warn", "Custom LoRA Detected", 'Skipping refresh for custom LoRA, please use the LoRA Info Pop-up window to update metadata and images for Custom LoRAs.', 5000);
return; // Exit early for custom LoRA
} else {
throw new Error(`Failed to perform hash lookup for LoRA: ${lora.name}, model probably removed.`);
}
}
const idResult = await idResponse.json();
if (idResult.status === 'success' && idResult.data && idResult.data.versionId) {
versionId = idResult.data.versionId;
debug.log(`Found versionId ${versionId} for LoRA ${lora.name}`);
} else {
throw new Error(`Missing or malformed data in API response for LoRA: ${lora.name}`);
}
}
// Now that we have the versionId, proceed to refresh with /refresh/{version_id}
debug.log(`Refreshing LoRA with version ID: ${versionId}`);
debug.log("Looking for LoRA:", {
searchVersionId: versionId,
searchId: lora.id,
matchingLoras: this.loraData.filter(l =>
l.versionId === versionId || l.id === lora.id
).map(l => ({
id: l.id,
versionId: l.versionId,
name: l.names
})),
// Add this to see what we actually have
allLoras: this.loraData.map(l => ({
id: l.id,
versionId: l.versionId,
name: l.name
}))
});
const response = await api.fetchApi(`/lora_sidebar/refresh/${versionId}`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
const result = await response.json();
debug.log("Refresh Result:", result);
if (result.status === 'success') {
if (result.data) {
debug.log("Updated LoRA Data:", result.data);
const updatedLoraData = result.data;
const index = this.loraData.findIndex(l => l.versionId === versionId || l.id === lora.id);
if (index !== -1) {
// Merge existing LoRA with updated data to preserve 'id'
this.loraData[index] = { ...this.loraData[index], ...updatedLoraData };
debug.log("Merged LoRA Data:", this.loraData[index]);
this.filteredData = [...this.loraData];
// check for categoryInfo
if (result.categoryInfo) {
this.categoryInfo = result.categoryInfo;
}
this.handleSearch(this.state.searchTerm || '');
app.extensionManager.toast.add({
severity: "success",
summary: "LoRA Refreshed",
detail: `${updatedLoraData.name} has been successfully refreshed.`,
life: 3000
});
} else {
throw new Error("LoRA not found in local data");
}
} else {
app.extensionManager.toast.add({
severity: "info",
summary: "No Update Needed",
detail: `No updates were required for ${lora.name}.`,
life: 3000
});
}
} else {
debug.log("Refresh Failed:", result);
throw new Error(result.message || 'Failed to refresh LoRA');
}
} catch (error) {
console.error("Error refreshing LoRA:", error);
app.extensionManager.toast.add({
severity: "error",
summary: "Refresh Failed",
detail: `Failed to refresh ${lora.name}. ${error.message}`,
life: 5000
});
}
}
setupCanvasDropHandling() {
const canvas = app.canvas.canvas;
let currentDragData = null;
let isDragging = false;
// Remove existing listeners to prevent stacking
canvas.removeEventListener('dragstart', this.handleDragStart);
canvas.removeEventListener('drop', this.handleDrop);
canvas.removeEventListener('dragover', this.handleDragOver);
// Make this available to other methods
this.handleDragStart = (e) => {
if (e.dataTransfer.types.includes('application/json')) {
currentDragData = e.dataTransfer.getData('application/json');
isDragging = true;
debug.log("Drag started, cached data");
}
};
this.handleDrop = (e) => {
debug.log("Drop handler called from:", e.target);
// First, try to get the drag data
let data = e.dataTransfer?.getData('application/json') || currentDragData;
if (!data) {
debug.log("No drop data available, allowing default behavior");
return; // Let ComfyUI handle it
}
// Check if this is our type of data before doing anything else
try {
const dragData = JSON.parse(data);
if (dragData.type !== "comfy-lora") {
debug.log("Non-comfy-lora object dropped, allowing default behavior");
return; // Let ComfyUI handle it
}
} catch (error) {
debug.log("Error parsing drop data, allowing default behavior", error);
return; // Let ComfyUI handle it
}
// At this point we know it's our lora data, so we can handle the drop
debug.log("Handling comfy-lora drop");
// Now we can check the drop location
const canvasRect = app.canvas.canvas.getBoundingClientRect();
const dropX = (e.clientX - canvasRect.left) / app.canvas.ds.scale - app.canvas.ds.offset[0];
const dropY = (e.clientY - canvasRect.top) / app.canvas.ds.scale - app.canvas.ds.offset[1];
const node = app.graph.getNodeOnPos(dropX, dropY);
if (node) {
debug.log("Dropped on a node:", node);
// Continue processing, as the drop is on a valid Litegraph node
} else {
// Check if drop is over the sidebar
const sidebar = document.querySelector('.lora-sidebar');
if (sidebar && (sidebar.contains(e.target) || e.target === sidebar)) {
debug.log("Drop occurred over sidebar, ignoring");
e.stopPropagation();
return;
}
}
// Early check to ignore non-comfy-lora objects
try {
const dragData = JSON.parse(data);
if (dragData.type !== "comfy-lora") {
debug.log("Non-comfy-lora object dropped, ignoring.");
return; // Ignore and allow default behavior
}
} catch (error) {
debug.log("Error parsing drop data, ignoring.", error);
return; // Ignore and allow default behavior
}
// If we reach here, it's a "comfy-lora" object
debug.log("Comfy-lora object dropped, processing.");
//const canvasRect = canvas.getBoundingClientRect();
//const dropX = (e.clientX - canvasRect.left) / app.canvas.ds.scale - app.canvas.ds.offset[0];
//const dropY = (e.clientY - canvasRect.top) / app.canvas.ds.scale - app.canvas.ds.offset[1];
try {
const dragData = JSON.parse(data);
if (dragData.type === "comfy-lora") {
const loraData = this.loraData.find(l => l.id === dragData.id);
if (!loraData) {
debug.log("Failed to find LoRA data for id:", dragData.id);
return;
}
debug.log("Found LoRA data for drop:", loraData);
// Ensure we have the full path including filename
const fullPath = loraData.path || "";
const fullFilename = fullPath.split(/[/\\]/).pop();
const filename = loraData.filename || dragData.id + ".safetensors"; // Fallback to ID if needed
const nodeData = {
name: loraData.name,
reco_weight: loraData.reco_weight,
path: loraData.subdir || "", // Keep this as is
filename: fullFilename,
trainedWords: loraData.trained_words
};
debug.log("Set nodedata for drop:", nodeData);
let attempts = 0;
const maxAttempts = 3;
const tryCreateNode = () => {
attempts++;
debug.log(`Attempt ${attempts} to create/update node`);
try {
const node = app.graph.getNodeOnPos(dropX, dropY);
if (node) {
if (node.type === "LoraLoader" || node.type === "Power Lora Loader (rgthree)") {
this.updateLoraNode(node, loraData, nodeData);
debug.log("Successfully updated node");
} else if (nodeData.trainedWords?.length > 0) {
// Check if node has any text widgets
const textWidget = node.widgets?.find(w =>
w.name === "text" ||
w.name === "string" ||
w.name === "prompt"
);
if (textWidget) {
const currentText = textWidget.value || "";
let newText = currentText;
let trainedWords = "";
// Handle trained words if they exist
if (nodeData.trainedWords?.length > 0) {
trainedWords = nodeData.trainedWords[0];
}
// If a1111Style is enabled, prefix with LoRA syntax
if (this.a1111Style) {
// const loraPath = nodeData.path ? `${nodeData.path}\\` : '';
const loraPath = loraData.subdir ? loraData.subdir : ''; // Just use subdir as-is from backend
const loraName = nodeData.filename.replace(/\.[^/.]+$/, ""); // this might not be safe because filenames can we be weird
// const loraPart = `${loraPath}${loraName}`;
const loraPart = loraPath ? `${loraPath}${loraName}` : loraName; // Add forward slash between path and name
const weight = nodeData.reco_weight ?? 1;
const weightStr = weight.toString().includes('.') ? weight.toFixed(2) : weight;
trainedWords = `<lora:${loraPart}:${weightStr}>, ${trainedWords}`;
}
if (currentText.length > 0) {
if (currentText.endsWith(", ")) {
newText = currentText + trainedWords;
} else if (currentText.endsWith(",")) {
newText = currentText + " " + trainedWords;
} else {
newText = currentText + ", " + trainedWords;
}
} else {
newText = trainedWords;
}
textWidget.value = newText;
node.setDirtyCanvas(true, true);
debug.log("Successfully added trigger word to text widget");
}
}
} else {
this.createLoraNode(nodeData, dropX, dropY, loraData);
debug.log("Successfully created node");
}
app.graph.setDirtyCanvas(true, true);
} catch (err) {
debug.log(`Attempt ${attempts} failed:`, err);
if (attempts < maxAttempts) {
setTimeout(tryCreateNode, attempts * 100);
}
}
};
tryCreateNode();
}
} catch (error) {
debug.log("Error processing comfy-lora dropped data:", error);
} finally {
currentDragData = null;
isDragging = false;
}
};
canvas.addEventListener('dragover', (e) => {
if (e.dataTransfer.types.includes('application/json')) {
e.preventDefault(); // Enable drop events to fire
}
}, { passive: false });
canvas.addEventListener('drop', this.handleDrop);
canvas.addEventListener('dragenter', (e) => e.preventDefault());
canvas.addEventListener('dragleave', (e) => e.preventDefault());
document.addEventListener('drop', (e) => {
if (isDragging) {
this.handleDrop(e);
}
});
document.addEventListener('dragend', (e) => {
if (isDragging) {
debug.log("Dragend caught with active drag");
this.handleDrop(e);
}
});
}
createLoraNode(nodeData, x, y, loraData) {
if (this.UseRG3) {
// Create Power Lora Loader node
const node = LiteGraph.createNode("Power Lora Loader (rgthree)");
node.pos = [x, y];
// Add the node to the graph (this needs to happen before we add widgets)
app.graph.add(node);
// add lora data
const widget = node.addNewLoraWidget();
const weight = loraData.reco_weight ?? 1;
const loraPath = loraData.subdir ? `${loraData.subdir}${nodeData.filename}` : nodeData.filename; // Just use the path as it comes from the backend now
widget.value = {
on: true,
lora: loraPath,
strength: weight,
strengthTwo: null,
};
widget.loraInfo = {
file: nodeData.filename,
path: loraData.subdir || "",
images: [],
};
// Handle node resizing
const computed = node.computeSize();
const tempHeight = node._tempHeight ?? 15;
node.size[1] = Math.max(tempHeight, computed[1]);
node.setDirtyCanvas(true, true);
} else {
// Original LoraLoader code...
const node = LiteGraph.createNode("LoraLoader");
node.pos = [x, y];
// Set node title
node.title = `Lora - ${nodeData.name}`;
// Get recommended weight, default to 1 if not present
const weight = nodeData.reco_weight ?? 1;
// Set widget values
for (const widget of node.widgets) {
if (widget.name === "lora_name") {
widget.value = loraData.subdir ? `${loraData.subdir}${nodeData.filename}` : nodeData.filename;
// widget.value = nodeData.path ? `${nodeData.path}\\${nodeData.filename}` : nodeData.filename;
} else if (widget.name === "strength_model") {
widget.value = weight;
} else if (widget.name === "strength_clip") {
widget.value = weight;
}
}
// Add the node to the graph
app.graph.add(node);
}
// Ensure the canvas is updated
app.graph.setDirtyCanvas(true, true);
}
updateLoraNode(node, loraData, nodeData) {
// Get just the filename without path
const filename = nodeData.filename; //do we need this?
// Handle different node types
if (node.type === "LoraLoader") {
// Update the node title
node.title = `Lora - ${loraData.name}`;
// Get recommended weight, default to 1 if not present
const weight = loraData.reco_weight ?? 1;
// Update widget values
for (const widget of node.widgets) {
if (widget.name === "lora_name") {
widget.value = loraData.subdir ? `${loraData.subdir}${nodeData.filename}` : nodeData.filename; // Just use the filename as provided by backend
} else if (widget.name === "strength_model") {
widget.value = weight;
} else if (widget.name === "strength_clip") {
widget.value = weight;
}
}
} else if (node.type === "Power Lora Loader (rgthree)") {
debug.log("Starting update with data:", loraData);
const widget = node.addNewLoraWidget();
debug.log("Created widget:", widget);
const weight = loraData.reco_weight ?? 1;
const loraPath = loraData.subdir ? `${loraData.subdir}${nodeData.filename}` : nodeData.filename;
widget.value = {
on: true,
lora: loraPath, // Use the filename directly from backend
strength: weight,
strengthTwo: null,
};
widget.loraInfo = {
file: filename,
path: loraData.subdir || "",
images: [],
};
// Handle node resizing
const computed = node.computeSize();
const tempHeight = node._tempHeight ?? 15;
node.size[1] = Math.max(tempHeight, computed[1]);
node.setDirtyCanvas(true, true);
}
// Ensure the canvas is updated
app.graph.setDirtyCanvas(true, true);
}
showLoraInfo(lora, iconElement) {
// Check if a popup already exists and remove it
if (this.currentPopup) {
this.currentPopup.remove();
}
// Create the popup container
const popup = $el("div.model-info-popup", {
className: "model-info-popup",
onmousedown: (e) => {
// Store the initial mouse down target
popup.dataset.mouseDownTarget = 'inside';
e.stopPropagation();
},
onmouseleave: () => {
// Clear the target if mouse leaves the popup
popup.dataset.mouseDownTarget = '';
}
});
// Create the close button
const closeButton = $el("button", {
className: "close-button",
textContent: "×",
onclick: () => {
popup.remove();
document.removeEventListener("click", closePopup);
this.currentPopup = null;
}
});
// Create the content container
const contentContainer = $el("div.popup-content");
// Add drag area as first child of content container
contentContainer.appendChild($el("div.popup-drag-area", {
draggable: true,
ondragstart: (e) => {
const dragData = JSON.stringify({
type: "comfy-lora",
id: lora.id
});
e.dataTransfer.setData("application/json", dragData);
const img = document.querySelector(`.lora-item img[src*="${encodeURIComponent(lora.id)}"]`);
if (img) {
e.dataTransfer.setDragImage(img, img.width / 2, img.height / 2);
}
// Let your existing handlers take care of everything else
this.handleDragStart(e);
}
}));
// Function to update media content
const updateMediaContent = (updatedLora) => {
// Remove existing media container if it exists
const existingMediaContainer = contentContainer.querySelector('.media-container');
if (existingMediaContainer) {
existingMediaContainer.remove();
}
// Create and append new media container with updated data
const mediaItems = updatedLora.images || [];
const mediaContainer = $el("div.media-container");
contentContainer.appendChild(mediaContainer);
let currentIndex = 0;
let prevButton, nextButton;
const updateMedia = () => {
mediaContainer.innerHTML = "";
if (mediaItems.length > 0) {
const item = mediaItems[currentIndex];
const isVideo = item.type === 'video';
const mediaElement = isVideo ?
$el("video", {
src: item.url,
controls: true,
loop: true,
}) :
$el("img", {
style: {
opacity: 0,
transition: 'opacity 0.3s ease, transform 0.3s ease',
}
});
if (!isVideo) {
mediaElement.onload = () => {
mediaElement.style.opacity = 1;
};
mediaElement.src = item.url;
}
// Add media controls container
const mediaControls = $el("div.media-controls", [
// Left side controls container
$el("div.media-controls-left", [
// Info button
$el("button.media-control-button", {
innerHTML: '<i class="pi pi-info-circle"></i>',
title: "Show generation info",
onclick: (e) => {
e.stopPropagation();
const item = mediaItems[currentIndex];
const promptInfo = item?.prompt || null;
if (!promptInfo) {
this.showToast("warn", "No Info", "No generation info available for this image");
return;
}
// Remove any existing info popup
const existingPopup = document.querySelector('.image-info-popup');
if (existingPopup) existingPopup.remove();
// Create new popup
const infoPopup = $el("div.image-info-popup", {
onclick: (e) => e.stopPropagation()
}, [
$el("div.image-info-content", {
textContent: promptInfo
})
]);
const button = e.currentTarget;
const rect = button.getBoundingClientRect();
infoPopup.style.position = 'absolute';
infoPopup.style.left = `${rect.right + 10}px`;
infoPopup.style.top = `${rect.top}px`;
// Ensure popup stays within viewport
setTimeout(() => {
const popupRect = infoPopup.getBoundingClientRect();
if (popupRect.right > window.innerWidth) {
infoPopup.style.left = `${rect.left - popupRect.width - 10}px`;
}
if (popupRect.bottom > window.innerHeight) {
infoPopup.style.top = `${window.innerHeight - popupRect.height - 10}px`;
}
}, 0);
document.body.appendChild(infoPopup);
// Close on outside click
const closePopup = (e) => {
if (!infoPopup.contains(e.target) && !button.contains(e.target)) {
infoPopup.remove();
document.removeEventListener('click', closePopup);
}
};
setTimeout(() => {
document.addEventListener('click', closePopup);
}, 0);
}
}),
// Copy button
$el("button.media-control-button", {
innerHTML: '<i class="pi pi-clipboard"></i>',
title: "Copy generation info",
onclick: (e) => {
e.stopPropagation();
const item = mediaItems[currentIndex];
const promptInfo = item?.prompt || null;
if (!promptInfo) {
this.showToast("warn", "No Info", "No generation info available to copy");
return;
}
navigator.clipboard.writeText(promptInfo).then(() => {
this.showToast("success", "Copied", "Generation info copied to clipboard");
}).catch(err => {
console.error('Failed to copy prompt:', err);
this.showToast("error", "Copy Failed", "Failed to copy generation info");
});
}
})
]),
// Right side controls container
$el("div.media-controls-right", [
// Set as preview button
$el("button.media-control-button", {
innerHTML: '<i class="pi pi-clone"></i>',
title: "Set as preview image",
onclick: async (e) => {
e.stopPropagation();
try {
const response = await fetch('/lora_sidebar/set_preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: lora.id,
url: item.url
})
});
if (response.ok) {
// Create new element with cache buster
const newLoraElement = this.createLoraElement(lora, true);
const existingLoraContainer = document.querySelector(`.lora-item img[src*="${encodeURIComponent(lora.id)}"]`)?.closest('.lora-item');
if (existingLoraContainer) {
if (existingLoraContainer.style.width) {
newLoraElement.style.width = existingLoraContainer.style.width;
}
existingLoraContainer.replaceWith(newLoraElement);
debug.log('We got a response and tried to replce the container.');
}
}
} catch (error) {
console.error('Error setting preview:', error);
}
}
}),
// Pop-out button
$el("button.media-control-button", {
innerHTML: '<i class="pi pi-window-maximize"></i>',
title: "View larger",
onclick: (e) => {
e.stopPropagation();
let currentIndex = mediaItems.indexOf(item);
const createPopupContent = (index) => {
const fullSizeUrl = mediaItems[index].url.replace(/\/width=\d+\//, '/');
const promptInfo = mediaItems[index]?.prompt;
return [
// Control buttons container
$el("div.popup-controls", [
// Set as preview button
$el("button.popup-control-button", {
innerHTML: '<i class="pi pi-clone"></i>',
title: "Set as preview image",
onclick: async (e) => {
e.stopPropagation();
try {
const response = await fetch('/lora_sidebar/set_preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: lora.id,
url: mediaItems[currentIndex].url
})
});
if (response.ok) {
const newLoraElement = this.createLoraElement(lora, true);
const existingLoraContainer = document.querySelector(`.lora-item img[src*="${encodeURIComponent(lora.id)}"]`)?.closest('.lora-item');
if (existingLoraContainer) {
if (existingLoraContainer.style.width) {
newLoraElement.style.width = existingLoraContainer.style.width;
}
existingLoraContainer.replaceWith(newLoraElement);
this.showToast("success", "LoRA Preview Thumbnail Updated.");
}
}
} catch (error) {
console.error('Error setting preview:', error);
}
}
}),
// Close button
$el("button.popup-control-button", {
innerHTML: '<i class="pi pi-times"></i>',
title: "Close",
onclick: (e) => {
e.stopPropagation();
popup.remove();
}
})
]),
// Navigation buttons
$el("button.nav-button.prev", {
innerHTML: '<i class="pi pi-chevron-left"></i>',
onclick: (e) => {
e.stopPropagation();
currentIndex = (currentIndex - 1 + mediaItems.length) % mediaItems.length;
imageContainer.replaceChildren(...createPopupContent(currentIndex));
},
style: { display: mediaItems.length > 1 ? 'flex' : 'none' }
}),
$el("button.nav-button.next", {
innerHTML: '<i class="pi pi-chevron-right"></i>',
onclick: (e) => {
e.stopPropagation();
currentIndex = (currentIndex + 1) % mediaItems.length;
imageContainer.replaceChildren(...createPopupContent(currentIndex));
},
style: { display: mediaItems.length > 1 ? 'flex' : 'none' }
}),
$el("img", {
src: fullSizeUrl,
onclick: (e) => e.stopPropagation()
}),
// New prompt display container
promptInfo && $el("div.popup-prompt-container", {
onclick: (e) => e.stopPropagation()
}, [
$el("div.popup-prompt-content", {
textContent: promptInfo
}),
$el("button.popup-prompt-copy", {
title: "Copy generation info",
innerHTML: '<i class="pi pi-clipboard"></i>',
onclick: (e) => {
e.stopPropagation();
navigator.clipboard.writeText(promptInfo).then(() => {
this.showToast("success", "Copied", "Generation info copied to clipboard");
}).catch(err => {
console.error('Failed to copy prompt:', err);
this.showToast("error", "Copy Failed", "Failed to copy generation info");
});
}
})
])
].filter(Boolean); // Remove null elements if no prompt
};
const imageContainer = $el("div.image-container");
const popup = $el("div.image-popup", {
onclick: (e) => {
if (e.target === popup) {
e.stopPropagation();
popup.remove();
}
}
}, [imageContainer]);
imageContainer.replaceChildren(...createPopupContent(currentIndex));
document.body.appendChild(popup);
// Close when clicking outside or pressing escape
const closePopup = (e) => {
if (e.key === 'Escape') {
e.stopPropagation(); // Prevent the escape from bubbling
popup.remove();
document.removeEventListener('keydown', closePopup);
}
};
// Also close if clicking on the info popup background
const infoPopup = document.querySelector('.model-info-popup');
if (infoPopup) {
infoPopup.addEventListener('click', (e) => {
// Only handle clicks on the actual background of the info popup
if (e.target === infoPopup) {
e.stopPropagation(); // Prevent the click from closing the info popup
popup.remove();
}
});
}
// Close if esc is pressed
document.addEventListener('keydown', closePopup);
}
})
])
]);
mediaContainer.appendChild(mediaElement);
mediaContainer.appendChild(mediaControls);
}
// Re-append carousel buttons
if (prevButton && nextButton) {
mediaContainer.appendChild(prevButton);
mediaContainer.appendChild(nextButton);
}
};
if (mediaItems.length > 1) {
prevButton = $el("button", {
textContent: "←",
onclick: () => {
currentIndex = (currentIndex - 1 + mediaItems.length) % mediaItems.length;
updateMedia();
},
className: "carousel-button prev"
});
nextButton = $el("button", {
textContent: "→",
onclick: () => {
currentIndex = (currentIndex + 1) % mediaItems.length;
updateMedia();
},
className: "carousel-button next"
});
mediaContainer.appendChild(prevButton);
mediaContainer.appendChild(nextButton);
}
updateMedia();
};
const updateTrainedWords = (updatedLora) => {
const existingWordsContainer = contentContainer.querySelector('.trained-words');
if (existingWordsContainer && updatedLora.trained_words?.length > 0) {
existingWordsContainer.replaceWith($el("div.trained-words", [
$el("h4", { textContent: "Trained Words:" }),
$el("div.word-pills", updatedLora.trained_words.map(word =>
$el("span.word-pill", {
textContent: word,
onclick: () => this.copyToClipboard(word)
})
)),
$el("button.copy-all-button", {
textContent: "Copy All",
onclick: () => this.copyToClipboard(updatedLora.trained_words.join(", "))
})
]));
}
};
const updateTags = (updatedLora) => {
const existingTagsContainer = contentContainer.querySelector('.tags');
if (existingTagsContainer && updatedLora.tags?.length > 0) {
existingTagsContainer.replaceWith($el("div.tags", [
$el("h4", { textContent: "Tags:" }),
$el("div.word-pills", updatedLora.tags.map(tag =>
$el("span.word-pill", {
textContent: tag,
onclick: () => this.copyToClipboard(tag)
})
))
]));
}
};
const fetchImagesIfNeeded = async () => {
debug.log("Checking for images:", {
hasImages: !!lora.images,
imageCount: lora.images?.length,
baseModel: lora.baseModel,
versionId: lora.versionId
});
if (!lora.images || lora.images.length === 0) {
// Only fetch for non-custom LoRAs that have a versionId
if (lora.baseModel !== 'custom' && lora.versionId) {
debug.log("Fetching images for lora:", lora.name);
try {
const response = await fetch(`/lora_sidebar/refresh/${lora.versionId}`, {
method: 'POST'
});
debug.log("Response received:", response.status);
if (response.ok) {
const result = await response.json();
debug.log("Got refresh result:", result);
if (result.status === 'success' && result.data) {
// Update the lora object with new data
Object.assign(lora, result.data);
// Update the media content
updateMediaContent(lora);
updateTrainedWords(lora);
updateTags(lora);
}
}
} catch (error) {
console.error('Error fetching LoRA images:', error);
}
} else {
debug.log("Skipping image fetch:", {
isCustom: lora.baseModel === 'custom',
hasVersionId: !!lora.versionId
});
}
}
};
// Add Fav star and CivitAI link if modelId is available
const topRow = $el("div.info-top-row", {
style: {
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
marginBottom: "3px"
}
});
const leftSection = $el("div.left-section");
if (lora.modelId) {
const civitAiLink = $el("a", {
href: `https://civitai.com/models/${lora.modelId}`,
textContent: `View on CivitAI`,
target: "_blank",
className: "civitai-link"
});
leftSection.appendChild(civitAiLink);
}
// Create favorite toggle
const favoriteToggle = $el("i", {
className: `favorite-toggle pi ${lora.favorite ? 'pi-star-fill' : 'pi-star'}`,
title: 'Add to Favorites',
style: {
cursor: "pointer",
color: lora.favorite ? "#FFD700" : "#888",
fontSize: "1.2em",
transition: "color 0.2s ease"
},
onclick: async (e) => {
e.stopPropagation();
await this.toggleFavorite(lora);
// Update the star appearance after toggle
favoriteToggle.className = `favorite-toggle pi ${lora.favorite ? 'pi-star-fill' : 'pi-star'}`;
favoriteToggle.style.color = lora.favorite ? "#FFD700" : "#888";
}
});
// Add sections to top row
topRow.appendChild(leftSection);
topRow.appendChild(favoriteToggle);
contentContainer.appendChild(topRow);
const createDescriptionPopup = (description, event) => {
// Remove any existing description popup
const existingPopup = document.querySelector('.description-popup');
if (existingPopup) {
existingPopup.remove();
}
if (!description) return;
const popup = $el("div.description-popup", [
$el("button.close-button", {
innerHTML: "×",
onclick: (e) => {
e.stopPropagation();
popup.remove();
}
}),
$el("div", {
innerHTML: description
})
]);
// Check if the description contains any media elements
if (description.includes('<img') || description.includes('<video')) {
popup.classList.add('has-media');
}
// Position popup
document.body.appendChild(popup);
const rect = popup.getBoundingClientRect();
// Calculate position
let left = event.clientX + 10;
let top = event.clientY + 10;
// Adjust for screen edges
if (left + rect.width > window.innerWidth - 20) {
left = window.innerWidth - rect.width - 20;
}
if (top + rect.height > window.innerHeight - 20) {
top = window.innerHeight - rect.height - 20;
}
// Ensure minimum left margin
left = Math.max(20, left);
top = Math.max(20, top);
popup.style.left = `${left}px`;
popup.style.top = `${top}px`;
// Stop propagation on popup clicks to prevent closing the main info popup
popup.addEventListener('click', (e) => {
e.stopPropagation();
});
// Handle outside clicks to close the description popup
const handleOutsideClick = (e) => {
if (!popup.contains(e.target) && !e.target.closest('.description-icon')) {
popup.remove();
document.removeEventListener('click', handleOutsideClick);
}
};
// Small delay to prevent immediate closing
setTimeout(() => {
document.addEventListener('click', handleOutsideClick);
}, 100);
return popup;
};
const createWeightEditor = (lora, contentContainer) => {
let weight = lora.reco_weight || 1;
let isEditing = false;
const createWeightDisplay = () => {
const container = $el("div.weight-container", [
$el("h4", { textContent: "Recommended Weight:" })
]);
const displayContainer = $el("div.weight-display");
const updateDisplay = () => {
// Remove all child elements properly
while (displayContainer.firstChild) {
displayContainer.removeChild(displayContainer.firstChild);
}
if (isEditing) {
// Show input and save icon
const input = $el("input.weight-input", {
type: "number",
value: weight,
step: "0.1",
min: "-10",
max: "10",
onkeydown: (e) => {
e.stopPropagation(); // Prevent event bubbling
if (e.key === 'Enter') {
handleSave(e.target.value);
}
if (e.key === 'Escape') {
isEditing = false;
updateDisplay();
}
},
onclick: (e) => e.stopPropagation() // Prevent event bubbling
});
const saveIcon = $el("i.weight-save-icon.pi.pi-check", {
onclick: (e) => {
e.stopPropagation(); // Prevent event bubbling
handleSave(input.value);
}
});
displayContainer.appendChild(input);
displayContainer.appendChild(saveIcon);
// Focus the input
setTimeout(() => input.focus(), 0);
} else {
// Show value and edit icon
const valueSpan = $el("span.weight-value", {
textContent: weight
});
const editIcon = $el("i.weight-edit-icon.pi.pi-pen-to-square", {
onclick: (e) => {
e.stopPropagation(); // Prevent event bubbling
isEditing = true;
updateDisplay();
}
});
displayContainer.appendChild(valueSpan);
displayContainer.appendChild(editIcon);
}
};
const handleSave = async (newValue) => {
const numValue = parseFloat(newValue);
if (isNaN(numValue) || numValue < -10 || numValue > 10) {
app.ui.dialog.show("Error", "Please enter a valid number between -10 and 10");
return;
}
try {
const response = await api.fetchApi('/lora_sidebar/update_info', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: lora.id,
field: 'reco_weight',
value: numValue
})
});
const result = await response.json();
if (result.status === 'success') {
// Update local state
lora.reco_weight = numValue;
weight = numValue;
isEditing = false;
updateDisplay();
// Also update user_edits in local state if it's returned
if (result.user_edits) {
lora.user_edits = result.user_edits;
}
this.showToast("success", "Weight Updated", `Recommended weight updated to ${numValue}`);
} else {
throw new Error(result.message || 'Failed to update weight');
}
} catch (error) {
console.error("Error updating weight:", error);
this.showToast("error", "Update Failed", "Failed to update recommended weight");
// Revert to previous value in case of error
updateDisplay();
}
};
container.appendChild(displayContainer);
updateDisplay();
return container;
};
const weightDisplay = createWeightDisplay();
// Insert weight display after name container but before trained words
const trainedWords = contentContainer.querySelector('.trained-words');
if (trainedWords) {
contentContainer.insertBefore(weightDisplay, trainedWords);
} else {
contentContainer.appendChild(weightDisplay);
}
};
const createVersionIdEditor = (lora, contentContainer) => {
// Only hide editor if both modelId and versionId are not null
if (lora.modelId !== null && lora.versionId !== null) return;
let isEditing = false;
const container = $el("div.version-id-container", [
$el("h4", { textContent: "Version ID:" })
]);
const displayContainer = $el("div.version-display");
const updateDisplay = () => {
displayContainer.innerHTML = '';
if (isEditing) {
const input = $el("input.version-input", {
type: "number",
value: lora.versionId || "", // Show existing versionId if it exists
placeholder: "Enter Version ID",
onkeydown: (e) => {
e.stopPropagation();
if (e.key === 'Enter') handleSave(e.target.value);
if (e.key === 'Escape') {
isEditing = false;
updateDisplay();
}
},
onclick: (e) => e.stopPropagation()
});
const saveIcon = $el("i.version-save-icon.pi.pi-check", {
onclick: (e) => {
e.stopPropagation();
handleSave(input.value);
}
});
displayContainer.appendChild(input);
displayContainer.appendChild(saveIcon);
setTimeout(() => input.focus(), 0);
} else {
const valueSpan = $el("span.version-value", {
// Show the actual versionId if it exists, otherwise "Custom"
textContent: lora.versionId || "Custom"
});
const editIcon = $el("i.version-edit-icon.pi.pi-pen-to-square", {
onclick: (e) => {
e.stopPropagation();
isEditing = true;
updateDisplay();
}
});
displayContainer.appendChild(valueSpan);
displayContainer.appendChild(editIcon);
}
};
const handleSave = async (value) => {
const versionId = parseInt(value);
if (isNaN(versionId) || versionId <= 0) {
app.ui.dialog.show("Error", "Please enter a valid Version ID");
return;
}
try {
const response = await api.fetchApi('/lora_sidebar/update_info', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: lora.id,
field: 'versionId',
value: versionId
})
});
const result = await response.json();
if (result.status === 'success') {
// Update local state
lora.versionId = versionId;
if (result.modelId) lora.modelId = result.modelId;
// Remove the editor since we now have a valid versionId
container.remove();
// Refresh the LoRA info to fetch new data
this.refreshLora(lora);
} else {
throw new Error(result.message || 'Failed to update Version ID');
}
} catch (error) {
console.error("Error updating Version ID:", error);
this.showToast("error", "Update Failed", "Failed to update Version ID");
}
};
container.appendChild(displayContainer);
updateDisplay();
return container;
};
// nameContainer creation
const nameContainer = $el("div.name-container");
// Create a row for name and icon
const nameRow = $el("div.name-row", {
style: {
display: 'flex',
alignItems: 'center',
gap: '8px',
position: 'relative' // For positioning the save button
}
});
// Create editable name element
const nameElement = $el("h3", {
textContent: lora.name,
className: "lora-name",
style: {
cursor: 'default'
},
ondblclick: async (e) => {
if (e.target !== nameElement) return;
const originalName = lora.name;
// Create container for input and save button
const editContainer = $el("div", {
style: {
position: 'relative',
width: '100%'
},
onclick: (e) => e.stopPropagation() // Prevent clicks from reaching the lora info popup
});
const input = $el("input", {
type: "text",
value: originalName,
style: {
width: '100%',
fontSize: '1rem', // Default font size
fontWeight: 'normal', // Normal weight
backgroundColor: '#2a2a2a',
border: '1px solid #444',
borderRadius: '4px',
color: 'inherit',
padding: '4px 8px',
margin: '0'
},
onkeydown: async (e) => {
e.stopPropagation();
if (e.key === 'Escape') {
nameElement.textContent = originalName;
return;
}
if (e.key === 'Enter') {
await saveName(e.target.value);
}
},
onclick: (e) => e.stopPropagation(),
onmousedown: (e) => e.stopPropagation(),
onmouseup: (e) => e.stopPropagation()
});
// Create save button
const saveButton = $el("div", {
style: {
position: 'absolute',
right: '3px', // Move slightly inward
top: '100%',
marginTop: '1px',
padding: '1px 3px', // Much smaller padding
backgroundColor: '#333', // Dark grey background
borderRadius: '1px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
},
onclick: async (e) => {
e.stopPropagation();
await saveName(input.value);
}
}, [
$el("i.pi.pi-check", {
style: {
color: '#4CAF50', // Green checkmark
fontSize: '0.8em' // Smaller icon
}
})
]);
const saveName = async (newValue) => {
const newName = newValue.trim();
if (newName && newName !== originalName) {
try {
const response = await api.fetchApi('/lora_sidebar/update_info', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: lora.id,
field: 'name',
value: newName
})
});
if (response.ok) {
lora.name = newName;
nameElement.textContent = newName;
this.showToast("success", "Updated", "Name updated successfully");
} else {
throw new Error('Failed to update name');
}
} catch (error) {
console.error("Error updating name:", error);
nameElement.textContent = originalName;
this.showToast("error", "Update Failed", "Failed to update name");
}
} else {
nameElement.textContent = originalName;
}
};
// Clear the name element and add our container
nameElement.textContent = '';
editContainer.appendChild(input);
editContainer.appendChild(saveButton);
nameElement.appendChild(editContainer);
// Focus and select the input
input.focus();
input.scrollLeft = 0;
input.setSelectionRange(0, 0);
}
});
nameRow.appendChild(nameElement);
// Add description icon separately
if (lora.model_desc) {
const descriptionIcon = $el("i.description-icon.pi.pi-info-circle", {
onclick: (e) => {
e.stopPropagation();
createDescriptionPopup(lora.model_desc, e);
}
});
nameRow.appendChild(descriptionIcon);
}
nameContainer.appendChild(nameRow);
// Add label pills container
const labelPills = $el("div.label-pills");
// Add base model pill if available
if (lora.baseModel) {
labelPills.appendChild(this.createEditablePill('baseModel', lora.baseModel, lora));
}
// Add type pill if available
if (lora.type) {
labelPills.appendChild(this.createEditablePill('type', lora.type, lora));
}
// Only append the labels container if we have pills to show
if (labelPills.children.length > 0) {
nameContainer.appendChild(labelPills);
}
const versionIdEditor = createVersionIdEditor.call(this, lora, contentContainer);
versionIdEditor && contentContainer.appendChild(versionIdEditor);
// Add version information in a version container
if (lora.versionName || lora.version_desc) {
const versionContainer = $el("div.version-info");
if (lora.versionName) {
const versionElement = $el("p", {
textContent: `Version: ${lora.versionName}`,
className: "version-name"
});
versionContainer.appendChild(versionElement);
}
if (lora.version_desc) {
// Create a container for the version description
const versionDescContainer = $el("div", {
className: "version-desc-container",
style: {
maxHeight: '200px', // Limit initial height
overflowY: 'auto', // Add scroll if content is too long
marginTop: '8px' // Add some spacing
}
});
// Create the description element that will hold the HTML content
const versionDescElement = $el("div", {
className: "version-desc",
innerHTML: this.sanitizeHTML(lora.version_desc), // Sanitize and set HTML content
style: {
padding: '4px',
fontSize: '0.9em',
lineHeight: '1.4'
}
});
// Add expand/collapse functionality if content is long
versionDescContainer.appendChild(versionDescElement);
// After adding to DOM, check if we need expand/collapse
setTimeout(() => {
if (versionDescElement.scrollHeight > 200) {
const expandButton = $el("button", {
className: "expand-desc-button",
textContent: "Show More",
style: {
marginTop: '4px',
padding: '2px 8px',
fontSize: '0.8em',
opacity: '0.8'
},
onclick: (e) => {
e.stopPropagation();
if (versionDescContainer.style.maxHeight === 'none') {
versionDescContainer.style.maxHeight = '200px';
expandButton.textContent = "Show More";
} else {
versionDescContainer.style.maxHeight = 'none';
expandButton.textContent = "Show Less";
}
}
});
versionContainer.appendChild(expandButton);
}
}, 0);
versionContainer.appendChild(versionDescContainer);
}
nameContainer.appendChild(versionContainer);
}
contentContainer.appendChild(nameContainer);
// Add subdir information
if (lora.subdir) {
const subdirElement = $el("p", {
textContent: `Subdirectory: ${lora.subdir.replace(/[/\\]$/, '')}`,
className: "lora-subdir"
});
contentContainer.appendChild(subdirElement);
}
createWeightEditor(lora, contentContainer);
// Add trained words
const trainedWordsContainer = $el("div.trained-words");
this.createEditableTagSection('trained_words', lora, trainedWordsContainer);
$el("button.copy-all-button", {
textContent: "Copy All",
onclick: () => this.copyToClipboard(updatedLora.trained_words.join(", "))
})
contentContainer.appendChild(trainedWordsContainer);
// Add tags
const tagsContainer = $el("div.tags");
this.createEditableTagSection('tags', lora, tagsContainer);
contentContainer.appendChild(tagsContainer);
// Update initial media content
updateMediaContent(lora);
// Fetch images if needed
fetchImagesIfNeeded();
// Append the close button and content container to popup
popup.appendChild(closeButton);
popup.appendChild(contentContainer);
// Add NSFW warning if applicable
if (this.isNSFW(lora)) {
const nsfwWarning = $el("p.nsfw-warning", {
textContent: lora.nsfw
? "This LoRA contains NSFW content"
: `This LoRA has an NSFW level of ${lora.nsfwLevel}`,
style: {
color: "#ff4444",
fontWeight: "bold"
}
});
contentContainer.appendChild(nsfwWarning);
}
const createButtonRow = () => {
const buttonRow = $el("div.button-row");
// Refresh Button
const refreshButton = $el("button", {
className: "info-button icon-button refresh-button",
title: "Refresh LoRA Data",
innerHTML: '<i class="pi pi-sync"></i>',
onclick: async (e) => {
e.stopPropagation();
await this.refreshLora(lora);
// Find the updated lora data
const updatedLora = this.loraData.find(l => l.id === lora.id);
if (updatedLora) {
// Remove current popup
if (this.currentPopup) {
this.currentPopup.remove();
}
// Show new popup with updated data
this.showLoraInfo(updatedLora, null);
}
}
});
buttonRow.appendChild(refreshButton);
// NSFW Toggle Button
const nsfwButton = $el("button", {
className: `info-button nsfw-toggle nsfw-${lora.nsfw ? 'true' : 'false'}`,
title: "NSFW Flag - Green = False, Red = True",
innerHTML: `<i class="pi ${lora.nsfw ? 'pi-flag-fill' : 'pi-flag'}"></i>`,
onclick: async (e) => {
e.stopPropagation();
try {
const response = await api.fetchApi('/lora_sidebar/update_info', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: lora.id,
field: 'nsfw',
value: !lora.nsfw
})
});
const result = await response.json();
if (result.status === 'success' || result.status === 'warning') {
// Update flag in the passed lora object
lora.nsfw = !lora.nsfw;
// Update in both main arrays
const loraInData = this.loraData.find(l => l.id === lora.id);
if (loraInData) {
loraInData.nsfw = lora.nsfw;
}
const loraInFiltered = this.filteredData.find(l => l.id === lora.id);
if (loraInFiltered) {
loraInFiltered.nsfw = lora.nsfw;
}
nsfwButton.innerHTML = `<i class="pi ${lora.nsfw ? 'pi-flag-fill' : 'pi-flag'}"></i>`;
nsfwButton.className = `info-button nsfw-toggle nsfw-${lora.nsfw ? 'true' : 'false'}`;
// Store user edits if provided
if (result.user_edits) {
lora.user_edits = result.user_edits;
}
// Update category info if provided
if (result.categoryInfo) {
this.categoryInfo = result.categoryInfo;
}
// Update the visual representation in the sidebar
const existingLoraContainer = document.querySelector(
`.lora-item img[src*="${encodeURIComponent(lora.id)}"]`
)?.closest('.lora-item');
if (existingLoraContainer) {
const newLoraElement = this.createLoraElement(lora, true);
if (existingLoraContainer.style.width) {
newLoraElement.style.width = existingLoraContainer.style.width;
}
existingLoraContainer.replaceWith(newLoraElement);
}
// Smoothly transition the popup
if (this.currentPopup) {
this.currentPopup.classList.add('hide');
await new Promise(resolve => setTimeout(resolve, 200)); // Wait for fade out
this.currentPopup.remove();
const newPopup = this.showLoraInfo(lora, null);
// Force a reflow before adding the show class
newPopup.offsetHeight;
newPopup.classList.add('show');
}
this.showToast("success", "NSFW Flag Updated", `NSFW flag set to ${lora.nsfw}`);
} else {
throw new Error(result.message || 'Failed to update NSFW flag');
}
} catch (error) {
console.error("Error updating NSFW flag:", error);
this.showToast("error", "Update Failed", "Failed to update NSFW flag");
}
}
});
buttonRow.appendChild(nsfwButton);
// Upload Image Button
const uploadButton = $el("button", {
className: "info-button icon-button",
title: "Add / Upload Image",
innerHTML: '<i class="pi pi-camera"></i>',
onclick: (e) => {
e.stopPropagation();
this.createUploadPopup(lora);
}
});
buttonRow.appendChild(uploadButton);
// File Details Button
const fileDetailsButton = $el("button", {
className: "info-button icon-button",
title: "LoRA File Details",
innerHTML: '<i class="pi pi-file"></i>',
onclick: (e) => {
e.stopPropagation();
this.showFileDetails(lora);
}
});
buttonRow.appendChild(fileDetailsButton);
// Delete button
const deleteButton = $el("button", {
className: "info-button icon-button delete-button",
title: "Remove LoRA",
innerHTML: '<i class="pi pi-trash"></i>',
onclick: (e) => {
e.stopPropagation();
this.confirmDeleteLora(lora);
}
});
buttonRow.appendChild(deleteButton);
return buttonRow;
};
// Add the button row to the content container (no await needed)
contentContainer.appendChild(createButtonRow());
// Position the popup
const sidebar = document.querySelector('.lora-sidebar');
const sidebarRect = sidebar.getBoundingClientRect();
popup.style.position = 'fixed';
popup.style.left = `${sidebarRect.left + 10}px`; // 10px padding from left
popup.style.top = '50%';
popup.style.transform = 'translateY(-50%)';
popup.style.maxHeight = '80vh';
popup.style.overflowY = 'auto';
popup.style.width = `${sidebarRect.width - 20}px`; // 20px total padding
// Append the popup to the body
document.body.appendChild(popup);
// Add show class to trigger transition
setTimeout(() => popup.classList.add('show'), 0);
// Close the popup when clicking outside (if persistence is disabled)
const closePopup = (event) => {
if (!this.infoPersist && !popup.contains(event.target) && event.target !== iconElement) {
popup.remove();
document.removeEventListener("click", closePopup);
this.currentPopup = null;
}
};
// Only add the click-outside listener if persistence is disabled
if (!this.infoPersist) {
setTimeout(() => {
document.addEventListener("click", closePopup);
}, 0);
}
// Update the current popup reference
this.currentPopup = popup;
return popup;
}
copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
this.showToast("success", "Copied", "Text copied to clipboard.");
}).catch(err => {
console.error('Failed to copy text:', err);
this.showToast("error", "Copy Failed", "Failed to copy text to clipboard.");
});
}
removePreviousPopups() {
const existingPopups = document.querySelectorAll('.confirm-delete-popup');
existingPopups.forEach(popup => popup.remove());
}
createEditablePill(pillType, value, lora) {
const isModel = pillType === 'baseModel';
const field = isModel ? 'baseModel' : 'type';
let isEditing = false;
// Get autocomplete suggestions from existing data
const getAutocompleteSuggestions = (input) => {
const searchTerm = input.toLowerCase();
const existingValues = new Set(
this.loraData
.map(lora => lora[field])
.filter(Boolean) // Remove null/undefined
);
return Array.from(existingValues)
.filter(val => val.toLowerCase().includes(searchTerm));
};
const pill = $el("span.label-pill", {
className: `label-pill ${isModel ? 'model' : 'type'}`,
style: {
display: 'flex',
alignItems: 'center',
gap: '5px',
position: 'relative'
}
}, [
$el("span", { textContent: value }),
$el("i.pi.pi-pencil", {
style: {
cursor: 'pointer',
fontSize: '0.8em',
opacity: '0.7'
},
onclick: (e) => {
e.stopPropagation();
if (!isEditing) {
isEditing = true;
showEditInput();
}
}
})
]);
const showEditInput = () => {
// Clear the pill content
while (pill.firstChild) {
pill.removeChild(pill.firstChild);
}
// Create autocomplete container
const autocompleteContainer = $el("div.autocomplete-container", {
style: {
position: 'absolute',
top: '100%',
left: '0',
backgroundColor: '#333',
border: '1px solid #444',
borderRadius: '4px',
width: '100%',
maxHeight: '150px',
overflowY: 'auto',
zIndex: '1000',
display: 'none'
}
});
const input = $el("input", {
type: "text",
value: value,
style: {
width: '100px',
backgroundColor: 'transparent',
border: 'none',
color: 'inherit',
padding: '2px',
fontSize: 'inherit'
},
oninput: (e) => {
const suggestions = getAutocompleteSuggestions(e.target.value);
updateAutocompleteSuggestions(suggestions);
},
onkeydown: async (e) => {
if (e.key === 'Escape') {
isEditing = false;
restorePill();
return;
}
if (e.key === 'Enter' && e.target.value.trim()) {
e.preventDefault();
try {
const response = await api.fetchApi('/lora_sidebar/update_info', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: lora.id,
field: field,
value: e.target.value.trim()
})
});
if (response.ok) {
value = e.target.value.trim();
lora[field] = value;
isEditing = false;
restorePill();
this.showToast("success", "Updated", `Updated ${isModel ? 'model' : 'type'}`);
}
} catch (error) {
console.error("Error updating:", error);
this.showToast("error", "Update Failed", "Failed to update");
}
}
}
});
const saveIcon = $el("i.weight-save-icon.pi.pi-check", {
style: {
cursor: 'pointer',
marginLeft: '5px'
},
onclick: () => {
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
}
});
const updateAutocompleteSuggestions = (suggestions) => {
autocompleteContainer.innerHTML = '';
if (suggestions.length > 0) {
suggestions.forEach(suggestion => {
const item = $el("div.autocomplete-item", {
textContent: suggestion,
style: {
padding: '5px 10px',
cursor: 'pointer',
hover: {
backgroundColor: '#444'
}
},
onclick: () => {
input.value = suggestion;
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
}
});
autocompleteContainer.appendChild(item);
});
autocompleteContainer.style.display = 'block';
} else {
autocompleteContainer.style.display = 'none';
}
};
const restorePill = () => {
while (pill.firstChild) {
pill.removeChild(pill.firstChild);
}
pill.appendChild($el("span", { textContent: value }));
pill.appendChild($el("i.pi.pi-pencil", {
style: {
cursor: 'pointer',
fontSize: '0.8em',
opacity: '0.7'
},
onclick: (e) => {
e.stopPropagation();
if (!isEditing) {
isEditing = true;
showEditInput();
}
}
}));
};
pill.appendChild(input);
pill.appendChild(saveIcon);
pill.appendChild(autocompleteContainer);
// Focus input after appending
setTimeout(() => input.focus(), 0);
// Click outside to close autocomplete
document.addEventListener('click', (e) => {
if (!pill.contains(e.target)) {
autocompleteContainer.style.display = 'none';
}
});
};
return pill;
}
createEditableTagSection(type, lora, container) {
const isTrainedWords = type === 'trained_words';
const title = isTrainedWords ? "Trained Words:" : "Tags:";
const items = lora[type] || [];
let isEditing = false;
const updateSection = (newItems) => {
while (container.firstChild) {
container.removeChild(container.firstChild);
}
// Create header with edit/save button
const header = $el("div.section-header", {
style: {
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '5px'
}
}, [
$el("h4", { textContent: title }),
$el("i", {
className: `weight-${isEditing ? 'save' : 'edit'}-icon pi pi-${isEditing ? 'check' : 'pen-to-square'}`,
style: {
cursor: 'pointer',
fontSize: '0.9em'
},
onclick: (e) => {
e.stopPropagation();
isEditing = !isEditing;
updateSection(items);
}
})
]);
container.appendChild(header);
if (isEditing) {
// Edit mode
const editContainer = $el("div.edit-container", {
style: {
marginBottom: '10px'
}
});
// Input field with add button
const inputGroup = $el("div.input-group", {
style: {
display: 'flex',
gap: '5px',
marginBottom: '10px'
}
}, [
$el("input", {
type: "text",
placeholder: `Add new ${isTrainedWords ? 'trigger word' : 'tag'}...`,
onkeydown: async (e) => {
if (e.key === 'Escape') {
isEditing = false;
updateSection(items);
return;
}
if (e.key === 'Enter' && e.target.value.trim()) {
e.preventDefault();
const newItem = e.target.value.trim();
if (!items.includes(newItem)) {
try {
const response = await api.fetchApi('/lora_sidebar/update_info', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: lora.id,
field: type,
value: [...items, newItem]
})
});
if (response.ok) {
items.push(newItem);
e.target.value = '';
updateSection(items);
this.showToast("success", "Updated", `Added new ${isTrainedWords ? 'trigger word' : 'tag'}`);
}
} catch (error) {
console.error("Error updating:", error);
this.showToast("error", "Update Failed", "Failed to update");
}
}
}
}
}),
$el("i.pi.pi-plus", {
style: {
cursor: 'pointer',
padding: '5px',
color: '#4CAF50',
fontSize: '1em'
},
onclick: (e) => {
const input = e.target.previousElementSibling;
if (input.value.trim()) {
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
}
}
})
]);
editContainer.appendChild(inputGroup);
// Add focus after appending
setTimeout(() => {
const input = inputGroup.querySelector('input');
if (input) input.focus();
}, 0);
// Current items with delete buttons
const itemsContainer = $el("div.word-pills", {
style: {
display: 'flex',
flexWrap: 'wrap',
gap: '5px'
}
});
items.forEach(item => {
const pillContainer = $el("div.pill-container", {
style: {
display: 'flex',
alignItems: 'center',
gap: '5px'
}
}, [
$el("span.word-pill", {
textContent: item,
onclick: () => this.copyToClipboard(item)
}),
$el("i.pi.pi-times", {
style: {
cursor: 'pointer',
color: '#ff4444',
fontSize: '0.8em'
},
onclick: async (e) => {
e.stopPropagation();
try {
const response = await api.fetchApi('/lora_sidebar/update_info', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: lora.id,
field: type,
value: items.filter(i => i !== item)
})
});
if (response.ok) {
items.splice(items.indexOf(item), 1);
updateSection(items);
this.showToast("success", "Updated", `Removed ${isTrainedWords ? 'trigger word' : 'tag'}`);
}
} catch (error) {
console.error("Error updating:", error);
this.showToast("error", "Update Failed", "Failed to update");
}
}
})
]);
itemsContainer.appendChild(pillContainer);
});
editContainer.appendChild(itemsContainer);
container.appendChild(editContainer);
} else {
// Display mode
const itemsContainer = $el("div.word-pills",
items.length > 0
? items.map(item =>
$el("span.word-pill", {
textContent: item,
onclick: () => this.copyToClipboard(item)
})
)
: [$el("span", { textContent: "None", className: "no-tags" })]
);
container.appendChild(itemsContainer);
// Only add the Copy All button for trained words section when there are items
if (type === 'trained_words' && items.length > 0) {
const copyAllButton = $el("button.copy-all-button", {
textContent: "Copy All",
onclick: () => this.copyToClipboard(items.join(", "))
});
container.appendChild(copyAllButton);
}
}
};
updateSection(items);
return container;
}
async handleLastGeneratedImage(lora) {
try {
// Get the latest temp image info
const response = await fetch('/lora_sidebar/latest_temp_image');
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to get latest generated image');
}
const imageInfo = await response.json();
// Use the existing upload endpoint with the URL
const uploadResponse = await fetch('/lora_sidebar/upload_images', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
lora_id: lora.id,
urls: [imageInfo.url] // Using the existing URL format
})
});
const result = await uploadResponse.json();
if (result.status === 'success') {
// Update the lora object with new images
lora.images = lora.images || [];
lora.images.push(...result.images);
// Refresh the info popup if it's open
const infoPopup = document.querySelector('.model-info-popup');
if (infoPopup) {
this.showLoraInfo(lora, null);
}
this.showToast("success", "Image Added", "Last generated image added successfully");
return true;
} else {
throw new Error(result.message || 'Failed to add image');
}
} catch (error) {
debug.log("Error adding last generated image:", error);
this.showToast("error", "Add Failed", error.message || "No generated images found");
return false;
}
}
async showFileDetails(lora) {
this.removePreviousPopups();
try {
const fullPath = lora.path || "";
const directory = fullPath.substring(0, fullPath.lastIndexOf('\\'));
const filename = fullPath.substring(fullPath.lastIndexOf('\\') + 1);
const response = await api.fetchApi(`/lora_sidebar/file_details/${lora.id}`);
const result = await response.json();
// Helper function to create a label with buttons
const createLabelWithCopy = (labelText, contentToCopy, canOpenFolder = false) => {
return $el("p.file-location-label", [
$el("span", { textContent: labelText }),
$el("div.action-buttons", {
style: {
marginLeft: "8px",
display: "inline-flex",
gap: "8px"
}
}, [
// Existing copy button
$el("i.pi.pi-clipboard", {
style: {
cursor: "pointer",
fontSize: "0.9em",
opacity: "0.7"
},
onclick: (e) => {
e.stopPropagation();
this.copyToClipboard(contentToCopy);
}
}),
// New folder open button - only show for directory paths
canOpenFolder && $el("i.pi.pi-folder-open", {
style: {
cursor: "pointer",
fontSize: "0.9em",
opacity: "0.7"
},
title: "Open in file browser",
onclick: async (e) => {
e.stopPropagation();
try {
const response = await fetch('/lora_sidebar/open_folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: contentToCopy })
});
if (!response.ok) {
// Fallback to copy if opening fails
this.copyToClipboard(contentToCopy);
this.showToast("info", "Copied Instead",
"Couldn't open folder, path copied to clipboard instead.");
}
} catch (error) {
// Fallback to copy on error
this.copyToClipboard(contentToCopy);
this.showToast("info", "Copied Instead",
"Couldn't open folder, path copied to clipboard instead.");
}
}
})
].filter(Boolean)) // Remove null items (when canOpenFolder is false)
]);
};
const detailsPopup = $el("div.confirm-delete-popup", {
onclick: (e) => e.stopPropagation()
}, [
$el("h3", {
textContent: "LoRA File Details",
style: {
margin: "0 0 15px 0"
}
}),
// Source Location
createLabelWithCopy("Source Location:", directory, true),
$el("p", {
className: "file-location",
textContent: directory || "Path not available"
}),
// Source File
createLabelWithCopy("Source File:", filename),
$el("p", {
className: "file-location",
textContent: filename || "Filename not available"
}),
// Managed Location
createLabelWithCopy("Managed Location:", result.managed_dir, true),
$el("p", {
className: "file-location",
textContent: result.managed_dir || "Path not available"
}),
$el("div.confirm-buttons", [
$el("button", {
textContent: "Close",
onclick: (e) => {
e.stopPropagation();
document.body.removeChild(detailsPopup);
}
})
])
]);
document.body.appendChild(detailsPopup);
const handleOutsideClick = (e) => {
if (!detailsPopup.contains(e.target)) {
e.stopPropagation();
// Check if popup still exists and is a child of document.body
if (document.body.contains(detailsPopup)) {
document.body.removeChild(detailsPopup);
}
document.removeEventListener('click', handleOutsideClick);
}
};
// Small delay to prevent immediate closing
setTimeout(() => {
document.addEventListener('click', handleOutsideClick);
}, 0);
} catch (error) {
console.error("Error fetching file details:", error);
this.showToast("error", "Error", "Failed to fetch file details");
}
}
confirmDeleteLora(lora) {
this.removePreviousPopups();
const confirmPopup = $el("div.confirm-delete-popup", {
onclick: (e) => e.stopPropagation()
}, [
$el("p", {
textContent: "Are you sure you want to delete the sidebar data for this LoRA? This won't remove the LoRA or local data from your hard drive."
}),
$el("div.confirm-buttons", [
$el("button", {
textContent: "Cancel",
onclick: (e) => {
e.stopPropagation();
document.body.removeChild(confirmPopup);
}
}),
$el("button", {
textContent: "Delete",
onclick: (e) => {
e.stopPropagation();
this.deleteLora(lora);
}
})
])
]);
document.body.appendChild(confirmPopup);
const handleOutsideClick = (e) => {
if (!confirmPopup.contains(e.target)) {
e.stopPropagation();
// Check if popup still exists and is a child of document.body
if (document.body.contains(confirmPopup)) {
document.body.removeChild(confirmPopup);
}
document.removeEventListener('click', handleOutsideClick);
}
};
// Small delay to prevent immediate closing
setTimeout(() => {
document.addEventListener('click', handleOutsideClick);
}, 0);
}
createUploadPopup(lora) {
// Remove any existing popups
this.removePreviousPopups();
// Create file input element (hidden)
const fileInput = $el("input", {
type: "file",
multiple: true,
accept: "image/*",
style: { display: "none" },
onchange: (e) => {
const files = Array.from(e.target.files);
if (files.length > 0) {
filePathInput.value = files.map(f => f.name).join(", ");
// Clear the error message if it exists
const errorMsg = popup.querySelector('.error-message');
if (errorMsg) errorMsg.remove();
}
}
});
// Create text input for file path
const filePathInput = $el("input", {
type: "text",
placeholder: "Select images, enter file path, or paste image URL...",
className: "file-path-input",
style: {
width: "100%",
padding: "8px",
marginBottom: "15px",
backgroundColor: "#2a2a2a",
border: "1px solid #444",
borderRadius: "4px",
color: "inherit"
}
});
// Create the popup
const popup = $el("div.confirm-delete-popup", {
onclick: (e) => e.stopPropagation(),
style: {
width: "400px",
padding: "20px"
}
}, [
$el("h3", {
textContent: "Add Images",
style: {
marginTop: "0",
marginBottom: "15px",
textAlign: "center"
}
}),
$el("p", {
textContent: "Select one or more local images to add to the carousel. You can also paste an image URL.",
style: {
marginBottom: "15px",
textAlign: "center",
color: "#999",
fontSize: "0.9em"
}
}),
filePathInput,
// Action buttons container
$el("div.action-buttons", {
style: {
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "10px",
marginBottom: "20px"
}
}, [
$el("button", {
textContent: "Browse Files...",
className: "confirm-button",
style: {
minWidth: "200px"
},
onclick: () => fileInput.click()
}),
$el("button", {
className: "confirm-button",
style: {
minWidth: "200px",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
height: "32px", // Match confirm button height
borderRadius: "6px", // More rounded
},
onclick: async (e) => {
e.stopPropagation();
const button = e.target.closest('button'); // Get the button element even if icon was clicked
const originalHtml = button.innerHTML;
button.disabled = true;
button.textContent = "Adding...";
try {
const success = await this.handleLastGeneratedImage(lora);
if (success) {
popup.remove();
}
} finally {
button.disabled = false;
button.innerHTML = originalHtml;
}
}
}, [
$el("i.pi.pi-microchip-ai", {
style: {
fontSize: "16px" // Adjust icon size as needed
}
}),
$el("span", { textContent: "Add Last Generated Image" })
])
]),
// Confirm/Cancel buttons at bottom
$el("div.confirm-buttons", {
style: {
display: "flex",
justifyContent: "center",
gap: "10px"
}
}, [
$el("button", {
textContent: "Cancel",
onclick: () => popup.remove(),
style: {
minWidth: "100px" // Consistent width for bottom buttons
}
}),
$el("button", {
textContent: "Upload",
onclick: async () => {
const files = fileInput.files;
const inputText = filePathInput.value.trim();
// Check for URLs
if (inputText && !files?.length) {
const urls = inputText.split(/[\n,]/).map(url => url.trim()).filter(url => url);
const validUrls = urls.filter(url => isValidUrl(url));
if (validUrls.length === 0) {
showError("Please enter valid image URLs or select local files");
return;
}
try {
const response = await fetch('/lora_sidebar/upload_images', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
lora_id: lora.id,
urls: validUrls
})
});
const result = await response.json();
if (result.status === 'success') {
handleUploadSuccess(result);
} else {
throw new Error(result.message || 'Upload failed');
}
} catch (error) {
console.error("Error uploading URLs:", error);
this.showToast("error", "Upload Failed", error.message);
} finally {
popup.remove();
}
return;
}
// Handle local files
if (!files || files.length === 0) {
showError("Please select at least one image or enter valid URLs");
return;
}
const formData = new FormData();
formData.append('lora_id', lora.id);
for (const file of files) {
formData.append('files[]', file);
}
try {
const response = await fetch('/lora_sidebar/upload_images', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.status === 'success') {
handleUploadSuccess(result);
} else {
throw new Error(result.message || 'Upload failed');
}
} catch (error) {
console.error("Error uploading files:", error);
this.showToast("error", "Upload Failed", error.message);
} finally {
popup.remove();
}
},
style: {
minWidth: "100px"
}
})
])
]);
const showError = (message) => {
// Remove existing error message if any
const existingError = popup.querySelector('.error-message');
if (existingError) existingError.remove();
// Add new error message
const errorMsg = $el("p.error-message", {
textContent: message,
style: {
color: "#ff4444",
marginTop: "5px",
fontSize: "0.9em",
textAlign: "center"
}
});
popup.insertBefore(errorMsg, popup.querySelector('.confirm-buttons'));
};
const handleUploadSuccess = (result) => {
lora.images = lora.images || [];
lora.images.push(...result.images);
const infoPopup = document.querySelector('.model-info-popup');
if (infoPopup) {
this.showLoraInfo(lora, null);
}
this.showToast("success", "Upload Complete", result.message);
};
// outside click handler
const handleOutsideClick = (e) => {
if (!popup.contains(e.target)) {
e.stopPropagation();
if (document.body.contains(popup)) {
document.body.removeChild(popup);
}
document.removeEventListener('click', handleOutsideClick);
}
};
document.body.appendChild(popup);
// Small delay to prevent immediate closing
setTimeout(() => {
document.addEventListener('click', handleOutsideClick);
}, 0);
fileInput.addEventListener('change', () => {
const files = fileInput.files;
if (files.length > 0) {
filePathInput.value = Array.from(files).map(f => f.name).join(", ");
}
});
}
async deleteLora(lora) {
try {
const response = await api.fetchApi('/lora_sidebar/delete_lora', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: lora.id })
});
if (response.ok) {
// Remove lora from data arrays
this.loraData = this.loraData.filter(l => l.id !== lora.id);
this.filteredData = this.filteredData.filter(l => l.id !== lora.id);
// Close popups
if (this.currentPopup) {
this.currentPopup.remove();
this.currentPopup = null;
}
document.querySelector('.confirm-delete-popup').remove();
// Redraw sidebar
this.renderLoraGallery();
this.showToast("success", "LoRA Deleted", `${lora.name} has been deleted successfully.`);
} else {
throw new Error('Failed to delete LoRA');
}
} catch (error) {
console.error("Error deleting LoRA:", error);
this.showToast("error", "Deletion Failed", `Failed to delete ${lora.name}.`);
}
}
handleFirstOpen() {
if (this.isFirstOpen) {
this.isFirstOpen = false;
this.isNewSession = this.checkNewSession();
// Re-initialize state if it's a new session
if (this.isNewSession) {
this.state = this.getDefaultState();
}
}
}
// Shared function for any option that requires resorting
async refreshSortedData() {
try {
// Clear current data
this.loraData = [];
this.filteredData = [];
// Clear all pending data before clearing HTML
const categories = this.galleryContainer.querySelectorAll('.lora-category');
categories.forEach(category => {
const content = category.querySelector('.lora-items-container');
if (content) {
delete content.dataset.pendingLorasData;
}
});
this.galleryContainer.innerHTML = '';
// Show loading state
this.loadingOverlay.style.display = "flex";
// Use existing loader!
await this.loadLoraData(0, this.batchSize);
// Re-render with new sorted data
this.handleSearch(this.state.searchTerm || '');
} catch (error) {
console.error("Error refreshing sorted data:", error);
this.showToast("error", "Sort Failed", "Failed to update sort order. Please try refreshing the page.");
} finally {
setTimeout(() => {
this.loadingOverlay.style.display = "none";
}, 2000); // give the UI some time to update
}
}
///////
updateShowNSFW(newVal) {
this.showNSFW = newVal;
if (!this.isFirstOpen) {
this.handleSearch(this.state.searchTerm || ''); // Re-filter using current search term
}
}
updateNSFWlevel(newVal) {
this.nsfwThreshold = newVal;
if (!this.isFirstOpen) {
this.handleSearch(this.state.searchTerm || '');
}
}
updateSavedModelFilter(newVal) {
this.savedModelFilter = newVal;
this.modelFilter = newVal; // Update the current model filter
if (!this.isFirstOpen) {
this.handleSearch(this.state.searchTerm || '');
}
}
updateSortOrder(newVal) {
this.sortModels = newVal;
if (!this.isFirstOpen) {
this.refreshSortedData();
}
}
updateLoraSize(newVal) {
this.savedElementSize = newVal;
this.elementSize = newVal;
if (!this.isFirstOpen) {
this.update();
}
}
updateTags(newVal) {
this.CUSTOM_TAGS = newVal.split(',').map(tag => tag.trim().toLowerCase());
if (this.tagSource === 'Custom' && !this.isFirstOpen) {
this.refreshSortedData();
}
}
updateTagSource(newVal) {
this.tagSource = newVal;
if (!this.isFirstOpen) {
this.refreshSortedData();
}
}
updateSmartInfo(newVal) {
this.smartInfo = newVal;
}
updateSortMethod(newVal) {
this.SorthMethod = newVal;
if (!this.isFirstOpen) {
this.refreshSortedData();
}
}
updateCatNew(newVal) {
this.CatNew = newVal;
}
updateNSFWfolder(newVal) {
this.nsfwFolder = newVal;
if (!this.isFirstOpen) {
this.refreshSortedData();
}
}
updateNSFWstring(newVal) {
this.nsfwString = newVal;
if (!this.isFirstOpen && this.nsfwFolder) {
this.debounce(this.refreshSortedData(),2500);
}
}
updateCategoryState(newVal) {
this.defaultCatState = newVal === "Expanded";
}
updateInfoPersist(newVal) {
this.infoPersist = newVal;
}
updateBatchSize(newVal) {
this.batchSize = newVal;
}
updateFavState(newVal) {
this.favState = newVal;
this.FavOpen = newVal;
}
updateA1111Style(newVal) {
this.a1111Style = newVal;
}
updateUseRG3(newVal) {
this.UseRG3 = newVal;
}
updateSmartEnabled(newVal) {
if (newVal) {
this.showToast("success", "LoRA Smart Info Enabled", 'Automatic LoRA Info & Previews are ON 💪');
} else {
this.showToast("warn", "LoRA Smart Info Disabled", 'Automatic LoRA Info & Previews are OFF 😢');
}
}
////////
async update() {
debug.log("LoRA Sidebar update called");
if (this.isFirstOpen) {
// Do nothing if the sidebar hasn't been opened yet
return;
}
if (this.isNewSession) {
const {
unprocessedCount,
newCount,
movedCount,
missingCount,
localMetadata,
remoteMetadata
} = await this.checkUnprocessedLoras();
debug.log(`Unprocessed LoRAs: ${unprocessedCount}`);
if (unprocessedCount > 0) {
const shouldProcess = await this.showUnprocessedLorasPopup({
new: newCount,
moved: movedCount,
missing: missingCount,
local: localMetadata,
remote: remoteMetadata
});
if (shouldProcess) {
await this.processLoras();
}
}
this.loadingOverlay.style.display = "flex";
await this.loadLoraData();
this.isNewSession = false;
this.saveState(); // Save the state after initial load
// Hide loading overlay
this.loadingOverlay.style.display = "none";
}
const initialCount = this.state.totalLoaded || 500;
this.handleSearch(this.state.searchTerm || '');
}
}
app.registerExtension({
name: "comfy.lora.sidebar",
async setup() {
console.log("%c[LoRA Sidebar]", "color: #00ff00", "Extension setup starting");
// Wait for stores to be available
await app.storesReady;
console.log("%c[LoRA Sidebar]", "color: #00ff00", "Stores ready");
const loraSidebar = new LoraSidebar(app);
app.loraSidebar = loraSidebar;
// Add smart info feature after sidebar is created
console.log("%c[LoRA Sidebar]", "color: #00ff00", "Creating SmartInfo instance");
loraSidebar.smartInfo = new LoraSmartInfo(loraSidebar);
app.extensionManager.registerSidebarTab({
id: "lora.sidebar",
icon: "pi pi-address-book",
title: "LoRA Sidebar",
tooltip: "LoRA Sidebar",
type: "custom",
render: (el) => {
el.appendChild(loraSidebar.element);
if (loraSidebar.isFirstOpen) {
loraSidebar.isFirstOpen = false;
loraSidebar.isNewSession = loraSidebar.checkNewSession();
}
loraSidebar.update().then(() => {
// After update completes, restore scroll position
const sidebarContentContainer = document.querySelector('.sidebar-content-container');
if (sidebarContentContainer && loraSidebar.state.scrollPosition) {
sidebarContentContainer.scrollTop = loraSidebar.state.scrollPosition;
}
});
loraSidebar.setupScrollHandler();
loraSidebar.setupCanvasDropHandling();
},
});
},
settings: [
{
id: "LoRA Sidebar.General.showModels",
name: "Base Models to Show",
category: ["LoRA Sidebar", "General", "Model Filter"],
type: "combo",
options: ["All", "Pony", "Flux", "SD 3.5", "Illustrious", "NoobAI", "SDXL", "SD1.5", "Wan Video", "Hunyuan Video", "Other"],
// can use this format to separate text from actual values { text: "My first option", value: "first" }
defaultValue: "All",
attrs: {
editable: false,
filter: true,
},
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.updateSavedModelFilter(newVal);
}
},
},
{
id: "LoRA Sidebar.General.batchSize",
name: "LoRA Loading Batch Size",
tooltip : 'How many LoRAs we load at once, for best performance with large collections set to just over 50% of your total amount. (i.e. for ~9600 set it to 5000)',
category: ["LoRA Sidebar", "General", "Batch Size"],
type: "slider",
defaultValue: 1000,
attrs: {
min: 250,
max: 15000,
step: 250,
},
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.updateBatchSize(newVal);
}
},
},
{
id: "LoRA Sidebar.General.catState",
name: `Default Category State`,
tooltip : 'Minimized is much faster if you have a LOT (1000s) of LoRAs',
category: ["LoRA Sidebar", "General", "Category State"],
type: 'combo',
options: ['Expanded', 'Minimized'],
defaultValue: 'Expanded',
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.updateCategoryState(newVal);
}
},
},
{
id: "LoRA Sidebar.General.favState",
name: `Expand Favorites`,
tooltip : 'Default Favorites to expanded regardless of other settings (Does not override manual toggle)',
category: ["LoRA Sidebar", "General", "Favorites"],
type: "boolean",
defaultValue: true,
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.updateFavState(newVal);
}
},
},
{
id: "LoRA Sidebar.General.infoPersist",
name: "Keep LoRA Info Open",
tooltip : 'Keeps the LoRA Info Window Open Unless Manually Closed',
category: ["LoRA Sidebar", "General", "Info Persist"],
type: "boolean",
defaultValue: true,
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.updateInfoPersist(newVal);
}
},
},
{
id: "LoRA Sidebar.General.a1111Style",
name: "Use A1111 Lora Prompting",
tooltip : 'If enabled, will add LoRA and Weight before trained words on drag and drop onto prompt nodes',
category: ["LoRA Sidebar", "General", "A1111 Style"],
type: "boolean",
defaultValue: false,
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.updateA1111Style(newVal);
}
},
},
{
id: "LoRA Sidebar.General.useRG3",
name: "Use rgthree Nodes",
tooltip : 'Creates a Lora Power Loader Node on drag and drop vs a core node',
category: ["LoRA Sidebar", "General", "RG3"],
type: "boolean",
defaultValue: false,
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.updateUseRG3(newVal);
}
},
},
{
id: "LoRA Sidebar.General.catNew",
name: "Use New Category",
tooltip : 'Automatically creates special category for LoRAs created 3 days ago or less. (NOT downloaded, but actually created)',
category: ["LoRA Sidebar", "General", "New Category"],
type: "boolean",
defaultValue: false, //TODO DEFAULTED TO FALSE UNTIL I FIX IT
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.updateCatNew(newVal);
}
},
},
{
id: "LoRA Sidebar.General.sortMethod",
name: "Sort LoRAs By",
tooltip : 'How LoRAs will be sorted, DateNewest will put the most recent files first. (By creation date)',
category: ["LoRA Sidebar", "General", "Sort Method"],
type: 'combo',
options: ['AlphaAsc', 'DateNewest', 'AlphaDesc', 'DateOldest'],
defaultValue: 'AlphaAsc',
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.updateSortMethod(newVal);
}
},
},
{
id: "LoRA Sidebar.General.sortModels",
name: "Categorize LoRAs By",
tooltip : 'Subdir uses your directory structure, Tags use model tags, either Custom or from CivitAI. For this change to take effect please refresh the broswer',
category: ["LoRA Sidebar", "General", "Sort Models"],
type: 'combo',
options: ['None', 'Subdir', 'Tags'],
defaultValue: 'None',
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.updateSortOrder(newVal);
}
},
},
{
id: "LoRA Sidebar.General.tagSource",
name: "Category Tags to Use",
tooltip : 'Custom will use tags frm the setting above. CivitAI uses the main site categories like Character, Clothing, Poses, etc.',
category: ["LoRA Sidebar", "General", "Tag Source"],
type: 'combo',
options: ['CivitAI', 'Custom'],
defaultValue: 'CivitAI',
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.updateTagSource(newVal);
}
},
},
{
id: "LoRA Sidebar.General.customTags",
name: "Custom Category Tags",
tooltip : 'Only used when sort is set to Tags and Tags is set to Custom. A comma seperated list of tags used to create categories, each Lora will only be assigned one category',
category: ["LoRA Sidebar", "General", "Custom Tags"],
type: 'text',
defaultValue: 'character, style, concept, clothing, poses, background',
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.updateTags(newVal);
}
},
},
{
id: "LoRA Sidebar.General.thumbnailSize",
name: "LoRA Display Size",
tooltip : 'The size of the LoRA thumbnails in the sidebar',
category: ["LoRA Sidebar", "General", "Thumbnail Size"],
type: "slider",
defaultValue: 125,
attrs: { min: 50, max: 400, step: 25, },
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.updateLoraSize(newVal);
}
},
},
{
id: "LoRA Sidebar.General.refreshAll",
name: "Reprocess All LoRAs on Refresh",
tooltip: "When enabled, forces all LoRAs to be reprocessed on the next refresh. Automatically disables after processing.",
category: ["LoRA Sidebar", "General", "Reprocess"],
type: "boolean",
defaultValue: false,
onChange: (() => {
let toastShown = false;
return async (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
try {
const response = await api.fetchApi('/lora_sidebar/set_version', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ use_old_version: newVal })
});
// Only show messages if we're enabling the setting
if (newVal && !toastShown) {
if (!response.ok) {
throw new Error('Failed to flag LoRAs for Reprocessing');
}
toastShown = true;
app.loraSidebar.showToast(
"info",
"Reprocess Enabled",
"All LoRAs will be reprocessed on next refresh. This setting will automatically disable after processing."
);
// Reset the flag after a short delay
setTimeout(() => { toastShown = false; }, 100);
}
} catch (error) {
// Only show errors when enabling and no toast is shown
if (newVal && !toastShown) {
console.error("Error updating version:", error);
toastShown = true;
app.loraSidebar.showToast(
"error",
"Update Failed",
"Failed to update reprocess setting. Please try again."
);
// Reset the flag after a short delay
setTimeout(() => { toastShown = false; }, 100);
}
}
}
};
})(),
},
{
id: "LoRA Sidebar.Smart Info.showTrained",
name: "Trained Words on Canvas Previews",
tooltip: "Display trained words under Canvas Preview images",
category: ["LoRA Sidebar", "Smart Info", "Trained Words"],
type: "boolean",
defaultValue: true,
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.smartInfo.updateShowTrained(newVal);
}
},
},
{
id: "LoRA Sidebar.Smart Info.showSidebar",
name: "LoRA Info in Sidebar",
tooltip: "Automatically disaply LoRA Info when Sidebar is open",
category: ["LoRA Sidebar", "Smart Info", "Sidebar Info"],
type: "boolean",
defaultValue: true,
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.smartInfo.updateShowSidebar(newVal);
}
},
},
{
id: "LoRA Sidebar.Smart Info.showCanvas",
name: "LoRA Previews on Canvas",
tooltip: "Display LoRA Previews on the Canvas",
category: ["LoRA Sidebar", "Smart Info", "Canvas Preview"],
type: "boolean",
defaultValue: true,
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.smartInfo.updateShowOnCanvas(newVal);
}
},
},
{
id: "LoRA Sidebar.Smart Info.showMenu",
name: "LoRA Previews on Menus",
tooltip: "Display LoRA preview images while using selection menus",
category: ["LoRA Sidebar", "Smart Info", "Menu Preview"],
type: "boolean",
defaultValue: true,
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.smartInfo.updateShowOnMenu(newVal);
}
},
},
{
id: "LoRA Sidebar.Smart Info.zoomLevel",
name: "Zoom Cutoff for Previews (in %)",
tooltip: "Sets the zoom level where we turn off LoRA canvas previews",
category: ["LoRA Sidebar", "Smart Info", "Zoom Level"],
type: "slider",
defaultValue: 50,
attrs: { min: 1, max: 100, step: 5, },
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.smartInfo.updateZoomLevel(newVal);
debug.log("Updated Zoom:", newVal);
}
},
},
{
id: "LoRA Sidebar.Smart Info.smartEnabled",
name: "Smart Info Feature",
tooltip: "Automagically show LoRA previews and info when working with LoRA nodes. (Disabling this will automatically Disable ALL other Smart Info features)",
category: ["LoRA Sidebar", "Smart Info", "Smart Enabled"],
type: "boolean",
defaultValue: true,
onChange: (newVal, oldVal) => {
let lastProcessed = { newVal: undefined, oldVal: undefined };
// Check if this change has already been processed
if (newVal === lastProcessed.newVal && oldVal === lastProcessed.oldVal) {
// This change has already been processed, skip
debug.log("COMFYUI FIX YOUR SHIT - Duplicate onChange call detected, skipping.");
return;
}
// Update last processed values
lastProcessed.newVal = newVal;
lastProcessed.oldVal = oldVal;
debug.log("Settings onChange called with:", { newVal, oldVal });
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.smartInfo.updateSettings(newVal);
app.loraSidebar.updateSmartEnabled(newVal);
}
},
},
{
id: "LoRA Sidebar.NSFW.folderString",
name: "NSFW Folder Name",
tooltip : 'Name of the folder(s) to use for NSFW folder checks.',
category: ["LoRA Sidebar", "NSFW", "NSFW Folder String"],
type: 'text',
defaultValue: 'NSFW',
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.updateNSFWstring(newVal);
}
},
},
{
id: "LoRA Sidebar.NSFW.nsfwFolder",
name: "Use NSFW Folders",
tooltip : 'Flags LoRAs as NSFW if they are in a folder containing "NSFW" (Ignores saved NSFW Flags).',
category: ["LoRA Sidebar", "NSFW", "NSFW Folder"],
type: "boolean",
defaultValue: true,
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.updateNSFWfolder(newVal);
}
},
},
{
id: "LoRA Sidebar.NSFW.nsfwLevel",
name: "Max Allowed NSFW Level (50 Allows All Content)",
tooltip : 'Calculates how NSFW a LoRA is using the CivitAI NSFW score of sample images',
category: ["LoRA Sidebar", "NSFW", "NSFW Level"],
type: "slider",
defaultValue: 25,
attrs: { min: 1, max: 50, step: 1, },
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.updateNSFWlevel(newVal);
}
},
},
{
id: "LoRA Sidebar.NSFW.hideNSFW",
name: "Show NSFW LoRAs",
tooltip : 'Uses the CivitAI NSFW flags which are not very reliable',
category: ["LoRA Sidebar", "NSFW", "Show NSFW"],
type: "boolean",
defaultValue: true,
onChange: (newVal, oldVal) => {
if (app.loraSidebar && oldVal !== undefined) {
app.loraSidebar.updateShowNSFW(newVal);
}
},
},
],
});