936 lines
38 KiB
JavaScript
936 lines
38 KiB
JavaScript
/**
|
|
* Models Tab - Handles model browsing, filtering, and information display
|
|
*/
|
|
|
|
import {
|
|
FILTER_OPTIONS,
|
|
BUTTON_CONFIGS
|
|
} from "../shared/config.js";
|
|
|
|
import { notifications } from "../shared/notifications.js";
|
|
|
|
import {
|
|
handleError
|
|
} from "../shared/errorHandler.js";
|
|
|
|
import {
|
|
selectors,
|
|
actions
|
|
} from "../shared/stateManager.js";
|
|
|
|
import {
|
|
createDetailedInfoDisplay
|
|
} from "../components/infoDisplay.js";
|
|
|
|
import {
|
|
escapeHtml,
|
|
generateHtmlContent,
|
|
openHtmlReport
|
|
} from "../reports/reportGenerator.js";
|
|
|
|
import {
|
|
confirmDialog,
|
|
alertDialog,
|
|
createDialog
|
|
} from "../components/dialogManager.js";
|
|
|
|
import { createInlineProgressBar } from "../components/progressBar.js";
|
|
|
|
import {
|
|
pullMetadata,
|
|
updateCacheInfo
|
|
} from "../shared/api/cacheApi.js";
|
|
|
|
// Import shared UI components - will be created next
|
|
import {
|
|
createHeader,
|
|
createFilterSection,
|
|
createSearchSection,
|
|
createToggleSection,
|
|
createStyledButton,
|
|
createInfoDisplay
|
|
} from "../components/cacheUI.js";
|
|
|
|
// Import file selector components
|
|
import {
|
|
createModelsFileSelector,
|
|
setupFileSelectorEventHandlers,
|
|
updateFileList
|
|
} from "../components/fileSelector.js";
|
|
|
|
/**
|
|
* Creates the Models tab header section
|
|
* @returns {HTMLElement} Header element
|
|
*/
|
|
function createModelsHeader() {
|
|
return createHeader('Model Browser', 'Browse cached models and their metadata');
|
|
}
|
|
|
|
/**
|
|
* Creates the filter controls section for the Models tab
|
|
* @returns {Object} Filter controls object with references to all filter elements
|
|
*/
|
|
function createModelsFilterControls() {
|
|
// Create individual filter elements
|
|
const { container: filterContainer, select: filterSelector } = createFilterSection('Folder:', FILTER_OPTIONS.folderType);
|
|
const { container: searchContainer, input: searchInput } = createSearchSection('', 'Type to search models...');
|
|
const { container: lastUsedContainer, select: lastUsedSelector } = createFilterSection('Last Used:', FILTER_OPTIONS.lastUsed);
|
|
const { container: updateContainer, select: updateSelector } = createFilterSection('Updates:', FILTER_OPTIONS.updates);
|
|
const { container: nsfwContainer, checkbox: nsfwCheckbox } = createToggleSection('Show NSFW Images', 'nsfw-toggle');
|
|
|
|
// Update filter dropdown styling to match gallery
|
|
[filterSelector, lastUsedSelector, updateSelector].forEach(select => {
|
|
select.style.cssText = `
|
|
width: 100%;
|
|
padding: 4px 8px;
|
|
background: #333;
|
|
color: #fff;
|
|
border: 1px solid #555;
|
|
border-radius: 4px;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
margin-bottom: 0;
|
|
`;
|
|
});
|
|
|
|
// Update filter labels to match gallery styling with middle alignment
|
|
[filterContainer, lastUsedContainer, updateContainer].forEach(container => {
|
|
const label = container.querySelector('label');
|
|
if (label) {
|
|
label.style.cssText = `
|
|
display: block;
|
|
color: #ccc;
|
|
font-size: 11px;
|
|
margin-bottom: 4px;
|
|
font-weight: normal;
|
|
`;
|
|
}
|
|
container.style.cssText = `
|
|
margin-bottom: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
`;
|
|
});
|
|
|
|
// Create sort dropdown (without direction options)
|
|
const sortContainer = document.createElement('div');
|
|
sortContainer.style.cssText = `
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
`;
|
|
|
|
const sortLabel = document.createElement('label');
|
|
sortLabel.textContent = 'Sort:';
|
|
sortLabel.style.cssText = `
|
|
color: #ccc;
|
|
font-size: 12px;
|
|
min-width: 35px;
|
|
`;
|
|
|
|
const sortSelector = document.createElement('select');
|
|
sortSelector.style.cssText = `
|
|
flex: 1;
|
|
padding: 4px 8px;
|
|
background: #333;
|
|
border: 1px solid #555;
|
|
border-radius: 4px;
|
|
color: white;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
`;
|
|
|
|
// Add sort options without direction suffixes
|
|
const baseSortOptions = [
|
|
{ value: 'name', text: 'Name' },
|
|
{ value: 'lastused', text: 'Last Used' },
|
|
{ value: 'size', text: 'File Size' },
|
|
{ value: 'type', text: 'Folder Type' }
|
|
];
|
|
|
|
baseSortOptions.forEach(option => {
|
|
const optionElement = document.createElement('option');
|
|
optionElement.value = option.value;
|
|
optionElement.textContent = option.text;
|
|
sortSelector.appendChild(optionElement);
|
|
});
|
|
|
|
// Create sort order button
|
|
const sortOrderButton = document.createElement('button');
|
|
sortOrderButton.textContent = '↑';
|
|
sortOrderButton.title = 'Toggle sort direction (Ascending/Descending)';
|
|
sortOrderButton.style.cssText = `
|
|
background: #444;
|
|
color: white;
|
|
border: none;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
min-width: 30px;
|
|
transition: background-color 0.2s;
|
|
`;
|
|
|
|
sortOrderButton.addEventListener('mouseenter', () => {
|
|
sortOrderButton.style.backgroundColor = '#555';
|
|
});
|
|
|
|
sortOrderButton.addEventListener('mouseleave', () => {
|
|
sortOrderButton.style.backgroundColor = '#444';
|
|
});
|
|
|
|
sortContainer.appendChild(sortLabel);
|
|
sortContainer.appendChild(sortSelector);
|
|
sortContainer.appendChild(sortOrderButton);
|
|
|
|
// Update NSFW checkbox styling to match gallery
|
|
nsfwContainer.style.cssText = `
|
|
margin-bottom: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
`;
|
|
|
|
const nsfwLabel = nsfwContainer.querySelector('label');
|
|
if (nsfwLabel) {
|
|
nsfwLabel.style.cssText = `
|
|
color: #ccc;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
font-weight: normal;
|
|
`;
|
|
}
|
|
|
|
nsfwCheckbox.style.cssText = `
|
|
margin: 0;
|
|
transform: scale(1.1);
|
|
`;
|
|
|
|
// Create main filters container with visual grouping
|
|
const filtersMainContainer = document.createElement('div');
|
|
filtersMainContainer.style.cssText = `
|
|
background: #2a2a2a;
|
|
border: 1px solid #444;
|
|
border-radius: 6px;
|
|
padding: 15px;
|
|
margin-bottom: 15px;
|
|
`;
|
|
|
|
// Create filters header
|
|
const filtersHeader = document.createElement('h4');
|
|
filtersHeader.style.cssText = `
|
|
margin: 0 0 10px 0;
|
|
color: #4CAF50;
|
|
font-size: 14px;
|
|
`;
|
|
filtersHeader.textContent = 'Folder & Search Options';
|
|
|
|
// Search gets full width and moved to bottom
|
|
searchContainer.style.cssText = `
|
|
margin-top: 15px;
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
`;
|
|
|
|
// Clear any existing content and rebuild search container
|
|
searchContainer.innerHTML = '';
|
|
|
|
// Update search input styling to match gallery
|
|
searchInput.style.cssText = `
|
|
flex: 1;
|
|
padding: 6px 10px;
|
|
background: #333;
|
|
border: 1px solid #555;
|
|
border-radius: 4px;
|
|
color: white;
|
|
font-size: 12px;
|
|
`;
|
|
|
|
// Add search label for better UX
|
|
const searchLabel = document.createElement('label');
|
|
searchLabel.textContent = 'Search:';
|
|
searchLabel.style.cssText = `
|
|
color: #ccc;
|
|
font-size: 12px;
|
|
min-width: 50px;
|
|
white-space: nowrap;
|
|
`;
|
|
|
|
// Build search container with label and input
|
|
searchContainer.appendChild(searchLabel);
|
|
searchContainer.appendChild(searchInput);
|
|
|
|
// Create filters section (without sorting)
|
|
const filtersContainer = document.createElement('div');
|
|
filtersContainer.style.cssText = `
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 1fr;
|
|
gap: 8px;
|
|
margin-bottom: 10px;
|
|
align-items: center;
|
|
`;
|
|
|
|
// Create filters subsection header
|
|
const filtersSubHeader = document.createElement('label');
|
|
filtersSubHeader.style.cssText = `
|
|
color: #ccc;
|
|
margin-bottom: 8px;
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
display: block;
|
|
`;
|
|
filtersSubHeader.textContent = 'Filters';
|
|
|
|
// Create sorting section
|
|
const sortingContainer = document.createElement('div');
|
|
sortingContainer.style.cssText = `
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
margin-bottom: 10px;
|
|
`;
|
|
|
|
// Assemble the filters container (filters first, then sorting, then search at bottom)
|
|
filtersMainContainer.appendChild(filtersHeader);
|
|
filtersMainContainer.appendChild(filtersSubHeader);
|
|
filtersMainContainer.appendChild(filtersContainer);
|
|
filtersMainContainer.appendChild(sortingContainer);
|
|
filtersMainContainer.appendChild(searchContainer);
|
|
|
|
// Add filters to the filters row
|
|
filtersContainer.appendChild(filterContainer);
|
|
filtersContainer.appendChild(lastUsedContainer);
|
|
filtersContainer.appendChild(updateContainer);
|
|
|
|
// Add sorting to the sorting row
|
|
sortingContainer.appendChild(sortContainer);
|
|
|
|
// Restore persisted filter values from state
|
|
const savedFilters = selectors.modelFilters();
|
|
if (savedFilters) {
|
|
// Restore folder type filter
|
|
if (savedFilters.type) {
|
|
filterSelector.value = savedFilters.type;
|
|
}
|
|
|
|
// Restore search input
|
|
if (savedFilters.search) {
|
|
searchInput.value = savedFilters.search;
|
|
}
|
|
|
|
// Restore last used filter
|
|
if (savedFilters.lastUsed) {
|
|
lastUsedSelector.value = savedFilters.lastUsed;
|
|
}
|
|
|
|
// Restore updates filter
|
|
if (savedFilters.updates) {
|
|
updateSelector.value = savedFilters.updates;
|
|
}
|
|
|
|
// Restore sort - extract base sort value (before any -desc suffix)
|
|
if (savedFilters.sort) {
|
|
const sortValue = savedFilters.sort.replace(/-desc$/, '');
|
|
const isDescending = savedFilters.sort.endsWith('-desc');
|
|
|
|
sortSelector.value = sortValue;
|
|
sortOrderButton.textContent = isDescending ? '↓' : '↑';
|
|
sortOrderButton.dataset.descending = isDescending ? 'true' : 'false';
|
|
}
|
|
|
|
// Restore NSFW checkbox
|
|
if (savedFilters.showNsfw !== undefined) {
|
|
nsfwCheckbox.checked = savedFilters.showNsfw;
|
|
}
|
|
|
|
console.debug('[ModelsTab] Restored filter values from state:', savedFilters);
|
|
}
|
|
|
|
return {
|
|
filtersMainContainer,
|
|
searchContainer,
|
|
searchInput,
|
|
filtersContainer,
|
|
sortingContainer,
|
|
filterContainer,
|
|
filterSelector,
|
|
sortContainer,
|
|
sortSelector,
|
|
sortOrderButton,
|
|
lastUsedContainer,
|
|
lastUsedSelector,
|
|
updateContainer,
|
|
updateSelector,
|
|
nsfwContainer,
|
|
nsfwCheckbox
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates the file selector dropdown section
|
|
* @returns {Object} File selector object with container and dropdown elements
|
|
*/
|
|
function createModelsFileSelectorWrapper() {
|
|
// Use the imported file selector function
|
|
return createModelsFileSelector();
|
|
}
|
|
|
|
/**
|
|
* Creates the action buttons section for the Models tab
|
|
* @returns {Object} Action buttons object with all button references
|
|
*/
|
|
function createModelsActionButtons() {
|
|
const refreshButton = createStyledButton(BUTTON_CONFIGS.refresh.text, BUTTON_CONFIGS.refresh.color, BUTTON_CONFIGS.refresh.icon);
|
|
|
|
const pullButton = createStyledButton(BUTTON_CONFIGS.pull.text, BUTTON_CONFIGS.pull.color, BUTTON_CONFIGS.pull.icon);
|
|
pullButton.disabled = true; // Initially disabled until a file is selected
|
|
pullButton.style.opacity = '0.5';
|
|
|
|
const editButton = createStyledButton(BUTTON_CONFIGS.edit.text, BUTTON_CONFIGS.edit.color, BUTTON_CONFIGS.edit.icon);
|
|
editButton.disabled = true; // Initially disabled until a file is selected
|
|
editButton.style.opacity = '0.5';
|
|
|
|
const scanButton = createStyledButton(BUTTON_CONFIGS.scan.text, BUTTON_CONFIGS.scan.color, BUTTON_CONFIGS.scan.icon);
|
|
|
|
const reportButton = createStyledButton(BUTTON_CONFIGS.report.text, BUTTON_CONFIGS.report.color, BUTTON_CONFIGS.report.icon);
|
|
|
|
return {
|
|
refreshButton,
|
|
pullButton,
|
|
editButton,
|
|
scanButton,
|
|
reportButton
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sets up event handlers for Models tab interactions
|
|
* @param {Object} filterControls - Filter controls object from createModelsFilterControls
|
|
* @param {Object} fileSelector - File selector object from createModelsFileSelector
|
|
* @param {Object} actionButtons - Action buttons object from createModelsActionButtons
|
|
* @param {HTMLElement} infoDisplay - Info display element
|
|
*/
|
|
function setupModelsEventHandlers(filterControls, fileSelector, actionButtons, infoDisplay, progressBar) {
|
|
// Set up file selector event handlers
|
|
setupFileSelectorEventHandlers(fileSelector, actionButtons, infoDisplay, filterControls);
|
|
|
|
// Add action button event handlers
|
|
const { refreshButton, pullButton, editButton, scanButton, reportButton } = actionButtons;
|
|
|
|
// Create an updateFileList wrapper that can be called by various handlers
|
|
const updateFileListWrapper = () => updateFileList(fileSelector, filterControls, actionButtons, infoDisplay);
|
|
|
|
// Refresh button handler
|
|
refreshButton.addEventListener('click', updateFileListWrapper);
|
|
|
|
// Pull button handler - pulls metadata from Civitai
|
|
pullButton.addEventListener('click', async () => {
|
|
const selectedHash = selectors.selectedHash();
|
|
if (!selectedHash) {
|
|
alertDialog('Please select a model first.', 'No Model Selected');
|
|
return;
|
|
}
|
|
|
|
const cacheData = selectors.cacheData();
|
|
const filePath = Object.keys(cacheData.hash).find(path => cacheData.hash[path] === selectedHash);
|
|
|
|
if (!filePath) {
|
|
alertDialog('Could not find file path for selected model.', 'Error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
pullButton.disabled = true;
|
|
pullButton.textContent = 'Pulling...';
|
|
pullButton.style.opacity = '0.6';
|
|
|
|
const confirmed = await confirmDialog(
|
|
`Pull latest metadata from Civitai for:\n${filePath.split('/').pop()}?`,
|
|
'Pull Metadata'
|
|
);
|
|
|
|
if (confirmed) {
|
|
// Show progress for metadata pull
|
|
progressBar.updateTitle('Pulling Metadata');
|
|
progressBar.reset();
|
|
progressBar.show();
|
|
|
|
progressBar.updateProgress(25, 'Connecting to Civitai...');
|
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
|
|
progressBar.updateProgress(50, 'Fetching model metadata...');
|
|
await pullMetadata(filePath, false);
|
|
|
|
progressBar.updateProgress(75, 'Refreshing local cache...');
|
|
// Refresh the data and update display
|
|
await updateFileListWrapper();
|
|
|
|
progressBar.updateProgress(100, 'Metadata updated successfully!');
|
|
|
|
alertDialog('Metadata pulled successfully!', 'Success');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error pulling metadata:', error);
|
|
progressBar.hide();
|
|
alertDialog(`Failed to pull metadata: ${error.message}`, 'Error');
|
|
} finally {
|
|
pullButton.disabled = false;
|
|
pullButton.textContent = BUTTON_CONFIGS.pull.text;
|
|
pullButton.style.opacity = '1';
|
|
}
|
|
});
|
|
|
|
// Edit button handler - opens edit dialog for model info
|
|
editButton.addEventListener('click', async () => {
|
|
const selectedHash = selectors.selectedHash();
|
|
if (!selectedHash) {
|
|
alertDialog('Please select a model first.', 'No Model Selected');
|
|
return;
|
|
}
|
|
|
|
const cacheData = selectors.cacheData();
|
|
const selectedInfo = cacheData.info[selectedHash] || {};
|
|
const filePath = Object.keys(cacheData.hash).find(path => cacheData.hash[path] === selectedHash);
|
|
|
|
// Create edit dialog content
|
|
const editContent = document.createElement('div');
|
|
editContent.innerHTML = `
|
|
<div style="padding: 20px; max-width: 600px;">
|
|
<h3 style="margin: 0 0 15px 0; color: #569cd6;">Edit Model Information</h3>
|
|
<p style="margin-bottom: 15px; color: #888;">File: ${filePath ? filePath.split('/').pop() : 'Unknown'}</p>
|
|
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 15px;">
|
|
<div>
|
|
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Model Name:</label>
|
|
<input type="text" id="edit-model-name" value="${escapeHtml(selectedInfo.model?.name || '')}"
|
|
style="width: 100%; padding: 8px; background: #1a1a1a; border: 1px solid #444; border-radius: 4px; color: white;">
|
|
<small style="color: #888; font-size: 11px;">The main model name (e.g., "RealisticVision")</small>
|
|
</div>
|
|
<div>
|
|
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Version Name:</label>
|
|
<input type="text" id="edit-version-name" value="${escapeHtml(selectedInfo.name || '')}"
|
|
style="width: 100%; padding: 8px; background: #1a1a1a; border: 1px solid #444; border-radius: 4px; color: white;">
|
|
<small style="color: #888; font-size: 11px;">Version name (e.g., "v5.1", "Final")</small>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 15px;">
|
|
<div>
|
|
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Model Type:</label>
|
|
<select id="edit-type" style="width: 100%; padding: 8px; background: #1a1a1a; border: 1px solid #444; border-radius: 4px; color: white;">
|
|
<option value="">Unknown</option>
|
|
<option value="Checkpoint" ${(selectedInfo.model?.type || selectedInfo.model_type) === 'Checkpoint' ? 'selected' : ''}>Checkpoint</option>
|
|
<option value="LORA" ${(selectedInfo.model?.type || selectedInfo.model_type) === 'LORA' ? 'selected' : ''}>LORA</option>
|
|
<option value="LyCORIS" ${(selectedInfo.model?.type || selectedInfo.model_type) === 'LyCORIS' ? 'selected' : ''}>LyCORIS</option>
|
|
<option value="TextualInversion" ${(selectedInfo.model?.type || selectedInfo.model_type) === 'TextualInversion' ? 'selected' : ''}>Textual Inversion</option>
|
|
<option value="VAE" ${(selectedInfo.model?.type || selectedInfo.model_type) === 'VAE' ? 'selected' : ''}>VAE</option>
|
|
<option value="Controlnet" ${(selectedInfo.model?.type || selectedInfo.model_type) === 'Controlnet' ? 'selected' : ''}>ControlNet</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Base Model:</label>
|
|
<select id="edit-base-model" style="width: 100%; padding: 8px; background: #1a1a1a; border: 1px solid #444; border-radius: 4px; color: white;">
|
|
<option value="">Unknown</option>
|
|
<option value="SD 1.5" ${(selectedInfo.baseModel || selectedInfo.base_model) === 'SD 1.5' ? 'selected' : ''}>SD 1.5</option>
|
|
<option value="SDXL 1.0" ${(selectedInfo.baseModel || selectedInfo.base_model) === 'SDXL 1.0' ? 'selected' : ''}>SDXL 1.0</option>
|
|
<option value="SD 2.1" ${(selectedInfo.baseModel || selectedInfo.base_model) === 'SD 2.1' ? 'selected' : ''}>SD 2.1</option>
|
|
<option value="Pony" ${(selectedInfo.baseModel || selectedInfo.base_model) === 'Pony' ? 'selected' : ''}>Pony</option>
|
|
<option value="Flux.1 D" ${(selectedInfo.baseModel || selectedInfo.base_model) === 'Flux.1 D' ? 'selected' : ''}>Flux.1 D</option>
|
|
<option value="Flux.1 S" ${(selectedInfo.baseModel || selectedInfo.base_model) === 'Flux.1 S' ? 'selected' : ''}>Flux.1 S</option>
|
|
<option value="Other" ${!['SD 1.5', 'SDXL 1.0', 'SD 2.1', 'Pony', 'Flux.1 D', 'Flux.1 S'].includes(selectedInfo.baseModel || selectedInfo.base_model) && (selectedInfo.baseModel || selectedInfo.base_model) ? 'selected' : ''}>Other</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-bottom: 15px;">
|
|
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Description:</label>
|
|
<textarea id="edit-description" rows="4"
|
|
style="width: 100%; padding: 8px; background: #1a1a1a; border: 1px solid #444; border-radius: 4px; color: white; resize: vertical;">${escapeHtml(selectedInfo.description || '')}</textarea>
|
|
</div>
|
|
|
|
<div style="margin-bottom: 15px;">
|
|
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Trigger Words (comma-separated):</label>
|
|
<input type="text" id="edit-triggers" value="${escapeHtml((selectedInfo.trainedWords || []).join(', '))}"
|
|
style="width: 100%; padding: 8px; background: #1a1a1a; border: 1px solid #444; border-radius: 4px; color: white;">
|
|
<small style="color: #888; font-size: 11px;">Enter activation keywords or trigger words separated by commas</small>
|
|
</div>
|
|
|
|
<div style="margin-bottom: 15px;">
|
|
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Personal Notes:</label>
|
|
<textarea id="edit-notes" rows="3"
|
|
style="width: 100%; padding: 8px; background: #1a1a1a; border: 1px solid #444; border-radius: 4px; color: white; resize: vertical;">${escapeHtml(selectedInfo.notes || '')}</textarea>
|
|
<small style="color: #888; font-size: 11px;">Your personal notes about this model (usage tips, quality, etc.)</small>
|
|
</div>
|
|
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
|
|
<div>
|
|
<label style="display: block; margin-bottom: 5px; font-weight: bold;">
|
|
<input type="checkbox" id="edit-nsfw" ${selectedInfo.nsfw ? 'checked' : ''}
|
|
style="margin-right: 5px;">
|
|
NSFW Content
|
|
</label>
|
|
</div>
|
|
<div>
|
|
<label style="display: block; margin-bottom: 5px; font-weight: bold;">
|
|
<input type="checkbox" id="edit-favorite" ${selectedInfo.favorite || selectedInfo.is_favorite ? 'checked' : ''}
|
|
style="margin-right: 5px;">
|
|
Mark as Favorite
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const dialog = createDialog({
|
|
title: 'Edit Model Information',
|
|
content: editContent,
|
|
width: '700px'
|
|
});
|
|
|
|
// Add save and cancel buttons
|
|
dialog.addFooterButton('Save', async () => {
|
|
try {
|
|
const modelName = document.getElementById('edit-model-name').value.trim();
|
|
const versionName = document.getElementById('edit-version-name').value.trim();
|
|
const modelType = document.getElementById('edit-type').value;
|
|
const baseModel = document.getElementById('edit-base-model').value;
|
|
const description = document.getElementById('edit-description').value.trim();
|
|
const triggerText = document.getElementById('edit-triggers').value.trim();
|
|
const notes = document.getElementById('edit-notes').value.trim();
|
|
const nsfw = document.getElementById('edit-nsfw').checked;
|
|
const favorite = document.getElementById('edit-favorite').checked;
|
|
|
|
const triggers = triggerText ? triggerText.split(',').map(t => t.trim()).filter(t => t.length > 0) : [];
|
|
|
|
const updatedInfo = {
|
|
...selectedInfo,
|
|
name: versionName, // This is the version name
|
|
description: description,
|
|
trainedWords: triggers,
|
|
notes: notes,
|
|
nsfw: nsfw,
|
|
favorite: favorite,
|
|
is_favorite: favorite // Support both formats
|
|
};
|
|
|
|
// Update model information properly
|
|
if (modelName || modelType || baseModel) {
|
|
updatedInfo.model = {
|
|
...selectedInfo.model,
|
|
...(modelName && { name: modelName }), // Set model name correctly
|
|
...(modelType && { type: modelType })
|
|
};
|
|
|
|
if (baseModel) {
|
|
updatedInfo.baseModel = baseModel;
|
|
updatedInfo.base_model = baseModel;
|
|
}
|
|
}
|
|
|
|
await updateCacheInfo(selectedHash, updatedInfo);
|
|
|
|
// Refresh data and update display
|
|
await updateFileListWrapper();
|
|
|
|
// Re-select the model to update the info display
|
|
const fileElement = document.querySelector(`.cache-dropdown-item.file[data-hash="${selectedHash}"]`);
|
|
if (fileElement) {
|
|
fileElement.click();
|
|
}
|
|
|
|
dialog.close();
|
|
alertDialog('Model information updated successfully!', 'Success');
|
|
} catch (error) {
|
|
console.error('Error updating model info:', error);
|
|
alertDialog(`Failed to update model information: ${error.message}`, 'Error');
|
|
}
|
|
}, { backgroundColor: '#4CAF50' });
|
|
|
|
dialog.addFooterButton('Cancel', () => {
|
|
dialog.close();
|
|
}, { backgroundColor: '#666' });
|
|
|
|
dialog.show();
|
|
});
|
|
|
|
// Scan button handler - opens comprehensive scan dialog
|
|
scanButton.addEventListener('click', async () => {
|
|
try {
|
|
const { ModelScanDialog } = await import('../dialogs/modelScanDialog.js');
|
|
const scanDialog = new ModelScanDialog({
|
|
onScanComplete: () => {
|
|
// Refresh the file list after successful scan
|
|
updateFileListWrapper();
|
|
}
|
|
});
|
|
|
|
await scanDialog.show();
|
|
} catch (error) {
|
|
console.error('Error opening scan dialog:', error);
|
|
notifications.error('Failed to open scan dialog', error.message);
|
|
}
|
|
});
|
|
|
|
// Report button handler - generates HTML report
|
|
reportButton.addEventListener('click', async () => {
|
|
try {
|
|
reportButton.disabled = true;
|
|
reportButton.textContent = 'Generating...';
|
|
reportButton.style.opacity = '0.6';
|
|
|
|
// Show progress bar and reset it
|
|
progressBar.updateTitle('Generating Report');
|
|
progressBar.reset();
|
|
progressBar.show();
|
|
|
|
const cacheData = selectors.cacheData();
|
|
const hashData = cacheData.hash;
|
|
const infoData = cacheData.info;
|
|
|
|
if (!hashData || Object.keys(hashData).length === 0) {
|
|
alertDialog('No model data available. Please refresh the cache first.', 'No Data');
|
|
return;
|
|
}
|
|
|
|
// Update progress
|
|
progressBar.updateProgress(10, 'Processing model data...');
|
|
await new Promise(resolve => setTimeout(resolve, 100)); // Brief pause for UI update
|
|
|
|
// Prepare models array for report generation
|
|
const models = Object.keys(hashData).map(filePath => ({
|
|
filePath: filePath,
|
|
hash: hashData[filePath],
|
|
info: infoData[hashData[filePath]] || {}
|
|
}));
|
|
|
|
// Update progress
|
|
progressBar.updateProgress(20, `Processing ${models.length} models...`);
|
|
await new Promise(resolve => setTimeout(resolve, 100)); // Brief pause for UI update
|
|
|
|
// Get current filter selections from the models tab
|
|
const currentSort = filterControls.sortSelector.value;
|
|
const isDescending = filterControls.sortOrderButton.textContent === '↓';
|
|
const currentFolderFilter = filterControls.filterSelector.value;
|
|
|
|
// Create the sort key for the report generator (which expects -desc suffix for descending)
|
|
const reportSortBy = isDescending ? `${currentSort}-desc` : currentSort;
|
|
|
|
// Build filter description for the report
|
|
let filterDescription = '';
|
|
if (currentFolderFilter !== 'all') {
|
|
const selectedFilterOption = filterControls.filterSelector.options[filterControls.filterSelector.selectedIndex];
|
|
filterDescription = `Filter: ${selectedFilterOption.text}`;
|
|
}
|
|
|
|
// Generate HTML report with detailed progress updates
|
|
const htmlContent = await generateHtmlContent({
|
|
models: models,
|
|
title: 'SageUtils Model Cache Report',
|
|
sortBy: reportSortBy,
|
|
folderFilter: currentFolderFilter,
|
|
filterDescription: filterDescription,
|
|
sortDescription: ` • Sorted by: ${filterControls.sortSelector.options[filterControls.sortSelector.selectedIndex].text} (${isDescending ? 'Descending' : 'Ascending'})`,
|
|
progressCallback: (progress, message) => {
|
|
// Map the HTML generation progress to the 40-95% range
|
|
const adjustedProgress = 40 + (progress * 0.55);
|
|
progressBar.updateProgress(adjustedProgress, message);
|
|
}
|
|
});
|
|
|
|
// Final steps
|
|
progressBar.updateProgress(95, 'Opening report...');
|
|
await new Promise(resolve => setTimeout(resolve, 100)); // Brief pause for UI update
|
|
|
|
// Open report in new window
|
|
openHtmlReport(htmlContent, 'SageUtils Model Cache Report');
|
|
|
|
progressBar.updateProgress(100, 'Report generated successfully!');
|
|
|
|
} catch (error) {
|
|
console.error('Error generating report:', error);
|
|
progressBar.hide();
|
|
alertDialog(`Failed to generate report: ${error.message}`, 'Error');
|
|
} finally {
|
|
reportButton.disabled = false;
|
|
reportButton.textContent = BUTTON_CONFIGS.report.text;
|
|
reportButton.style.opacity = '1';
|
|
}
|
|
});
|
|
|
|
// Add filter change handlers
|
|
filterControls.filterSelector.addEventListener('change', () => {
|
|
actions.updateFilters({ type: filterControls.filterSelector.value });
|
|
updateFileListWrapper();
|
|
});
|
|
filterControls.searchInput.addEventListener('input', () => {
|
|
actions.updateFilters({ search: filterControls.searchInput.value });
|
|
updateFileListWrapper();
|
|
});
|
|
filterControls.lastUsedSelector.addEventListener('change', () => {
|
|
actions.updateFilters({ lastUsed: filterControls.lastUsedSelector.value });
|
|
updateFileListWrapper();
|
|
});
|
|
filterControls.updateSelector.addEventListener('change', () => {
|
|
actions.updateFilters({ updates: filterControls.updateSelector.value });
|
|
updateFileListWrapper();
|
|
});
|
|
filterControls.sortSelector.addEventListener('change', () => {
|
|
const sortValue = filterControls.sortSelector.value;
|
|
const isDescending = filterControls.sortOrderButton.textContent === '↓';
|
|
actions.updateFilters({ sort: isDescending ? `${sortValue}-desc` : sortValue });
|
|
updateFileListWrapper();
|
|
});
|
|
|
|
// Add sort order button handler
|
|
filterControls.sortOrderButton.addEventListener('click', () => {
|
|
// Toggle the sort order button
|
|
const isDescending = filterControls.sortOrderButton.textContent === '↓';
|
|
filterControls.sortOrderButton.textContent = isDescending ? '↑' : '↓';
|
|
filterControls.sortOrderButton.title = isDescending ?
|
|
'Toggle sort direction (Ascending/Descending)' :
|
|
'Toggle sort direction (Descending/Ascending)';
|
|
|
|
// Update state with new sort direction
|
|
const sortValue = filterControls.sortSelector.value;
|
|
actions.updateFilters({ sort: isDescending ? sortValue : `${sortValue}-desc` });
|
|
|
|
// Trigger re-sort
|
|
updateFileListWrapper();
|
|
});
|
|
|
|
// NSFW checkbox should only affect the info display, not the file list
|
|
filterControls.nsfwCheckbox.addEventListener('change', async () => {
|
|
// Update state with NSFW preference
|
|
actions.updateFilters({ showNsfw: filterControls.nsfwCheckbox.checked });
|
|
|
|
// Only update the info display if a model is currently selected
|
|
const selectedHash = selectors.selectedHash();
|
|
if (selectedHash) {
|
|
const cacheData = selectors.cacheData();
|
|
const selectedInfo = cacheData.info[selectedHash];
|
|
const showNsfw = filterControls.nsfwCheckbox.checked;
|
|
|
|
// Show loading while updating info display
|
|
infoDisplay.innerHTML = '<div style="text-align: center; padding: 20px; color: #888; font-style: italic;">Updating display...</div>';
|
|
|
|
// Update info display with new NSFW setting using the same approach as file selection
|
|
try {
|
|
const infoElement = await createDetailedInfoDisplay(selectedHash, selectedInfo, showNsfw);
|
|
infoDisplay.innerHTML = '';
|
|
infoDisplay.appendChild(infoElement);
|
|
} catch (error) {
|
|
console.error('Error updating info display:', error);
|
|
// Fallback to basic display
|
|
infoDisplay.innerHTML = `
|
|
<div style="padding: 15px; background: #2a2a2a; border-radius: 8px; margin: 10px 0;">
|
|
<h3 style="margin: 0 0 10px 0; color: #569cd6;">Error loading model information</h3>
|
|
<p>Failed to update display: ${error.message}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Initialize data loading
|
|
updateFileListWrapper();
|
|
}
|
|
|
|
/**
|
|
* Assembles the complete Models tab layout
|
|
* @param {HTMLElement} container - Container element to populate
|
|
* @param {Object} components - All tab components
|
|
*/
|
|
function assembleModelsTabLayout(container, components) {
|
|
const {
|
|
header,
|
|
filterControls,
|
|
fileSelector,
|
|
actionButtons,
|
|
infoDisplay
|
|
} = components;
|
|
|
|
// Clear container
|
|
container.innerHTML = '';
|
|
|
|
// Create main content container with gallery-style padding
|
|
const contentContainer = document.createElement('div');
|
|
contentContainer.style.cssText = `
|
|
padding: 15px;
|
|
`;
|
|
|
|
// Add header with reduced padding to match gallery
|
|
header.style.padding = '10px 15px';
|
|
container.appendChild(header);
|
|
|
|
// Add organized filters section
|
|
contentContainer.appendChild(filterControls.filtersMainContainer);
|
|
|
|
// Add file selector
|
|
contentContainer.appendChild(fileSelector.selectorContainer);
|
|
fileSelector.selectorContainer.appendChild(fileSelector.selectorLabelContainer);
|
|
fileSelector.selectorContainer.appendChild(fileSelector.selector);
|
|
|
|
// Add action buttons
|
|
const buttonContainer = document.createElement('div');
|
|
buttonContainer.style.cssText = `
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 15px;
|
|
flex-wrap: wrap;
|
|
`;
|
|
|
|
Object.values(actionButtons).forEach(button => {
|
|
buttonContainer.appendChild(button);
|
|
});
|
|
contentContainer.appendChild(buttonContainer);
|
|
|
|
// Add progress bar (initially hidden)
|
|
const progressBar = createInlineProgressBar({
|
|
title: 'Operation Progress',
|
|
initialMessage: 'Ready...'
|
|
});
|
|
contentContainer.appendChild(progressBar.container);
|
|
|
|
// Add NSFW checkbox right above model information
|
|
contentContainer.appendChild(filterControls.nsfwContainer);
|
|
|
|
// Add info display
|
|
contentContainer.appendChild(infoDisplay);
|
|
|
|
// Add content container to main container
|
|
container.appendChild(contentContainer);
|
|
|
|
// Store progress bar reference for use in event handlers
|
|
container._progressBar = progressBar;
|
|
|
|
return progressBar; // Return progress bar for event handler setup
|
|
}
|
|
|
|
/**
|
|
* Main function to create the Models tab
|
|
* @param {HTMLElement} container - Container element to populate
|
|
*/
|
|
export function createModelsTab(container) {
|
|
// Create all components
|
|
const header = createModelsHeader();
|
|
const filterControls = createModelsFilterControls();
|
|
const fileSelector = createModelsFileSelectorWrapper();
|
|
const actionButtons = createModelsActionButtons();
|
|
const infoDisplay = createInfoDisplay();
|
|
|
|
// Assemble the layout first (this creates the progress bar)
|
|
const progressBar = assembleModelsTabLayout(container, {
|
|
header,
|
|
filterControls,
|
|
fileSelector,
|
|
actionButtons,
|
|
infoDisplay
|
|
});
|
|
|
|
// Set up event handlers after layout assembly (so progress bar exists)
|
|
setupModelsEventHandlers(filterControls, fileSelector, actionButtons, infoDisplay, progressBar);
|
|
}
|