1113 lines
47 KiB
JavaScript
1113 lines
47 KiB
JavaScript
/**
|
|
* Models Tab - Handles model browsing, filtering, and information display
|
|
*/
|
|
|
|
import {
|
|
FILTER_OPTIONS,
|
|
BUTTON_CONFIGS,
|
|
API_ENDPOINTS,
|
|
FILE_TYPES,
|
|
getFileType,
|
|
supportsPreview,
|
|
getFileTypeIcon
|
|
} from "../shared/config.js";
|
|
|
|
import {
|
|
handleError,
|
|
handleApiError,
|
|
handleFileError,
|
|
createSafeWrapper
|
|
} from "../shared/errorHandler.js";
|
|
|
|
import {
|
|
actions,
|
|
selectors
|
|
} from "../shared/stateManager.js";
|
|
|
|
import {
|
|
fetchCacheHash,
|
|
fetchCacheInfo
|
|
} from "../shared/cacheApi.js";
|
|
|
|
import {
|
|
createDetailedInfoDisplay
|
|
} from "../shared/infoDisplay.js";
|
|
|
|
import {
|
|
escapeHtml,
|
|
formatFileSize,
|
|
getBaseModelStyle,
|
|
generateTableRows,
|
|
generateHtmlContent,
|
|
openHtmlReport
|
|
} from "../shared/reportGenerator.js";
|
|
|
|
import {
|
|
confirmDialog,
|
|
alertDialog,
|
|
promptDialog,
|
|
createDialog
|
|
} from "../shared/dialogManager.js";
|
|
|
|
import {
|
|
pullMetadata,
|
|
updateCacheInfo
|
|
} from "../shared/cacheApi.js";
|
|
|
|
// Import shared UI components - will be created next
|
|
import {
|
|
createHeader,
|
|
createFilterSection,
|
|
createSearchSection,
|
|
createToggleSection,
|
|
createLabeledContainer,
|
|
createCustomDropdown as createCacheDropdown,
|
|
createStyledButton,
|
|
addDropdownStyles as addCacheDropdownStyles,
|
|
createInfoDisplay
|
|
} from "../shared/cacheUIComponents.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() {
|
|
// Filter dropdown
|
|
const { container: filterContainer, select: filterSelector } = createFilterSection('Filter by Type:', FILTER_OPTIONS.modelType);
|
|
|
|
// Search filter
|
|
const { container: searchContainer, input: searchInput } = createSearchSection();
|
|
|
|
// Last Used filter
|
|
const { container: lastUsedContainer, select: lastUsedSelector } = createFilterSection('Filter by Last Used:', FILTER_OPTIONS.lastUsed);
|
|
|
|
// Update Available filter
|
|
const { container: updateContainer, select: updateSelector } = createFilterSection('Filter by Updates:', FILTER_OPTIONS.updates);
|
|
|
|
// Sort options
|
|
const { container: sortContainer, select: sortSelector } = createFilterSection('Sort by:', FILTER_OPTIONS.sort);
|
|
|
|
// NSFW toggle
|
|
const { container: nsfwContainer, checkbox: nsfwCheckbox } = createToggleSection('Show NSFW Images', 'nsfw-toggle');
|
|
|
|
return {
|
|
filterContainer,
|
|
filterSelector,
|
|
searchContainer,
|
|
searchInput,
|
|
lastUsedContainer,
|
|
lastUsedSelector,
|
|
updateContainer,
|
|
updateSelector,
|
|
sortContainer,
|
|
sortSelector,
|
|
nsfwContainer,
|
|
nsfwCheckbox
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates the file selector dropdown section
|
|
* @returns {Object} File selector object with container and dropdown elements
|
|
*/
|
|
function createModelsFileSelector() {
|
|
const selectorContainer = document.createElement('div');
|
|
selectorContainer.style.marginBottom = '15px';
|
|
|
|
const { container: selectorLabelContainer, label: selectorLabel } = createLabeledContainer('Select File:');
|
|
|
|
// Create custom dropdown container
|
|
const { container: selector, button: dropdownButton, menu: dropdownMenu } = createCacheDropdown();
|
|
selector.id = 'cache-file-selector';
|
|
|
|
// Add CSS for custom dropdown
|
|
addCacheDropdownStyles();
|
|
|
|
return {
|
|
selectorContainer,
|
|
selectorLabelContainer,
|
|
selectorLabel,
|
|
selector,
|
|
dropdownButton,
|
|
dropdownMenu
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
const { dropdownButton, dropdownMenu, selector } = fileSelector;
|
|
|
|
// Handle dropdown button click
|
|
dropdownButton.addEventListener('click', (e) => {
|
|
e.stopPropagation(); // Prevent event from bubbling to document click handler
|
|
|
|
const currentDropdownState = selectors.isDropdownOpen();
|
|
actions.toggleDropdown(!currentDropdownState); // Toggle dropdown state
|
|
|
|
if (!currentDropdownState) { // Opening dropdown
|
|
// Position dropdown menu dynamically
|
|
const rect = dropdownButton.getBoundingClientRect();
|
|
dropdownMenu.style.left = `${rect.left}px`;
|
|
dropdownMenu.style.top = `${rect.bottom}px`;
|
|
dropdownMenu.style.width = `${rect.width}px`;
|
|
dropdownMenu.style.display = 'block';
|
|
dropdownButton.innerHTML = '<span>Select a file...</span><span>▲</span>';
|
|
} else { // Closing dropdown
|
|
dropdownMenu.style.display = 'none';
|
|
dropdownButton.innerHTML = '<span>Select a file...</span><span>▼</span>';
|
|
|
|
// Hide all submenus when closing dropdown
|
|
document.querySelectorAll('.cache-dropdown-submenu').forEach(menu => {
|
|
menu.style.display = 'none';
|
|
});
|
|
}
|
|
});
|
|
|
|
// Close dropdown when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
const isDropdownOpen = selectors.isDropdownOpen();
|
|
if (!selector.contains(e.target) && !dropdownMenu.contains(e.target) && isDropdownOpen) {
|
|
dropdownMenu.style.display = 'none';
|
|
actions.toggleDropdown(false); // Close dropdown via state action
|
|
const selectedHash = selectors.selectedHash();
|
|
dropdownButton.innerHTML = selectedHash ?
|
|
dropdownButton.innerHTML.replace('▲', '▼') :
|
|
'<span>Select a file...</span><span>▼</span>';
|
|
|
|
// Hide all submenus when closing dropdown
|
|
document.querySelectorAll('.cache-dropdown-submenu').forEach(menu => {
|
|
menu.style.display = 'none';
|
|
});
|
|
}
|
|
});
|
|
|
|
// Add action button event handlers
|
|
const { refreshButton, pullButton, editButton, scanButton, reportButton } = actionButtons;
|
|
|
|
// Refresh button handler (already added above)
|
|
refreshButton.addEventListener('click', updateFileList);
|
|
|
|
// 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) {
|
|
await pullMetadata(filePath, false);
|
|
|
|
// Refresh the data and update display
|
|
await updateFileList();
|
|
|
|
alertDialog('Metadata pulled successfully!', 'Success');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error pulling metadata:', error);
|
|
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 })
|
|
};
|
|
|
|
// Also set legacy fields for compatibility
|
|
if (modelType) updatedInfo.model_type = modelType;
|
|
if (baseModel) {
|
|
updatedInfo.baseModel = baseModel;
|
|
updatedInfo.base_model = baseModel;
|
|
}
|
|
}
|
|
|
|
await updateCacheInfo(selectedHash, updatedInfo);
|
|
|
|
// Refresh data and update display
|
|
await updateFileList();
|
|
|
|
// 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 - scans for new models
|
|
scanButton.addEventListener('click', async () => {
|
|
try {
|
|
scanButton.disabled = true;
|
|
scanButton.textContent = 'Scanning...';
|
|
scanButton.style.opacity = '0.6';
|
|
|
|
const confirmed = await confirmDialog(
|
|
'Scan for new models in the configured directories?\nThis may take a few moments.',
|
|
'Scan for Models'
|
|
);
|
|
|
|
if (confirmed) {
|
|
// For now, we'll just refresh the cache data as there's no dedicated scan endpoint
|
|
// In a full implementation, this would call a scan endpoint
|
|
await updateFileList();
|
|
alertDialog('Scan completed! Cache has been refreshed.', 'Scan Complete');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error scanning for models:', error);
|
|
alertDialog(`Failed to scan for models: ${error.message}`, 'Error');
|
|
} finally {
|
|
scanButton.disabled = false;
|
|
scanButton.textContent = BUTTON_CONFIGS.scan.text;
|
|
scanButton.style.opacity = '1';
|
|
}
|
|
});
|
|
|
|
// Report button handler - generates HTML report
|
|
reportButton.addEventListener('click', async () => {
|
|
try {
|
|
reportButton.disabled = true;
|
|
reportButton.textContent = 'Generating...';
|
|
reportButton.style.opacity = '0.6';
|
|
|
|
const cacheData = selectors.cacheData();
|
|
const hashData = cacheData.hash;
|
|
const infoData = cacheData.info;
|
|
|
|
if (!hashData || Object.keys(hashData).length === 0) {
|
|
alertDialog('No cached models found to generate report.', 'No Data');
|
|
return;
|
|
}
|
|
|
|
// Prepare models array for report generation
|
|
const models = Object.keys(hashData).map(filePath => ({
|
|
filePath: filePath,
|
|
hash: hashData[filePath],
|
|
info: infoData[hashData[filePath]] || {}
|
|
}));
|
|
|
|
// Generate HTML report with optimized image loading
|
|
// Cached images are shown immediately, others can be loaded on demand
|
|
const htmlContent = await generateHtmlContent({
|
|
models: models,
|
|
title: 'SageUtils Model Cache Report'
|
|
});
|
|
|
|
// Open report in new window
|
|
openHtmlReport(htmlContent, 'SageUtils Model Cache Report');
|
|
|
|
} catch (error) {
|
|
console.error('Error generating report:', error);
|
|
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', updateFileList);
|
|
filterControls.searchInput.addEventListener('input', updateFileList);
|
|
filterControls.lastUsedSelector.addEventListener('change', updateFileList);
|
|
filterControls.updateSelector.addEventListener('change', updateFileList);
|
|
filterControls.sortSelector.addEventListener('change', updateFileList);
|
|
|
|
// NSFW checkbox should only affect the info display, not the file list
|
|
filterControls.nsfwCheckbox.addEventListener('change', async () => {
|
|
// 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
|
|
updateFileList();
|
|
|
|
// Populate dropdown with files
|
|
async function updateFileList() {
|
|
try {
|
|
const { dropdownMenu, dropdownButton } = fileSelector;
|
|
|
|
// Show loading state
|
|
infoDisplay.innerHTML = '<div style="text-align: center; padding: 20px; color: #888; font-style: italic;">Loading cache data...</div>';
|
|
|
|
// Clean up any existing submenus
|
|
document.querySelectorAll('.cache-dropdown-submenu').forEach(menu => {
|
|
menu.remove();
|
|
});
|
|
|
|
// Fetch both hash and info data
|
|
const [hashData, infoData] = await Promise.all([
|
|
fetchCacheHash(),
|
|
fetchCacheInfo()
|
|
]);
|
|
|
|
// Store cache data in state management
|
|
actions.setCacheData({ hash: hashData, info: infoData });
|
|
|
|
// Clear and populate dropdown menu
|
|
dropdownMenu.innerHTML = '';
|
|
dropdownButton.innerHTML = '<span>Select a file...</span><span>▼</span>';
|
|
actions.selectModel(null);
|
|
|
|
// Get current filter values (for now, use defaults since filters aren't fully implemented)
|
|
const filterType = filterControls.filterSelector.value;
|
|
const searchTerm = filterControls.searchInput.value.toLowerCase().trim();
|
|
const lastUsedFilter = filterControls.lastUsedSelector.value;
|
|
const updateFilter = filterControls.updateSelector.value;
|
|
const sortBy = filterControls.sortSelector.value;
|
|
|
|
// Filter files based on criteria
|
|
const filteredFiles = Object.keys(hashData).filter(filePath => {
|
|
const hash = hashData[filePath];
|
|
const info = infoData[hash];
|
|
|
|
// Check model type filter
|
|
if (filterType !== 'all') {
|
|
if (!info || !info.model || info.model.type !== filterType) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Check search term filter
|
|
if (searchTerm) {
|
|
const fileName = filePath.split('/').pop() || '';
|
|
const modelName = (info && info.model && info.model.name) || '';
|
|
const versionName = (info && info.name) || '';
|
|
|
|
const searchableText = `${fileName} ${modelName} ${versionName}`.toLowerCase();
|
|
if (!searchableText.includes(searchTerm)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Check last used filter
|
|
if (lastUsedFilter !== 'all') {
|
|
const lastUsed = info && (info.lastUsed || info.last_accessed);
|
|
|
|
if (lastUsedFilter === 'never') {
|
|
// Show models that have never been used
|
|
if (lastUsed) {
|
|
return false;
|
|
}
|
|
} else {
|
|
// Show models used within the specified time frame
|
|
if (!lastUsed) {
|
|
return false;
|
|
}
|
|
|
|
const lastUsedDate = new Date(lastUsed);
|
|
const now = new Date();
|
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
const monthAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
|
|
switch (lastUsedFilter) {
|
|
case 'today':
|
|
if (lastUsedDate < today) {
|
|
return false;
|
|
}
|
|
break;
|
|
case 'week':
|
|
if (lastUsedDate < weekAgo) {
|
|
return false;
|
|
}
|
|
break;
|
|
case 'month':
|
|
if (lastUsedDate < monthAgo) {
|
|
return false;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check update filter
|
|
if (updateFilter !== 'all') {
|
|
const hasUpdate = info && info.update_available;
|
|
|
|
if (updateFilter === 'available') {
|
|
// Show only models with updates available
|
|
if (!hasUpdate) {
|
|
return false;
|
|
}
|
|
} else if (updateFilter === 'none') {
|
|
// Show only models without updates available
|
|
if (hasUpdate) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
// Sort files
|
|
const sortedFiles = sortFiles(filteredFiles, sortBy, hashData, infoData);
|
|
|
|
if (sortedFiles.length === 0) {
|
|
const filterText = filterType === 'all' ? 'cached files' : `${filterType.toLowerCase()}s`;
|
|
const noFilesItem = document.createElement('div');
|
|
noFilesItem.className = 'cache-dropdown-item';
|
|
noFilesItem.textContent = `No ${filterText} found`;
|
|
noFilesItem.style.color = '#888';
|
|
dropdownMenu.appendChild(noFilesItem);
|
|
infoDisplay.innerHTML = `<div style="text-align: center; padding: 20px; color: #888;">No ${filterText} available</div>`;
|
|
return;
|
|
}
|
|
|
|
// Organize files into folder structure
|
|
const folderStructure = organizeFolderStructure(sortedFiles, hashData, infoData);
|
|
|
|
// Create dropdown items
|
|
createDropdownItems(folderStructure, sortBy, hashData, infoData, fileSelector, actionButtons, infoDisplay, filterControls);
|
|
|
|
infoDisplay.innerHTML = '<div style="text-align: center; padding: 20px; color: #888;">Select a file to view its information</div>';
|
|
|
|
} catch (error) {
|
|
console.error('Error updating file list:', error);
|
|
const { dropdownMenu } = fileSelector;
|
|
dropdownMenu.innerHTML = '<div class="cache-dropdown-item" style="color: #f44336;">Error loading files</div>';
|
|
infoDisplay.innerHTML = '<div style="text-align: center; padding: 20px; color: #f44336;">Error loading cache data</div>';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper function to sort files based on selected criteria
|
|
function sortFiles(files, sortBy, hashData, infoData) {
|
|
return files.sort((a, b) => {
|
|
const hashA = hashData[a];
|
|
const hashB = hashData[b];
|
|
const infoA = infoData[hashA];
|
|
const infoB = infoData[hashB];
|
|
|
|
switch (sortBy) {
|
|
case 'name':
|
|
return a.localeCompare(b);
|
|
case 'name-desc':
|
|
return b.localeCompare(a);
|
|
case 'lastused': {
|
|
const lastUsedA = (infoA && (infoA.lastUsed || infoA.last_accessed)) ? new Date(infoA.lastUsed || infoA.last_accessed) : new Date(0);
|
|
const lastUsedB = (infoB && (infoB.lastUsed || infoB.last_accessed)) ? new Date(infoB.lastUsed || infoB.last_accessed) : new Date(0);
|
|
return lastUsedB - lastUsedA; // Recent first
|
|
}
|
|
case 'size': {
|
|
const sizeA = (infoA && infoA.file_size) ? infoA.file_size : 0;
|
|
const sizeB = (infoB && infoB.file_size) ? infoB.file_size : 0;
|
|
return sizeA - sizeB; // Small to large
|
|
}
|
|
case 'type': {
|
|
const typeA = (infoA && infoA.model && infoA.model.type) ? infoA.model.type : 'ZZZ';
|
|
const typeB = (infoB && infoB.model && infoB.model.type) ? infoB.model.type : 'ZZZ';
|
|
if (typeA !== typeB) {
|
|
return typeA.localeCompare(typeB);
|
|
}
|
|
return a.localeCompare(b); // Secondary sort by name
|
|
}
|
|
default:
|
|
return a.localeCompare(b);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Helper function to organize files into folder structure
|
|
function organizeFolderStructure(sortedFiles, hashData, infoData) {
|
|
const folderStructure = {};
|
|
|
|
sortedFiles.forEach(filePath => {
|
|
const hash = hashData[filePath];
|
|
const info = infoData[hash];
|
|
|
|
// Extract relative path based on model type
|
|
let relativePath = filePath;
|
|
if (info && info.model && info.model.type) {
|
|
const modelType = info.model.type.toLowerCase();
|
|
|
|
// Find the appropriate base directory in the path
|
|
let baseDirName = '';
|
|
if (modelType === 'checkpoint') {
|
|
baseDirName = 'checkpoints';
|
|
} else if (modelType === 'lora') {
|
|
baseDirName = 'loras';
|
|
}
|
|
|
|
if (baseDirName) {
|
|
// Look for the base directory in the path
|
|
const pathParts = filePath.split('/');
|
|
const baseDirIndex = pathParts.findIndex(part =>
|
|
part.toLowerCase() === baseDirName ||
|
|
part.toLowerCase().includes(baseDirName)
|
|
);
|
|
|
|
if (baseDirIndex !== -1 && baseDirIndex < pathParts.length - 1) {
|
|
// Extract the relative path from the base directory
|
|
relativePath = pathParts.slice(baseDirIndex + 1).join('/');
|
|
} else {
|
|
// Fallback to just the filename if we can't find the base directory
|
|
relativePath = pathParts[pathParts.length - 1];
|
|
}
|
|
} else {
|
|
// For other types, just show the filename
|
|
relativePath = filePath.split('/').pop() || filePath;
|
|
}
|
|
}
|
|
|
|
// Split the relative path into folder structure
|
|
const pathParts = relativePath.split('/');
|
|
const fileName = pathParts.pop(); // Remove filename
|
|
const folderPath = pathParts.join('/');
|
|
|
|
// Initialize folder structure
|
|
if (!folderStructure[folderPath]) {
|
|
folderStructure[folderPath] = [];
|
|
}
|
|
|
|
// Add file to the appropriate folder
|
|
folderStructure[folderPath].push({
|
|
hash: hash,
|
|
fileName: fileName,
|
|
fullPath: filePath,
|
|
info: info
|
|
});
|
|
});
|
|
|
|
return folderStructure;
|
|
}
|
|
|
|
// Helper function to create dropdown items
|
|
function createDropdownItems(folderStructure, sortBy, hashData, infoData, fileSelector, actionButtons, infoDisplay, filterControls) {
|
|
const { dropdownMenu, dropdownButton } = fileSelector;
|
|
|
|
// Sort folders and create dropdown items
|
|
const sortedFolders = Object.keys(folderStructure).sort((a, b) => {
|
|
// Empty folder (root files) should come first
|
|
if (a === '') return -1;
|
|
if (b === '') return 1;
|
|
return a.localeCompare(b);
|
|
});
|
|
|
|
// Add root files first
|
|
if (folderStructure['']) {
|
|
const rootFiles = folderStructure[''];
|
|
const rootFilePaths = rootFiles.map(file => file.fullPath);
|
|
const sortedRootFilePaths = sortFiles(rootFilePaths, sortBy, hashData, infoData);
|
|
|
|
// Create a map for quick lookup
|
|
const rootFileMap = new Map();
|
|
rootFiles.forEach(file => rootFileMap.set(file.fullPath, file));
|
|
|
|
// Add files in sorted order
|
|
sortedRootFilePaths.forEach(filePath => {
|
|
const file = rootFileMap.get(filePath);
|
|
if (file) {
|
|
dropdownMenu.appendChild(createFileItem(file, fileSelector, actionButtons, infoDisplay, filterControls));
|
|
}
|
|
});
|
|
}
|
|
|
|
// Add folders (simplified for now - no submenus)
|
|
sortedFolders.forEach(folderPath => {
|
|
if (folderPath === '') return; // Skip root files, already added
|
|
|
|
const files = folderStructure[folderPath];
|
|
const folderItem = document.createElement('div');
|
|
folderItem.className = 'cache-dropdown-item folder';
|
|
folderItem.textContent = folderPath;
|
|
|
|
const submenu = createSubmenu(files, sortBy, hashData, infoData, fileSelector, actionButtons, infoDisplay, filterControls);
|
|
// Append submenu to document body instead of folder item to avoid clipping
|
|
document.body.appendChild(submenu);
|
|
|
|
let submenuTimeout;
|
|
|
|
// Show submenu on hover with slight delay
|
|
folderItem.addEventListener('mouseenter', (e) => {
|
|
// Clear any existing timeout
|
|
clearTimeout(submenuTimeout);
|
|
|
|
// Hide all other submenus first
|
|
document.querySelectorAll('.cache-dropdown-submenu').forEach(menu => {
|
|
if (menu !== submenu) {
|
|
menu.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
// Position submenu next to the folder item
|
|
const rect = folderItem.getBoundingClientRect();
|
|
submenu.style.left = `${rect.right + 5}px`;
|
|
submenu.style.top = `${rect.top}px`;
|
|
|
|
// Show this submenu
|
|
submenu.style.display = 'block';
|
|
});
|
|
|
|
// Keep submenu open when hovering over the submenu itself
|
|
submenu.addEventListener('mouseenter', () => {
|
|
clearTimeout(submenuTimeout);
|
|
submenu.style.display = 'block';
|
|
});
|
|
|
|
// Hide submenu when leaving both folder and submenu
|
|
folderItem.addEventListener('mouseleave', (e) => {
|
|
submenuTimeout = setTimeout(() => {
|
|
submenu.style.display = 'none';
|
|
}, 150);
|
|
});
|
|
|
|
submenu.addEventListener('mouseleave', (e) => {
|
|
submenuTimeout = setTimeout(() => {
|
|
submenu.style.display = 'none';
|
|
}, 150);
|
|
});
|
|
|
|
// Re-enter submenu cancels the hide timeout
|
|
submenu.addEventListener('mouseenter', () => {
|
|
clearTimeout(submenuTimeout);
|
|
});
|
|
|
|
// Re-enter folder cancels the hide timeout
|
|
folderItem.addEventListener('mouseenter', () => {
|
|
clearTimeout(submenuTimeout);
|
|
});
|
|
|
|
// Click handling for folders
|
|
folderItem.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
|
|
// Hide all other submenus
|
|
document.querySelectorAll('.cache-dropdown-submenu').forEach(menu => {
|
|
if (menu !== submenu) {
|
|
menu.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
// Toggle this submenu
|
|
if (submenu.style.display === 'block') {
|
|
submenu.style.display = 'none';
|
|
} else {
|
|
// Position submenu next to the folder item
|
|
const rect = folderItem.getBoundingClientRect();
|
|
submenu.style.left = `${rect.right + 5}px`;
|
|
submenu.style.top = `${rect.top}px`;
|
|
submenu.style.display = 'block';
|
|
}
|
|
});
|
|
|
|
dropdownMenu.appendChild(folderItem);
|
|
});
|
|
}
|
|
|
|
// Helper function to create submenu for folder contents
|
|
function createSubmenu(files, sortBy, hashData, infoData, fileSelector, actionButtons, infoDisplay, filterControls) {
|
|
const submenu = document.createElement('div');
|
|
submenu.className = 'cache-dropdown-submenu';
|
|
|
|
// Sort files within the submenu using the same criteria
|
|
const sortedSubFiles = files.map(file => file.fullPath);
|
|
const sortedSubFilePaths = sortFiles(sortedSubFiles, sortBy, hashData, infoData);
|
|
|
|
// Create a map for quick lookup
|
|
const fileMap = new Map();
|
|
files.forEach(file => fileMap.set(file.fullPath, file));
|
|
|
|
// Add files in sorted order
|
|
sortedSubFilePaths.forEach(filePath => {
|
|
const file = fileMap.get(filePath);
|
|
if (file) {
|
|
const fileItem = createFileItem(file, fileSelector, actionButtons, infoDisplay, filterControls);
|
|
submenu.appendChild(fileItem);
|
|
}
|
|
});
|
|
|
|
return submenu;
|
|
}
|
|
|
|
// Helper function to create individual file item (extracted for reuse)
|
|
function createFileItem(file, fileSelector, actionButtons, infoDisplay, filterControls) {
|
|
const { dropdownButton, dropdownMenu } = fileSelector;
|
|
const { pullButton, editButton } = actionButtons;
|
|
|
|
const item = document.createElement('div');
|
|
item.className = 'cache-dropdown-item file';
|
|
item.dataset.hash = file.hash;
|
|
|
|
let displayName = file.fileName;
|
|
if (file.info && file.info.model && file.info.model.type) {
|
|
displayName += ` [${file.info.model.type}]`;
|
|
}
|
|
|
|
item.textContent = displayName;
|
|
item.title = file.fullPath;
|
|
|
|
item.addEventListener('click', async () => {
|
|
actions.selectModel(file.hash);
|
|
dropdownButton.innerHTML = `<span>${displayName}</span><span>▼</span>`;
|
|
dropdownMenu.style.display = 'none';
|
|
actions.toggleDropdown(false);
|
|
|
|
// Hide all submenus when selecting a file
|
|
document.querySelectorAll('.cache-dropdown-submenu').forEach(menu => {
|
|
menu.style.display = 'none';
|
|
});
|
|
|
|
// Enable buttons when a file is selected
|
|
if (pullButton) {
|
|
pullButton.disabled = false;
|
|
pullButton.style.opacity = '1';
|
|
}
|
|
if (editButton) {
|
|
editButton.disabled = false;
|
|
editButton.style.opacity = '1';
|
|
}
|
|
|
|
// Show loading while creating info display
|
|
infoDisplay.innerHTML = '<div style="text-align: center; padding: 20px; color: #888; font-style: italic;">Loading model information...</div>';
|
|
|
|
// Update info display - use selector to get current selected hash
|
|
const selectedHash = selectors.selectedHash();
|
|
const cacheData = selectors.cacheData();
|
|
const selectedInfo = cacheData.info[selectedHash];
|
|
const showNsfw = filterControls.nsfwCheckbox.checked; // Connected to actual checkbox
|
|
|
|
// Use proper detailed info display
|
|
try {
|
|
const infoElement = await createDetailedInfoDisplay(selectedHash, selectedInfo, showNsfw);
|
|
infoDisplay.innerHTML = '';
|
|
infoDisplay.appendChild(infoElement);
|
|
} catch (error) {
|
|
console.error('Error creating info display:', error);
|
|
// Fallback to basic display
|
|
const infoHtml = `
|
|
<div style="padding: 15px; background: #2a2a2a; border-radius: 8px; margin: 10px 0;">
|
|
<h3 style="margin: 0 0 10px 0; color: #569cd6;">${file.fileName}</h3>
|
|
<p><strong>Path:</strong> ${file.fullPath}</p>
|
|
<p><strong>Hash:</strong> ${file.hash.substring(0, 16)}...</p>
|
|
${file.info ? `
|
|
<p><strong>Type:</strong> ${file.info.model?.type || 'Unknown'}</p>
|
|
<p><strong>Size:</strong> ${file.info.file_size ? formatFileSize(file.info.file_size) : 'Unknown'}</p>
|
|
${file.info.model?.name ? `<p><strong>Model:</strong> ${file.info.model.name}</p>` : ''}
|
|
` : '<p><em>No additional information available</em></p>'}
|
|
</div>
|
|
`;
|
|
infoDisplay.innerHTML = infoHtml;
|
|
}
|
|
});
|
|
|
|
return item;
|
|
}
|
|
|
|
/**
|
|
* 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 = '';
|
|
|
|
// Add header
|
|
container.appendChild(header);
|
|
|
|
// Add filter controls
|
|
container.appendChild(filterControls.filterContainer);
|
|
container.appendChild(filterControls.searchContainer);
|
|
container.appendChild(filterControls.lastUsedContainer);
|
|
container.appendChild(filterControls.updateContainer);
|
|
container.appendChild(filterControls.sortContainer);
|
|
// Note: NSFW checkbox moved below to be above model information
|
|
|
|
// Add file selector
|
|
container.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: 10px;
|
|
margin-bottom: 15px;
|
|
flex-wrap: wrap;
|
|
`;
|
|
|
|
Object.values(actionButtons).forEach(button => {
|
|
buttonContainer.appendChild(button);
|
|
});
|
|
container.appendChild(buttonContainer);
|
|
|
|
// Add NSFW checkbox right above model information
|
|
container.appendChild(filterControls.nsfwContainer);
|
|
|
|
// Add info display
|
|
container.appendChild(infoDisplay);
|
|
}
|
|
|
|
/**
|
|
* 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 = createModelsFileSelector();
|
|
const actionButtons = createModelsActionButtons();
|
|
const infoDisplay = createInfoDisplay();
|
|
|
|
// Set up event handlers
|
|
setupModelsEventHandlers(filterControls, fileSelector, actionButtons, infoDisplay);
|
|
|
|
// Assemble the layout
|
|
assembleModelsTabLayout(container, {
|
|
header,
|
|
filterControls,
|
|
fileSelector,
|
|
actionButtons,
|
|
infoDisplay
|
|
});
|
|
}
|