mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-24 06:32:12 -03:00
feat: Move LoRA related components to shared directory for consistency
- Added PresetTags.js to handle LoRA model preset parameter tags. - Introduced RecipeTab.js for managing recipes associated with LoRA models. - Created TriggerWords.js to manage trigger word functionality for LoRA models. - Implemented utility functions in utils.js for general model modal operations.
This commit is contained in:
570
static/js/components/shared/ModelCard.js
Normal file
570
static/js/components/shared/ModelCard.js
Normal file
@@ -0,0 +1,570 @@
|
||||
import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js';
|
||||
import { state, getCurrentPageState } from '../../state/index.js';
|
||||
import { showModelModal } from './ModelModal.js';
|
||||
import { bulkManager } from '../../managers/BulkManager.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||
import { replacePreview, saveModelMetadata as saveLoraMetadata } from '../../api/loraApi.js';
|
||||
import { replaceCheckpointPreview as apiReplaceCheckpointPreview, saveModelMetadata as saveCheckpointMetadata } from '../../api/checkpointApi.js';
|
||||
import { showDeleteModal } from '../../utils/modalUtils.js';
|
||||
|
||||
// Add global event delegation handlers
|
||||
export function setupModelCardEventDelegation(modelType) {
|
||||
const gridElement = document.getElementById('modelGrid');
|
||||
if (!gridElement) return;
|
||||
|
||||
// Remove any existing event listener to prevent duplication
|
||||
gridElement.removeEventListener('click', gridElement._handleModelCardEvent);
|
||||
|
||||
// Create event handler with modelType context
|
||||
const handleModelCardEvent = (event) => handleModelCardEvent_internal(event, modelType);
|
||||
|
||||
// Add the event delegation handler
|
||||
gridElement.addEventListener('click', handleModelCardEvent);
|
||||
|
||||
// Store reference to the handler for cleanup
|
||||
gridElement._handleModelCardEvent = handleModelCardEvent;
|
||||
}
|
||||
|
||||
// Event delegation handler for all model card events
|
||||
function handleModelCardEvent_internal(event, modelType) {
|
||||
// Find the closest card element
|
||||
const card = event.target.closest('.lora-card');
|
||||
if (!card) return;
|
||||
|
||||
// Handle specific elements within the card
|
||||
if (event.target.closest('.toggle-blur-btn')) {
|
||||
event.stopPropagation();
|
||||
toggleBlurContent(card);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.show-content-btn')) {
|
||||
event.stopPropagation();
|
||||
showBlurredContent(card);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.fa-star')) {
|
||||
event.stopPropagation();
|
||||
toggleFavorite(card, modelType);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.fa-globe')) {
|
||||
event.stopPropagation();
|
||||
if (card.dataset.from_civitai === 'true') {
|
||||
openCivitai(card.dataset.filepath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.fa-paper-plane')) {
|
||||
event.stopPropagation();
|
||||
handleSendToWorkflow(card, event.shiftKey, modelType);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.fa-copy')) {
|
||||
event.stopPropagation();
|
||||
handleCopyAction(card, modelType);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.fa-trash')) {
|
||||
event.stopPropagation();
|
||||
showDeleteModal(card.dataset.filepath, modelType);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.fa-image')) {
|
||||
event.stopPropagation();
|
||||
handleReplacePreview(card.dataset.filepath, modelType);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.fa-folder-open')) {
|
||||
event.stopPropagation();
|
||||
handleExampleImagesAccess(card, modelType);
|
||||
return;
|
||||
}
|
||||
|
||||
// If no specific element was clicked, handle the card click (show modal or toggle selection)
|
||||
handleCardClick(card, modelType);
|
||||
}
|
||||
|
||||
// Helper functions for event handling
|
||||
function toggleBlurContent(card) {
|
||||
const preview = card.querySelector('.card-preview');
|
||||
const isBlurred = preview.classList.toggle('blurred');
|
||||
const icon = card.querySelector('.toggle-blur-btn i');
|
||||
|
||||
// Update the icon based on blur state
|
||||
if (isBlurred) {
|
||||
icon.className = 'fas fa-eye';
|
||||
} else {
|
||||
icon.className = 'fas fa-eye-slash';
|
||||
}
|
||||
|
||||
// Toggle the overlay visibility
|
||||
const overlay = card.querySelector('.nsfw-overlay');
|
||||
if (overlay) {
|
||||
overlay.style.display = isBlurred ? 'flex' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function showBlurredContent(card) {
|
||||
const preview = card.querySelector('.card-preview');
|
||||
preview.classList.remove('blurred');
|
||||
|
||||
// Update the toggle button icon
|
||||
const toggleBtn = card.querySelector('.toggle-blur-btn');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
|
||||
}
|
||||
|
||||
// Hide the overlay
|
||||
const overlay = card.querySelector('.nsfw-overlay');
|
||||
if (overlay) {
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFavorite(card, modelType) {
|
||||
const starIcon = card.querySelector('.fa-star');
|
||||
const isFavorite = starIcon.classList.contains('fas');
|
||||
const newFavoriteState = !isFavorite;
|
||||
|
||||
try {
|
||||
// Use the appropriate save function based on model type
|
||||
const saveFunction = modelType === 'lora' ? saveLoraMetadata : saveCheckpointMetadata;
|
||||
await saveFunction(card.dataset.filepath, {
|
||||
favorite: newFavoriteState
|
||||
});
|
||||
|
||||
if (newFavoriteState) {
|
||||
showToast('Added to favorites', 'success');
|
||||
} else {
|
||||
showToast('Removed from favorites', 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update favorite status:', error);
|
||||
showToast('Failed to update favorite status', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function handleSendToWorkflow(card, replaceMode, modelType) {
|
||||
if (modelType === 'lora') {
|
||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||
const strength = usageTips.strength || 1;
|
||||
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
||||
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
|
||||
} else {
|
||||
// Checkpoint send functionality - to be implemented
|
||||
showToast('Send checkpoint to workflow - feature to be implemented', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyAction(card, modelType) {
|
||||
if (modelType === 'lora') {
|
||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||
const strength = usageTips.strength || 1;
|
||||
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
||||
copyToClipboard(loraSyntax, 'LoRA syntax copied to clipboard');
|
||||
} else {
|
||||
// Checkpoint copy functionality - copy checkpoint name
|
||||
const checkpointName = card.dataset.file_name;
|
||||
copyToClipboard(checkpointName, 'Checkpoint name copied');
|
||||
}
|
||||
}
|
||||
|
||||
function handleReplacePreview(filePath, modelType) {
|
||||
if (modelType === 'lora') {
|
||||
replacePreview(filePath);
|
||||
} else {
|
||||
if (window.replaceCheckpointPreview) {
|
||||
window.replaceCheckpointPreview(filePath);
|
||||
} else {
|
||||
apiReplaceCheckpointPreview(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExampleImagesAccess(card, modelType) {
|
||||
const modelHash = card.dataset.sha256;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/has-example-images?model_hash=${modelHash}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.has_images) {
|
||||
openExampleImagesFolder(modelHash);
|
||||
} else {
|
||||
showExampleAccessModal(card, modelType);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for example images:', error);
|
||||
showToast('Error checking for example images', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCardClick(card, modelType) {
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
if (state.bulkMode) {
|
||||
// Toggle selection using the bulk manager
|
||||
bulkManager.toggleCardSelection(card);
|
||||
} else if (pageState && pageState.duplicatesMode) {
|
||||
// In duplicates mode, don't open modal when clicking cards
|
||||
return;
|
||||
} else {
|
||||
// Normal behavior - show modal
|
||||
showModelModalFromCard(card, modelType);
|
||||
}
|
||||
}
|
||||
|
||||
function showModelModalFromCard(card, modelType) {
|
||||
// Get the appropriate preview versions map
|
||||
const previewVersionsKey = modelType === 'lora' ? 'loras' : 'checkpoints';
|
||||
const previewVersions = state.pages[previewVersionsKey]?.previewVersions || new Map();
|
||||
const version = previewVersions.get(card.dataset.filepath);
|
||||
const previewUrl = card.dataset.preview_url || '/loras_static/images/no-preview.png';
|
||||
const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl;
|
||||
|
||||
// Create model metadata object
|
||||
const modelMeta = {
|
||||
sha256: card.dataset.sha256,
|
||||
file_path: card.dataset.filepath,
|
||||
model_name: card.dataset.name,
|
||||
file_name: card.dataset.file_name,
|
||||
folder: card.dataset.folder,
|
||||
modified: card.dataset.modified,
|
||||
file_size: parseInt(card.dataset.file_size || '0'),
|
||||
from_civitai: card.dataset.from_civitai === 'true',
|
||||
base_model: card.dataset.base_model,
|
||||
notes: card.dataset.notes || '',
|
||||
preview_url: versionedPreviewUrl,
|
||||
favorite: card.dataset.favorite === 'true',
|
||||
// Parse civitai metadata from the card's dataset
|
||||
civitai: JSON.parse(card.dataset.meta || '{}'),
|
||||
tags: JSON.parse(card.dataset.tags || '[]'),
|
||||
modelDescription: card.dataset.modelDescription || '',
|
||||
// LoRA specific fields
|
||||
...(modelType === 'lora' && {
|
||||
usage_tips: card.dataset.usage_tips,
|
||||
})
|
||||
};
|
||||
|
||||
showModelModal(modelMeta, modelType);
|
||||
}
|
||||
|
||||
// Function to show the example access modal (generalized for lora and checkpoint)
|
||||
function showExampleAccessModal(card, modelType) {
|
||||
const modal = document.getElementById('exampleAccessModal');
|
||||
if (!modal) return;
|
||||
|
||||
// Get download button and determine if download should be enabled
|
||||
const downloadBtn = modal.querySelector('#downloadExamplesBtn');
|
||||
let hasRemoteExamples = false;
|
||||
|
||||
try {
|
||||
const metaData = JSON.parse(card.dataset.meta || '{}');
|
||||
hasRemoteExamples = metaData.images &&
|
||||
Array.isArray(metaData.images) &&
|
||||
metaData.images.length > 0 &&
|
||||
metaData.images[0].url;
|
||||
} catch (e) {
|
||||
console.error('Error parsing meta data:', e);
|
||||
}
|
||||
|
||||
// Enable or disable download button
|
||||
if (downloadBtn) {
|
||||
if (hasRemoteExamples) {
|
||||
downloadBtn.classList.remove('disabled');
|
||||
downloadBtn.removeAttribute('title');
|
||||
downloadBtn.onclick = () => {
|
||||
modalManager.closeModal('exampleAccessModal');
|
||||
// Open settings modal and scroll to example images section
|
||||
const settingsModal = document.getElementById('settingsModal');
|
||||
if (settingsModal) {
|
||||
modalManager.showModal('settingsModal');
|
||||
setTimeout(() => {
|
||||
const exampleSection = settingsModal.querySelector('.settings-section:nth-child(5)');
|
||||
if (exampleSection) {
|
||||
exampleSection.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
downloadBtn.classList.add('disabled');
|
||||
downloadBtn.setAttribute('title', 'No remote example images available for this model on Civitai');
|
||||
downloadBtn.onclick = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Set up import button
|
||||
const importBtn = modal.querySelector('#importExamplesBtn');
|
||||
if (importBtn) {
|
||||
importBtn.onclick = () => {
|
||||
modalManager.closeModal('exampleAccessModal');
|
||||
|
||||
// Get the model data from card dataset (works for both lora and checkpoint)
|
||||
const modelMeta = {
|
||||
sha256: card.dataset.sha256,
|
||||
file_path: card.dataset.filepath,
|
||||
model_name: card.dataset.name,
|
||||
file_name: card.dataset.file_name,
|
||||
folder: card.dataset.folder,
|
||||
modified: card.dataset.modified,
|
||||
file_size: card.dataset.file_size,
|
||||
from_civitai: card.dataset.from_civitai === 'true',
|
||||
base_model: card.dataset.base_model,
|
||||
notes: card.dataset.notes,
|
||||
favorite: card.dataset.favorite === 'true',
|
||||
civitai: JSON.parse(card.dataset.meta || '{}'),
|
||||
tags: JSON.parse(card.dataset.tags || '[]'),
|
||||
modelDescription: card.dataset.modelDescription || ''
|
||||
};
|
||||
|
||||
// Add usage_tips if present (for lora)
|
||||
if (card.dataset.usage_tips) {
|
||||
modelMeta.usage_tips = card.dataset.usage_tips;
|
||||
}
|
||||
|
||||
// Show the model modal
|
||||
showModelModal(modelMeta, modelType);
|
||||
|
||||
// Scroll to import area after modal is visible
|
||||
setTimeout(() => {
|
||||
const importArea = document.querySelector('.example-import-area');
|
||||
if (importArea) {
|
||||
const showcaseTab = document.getElementById('showcase-tab');
|
||||
if (showcaseTab) {
|
||||
// First make sure showcase tab is visible
|
||||
const tabBtn = document.querySelector('.tab-btn[data-tab="showcase"]');
|
||||
if (tabBtn && !tabBtn.classList.contains('active')) {
|
||||
tabBtn.click();
|
||||
}
|
||||
|
||||
// Then toggle showcase if collapsed
|
||||
const carousel = showcaseTab.querySelector('.carousel');
|
||||
if (carousel && carousel.classList.contains('collapsed')) {
|
||||
const scrollIndicator = showcaseTab.querySelector('.scroll-indicator');
|
||||
if (scrollIndicator) {
|
||||
scrollIndicator.click();
|
||||
}
|
||||
}
|
||||
|
||||
// Finally scroll to the import area
|
||||
importArea.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
}
|
||||
|
||||
// Show the modal
|
||||
modalManager.showModal('exampleAccessModal');
|
||||
}
|
||||
|
||||
export function createModelCard(model, modelType) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'lora-card'; // Reuse the same class for styling
|
||||
card.dataset.sha256 = model.sha256;
|
||||
card.dataset.filepath = model.file_path;
|
||||
card.dataset.name = model.model_name;
|
||||
card.dataset.file_name = model.file_name;
|
||||
card.dataset.folder = model.folder;
|
||||
card.dataset.modified = model.modified;
|
||||
card.dataset.file_size = model.file_size;
|
||||
card.dataset.from_civitai = model.from_civitai;
|
||||
card.dataset.notes = model.notes || '';
|
||||
card.dataset.base_model = model.base_model || (modelType === 'checkpoint' ? 'Unknown' : '');
|
||||
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
||||
|
||||
// LoRA specific data
|
||||
if (modelType === 'lora') {
|
||||
card.dataset.usage_tips = model.usage_tips;
|
||||
}
|
||||
|
||||
// Store metadata if available
|
||||
if (model.civitai) {
|
||||
card.dataset.meta = JSON.stringify(model.civitai || {});
|
||||
}
|
||||
|
||||
// Store tags if available
|
||||
if (model.tags && Array.isArray(model.tags)) {
|
||||
card.dataset.tags = JSON.stringify(model.tags);
|
||||
}
|
||||
|
||||
if (model.modelDescription) {
|
||||
card.dataset.modelDescription = model.modelDescription;
|
||||
}
|
||||
|
||||
// Store NSFW level if available
|
||||
const nsfwLevel = model.preview_nsfw_level !== undefined ? model.preview_nsfw_level : 0;
|
||||
card.dataset.nsfwLevel = nsfwLevel;
|
||||
|
||||
// Determine if the preview should be blurred based on NSFW level and user settings
|
||||
const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13;
|
||||
if (shouldBlur) {
|
||||
card.classList.add('nsfw-content');
|
||||
}
|
||||
|
||||
// Apply selection state if in bulk mode and this card is in the selected set (LoRA only)
|
||||
if (modelType === 'lora' && state.bulkMode && state.selectedLoras.has(model.file_path)) {
|
||||
card.classList.add('selected');
|
||||
}
|
||||
|
||||
// Get the appropriate preview versions map
|
||||
const previewVersionsKey = modelType === 'lora' ? 'loras' : 'checkpoints';
|
||||
const previewVersions = state.pages[previewVersionsKey]?.previewVersions || new Map();
|
||||
const version = previewVersions.get(model.file_path);
|
||||
const previewUrl = model.preview_url || '/loras_static/images/no-preview.png';
|
||||
const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl;
|
||||
|
||||
// Determine NSFW warning text based on level
|
||||
let nsfwText = "Mature Content";
|
||||
if (nsfwLevel >= NSFW_LEVELS.XXX) {
|
||||
nsfwText = "XXX-rated Content";
|
||||
} else if (nsfwLevel >= NSFW_LEVELS.X) {
|
||||
nsfwText = "X-rated Content";
|
||||
} else if (nsfwLevel >= NSFW_LEVELS.R) {
|
||||
nsfwText = "R-rated Content";
|
||||
}
|
||||
|
||||
// Check if autoplayOnHover is enabled for video previews
|
||||
const autoplayOnHover = state.global?.settings?.autoplayOnHover || false;
|
||||
const isVideo = previewUrl.endsWith('.mp4');
|
||||
const videoAttrs = autoplayOnHover ? 'controls muted loop' : 'controls autoplay muted loop';
|
||||
|
||||
// Get favorite status from model data
|
||||
const isFavorite = model.favorite === true;
|
||||
|
||||
// Generate action icons based on model type
|
||||
const actionIcons = modelType === 'lora' ?
|
||||
`<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}"
|
||||
title="${isFavorite ? 'Remove from favorites' : 'Add to favorites'}">
|
||||
</i>
|
||||
<i class="fas fa-globe"
|
||||
title="${model.from_civitai ? 'View on Civitai' : 'Not available from Civitai'}"
|
||||
${!model.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
|
||||
</i>
|
||||
<i class="fas fa-paper-plane"
|
||||
title="Send to ComfyUI (Click: Append, Shift+Click: Replace)">
|
||||
</i>
|
||||
<i class="fas fa-copy"
|
||||
title="Copy LoRA Syntax">
|
||||
</i>` :
|
||||
`<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}"
|
||||
title="${isFavorite ? 'Remove from favorites' : 'Add to favorites'}">
|
||||
</i>
|
||||
<i class="fas fa-globe"
|
||||
title="${model.from_civitai ? 'View on Civitai' : 'Not available from Civitai'}"
|
||||
${!model.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
|
||||
</i>
|
||||
<i class="fas fa-paper-plane"
|
||||
title="Send to workflow - feature to be implemented">
|
||||
</i>
|
||||
<i class="fas fa-copy"
|
||||
title="Copy Checkpoint Name">
|
||||
</i>`;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
||||
${isVideo ?
|
||||
`<video ${videoAttrs}>
|
||||
<source src="${versionedPreviewUrl}" type="video/mp4">
|
||||
</video>` :
|
||||
`<img src="${versionedPreviewUrl}" alt="${model.model_name}">`
|
||||
}
|
||||
<div class="card-header">
|
||||
${shouldBlur ?
|
||||
`<button class="toggle-blur-btn" title="Toggle blur">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>` : ''}
|
||||
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${model.base_model}">
|
||||
${model.base_model}
|
||||
</span>
|
||||
<div class="card-actions">
|
||||
${actionIcons}
|
||||
</div>
|
||||
</div>
|
||||
${shouldBlur ? `
|
||||
<div class="nsfw-overlay">
|
||||
<div class="nsfw-warning">
|
||||
<p>${nsfwText}</p>
|
||||
<button class="show-content-btn">Show</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="card-footer">
|
||||
<div class="model-info">
|
||||
<span class="model-name">${model.model_name}</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<i class="fas fa-folder-open"
|
||||
title="Open Example Images Folder">
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add video auto-play on hover functionality if needed
|
||||
const videoElement = card.querySelector('video');
|
||||
if (videoElement && autoplayOnHover) {
|
||||
const cardPreview = card.querySelector('.card-preview');
|
||||
|
||||
// Remove autoplay attribute and pause initially
|
||||
videoElement.removeAttribute('autoplay');
|
||||
videoElement.pause();
|
||||
|
||||
// Add mouse events to trigger play/pause using event attributes
|
||||
cardPreview.setAttribute('onmouseenter', 'this.querySelector("video")?.play()');
|
||||
cardPreview.setAttribute('onmouseleave', 'const v=this.querySelector("video"); if(v){v.pause();v.currentTime=0;}');
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// Add a method to update card appearance based on bulk mode (LoRA only)
|
||||
export function updateCardsForBulkMode(isBulkMode) {
|
||||
// Update the state
|
||||
state.bulkMode = isBulkMode;
|
||||
|
||||
document.body.classList.toggle('bulk-mode', isBulkMode);
|
||||
|
||||
// Get all lora cards - this can now be from the DOM or through the virtual scroller
|
||||
const loraCards = document.querySelectorAll('.lora-card');
|
||||
|
||||
loraCards.forEach(card => {
|
||||
// Get all action containers for this card
|
||||
const actions = card.querySelectorAll('.card-actions');
|
||||
|
||||
// Handle display property based on mode
|
||||
if (isBulkMode) {
|
||||
// Hide actions when entering bulk mode
|
||||
actions.forEach(actionGroup => {
|
||||
actionGroup.style.display = 'none';
|
||||
});
|
||||
} else {
|
||||
// Ensure actions are visible when exiting bulk mode
|
||||
actions.forEach(actionGroup => {
|
||||
// We need to reset to default display style which is flex
|
||||
actionGroup.style.display = 'flex';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// If using virtual scroller, we need to rerender after toggling bulk mode
|
||||
if (state.virtualScroller && typeof state.virtualScroller.scheduleRender === 'function') {
|
||||
state.virtualScroller.scheduleRender();
|
||||
}
|
||||
|
||||
// Apply selection state to cards if entering bulk mode
|
||||
if (isBulkMode) {
|
||||
bulkManager.applySelectionState();
|
||||
}
|
||||
}
|
||||
110
static/js/components/shared/ModelDescription.js
Normal file
110
static/js/components/shared/ModelDescription.js
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* ModelDescription.js
|
||||
* Handles model description related functionality - General version
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set up tab switching functionality
|
||||
*/
|
||||
export function setupTabSwitching() {
|
||||
const tabButtons = document.querySelectorAll('.showcase-tabs .tab-btn');
|
||||
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
// Remove active class from all tabs
|
||||
document.querySelectorAll('.showcase-tabs .tab-btn').forEach(btn =>
|
||||
btn.classList.remove('active')
|
||||
);
|
||||
document.querySelectorAll('.tab-content .tab-pane').forEach(tab =>
|
||||
tab.classList.remove('active')
|
||||
);
|
||||
|
||||
// Add active class to clicked tab
|
||||
button.classList.add('active');
|
||||
const tabId = `${button.dataset.tab}-tab`;
|
||||
document.getElementById(tabId).classList.add('active');
|
||||
|
||||
// If switching to description tab, make sure content is properly sized
|
||||
if (button.dataset.tab === 'description') {
|
||||
const descriptionContent = document.querySelector('.model-description-content');
|
||||
if (descriptionContent) {
|
||||
const hasContent = descriptionContent.innerHTML.trim() !== '';
|
||||
document.querySelector('.model-description-loading')?.classList.add('hidden');
|
||||
|
||||
// If no content, show a message
|
||||
if (!hasContent) {
|
||||
descriptionContent.innerHTML = '<div class="no-description">No model description available</div>';
|
||||
descriptionContent.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load model description - General version supports both LoRA and Checkpoint
|
||||
* @param {string} modelId - Model ID
|
||||
* @param {string} filePath - File path
|
||||
*/
|
||||
export async function loadModelDescription(modelId, filePath) {
|
||||
try {
|
||||
const descriptionContainer = document.querySelector('.model-description-content');
|
||||
const loadingElement = document.querySelector('.model-description-loading');
|
||||
|
||||
if (!descriptionContainer || !loadingElement) return;
|
||||
|
||||
// Show loading indicator
|
||||
loadingElement.classList.remove('hidden');
|
||||
descriptionContainer.classList.add('hidden');
|
||||
|
||||
// Determine API endpoint based on file path or context
|
||||
let apiEndpoint = `/api/lora-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`;
|
||||
|
||||
// If this is a checkpoint (can be determined from file path or other context)
|
||||
if (filePath.includes('.safetensors') || filePath.includes('.ckpt')) {
|
||||
// For now, use the same endpoint - can be updated later if checkpoint-specific endpoint is needed
|
||||
apiEndpoint = `/api/lora-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`;
|
||||
}
|
||||
|
||||
// Try to get model description from API
|
||||
const response = await fetch(apiEndpoint);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch model description: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.description) {
|
||||
// Update the description content
|
||||
descriptionContainer.innerHTML = data.description;
|
||||
|
||||
// Process any links in the description to open in new tab
|
||||
const links = descriptionContainer.querySelectorAll('a');
|
||||
links.forEach(link => {
|
||||
link.setAttribute('target', '_blank');
|
||||
link.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
// Show the description and hide loading indicator
|
||||
descriptionContainer.classList.remove('hidden');
|
||||
loadingElement.classList.add('hidden');
|
||||
} else {
|
||||
throw new Error(data.error || 'No description available');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading model description:', error);
|
||||
const loadingElement = document.querySelector('.model-description-loading');
|
||||
if (loadingElement) {
|
||||
loadingElement.innerHTML = `<div class="error-message">Failed to load model description. ${error.message}</div>`;
|
||||
}
|
||||
|
||||
// Show empty state message in the description container
|
||||
const descriptionContainer = document.querySelector('.model-description-content');
|
||||
if (descriptionContainer) {
|
||||
descriptionContainer.innerHTML = '<div class="no-description">No model description available</div>';
|
||||
descriptionContainer.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
470
static/js/components/shared/ModelMetadata.js
Normal file
470
static/js/components/shared/ModelMetadata.js
Normal file
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* ModelMetadata.js
|
||||
* Handles model metadata editing functionality - General version
|
||||
*/
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { BASE_MODELS } from '../../utils/constants.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { saveModelMetadata as saveLoraMetadata, renameLoraFile } from '../../api/loraApi.js';
|
||||
import { saveModelMetadata as saveCheckpointMetadata, renameCheckpointFile } from '../../api/checkpointApi.js';
|
||||
|
||||
/**
|
||||
* Set up model name editing functionality
|
||||
* @param {string} filePath - File path
|
||||
*/
|
||||
export function setupModelNameEditing(filePath) {
|
||||
const modelNameContent = document.querySelector('.model-name-content');
|
||||
const editBtn = document.querySelector('.edit-model-name-btn');
|
||||
|
||||
if (!modelNameContent || !editBtn) return;
|
||||
|
||||
// Store the file path in a data attribute for later use
|
||||
modelNameContent.dataset.filePath = filePath;
|
||||
|
||||
// Show edit button on hover
|
||||
const modelNameHeader = document.querySelector('.model-name-header');
|
||||
modelNameHeader.addEventListener('mouseenter', () => {
|
||||
editBtn.classList.add('visible');
|
||||
});
|
||||
|
||||
modelNameHeader.addEventListener('mouseleave', () => {
|
||||
if (!modelNameHeader.classList.contains('editing')) {
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle edit button click
|
||||
editBtn.addEventListener('click', () => {
|
||||
modelNameHeader.classList.add('editing');
|
||||
modelNameContent.setAttribute('contenteditable', 'true');
|
||||
// Store original value for comparison later
|
||||
modelNameContent.dataset.originalValue = modelNameContent.textContent.trim();
|
||||
modelNameContent.focus();
|
||||
|
||||
// Place cursor at the end
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
if (modelNameContent.childNodes.length > 0) {
|
||||
range.setStart(modelNameContent.childNodes[0], modelNameContent.textContent.length);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
editBtn.classList.add('visible');
|
||||
});
|
||||
|
||||
// Handle keyboard events in edit mode
|
||||
modelNameContent.addEventListener('keydown', function(e) {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.blur(); // Trigger save on Enter
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
// Restore original value
|
||||
this.textContent = this.dataset.originalValue;
|
||||
exitEditMode();
|
||||
}
|
||||
});
|
||||
|
||||
// Limit model name length
|
||||
modelNameContent.addEventListener('input', function() {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
// Limit model name length
|
||||
if (this.textContent.length > 100) {
|
||||
this.textContent = this.textContent.substring(0, 100);
|
||||
// Place cursor at the end
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.setStart(this.childNodes[0], 100);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
|
||||
showToast('Model name is limited to 100 characters', 'warning');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle focus out - save changes
|
||||
modelNameContent.addEventListener('blur', async function() {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
const newModelName = this.textContent.trim();
|
||||
const originalValue = this.dataset.originalValue;
|
||||
|
||||
// Basic validation
|
||||
if (!newModelName) {
|
||||
// Restore original value if empty
|
||||
this.textContent = originalValue;
|
||||
showToast('Model name cannot be empty', 'error');
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
|
||||
if (newModelName === originalValue) {
|
||||
// No changes, just exit edit mode
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the file path from the dataset
|
||||
const filePath = this.dataset.filePath;
|
||||
|
||||
// Determine model type based on file extension
|
||||
const isCheckpoint = filePath.includes('.safetensors') || filePath.includes('.ckpt');
|
||||
const saveFunction = isCheckpoint ? saveCheckpointMetadata : saveLoraMetadata;
|
||||
|
||||
await saveFunction(filePath, { model_name: newModelName });
|
||||
|
||||
showToast('Model name updated successfully', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error updating model name:', error);
|
||||
this.textContent = originalValue; // Restore original model name
|
||||
showToast('Failed to update model name', 'error');
|
||||
} finally {
|
||||
exitEditMode();
|
||||
}
|
||||
});
|
||||
|
||||
function exitEditMode() {
|
||||
modelNameContent.removeAttribute('contenteditable');
|
||||
modelNameHeader.classList.remove('editing');
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up base model editing functionality
|
||||
* @param {string} filePath - File path
|
||||
*/
|
||||
export function setupBaseModelEditing(filePath) {
|
||||
const baseModelContent = document.querySelector('.base-model-content');
|
||||
const editBtn = document.querySelector('.edit-base-model-btn');
|
||||
|
||||
if (!baseModelContent || !editBtn) return;
|
||||
|
||||
// Store the file path in a data attribute for later use
|
||||
baseModelContent.dataset.filePath = filePath;
|
||||
|
||||
// Show edit button on hover
|
||||
const baseModelDisplay = document.querySelector('.base-model-display');
|
||||
baseModelDisplay.addEventListener('mouseenter', () => {
|
||||
editBtn.classList.add('visible');
|
||||
});
|
||||
|
||||
baseModelDisplay.addEventListener('mouseleave', () => {
|
||||
if (!baseModelDisplay.classList.contains('editing')) {
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle edit button click
|
||||
editBtn.addEventListener('click', () => {
|
||||
baseModelDisplay.classList.add('editing');
|
||||
|
||||
// Store the original value to check for changes later
|
||||
const originalValue = baseModelContent.textContent.trim();
|
||||
|
||||
// Create dropdown selector to replace the base model content
|
||||
const currentValue = originalValue;
|
||||
const dropdown = document.createElement('select');
|
||||
dropdown.className = 'base-model-selector';
|
||||
|
||||
// Flag to track if a change was made
|
||||
let valueChanged = false;
|
||||
|
||||
// Add options from BASE_MODELS constants
|
||||
const baseModelCategories = {
|
||||
'Stable Diffusion 1.x': [BASE_MODELS.SD_1_4, BASE_MODELS.SD_1_5, BASE_MODELS.SD_1_5_LCM, BASE_MODELS.SD_1_5_HYPER],
|
||||
'Stable Diffusion 2.x': [BASE_MODELS.SD_2_0, BASE_MODELS.SD_2_1],
|
||||
'Stable Diffusion 3.x': [BASE_MODELS.SD_3, BASE_MODELS.SD_3_5, BASE_MODELS.SD_3_5_MEDIUM, BASE_MODELS.SD_3_5_LARGE, BASE_MODELS.SD_3_5_LARGE_TURBO],
|
||||
'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER],
|
||||
'Video Models': [BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.WAN_VIDEO, BASE_MODELS.HUNYUAN_VIDEO],
|
||||
'Other Models': [
|
||||
BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.FLUX_1_KONTEXT, BASE_MODELS.AURAFLOW,
|
||||
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
|
||||
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
|
||||
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM,
|
||||
BASE_MODELS.UNKNOWN
|
||||
]
|
||||
};
|
||||
|
||||
// Create option groups for better organization
|
||||
Object.entries(baseModelCategories).forEach(([category, models]) => {
|
||||
const group = document.createElement('optgroup');
|
||||
group.label = category;
|
||||
|
||||
models.forEach(model => {
|
||||
const option = document.createElement('option');
|
||||
option.value = model;
|
||||
option.textContent = model;
|
||||
option.selected = model === currentValue;
|
||||
group.appendChild(option);
|
||||
});
|
||||
|
||||
dropdown.appendChild(group);
|
||||
});
|
||||
|
||||
// Replace content with dropdown
|
||||
baseModelContent.style.display = 'none';
|
||||
baseModelDisplay.insertBefore(dropdown, editBtn);
|
||||
|
||||
// Hide edit button during editing
|
||||
editBtn.style.display = 'none';
|
||||
|
||||
// Focus the dropdown
|
||||
dropdown.focus();
|
||||
|
||||
// Handle dropdown change
|
||||
dropdown.addEventListener('change', function() {
|
||||
const selectedModel = this.value;
|
||||
baseModelContent.textContent = selectedModel;
|
||||
|
||||
// Mark that a change was made if the value differs from original
|
||||
if (selectedModel !== originalValue) {
|
||||
valueChanged = true;
|
||||
} else {
|
||||
valueChanged = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Function to save changes and exit edit mode
|
||||
const saveAndExit = function() {
|
||||
// Check if dropdown still exists and remove it
|
||||
if (dropdown && dropdown.parentNode === baseModelDisplay) {
|
||||
baseModelDisplay.removeChild(dropdown);
|
||||
}
|
||||
|
||||
// Show the content and edit button
|
||||
baseModelContent.style.display = '';
|
||||
editBtn.style.display = '';
|
||||
|
||||
// Remove editing class
|
||||
baseModelDisplay.classList.remove('editing');
|
||||
|
||||
// Only save if the value has actually changed
|
||||
if (valueChanged || baseModelContent.textContent.trim() !== originalValue) {
|
||||
// Get file path from the dataset
|
||||
const filePath = baseModelContent.dataset.filePath;
|
||||
|
||||
// Save the changes, passing the original value for comparison
|
||||
saveBaseModel(filePath, originalValue);
|
||||
}
|
||||
|
||||
// Remove this event listener
|
||||
document.removeEventListener('click', outsideClickHandler);
|
||||
};
|
||||
|
||||
// Handle outside clicks to save and exit
|
||||
const outsideClickHandler = function(e) {
|
||||
// If click is outside the dropdown and base model display
|
||||
if (!baseModelDisplay.contains(e.target)) {
|
||||
saveAndExit();
|
||||
}
|
||||
};
|
||||
|
||||
// Add delayed event listener for outside clicks
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', outsideClickHandler);
|
||||
}, 0);
|
||||
|
||||
// Also handle dropdown blur event
|
||||
dropdown.addEventListener('blur', function(e) {
|
||||
// Only save if the related target is not the edit button or inside the baseModelDisplay
|
||||
if (!baseModelDisplay.contains(e.relatedTarget)) {
|
||||
saveAndExit();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save base model
|
||||
* @param {string} filePath - File path
|
||||
* @param {string} originalValue - Original value (for comparison)
|
||||
*/
|
||||
async function saveBaseModel(filePath, originalValue) {
|
||||
const baseModelElement = document.querySelector('.base-model-content');
|
||||
const newBaseModel = baseModelElement.textContent.trim();
|
||||
|
||||
// Only save if the value has actually changed
|
||||
if (newBaseModel === originalValue) {
|
||||
return; // No change, no need to save
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine model type based on file extension
|
||||
const isCheckpoint = filePath.includes('.safetensors') || filePath.includes('.ckpt');
|
||||
const saveFunction = isCheckpoint ? saveCheckpointMetadata : saveLoraMetadata;
|
||||
|
||||
await saveFunction(filePath, { base_model: newBaseModel });
|
||||
|
||||
showToast('Base model updated successfully', 'success');
|
||||
} catch (error) {
|
||||
showToast('Failed to update base model', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up file name editing functionality
|
||||
* @param {string} filePath - File path
|
||||
*/
|
||||
export function setupFileNameEditing(filePath) {
|
||||
const fileNameContent = document.querySelector('.file-name-content');
|
||||
const editBtn = document.querySelector('.edit-file-name-btn');
|
||||
|
||||
if (!fileNameContent || !editBtn) return;
|
||||
|
||||
// Store the original file path
|
||||
fileNameContent.dataset.filePath = filePath;
|
||||
|
||||
// Show edit button on hover
|
||||
const fileNameWrapper = document.querySelector('.file-name-wrapper');
|
||||
fileNameWrapper.addEventListener('mouseenter', () => {
|
||||
editBtn.classList.add('visible');
|
||||
});
|
||||
|
||||
fileNameWrapper.addEventListener('mouseleave', () => {
|
||||
if (!fileNameWrapper.classList.contains('editing')) {
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle edit button click
|
||||
editBtn.addEventListener('click', () => {
|
||||
fileNameWrapper.classList.add('editing');
|
||||
fileNameContent.setAttribute('contenteditable', 'true');
|
||||
fileNameContent.focus();
|
||||
|
||||
// Store original value for comparison later
|
||||
fileNameContent.dataset.originalValue = fileNameContent.textContent.trim();
|
||||
|
||||
// Place cursor at the end
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.selectNodeContents(fileNameContent);
|
||||
range.collapse(false);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
|
||||
editBtn.classList.add('visible');
|
||||
});
|
||||
|
||||
// Handle keyboard events in edit mode
|
||||
fileNameContent.addEventListener('keydown', function(e) {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.blur(); // Trigger save on Enter
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
// Restore original value
|
||||
this.textContent = this.dataset.originalValue;
|
||||
exitEditMode();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle input validation
|
||||
fileNameContent.addEventListener('input', function() {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
// Replace invalid characters for filenames
|
||||
const invalidChars = /[\\/:*?"<>|]/g;
|
||||
if (invalidChars.test(this.textContent)) {
|
||||
const cursorPos = window.getSelection().getRangeAt(0).startOffset;
|
||||
this.textContent = this.textContent.replace(invalidChars, '');
|
||||
|
||||
// Restore cursor position
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
const newPos = Math.min(cursorPos, this.textContent.length);
|
||||
|
||||
if (this.firstChild) {
|
||||
range.setStart(this.firstChild, newPos);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
showToast('Invalid characters removed from filename', 'warning');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle focus out - save changes
|
||||
fileNameContent.addEventListener('blur', async function() {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
const newFileName = this.textContent.trim();
|
||||
const originalValue = this.dataset.originalValue;
|
||||
|
||||
// Basic validation
|
||||
if (!newFileName) {
|
||||
// Restore original value if empty
|
||||
this.textContent = originalValue;
|
||||
showToast('File name cannot be empty', 'error');
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
|
||||
if (newFileName === originalValue) {
|
||||
// No changes, just exit edit mode
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the file path from the dataset
|
||||
const filePath = this.dataset.filePath;
|
||||
|
||||
// Determine model type and use appropriate rename function
|
||||
const isCheckpoint = filePath.includes('.safetensors') || filePath.includes('.ckpt');
|
||||
let result;
|
||||
|
||||
if (isCheckpoint) {
|
||||
// Use checkpoint rename function if it exists, otherwise fallback to generic approach
|
||||
if (typeof renameCheckpointFile === 'function') {
|
||||
result = await renameCheckpointFile(filePath, newFileName);
|
||||
} else {
|
||||
// Fallback: use checkpoint metadata save function
|
||||
await saveCheckpointMetadata(filePath, { file_name: newFileName });
|
||||
result = { success: true };
|
||||
}
|
||||
} else {
|
||||
// Use LoRA rename function
|
||||
result = await renameLoraFile(filePath, newFileName);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
showToast('File name updated successfully', 'success');
|
||||
|
||||
// Update virtual scroller if available (mainly for LoRAs)
|
||||
if (state.virtualScroller && typeof state.virtualScroller.updateSingleItem === 'function') {
|
||||
const newFilePath = filePath.replace(originalValue, newFileName);
|
||||
state.virtualScroller.updateSingleItem(filePath, {
|
||||
file_name: newFileName,
|
||||
file_path: newFilePath
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error renaming file:', error);
|
||||
this.textContent = originalValue; // Restore original file name
|
||||
showToast(`Failed to rename file: ${error.message}`, 'error');
|
||||
} finally {
|
||||
exitEditMode();
|
||||
}
|
||||
});
|
||||
|
||||
function exitEditMode() {
|
||||
fileNameContent.removeAttribute('contenteditable');
|
||||
fileNameWrapper.classList.remove('editing');
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
443
static/js/components/shared/ModelModal.js
Normal file
443
static/js/components/shared/ModelModal.js
Normal file
@@ -0,0 +1,443 @@
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
import {
|
||||
toggleShowcase,
|
||||
setupShowcaseScroll,
|
||||
scrollToTop,
|
||||
loadExampleImages
|
||||
} from './showcase/ShowcaseView.js';
|
||||
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
|
||||
import {
|
||||
setupModelNameEditing,
|
||||
setupBaseModelEditing,
|
||||
setupFileNameEditing
|
||||
} from './ModelMetadata.js';
|
||||
import { setupTagEditMode } from './ModelTags.js';
|
||||
import { saveModelMetadata as saveLoraMetadata } from '../../api/loraApi.js';
|
||||
import { saveModelMetadata as saveCheckpointMetadata } from '../../api/checkpointApi.js';
|
||||
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
|
||||
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
|
||||
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
||||
import { loadRecipesForLora } from './RecipeTab.js';
|
||||
|
||||
/**
|
||||
* Display the model modal with the given model data
|
||||
* @param {Object} model - Model data object
|
||||
* @param {string} modelType - Type of model ('lora' or 'checkpoint')
|
||||
*/
|
||||
export function showModelModal(model, modelType) {
|
||||
const modalId = 'modelModal';
|
||||
const modalTitle = model.model_name;
|
||||
|
||||
// Prepare LoRA specific data
|
||||
const escapedWords = modelType === 'lora' && model.civitai?.trainedWords?.length ?
|
||||
model.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
|
||||
|
||||
// Generate model type specific content
|
||||
const typeSpecificContent = modelType === 'lora' ? renderLoraSpecificContent(model, escapedWords) : '';
|
||||
|
||||
// Generate tabs based on model type
|
||||
const tabsContent = modelType === 'lora' ?
|
||||
`<button class="tab-btn active" data-tab="showcase">Examples</button>
|
||||
<button class="tab-btn" data-tab="description">Model Description</button>
|
||||
<button class="tab-btn" data-tab="recipes">Recipes</button>` :
|
||||
`<button class="tab-btn active" data-tab="showcase">Examples</button>
|
||||
<button class="tab-btn" data-tab="description">Model Description</button>`;
|
||||
|
||||
const tabPanesContent = modelType === 'lora' ?
|
||||
`<div id="showcase-tab" class="tab-pane active">
|
||||
<div class="example-images-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading example images...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="description-tab" class="tab-pane">
|
||||
<div class="model-description-container">
|
||||
<div class="model-description-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading model description...
|
||||
</div>
|
||||
<div class="model-description-content">
|
||||
${model.modelDescription || ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="recipes-tab" class="tab-pane">
|
||||
<div class="recipes-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading recipes...
|
||||
</div>
|
||||
</div>` :
|
||||
`<div id="showcase-tab" class="tab-pane active">
|
||||
<div class="recipes-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading examples...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="description-tab" class="tab-pane">
|
||||
<div class="model-description-container">
|
||||
<div class="model-description-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading model description...
|
||||
</div>
|
||||
<div class="model-description-content">
|
||||
${model.modelDescription || ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const content = `
|
||||
<div class="modal-content">
|
||||
<button class="close" onclick="modalManager.closeModal('${modalId}')">×</button>
|
||||
<header class="modal-header">
|
||||
<div class="model-name-header">
|
||||
<h2 class="model-name-content">${modalTitle}</h2>
|
||||
<button class="edit-model-name-btn" title="Edit model name">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${model.civitai?.creator ? `
|
||||
<div class="creator-info">
|
||||
${model.civitai.creator.image ?
|
||||
`<div class="creator-avatar">
|
||||
<img src="${model.civitai.creator.image}" alt="${model.civitai.creator.username}" onerror="this.onerror=null; this.src='static/icons/user-placeholder.png';">
|
||||
</div>` :
|
||||
`<div class="creator-avatar creator-placeholder">
|
||||
<i class="fas fa-user"></i>
|
||||
</div>`
|
||||
}
|
||||
<span class="creator-username">${model.civitai.creator.username}</span>
|
||||
</div>` : ''}
|
||||
|
||||
${renderCompactTags(model.tags || [], model.file_path)}
|
||||
</header>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="info-section">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label>Version</label>
|
||||
<span>${model.civitai?.name || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>File Name</label>
|
||||
<div class="file-name-wrapper">
|
||||
<span id="file-name" class="file-name-content">${model.file_name || 'N/A'}</span>
|
||||
<button class="edit-file-name-btn" title="Edit file name">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item location-size">
|
||||
<div class="location-wrapper">
|
||||
<label>Location</label>
|
||||
<span class="file-path">${model.file_path.replace(/[^/]+$/, '') || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item base-size">
|
||||
<div class="base-wrapper">
|
||||
<label>Base Model</label>
|
||||
<div class="base-model-display">
|
||||
<span class="base-model-content">${model.base_model || (modelType === 'checkpoint' ? 'Unknown' : 'N/A')}</span>
|
||||
<button class="edit-base-model-btn" title="Edit base model">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="size-wrapper">
|
||||
<label>Size</label>
|
||||
<span>${formatFileSize(model.file_size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
${typeSpecificContent}
|
||||
<div class="info-item notes">
|
||||
<label>Additional Notes ${modelType === 'lora' ? '<i class="fas fa-info-circle notes-hint" title="Press Enter to save, Shift+Enter for new line"></i>' : ''}</label>
|
||||
<div class="editable-field">
|
||||
<div class="notes-content" contenteditable="true" spellcheck="false">${model.notes || 'Add your notes here...'}</div>
|
||||
${modelType === 'checkpoint' ? `<button class="save-btn" onclick="saveModelNotes('${model.file_path}', '${modelType}')">
|
||||
<i class="fas fa-save"></i>
|
||||
</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item full-width">
|
||||
<label>About this version</label>
|
||||
<div class="description-text">${model.civitai?.description || 'N/A'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="showcase-section" data-model-hash="${model.sha256 || ''}" data-filepath="${model.file_path}">
|
||||
<div class="showcase-tabs">
|
||||
${tabsContent}
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
${tabPanesContent}
|
||||
</div>
|
||||
|
||||
<button class="back-to-top" data-action="scroll-to-top">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const onCloseCallback = function() {
|
||||
// Clean up all handlers when modal closes for LoRA
|
||||
const modalElement = document.getElementById(modalId);
|
||||
if (modalElement && modalElement._clickHandler) {
|
||||
modalElement.removeEventListener('click', modalElement._clickHandler);
|
||||
delete modalElement._clickHandler;
|
||||
}
|
||||
};
|
||||
|
||||
modalManager.showModal(modalId, content, null, onCloseCallback);
|
||||
setupEditableFields(model.file_path, modelType);
|
||||
setupShowcaseScroll(modalId);
|
||||
setupTabSwitching();
|
||||
setupTagTooltip();
|
||||
setupTagEditMode();
|
||||
setupModelNameEditing(model.file_path);
|
||||
setupBaseModelEditing(model.file_path);
|
||||
setupFileNameEditing(model.file_path);
|
||||
setupEventHandlers(model.file_path);
|
||||
|
||||
// LoRA specific setup
|
||||
if (modelType === 'lora') {
|
||||
setupTriggerWordsEditMode();
|
||||
|
||||
// Load recipes for this LoRA
|
||||
loadRecipesForLora(model.model_name, model.sha256);
|
||||
}
|
||||
|
||||
// If we have a model ID but no description, fetch it
|
||||
if (model.civitai?.modelId && !model.modelDescription) {
|
||||
loadModelDescription(model.civitai.modelId, model.file_path);
|
||||
}
|
||||
|
||||
// Load example images asynchronously - merge regular and custom images
|
||||
const regularImages = model.civitai?.images || [];
|
||||
const customImages = model.civitai?.customImages || [];
|
||||
// Combine images - regular images first, then custom images
|
||||
const allImages = [...regularImages, ...customImages];
|
||||
loadExampleImages(allImages, model.sha256);
|
||||
}
|
||||
|
||||
function renderLoraSpecificContent(lora, escapedWords) {
|
||||
return `
|
||||
<div class="info-item usage-tips">
|
||||
<label>Usage Tips</label>
|
||||
<div class="editable-field">
|
||||
<div class="preset-controls">
|
||||
<select id="preset-selector">
|
||||
<option value="">Add preset parameter...</option>
|
||||
<option value="strength_min">Strength Min</option>
|
||||
<option value="strength_max">Strength Max</option>
|
||||
<option value="strength">Strength</option>
|
||||
<option value="clip_skip">Clip Skip</option>
|
||||
</select>
|
||||
<input type="number" id="preset-value" step="0.01" placeholder="Value" style="display:none;">
|
||||
<button class="add-preset-btn">Add</button>
|
||||
</div>
|
||||
<div class="preset-tags">
|
||||
${renderPresetTags(parsePresets(lora.usage_tips))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${renderTriggerWords(escapedWords, lora.file_path)}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event handlers using event delegation for LoRA modal
|
||||
* @param {string} filePath - Path to the model file
|
||||
*/
|
||||
function setupEventHandlers(filePath) {
|
||||
const modalElement = document.getElementById('modelModal');
|
||||
|
||||
// Remove existing event listeners first
|
||||
modalElement.removeEventListener('click', handleModalClick);
|
||||
|
||||
// Create and store the handler function
|
||||
function handleModalClick(event) {
|
||||
const target = event.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
const action = target.dataset.action;
|
||||
|
||||
switch (action) {
|
||||
case 'close-modal':
|
||||
modalManager.closeModal('modelModal');
|
||||
break;
|
||||
case 'scroll-to-top':
|
||||
scrollToTop(target);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the event listener with the named function
|
||||
modalElement.addEventListener('click', handleModalClick);
|
||||
|
||||
// Store reference to the handler on the element for potential cleanup
|
||||
modalElement._clickHandler = handleModalClick;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up editable fields in the model modal
|
||||
* @param {string} filePath - The full file path of the model
|
||||
* @param {string} modelType - Type of model ('lora' or 'checkpoint')
|
||||
*/
|
||||
function setupEditableFields(filePath, modelType) {
|
||||
const editableFields = document.querySelectorAll('.editable-field [contenteditable]');
|
||||
|
||||
editableFields.forEach(field => {
|
||||
field.addEventListener('focus', function() {
|
||||
if (this.textContent === 'Add your notes here...') {
|
||||
this.textContent = '';
|
||||
}
|
||||
});
|
||||
|
||||
field.addEventListener('blur', function() {
|
||||
if (this.textContent.trim() === '') {
|
||||
if (this.classList.contains('notes-content')) {
|
||||
this.textContent = 'Add your notes here...';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add keydown event listeners for notes
|
||||
const notesContent = document.querySelector('.notes-content');
|
||||
if (notesContent) {
|
||||
notesContent.addEventListener('keydown', async function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.shiftKey) {
|
||||
// Allow shift+enter for new line
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
await saveNotes(filePath, modelType);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// LoRA specific field setup
|
||||
if (modelType === 'lora') {
|
||||
setupLoraSpecificFields(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
function setupLoraSpecificFields(filePath) {
|
||||
const presetSelector = document.getElementById('preset-selector');
|
||||
const presetValue = document.getElementById('preset-value');
|
||||
const addPresetBtn = document.querySelector('.add-preset-btn');
|
||||
const presetTags = document.querySelector('.preset-tags');
|
||||
|
||||
if (!presetSelector || !presetValue || !addPresetBtn || !presetTags) return;
|
||||
|
||||
presetSelector.addEventListener('change', function() {
|
||||
const selected = this.value;
|
||||
if (selected) {
|
||||
presetValue.style.display = 'inline-block';
|
||||
presetValue.min = selected.includes('strength') ? -10 : 0;
|
||||
presetValue.max = selected.includes('strength') ? 10 : 10;
|
||||
presetValue.step = 0.5;
|
||||
if (selected === 'clip_skip') {
|
||||
presetValue.type = 'number';
|
||||
presetValue.step = 1;
|
||||
}
|
||||
// Add auto-focus
|
||||
setTimeout(() => presetValue.focus(), 0);
|
||||
} else {
|
||||
presetValue.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
addPresetBtn.addEventListener('click', async function() {
|
||||
const key = presetSelector.value;
|
||||
const value = presetValue.value;
|
||||
|
||||
if (!key || !value) return;
|
||||
|
||||
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
const currentPresets = parsePresets(loraCard?.dataset.usage_tips);
|
||||
|
||||
currentPresets[key] = parseFloat(value);
|
||||
const newPresetsJson = JSON.stringify(currentPresets);
|
||||
|
||||
await saveLoraMetadata(filePath, {
|
||||
usage_tips: newPresetsJson
|
||||
});
|
||||
|
||||
presetTags.innerHTML = renderPresetTags(currentPresets);
|
||||
|
||||
presetSelector.value = '';
|
||||
presetValue.value = '';
|
||||
presetValue.style.display = 'none';
|
||||
});
|
||||
|
||||
// Add keydown event for preset value
|
||||
presetValue.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addPresetBtn.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save model notes
|
||||
* @param {string} filePath - Path to the model file
|
||||
* @param {string} modelType - Type of model ('lora' or 'checkpoint')
|
||||
*/
|
||||
async function saveNotes(filePath, modelType) {
|
||||
const content = document.querySelector('.notes-content').textContent;
|
||||
try {
|
||||
const saveFunction = modelType === 'lora' ? saveLoraMetadata : saveCheckpointMetadata;
|
||||
await saveFunction(filePath, { notes: content });
|
||||
|
||||
showToast('Notes saved successfully', 'success');
|
||||
} catch (error) {
|
||||
showToast('Failed to save notes', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Export the model modal API
|
||||
const modelModal = {
|
||||
show: showModelModal,
|
||||
toggleShowcase,
|
||||
scrollToTop
|
||||
};
|
||||
|
||||
export { modelModal };
|
||||
|
||||
// Define global functions for use in HTML
|
||||
window.toggleShowcase = function(element) {
|
||||
toggleShowcase(element);
|
||||
};
|
||||
|
||||
window.scrollToTopModel = function(button) {
|
||||
scrollToTop(button);
|
||||
};
|
||||
|
||||
// Legacy global functions for backward compatibility
|
||||
window.scrollToTopLora = function(button) {
|
||||
scrollToTop(button);
|
||||
};
|
||||
|
||||
window.scrollToTopCheckpoint = function(button) {
|
||||
scrollToTop(button);
|
||||
};
|
||||
|
||||
window.saveModelNotes = function(filePath, modelType) {
|
||||
saveNotes(filePath, modelType);
|
||||
};
|
||||
|
||||
// Legacy functions
|
||||
window.saveLoraNotes = function(filePath) {
|
||||
saveNotes(filePath, 'lora');
|
||||
};
|
||||
|
||||
window.saveCheckpointNotes = function(filePath) {
|
||||
saveNotes(filePath, 'checkpoint');
|
||||
};
|
||||
478
static/js/components/shared/ModelTags.js
Normal file
478
static/js/components/shared/ModelTags.js
Normal file
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* ModelTags.js
|
||||
* Module for handling model tag editing functionality - 共享版本
|
||||
*/
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { saveModelMetadata as saveLoraMetadata } from '../../api/loraApi.js';
|
||||
import { saveModelMetadata as saveCheckpointMetadata } from '../../api/checkpointApi.js';
|
||||
|
||||
// Preset tag suggestions
|
||||
const PRESET_TAGS = [
|
||||
'character', 'style', 'concept', 'clothing',
|
||||
'poses', 'background', 'vehicle', 'buildings',
|
||||
'objects', 'animal'
|
||||
];
|
||||
|
||||
// Create a named function so we can remove it later
|
||||
let saveTagsHandler = null;
|
||||
|
||||
/**
|
||||
* Set up tag editing mode
|
||||
*/
|
||||
export function setupTagEditMode() {
|
||||
const editBtn = document.querySelector('.edit-tags-btn');
|
||||
if (!editBtn) return;
|
||||
|
||||
// Store original tags for restoring on cancel
|
||||
let originalTags = [];
|
||||
|
||||
// Remove any previously attached click handler
|
||||
if (editBtn._hasClickHandler) {
|
||||
editBtn.removeEventListener('click', editBtn._clickHandler);
|
||||
}
|
||||
|
||||
// Create new handler and store reference
|
||||
const editBtnClickHandler = function() {
|
||||
const tagsSection = document.querySelector('.model-tags-container');
|
||||
const isEditMode = tagsSection.classList.toggle('edit-mode');
|
||||
const filePath = this.dataset.filePath;
|
||||
|
||||
// Toggle edit mode UI elements
|
||||
const compactTagsDisplay = tagsSection.querySelector('.model-tags-compact');
|
||||
const tagsEditContainer = tagsSection.querySelector('.metadata-edit-container');
|
||||
|
||||
if (isEditMode) {
|
||||
// Enter edit mode
|
||||
this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon
|
||||
this.title = "Cancel editing";
|
||||
|
||||
// Get all tags from tooltip, not just the visible ones in compact display
|
||||
originalTags = Array.from(
|
||||
tagsSection.querySelectorAll('.tooltip-tag')
|
||||
).map(tag => tag.textContent);
|
||||
|
||||
// Hide compact display, show edit container
|
||||
compactTagsDisplay.style.display = 'none';
|
||||
|
||||
// If edit container doesn't exist yet, create it
|
||||
if (!tagsEditContainer) {
|
||||
const editContainer = document.createElement('div');
|
||||
editContainer.className = 'metadata-edit-container';
|
||||
|
||||
// Move the edit button inside the container header for better visibility
|
||||
const editBtnClone = editBtn.cloneNode(true);
|
||||
editBtnClone.classList.add('metadata-header-btn');
|
||||
|
||||
// Create edit UI with edit button in the header
|
||||
editContainer.innerHTML = createTagEditUI(originalTags, editBtnClone.outerHTML);
|
||||
tagsSection.appendChild(editContainer);
|
||||
|
||||
// Setup the tag input field behavior
|
||||
setupTagInput();
|
||||
|
||||
// Create and add preset suggestions dropdown
|
||||
const tagForm = editContainer.querySelector('.metadata-add-form');
|
||||
const suggestionsDropdown = createSuggestionsDropdown(originalTags);
|
||||
tagForm.appendChild(suggestionsDropdown);
|
||||
|
||||
// Setup delete buttons for existing tags
|
||||
setupDeleteButtons();
|
||||
|
||||
// Transfer click event from original button to the cloned one
|
||||
const newEditBtn = editContainer.querySelector('.metadata-header-btn');
|
||||
if (newEditBtn) {
|
||||
newEditBtn.addEventListener('click', function() {
|
||||
editBtn.click();
|
||||
});
|
||||
}
|
||||
|
||||
// Hide the original button when in edit mode
|
||||
editBtn.style.display = 'none';
|
||||
} else {
|
||||
// Just show the existing edit container
|
||||
tagsEditContainer.style.display = 'block';
|
||||
editBtn.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
// Exit edit mode
|
||||
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
|
||||
this.title = "Edit tags";
|
||||
editBtn.style.display = 'block';
|
||||
|
||||
// Show compact display, hide edit container
|
||||
compactTagsDisplay.style.display = 'flex';
|
||||
if (tagsEditContainer) tagsEditContainer.style.display = 'none';
|
||||
|
||||
// Check if we're exiting edit mode due to "Save" or "Cancel"
|
||||
if (!this.dataset.skipRestore) {
|
||||
// If canceling, restore original tags
|
||||
restoreOriginalTags(tagsSection, originalTags);
|
||||
} else {
|
||||
// Reset the skip restore flag
|
||||
delete this.dataset.skipRestore;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Store the handler reference on the button itself
|
||||
editBtn._clickHandler = editBtnClickHandler;
|
||||
editBtn._hasClickHandler = true;
|
||||
editBtn.addEventListener('click', editBtnClickHandler);
|
||||
|
||||
// Clean up any previous document click handler
|
||||
if (saveTagsHandler) {
|
||||
document.removeEventListener('click', saveTagsHandler);
|
||||
}
|
||||
|
||||
// Create new save handler and store reference
|
||||
saveTagsHandler = function(e) {
|
||||
if (e.target.classList.contains('save-tags-btn') ||
|
||||
e.target.closest('.save-tags-btn')) {
|
||||
saveTags();
|
||||
}
|
||||
};
|
||||
|
||||
// Add the new handler
|
||||
document.addEventListener('click', saveTagsHandler);
|
||||
}
|
||||
|
||||
// ...existing helper functions...
|
||||
|
||||
/**
|
||||
* Save tags - 支持LoRA和Checkpoint
|
||||
*/
|
||||
async function saveTags() {
|
||||
const editBtn = document.querySelector('.edit-tags-btn');
|
||||
if (!editBtn) return;
|
||||
|
||||
const filePath = editBtn.dataset.filePath;
|
||||
const tagElements = document.querySelectorAll('.metadata-item');
|
||||
const tags = Array.from(tagElements).map(tag => tag.dataset.tag);
|
||||
|
||||
// Get original tags to compare
|
||||
const originalTagElements = document.querySelectorAll('.tooltip-tag');
|
||||
const originalTags = Array.from(originalTagElements).map(tag => tag.textContent);
|
||||
|
||||
// Check if tags have actually changed
|
||||
const tagsChanged = JSON.stringify(tags) !== JSON.stringify(originalTags);
|
||||
|
||||
if (!tagsChanged) {
|
||||
// No changes made, just exit edit mode without API call
|
||||
editBtn.dataset.skipRestore = "true";
|
||||
editBtn.click();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine model type and use appropriate save function
|
||||
const isCheckpoint = filePath.includes('.safetensors') || filePath.includes('.ckpt');
|
||||
const saveFunction = isCheckpoint ? saveCheckpointMetadata : saveLoraMetadata;
|
||||
|
||||
// Save tags metadata
|
||||
await saveFunction(filePath, { tags: tags });
|
||||
|
||||
// Set flag to skip restoring original tags when exiting edit mode
|
||||
editBtn.dataset.skipRestore = "true";
|
||||
|
||||
// Update the compact tags display
|
||||
const compactTagsContainer = document.querySelector('.model-tags-container');
|
||||
if (compactTagsContainer) {
|
||||
// Generate new compact tags HTML
|
||||
const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact');
|
||||
|
||||
if (compactTagsDisplay) {
|
||||
// Clear current tags
|
||||
compactTagsDisplay.innerHTML = '';
|
||||
|
||||
// Add visible tags (up to 5)
|
||||
const visibleTags = tags.slice(0, 5);
|
||||
visibleTags.forEach(tag => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'model-tag-compact';
|
||||
span.textContent = tag;
|
||||
compactTagsDisplay.appendChild(span);
|
||||
});
|
||||
|
||||
// Add more indicator if needed
|
||||
const remainingCount = Math.max(0, tags.length - 5);
|
||||
if (remainingCount > 0) {
|
||||
const more = document.createElement('span');
|
||||
more.className = 'model-tag-more';
|
||||
more.dataset.count = remainingCount;
|
||||
more.textContent = `+${remainingCount}`;
|
||||
compactTagsDisplay.appendChild(more);
|
||||
}
|
||||
}
|
||||
|
||||
// Update tooltip content
|
||||
const tooltipContent = compactTagsContainer.querySelector('.tooltip-content');
|
||||
if (tooltipContent) {
|
||||
tooltipContent.innerHTML = '';
|
||||
|
||||
tags.forEach(tag => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'tooltip-tag';
|
||||
span.textContent = tag;
|
||||
tooltipContent.appendChild(span);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Exit edit mode
|
||||
editBtn.click();
|
||||
|
||||
showToast('Tags updated successfully', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error saving tags:', error);
|
||||
showToast('Failed to update tags', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the tag editing UI
|
||||
* @param {Array} currentTags - Current tags
|
||||
* @param {string} editBtnHTML - HTML for the edit button to include in header
|
||||
* @returns {string} HTML markup for tag editing UI
|
||||
*/
|
||||
function createTagEditUI(currentTags, editBtnHTML = '') {
|
||||
return `
|
||||
<div class="metadata-edit-content">
|
||||
<div class="metadata-edit-header">
|
||||
<label>Edit Tags</label>
|
||||
${editBtnHTML}
|
||||
</div>
|
||||
<div class="metadata-items">
|
||||
${currentTags.map(tag => `
|
||||
<div class="metadata-item" data-tag="${tag}">
|
||||
<span class="metadata-item-content">${tag}</span>
|
||||
<button class="metadata-delete-btn">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div class="metadata-edit-controls">
|
||||
<button class="save-tags-btn" title="Save changes">
|
||||
<i class="fas fa-save"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
<div class="metadata-add-form">
|
||||
<input type="text" class="metadata-input" placeholder="Type to add or click suggestions below">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create suggestions dropdown with preset tags
|
||||
* @param {Array} existingTags - Already added tags
|
||||
* @returns {HTMLElement} - Dropdown element
|
||||
*/
|
||||
function createSuggestionsDropdown(existingTags = []) {
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'metadata-suggestions-dropdown';
|
||||
|
||||
// Create header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'metadata-suggestions-header';
|
||||
header.innerHTML = `
|
||||
<span>Suggested Tags</span>
|
||||
<small>Click to add</small>
|
||||
`;
|
||||
dropdown.appendChild(header);
|
||||
|
||||
// Create tag container
|
||||
const container = document.createElement('div');
|
||||
container.className = 'metadata-suggestions-container';
|
||||
|
||||
// Add each preset tag as a suggestion
|
||||
PRESET_TAGS.forEach(tag => {
|
||||
const isAdded = existingTags.includes(tag);
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`;
|
||||
item.title = tag;
|
||||
item.innerHTML = `
|
||||
<span class="metadata-suggestion-text">${tag}</span>
|
||||
${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
|
||||
`;
|
||||
|
||||
if (!isAdded) {
|
||||
item.addEventListener('click', () => {
|
||||
addNewTag(tag);
|
||||
|
||||
// Also populate the input field for potential editing
|
||||
const input = document.querySelector('.metadata-input');
|
||||
if (input) input.value = tag;
|
||||
|
||||
// Focus on the input
|
||||
if (input) input.focus();
|
||||
|
||||
// Update dropdown without removing it
|
||||
updateSuggestionsDropdown();
|
||||
});
|
||||
}
|
||||
|
||||
container.appendChild(item);
|
||||
});
|
||||
|
||||
dropdown.appendChild(container);
|
||||
return dropdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up tag input behavior
|
||||
*/
|
||||
function setupTagInput() {
|
||||
const tagInput = document.querySelector('.metadata-input');
|
||||
|
||||
if (tagInput) {
|
||||
tagInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addNewTag(this.value);
|
||||
this.value = ''; // Clear input after adding
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up delete buttons for tags
|
||||
*/
|
||||
function setupDeleteButtons() {
|
||||
document.querySelectorAll('.metadata-delete-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const tag = this.closest('.metadata-item');
|
||||
tag.remove();
|
||||
|
||||
// Update status of items in the suggestion dropdown
|
||||
updateSuggestionsDropdown();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new tag
|
||||
* @param {string} tag - Tag to add
|
||||
*/
|
||||
function addNewTag(tag) {
|
||||
tag = tag.trim().toLowerCase();
|
||||
if (!tag) return;
|
||||
|
||||
const tagsContainer = document.querySelector('.metadata-items');
|
||||
if (!tagsContainer) return;
|
||||
|
||||
// Validation: Check length
|
||||
if (tag.length > 30) {
|
||||
showToast('Tag should not exceed 30 characters', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation: Check total number
|
||||
const currentTags = tagsContainer.querySelectorAll('.metadata-item');
|
||||
if (currentTags.length >= 30) {
|
||||
showToast('Maximum 30 tags allowed', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation: Check for duplicates
|
||||
const existingTags = Array.from(currentTags).map(tag => tag.dataset.tag);
|
||||
if (existingTags.includes(tag)) {
|
||||
showToast('This tag already exists', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new tag
|
||||
const newTag = document.createElement('div');
|
||||
newTag.className = 'metadata-item';
|
||||
newTag.dataset.tag = tag;
|
||||
newTag.innerHTML = `
|
||||
<span class="metadata-item-content">${tag}</span>
|
||||
<button class="metadata-delete-btn">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add event listener to delete button
|
||||
const deleteBtn = newTag.querySelector('.metadata-delete-btn');
|
||||
deleteBtn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
newTag.remove();
|
||||
|
||||
// Update status of items in the suggestion dropdown
|
||||
updateSuggestionsDropdown();
|
||||
});
|
||||
|
||||
tagsContainer.appendChild(newTag);
|
||||
|
||||
// Update status of items in the suggestions dropdown
|
||||
updateSuggestionsDropdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status of items in the suggestions dropdown
|
||||
*/
|
||||
function updateSuggestionsDropdown() {
|
||||
const dropdown = document.querySelector('.metadata-suggestions-dropdown');
|
||||
if (!dropdown) return;
|
||||
|
||||
// Get all current tags
|
||||
const currentTags = document.querySelectorAll('.metadata-item');
|
||||
const existingTags = Array.from(currentTags).map(tag => tag.dataset.tag);
|
||||
|
||||
// Update status of each item in dropdown
|
||||
dropdown.querySelectorAll('.metadata-suggestion-item').forEach(item => {
|
||||
const tagText = item.querySelector('.metadata-suggestion-text').textContent;
|
||||
const isAdded = existingTags.includes(tagText);
|
||||
|
||||
if (isAdded) {
|
||||
item.classList.add('already-added');
|
||||
|
||||
// Add indicator if it doesn't exist
|
||||
let indicator = item.querySelector('.added-indicator');
|
||||
if (!indicator) {
|
||||
indicator = document.createElement('span');
|
||||
indicator.className = 'added-indicator';
|
||||
indicator.innerHTML = '<i class="fas fa-check"></i>';
|
||||
item.appendChild(indicator);
|
||||
}
|
||||
|
||||
// Remove click event
|
||||
item.onclick = null;
|
||||
} else {
|
||||
// Re-enable items that are no longer in the list
|
||||
item.classList.remove('already-added');
|
||||
|
||||
// Remove indicator if it exists
|
||||
const indicator = item.querySelector('.added-indicator');
|
||||
if (indicator) indicator.remove();
|
||||
|
||||
// Restore click event if not already set
|
||||
if (!item.onclick) {
|
||||
item.onclick = () => {
|
||||
const tag = item.querySelector('.metadata-suggestion-text').textContent;
|
||||
addNewTag(tag);
|
||||
|
||||
// Also populate the input field
|
||||
const input = document.querySelector('.metadata-input');
|
||||
if (input) input.value = tag;
|
||||
|
||||
// Focus the input
|
||||
if (input) input.focus();
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore original tags when canceling edit
|
||||
* @param {HTMLElement} section - The tags section
|
||||
* @param {Array} originalTags - Original tags array
|
||||
*/
|
||||
function restoreOriginalTags(section, originalTags) {
|
||||
// Nothing to do here as we're just hiding the edit UI
|
||||
// and showing the original compact tags which weren't modified
|
||||
}
|
||||
66
static/js/components/shared/PresetTags.js
Normal file
66
static/js/components/shared/PresetTags.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* PresetTags.js
|
||||
* Handles LoRA model preset parameter tags - Shared version
|
||||
*/
|
||||
import { saveModelMetadata } from '../../api/loraApi.js';
|
||||
|
||||
/**
|
||||
* Parse preset parameters
|
||||
* @param {string} usageTips - JSON string containing preset parameters
|
||||
* @returns {Object} Parsed preset parameters object
|
||||
*/
|
||||
export function parsePresets(usageTips) {
|
||||
if (!usageTips) return {};
|
||||
try {
|
||||
return JSON.parse(usageTips);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render preset tags
|
||||
* @param {Object} presets - Preset parameters object
|
||||
* @returns {string} HTML content
|
||||
*/
|
||||
export function renderPresetTags(presets) {
|
||||
return Object.entries(presets).map(([key, value]) => `
|
||||
<div class="preset-tag" data-key="${key}">
|
||||
<span>${formatPresetKey(key)}: ${value}</span>
|
||||
<i class="fas fa-times" onclick="removePreset('${key}')"></i>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format preset key name
|
||||
* @param {string} key - Preset key name
|
||||
* @returns {string} Formatted key name
|
||||
*/
|
||||
function formatPresetKey(key) {
|
||||
return key.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove preset parameter
|
||||
* @param {string} key - Preset key name to remove
|
||||
*/
|
||||
window.removePreset = async function(key) {
|
||||
const filePath = document.querySelector('#modelModal .modal-content')
|
||||
.querySelector('.file-path').textContent +
|
||||
document.querySelector('#modelModal .modal-content')
|
||||
.querySelector('#file-name').textContent + '.safetensors';
|
||||
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
const currentPresets = parsePresets(loraCard.dataset.usage_tips);
|
||||
|
||||
delete currentPresets[key];
|
||||
const newPresetsJson = JSON.stringify(currentPresets);
|
||||
|
||||
await saveModelMetadata(filePath, {
|
||||
usage_tips: newPresetsJson
|
||||
});
|
||||
|
||||
document.querySelector('.preset-tags').innerHTML = renderPresetTags(currentPresets);
|
||||
};
|
||||
229
static/js/components/shared/RecipeTab.js
Normal file
229
static/js/components/shared/RecipeTab.js
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* RecipeTab - Handles the recipes tab in model modals (LoRA specific functionality)
|
||||
* Moved to shared directory for consistency
|
||||
*/
|
||||
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
||||
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||
|
||||
/**
|
||||
* Loads recipes that use the specified Lora and renders them in the tab
|
||||
* @param {string} loraName - The display name of the Lora
|
||||
* @param {string} sha256 - The SHA256 hash of the Lora
|
||||
*/
|
||||
export function loadRecipesForLora(loraName, sha256) {
|
||||
const recipeTab = document.getElementById('recipes-tab');
|
||||
if (!recipeTab) return;
|
||||
|
||||
// Show loading state
|
||||
recipeTab.innerHTML = `
|
||||
<div class="recipes-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading recipes...
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Fetch recipes that use this Lora by hash
|
||||
fetch(`/api/recipes/for-lora?hash=${encodeURIComponent(sha256.toLowerCase())}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to load recipes');
|
||||
}
|
||||
|
||||
renderRecipes(recipeTab, data.recipes, loraName, sha256);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading recipes for Lora:', error);
|
||||
recipeTab.innerHTML = `
|
||||
<div class="recipes-error">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<p>Failed to load recipes. Please try again later.</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the recipe cards in the tab
|
||||
* @param {HTMLElement} tabElement - The tab element to render into
|
||||
* @param {Array} recipes - Array of recipe objects
|
||||
* @param {string} loraName - The display name of the Lora
|
||||
* @param {string} loraHash - The hash of the Lora
|
||||
*/
|
||||
function renderRecipes(tabElement, recipes, loraName, loraHash) {
|
||||
if (!recipes || recipes.length === 0) {
|
||||
tabElement.innerHTML = `
|
||||
<div class="recipes-empty">
|
||||
<i class="fas fa-book-open"></i>
|
||||
<p>No recipes found that use this Lora.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Create header with count and view all button
|
||||
const headerElement = document.createElement('div');
|
||||
headerElement.className = 'recipes-header';
|
||||
headerElement.innerHTML = `
|
||||
<h3>Found ${recipes.length} recipe${recipes.length > 1 ? 's' : ''} using this Lora</h3>
|
||||
<button class="view-all-btn" title="View all in Recipes page">
|
||||
<i class="fas fa-external-link-alt"></i> View All in Recipes
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add click handler for "View All" button
|
||||
headerElement.querySelector('.view-all-btn').addEventListener('click', () => {
|
||||
navigateToRecipesPage(loraName, loraHash);
|
||||
});
|
||||
|
||||
// Create grid container for recipe cards
|
||||
const cardGrid = document.createElement('div');
|
||||
cardGrid.className = 'card-grid';
|
||||
|
||||
// Create recipe cards matching the structure in recipes.html
|
||||
recipes.forEach(recipe => {
|
||||
// Get basic info
|
||||
const baseModel = recipe.base_model || '';
|
||||
const loras = recipe.loras || [];
|
||||
const lorasCount = loras.length;
|
||||
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
|
||||
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
|
||||
|
||||
// Ensure file_url exists, fallback to file_path if needed
|
||||
const imageUrl = recipe.file_url ||
|
||||
(recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` :
|
||||
'/loras_static/images/no-preview.png');
|
||||
|
||||
// Create card element matching the structure in recipes.html
|
||||
const card = document.createElement('div');
|
||||
card.className = 'lora-card';
|
||||
card.dataset.filePath = recipe.file_path || '';
|
||||
card.dataset.title = recipe.title || '';
|
||||
card.dataset.created = recipe.created_date || '';
|
||||
card.dataset.id = recipe.id || '';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-preview">
|
||||
<img src="${imageUrl}" alt="${recipe.title}" loading="lazy">
|
||||
<div class="card-header">
|
||||
${baseModel ? `<span class="base-model-label" title="${baseModel}">${baseModel}</span>` : ''}
|
||||
<div class="card-actions">
|
||||
<i class="fas fa-copy" title="Copy Recipe Syntax"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="model-info">
|
||||
<span class="model-name">${recipe.title}</span>
|
||||
</div>
|
||||
<div class="lora-count ${allLorasAvailable ? 'ready' : (lorasCount > 0 ? 'missing' : '')}"
|
||||
title="${getLoraStatusTitle(lorasCount, missingLorasCount)}">
|
||||
<i class="fas fa-layer-group"></i> ${lorasCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add event listeners for action buttons
|
||||
card.querySelector('.fa-copy').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
copyRecipeSyntax(recipe.id);
|
||||
});
|
||||
|
||||
// Add click handler for the entire card
|
||||
card.addEventListener('click', () => {
|
||||
navigateToRecipeDetails(recipe.id);
|
||||
});
|
||||
|
||||
// Add card to grid
|
||||
cardGrid.appendChild(card);
|
||||
});
|
||||
|
||||
// Clear loading indicator and append content
|
||||
tabElement.innerHTML = '';
|
||||
tabElement.appendChild(headerElement);
|
||||
tabElement.appendChild(cardGrid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a descriptive title for the LoRA status indicator
|
||||
* @param {number} totalCount - Total number of LoRAs in recipe
|
||||
* @param {number} missingCount - Number of missing LoRAs
|
||||
* @returns {string} Status title text
|
||||
*/
|
||||
function getLoraStatusTitle(totalCount, missingCount) {
|
||||
if (totalCount === 0) return "No LoRAs in this recipe";
|
||||
if (missingCount === 0) return "All LoRAs available - Ready to use";
|
||||
return `${missingCount} of ${totalCount} LoRAs missing`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies recipe syntax to clipboard
|
||||
* @param {string} recipeId - The recipe ID
|
||||
*/
|
||||
function copyRecipeSyntax(recipeId) {
|
||||
if (!recipeId) {
|
||||
showToast('Cannot copy recipe syntax: Missing recipe ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/recipe/${recipeId}/syntax`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.syntax) {
|
||||
return copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard');
|
||||
} else {
|
||||
throw new Error(data.error || 'No syntax returned');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
showToast('Failed to copy recipe syntax', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to the recipes page with filter for the current Lora
|
||||
* @param {string} loraName - The Lora display name to filter by
|
||||
* @param {string} loraHash - The hash of the Lora to filter by
|
||||
* @param {boolean} createNew - Whether to open the create recipe dialog
|
||||
*/
|
||||
function navigateToRecipesPage(loraName, loraHash) {
|
||||
// Close the current modal
|
||||
if (window.modalManager) {
|
||||
modalManager.closeModal('modelModal');
|
||||
}
|
||||
|
||||
// Clear any previous filters first
|
||||
removeSessionItem('lora_to_recipe_filterLoraName');
|
||||
removeSessionItem('lora_to_recipe_filterLoraHash');
|
||||
removeSessionItem('viewRecipeId');
|
||||
|
||||
// Store the LoRA name and hash filter in sessionStorage
|
||||
setSessionItem('lora_to_recipe_filterLoraName', loraName);
|
||||
setSessionItem('lora_to_recipe_filterLoraHash', loraHash);
|
||||
|
||||
// Directly navigate to recipes page
|
||||
window.location.href = '/loras/recipes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates directly to a specific recipe's details
|
||||
* @param {string} recipeId - The recipe ID to view
|
||||
*/
|
||||
function navigateToRecipeDetails(recipeId) {
|
||||
// Close the current modal
|
||||
if (window.modalManager) {
|
||||
modalManager.closeModal('modelModal');
|
||||
}
|
||||
|
||||
// Clear any previous filters first
|
||||
removeSessionItem('filterLoraName');
|
||||
removeSessionItem('filterLoraHash');
|
||||
removeSessionItem('viewRecipeId');
|
||||
|
||||
// Store the recipe ID in sessionStorage to load on recipes page
|
||||
setSessionItem('viewRecipeId', recipeId);
|
||||
|
||||
// Directly navigate to recipes page
|
||||
window.location.href = '/loras/recipes';
|
||||
}
|
||||
649
static/js/components/shared/TriggerWords.js
Normal file
649
static/js/components/shared/TriggerWords.js
Normal file
@@ -0,0 +1,649 @@
|
||||
/**
|
||||
* TriggerWords.js
|
||||
* Module that handles trigger word functionality for LoRA models
|
||||
* Moved to shared directory for consistency
|
||||
*/
|
||||
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
||||
import { saveModelMetadata } from '../../api/loraApi.js';
|
||||
|
||||
/**
|
||||
* Fetch trained words for a model
|
||||
* @param {string} filePath - Path to the model file
|
||||
* @returns {Promise<Object>} - Object with trained words and class tokens
|
||||
*/
|
||||
async function fetchTrainedWords(filePath) {
|
||||
try {
|
||||
const response = await fetch(`/api/trained-words?file_path=${encodeURIComponent(filePath)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
return {
|
||||
trainedWords: data.trained_words || [], // Returns array of [word, frequency] pairs
|
||||
classTokens: data.class_tokens // Can be null or a string
|
||||
};
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to fetch trained words');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching trained words:', error);
|
||||
showToast('Could not load trained words', 'error');
|
||||
return { trainedWords: [], classTokens: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create suggestion dropdown with trained words as tags
|
||||
* @param {Array} trainedWords - Array of [word, frequency] pairs
|
||||
* @param {string|null} classTokens - Class tokens from training
|
||||
* @param {Array} existingWords - Already added trigger words
|
||||
* @returns {HTMLElement} - Dropdown element
|
||||
*/
|
||||
function createSuggestionDropdown(trainedWords, classTokens, existingWords = []) {
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'metadata-suggestions-dropdown';
|
||||
|
||||
// Create header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'metadata-suggestions-header';
|
||||
|
||||
// No suggestions case
|
||||
if ((!trainedWords || trainedWords.length === 0) && !classTokens) {
|
||||
header.innerHTML = '<span>No suggestions available</span>';
|
||||
dropdown.appendChild(header);
|
||||
dropdown.innerHTML += '<div class="no-suggestions">No trained words or class tokens found in this model. You can manually enter trigger words.</div>';
|
||||
return dropdown;
|
||||
}
|
||||
|
||||
// Sort trained words by frequency (highest first) if available
|
||||
if (trainedWords && trainedWords.length > 0) {
|
||||
trainedWords.sort((a, b) => b[1] - a[1]);
|
||||
}
|
||||
|
||||
// Add class tokens section if available
|
||||
if (classTokens) {
|
||||
// Add class tokens header
|
||||
const classTokensHeader = document.createElement('div');
|
||||
classTokensHeader.className = 'metadata-suggestions-header';
|
||||
classTokensHeader.innerHTML = `
|
||||
<span>Class Token</span>
|
||||
<small>Add to your prompt for best results</small>
|
||||
`;
|
||||
dropdown.appendChild(classTokensHeader);
|
||||
|
||||
// Add class tokens container
|
||||
const classTokensContainer = document.createElement('div');
|
||||
classTokensContainer.className = 'class-tokens-container';
|
||||
|
||||
// Create a special item for the class token
|
||||
const tokenItem = document.createElement('div');
|
||||
tokenItem.className = `metadata-suggestion-item class-token-item ${existingWords.includes(classTokens) ? 'already-added' : ''}`;
|
||||
tokenItem.title = `Class token: ${classTokens}`;
|
||||
tokenItem.innerHTML = `
|
||||
<span class="metadata-suggestion-text">${classTokens}</span>
|
||||
<div class="metadata-suggestion-meta">
|
||||
<span class="token-badge">Class Token</span>
|
||||
${existingWords.includes(classTokens) ?
|
||||
'<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add click handler if not already added
|
||||
if (!existingWords.includes(classTokens)) {
|
||||
tokenItem.addEventListener('click', () => {
|
||||
// Automatically add this word
|
||||
addNewTriggerWord(classTokens);
|
||||
|
||||
// Also populate the input field for potential editing
|
||||
const input = document.querySelector('.metadata-input');
|
||||
if (input) input.value = classTokens;
|
||||
|
||||
// Focus on the input
|
||||
if (input) input.focus();
|
||||
|
||||
// Update dropdown without removing it
|
||||
updateTrainedWordsDropdown();
|
||||
});
|
||||
}
|
||||
|
||||
classTokensContainer.appendChild(tokenItem);
|
||||
dropdown.appendChild(classTokensContainer);
|
||||
|
||||
// Add separator if we also have trained words
|
||||
if (trainedWords && trainedWords.length > 0) {
|
||||
const separator = document.createElement('div');
|
||||
separator.className = 'dropdown-separator';
|
||||
dropdown.appendChild(separator);
|
||||
}
|
||||
}
|
||||
|
||||
// Add trained words header if we have any
|
||||
if (trainedWords && trainedWords.length > 0) {
|
||||
header.innerHTML = `
|
||||
<span>Word Suggestions</span>
|
||||
<small>${trainedWords.length} words found</small>
|
||||
`;
|
||||
dropdown.appendChild(header);
|
||||
|
||||
// Create tag container for trained words
|
||||
const container = document.createElement('div');
|
||||
container.className = 'metadata-suggestions-container';
|
||||
|
||||
// Add each trained word as a tag
|
||||
trainedWords.forEach(([word, frequency]) => {
|
||||
const isAdded = existingWords.includes(word);
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`;
|
||||
item.title = word; // Show full word on hover if truncated
|
||||
item.innerHTML = `
|
||||
<span class="metadata-suggestion-text">${word}</span>
|
||||
<div class="metadata-suggestion-meta">
|
||||
<span class="trained-word-freq">${frequency}</span>
|
||||
${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!isAdded) {
|
||||
item.addEventListener('click', () => {
|
||||
// Automatically add this word
|
||||
addNewTriggerWord(word);
|
||||
|
||||
// Also populate the input field for potential editing
|
||||
const input = document.querySelector('.metadata-input');
|
||||
if (input) input.value = word;
|
||||
|
||||
// Focus on the input
|
||||
if (input) input.focus();
|
||||
|
||||
// Update dropdown without removing it
|
||||
updateTrainedWordsDropdown();
|
||||
});
|
||||
}
|
||||
|
||||
container.appendChild(item);
|
||||
});
|
||||
|
||||
dropdown.appendChild(container);
|
||||
} else if (!classTokens) {
|
||||
// If we have neither class tokens nor trained words
|
||||
dropdown.innerHTML += '<div class="no-suggestions">No word suggestions found in this model. You can manually enter trigger words.</div>';
|
||||
}
|
||||
|
||||
return dropdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render trigger words
|
||||
* @param {Array} words - Array of trigger words
|
||||
* @param {string} filePath - File path
|
||||
* @returns {string} HTML content
|
||||
*/
|
||||
export function renderTriggerWords(words, filePath) {
|
||||
if (!words.length) return `
|
||||
<div class="info-item full-width trigger-words">
|
||||
<div class="trigger-words-header">
|
||||
<label>Trigger Words</label>
|
||||
<button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${filePath}" title="Edit trigger words">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="trigger-words-content">
|
||||
<span class="no-trigger-words">No trigger word needed</span>
|
||||
<div class="trigger-words-tags" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="metadata-edit-controls" style="display:none;">
|
||||
<button class="metadata-save-btn" title="Save changes">
|
||||
<i class="fas fa-save"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
<div class="metadata-add-form" style="display:none;">
|
||||
<input type="text" class="metadata-input" placeholder="Type to add or click suggestions below">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return `
|
||||
<div class="info-item full-width trigger-words">
|
||||
<div class="trigger-words-header">
|
||||
<label>Trigger Words</label>
|
||||
<button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${filePath}" title="Edit trigger words">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="trigger-words-content">
|
||||
<div class="trigger-words-tags">
|
||||
${words.map(word => `
|
||||
<div class="trigger-word-tag" data-word="${word}" onclick="copyTriggerWord('${word}')">
|
||||
<span class="trigger-word-content">${word}</span>
|
||||
<span class="trigger-word-copy">
|
||||
<i class="fas fa-copy"></i>
|
||||
</span>
|
||||
<button class="metadata-delete-btn" style="display:none;" onclick="event.stopPropagation();">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metadata-edit-controls" style="display:none;">
|
||||
<button class="metadata-save-btn" title="Save changes">
|
||||
<i class="fas fa-save"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
<div class="metadata-add-form" style="display:none;">
|
||||
<input type="text" class="metadata-input" placeholder="Type to add or click suggestions below">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up trigger words edit mode
|
||||
*/
|
||||
export function setupTriggerWordsEditMode() {
|
||||
// Store trained words data
|
||||
let trainedWordsList = [];
|
||||
let classTokensValue = null;
|
||||
let isTrainedWordsLoaded = false;
|
||||
// Store original trigger words for restoring on cancel
|
||||
let originalTriggerWords = [];
|
||||
|
||||
const editBtn = document.querySelector('.edit-trigger-words-btn');
|
||||
if (!editBtn) return;
|
||||
|
||||
editBtn.addEventListener('click', async function() {
|
||||
const triggerWordsSection = this.closest('.trigger-words');
|
||||
const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
|
||||
const filePath = this.dataset.filePath;
|
||||
|
||||
// Toggle edit mode UI elements
|
||||
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
|
||||
const editControls = triggerWordsSection.querySelector('.metadata-edit-controls');
|
||||
const addForm = triggerWordsSection.querySelector('.metadata-add-form');
|
||||
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
|
||||
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
|
||||
|
||||
if (isEditMode) {
|
||||
this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon
|
||||
this.title = "Cancel editing";
|
||||
|
||||
// Store original trigger words for potential restoration
|
||||
originalTriggerWords = Array.from(triggerWordTags).map(tag => tag.dataset.word);
|
||||
|
||||
// Show edit controls and input form
|
||||
editControls.style.display = 'flex';
|
||||
addForm.style.display = 'flex';
|
||||
|
||||
// If we have no trigger words yet, hide the "No trigger word needed" text
|
||||
// and show the empty tags container
|
||||
if (noTriggerWords) {
|
||||
noTriggerWords.style.display = 'none';
|
||||
if (tagsContainer) tagsContainer.style.display = 'flex';
|
||||
}
|
||||
|
||||
// Disable click-to-copy and show delete buttons
|
||||
triggerWordTags.forEach(tag => {
|
||||
tag.onclick = null;
|
||||
const copyIcon = tag.querySelector('.trigger-word-copy');
|
||||
const deleteBtn = tag.querySelector('.metadata-delete-btn');
|
||||
|
||||
if (copyIcon) copyIcon.style.display = 'none';
|
||||
if (deleteBtn) {
|
||||
deleteBtn.style.display = 'block';
|
||||
|
||||
// Re-attach event listener to ensure it works every time
|
||||
// First remove any existing listeners to avoid duplication
|
||||
deleteBtn.removeEventListener('click', deleteTriggerWord);
|
||||
deleteBtn.addEventListener('click', deleteTriggerWord);
|
||||
}
|
||||
});
|
||||
|
||||
// Load trained words and display dropdown when entering edit mode
|
||||
// Add loading indicator
|
||||
const loadingIndicator = document.createElement('div');
|
||||
loadingIndicator.className = 'metadata-loading';
|
||||
loadingIndicator.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading suggestions...';
|
||||
addForm.appendChild(loadingIndicator);
|
||||
|
||||
// Get currently added trigger words
|
||||
const currentTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
|
||||
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
|
||||
|
||||
// Asynchronously load trained words if not already loaded
|
||||
if (!isTrainedWordsLoaded) {
|
||||
const result = await fetchTrainedWords(filePath);
|
||||
trainedWordsList = result.trainedWords;
|
||||
classTokensValue = result.classTokens;
|
||||
isTrainedWordsLoaded = true;
|
||||
}
|
||||
|
||||
// Remove loading indicator
|
||||
loadingIndicator.remove();
|
||||
|
||||
// Create and display suggestion dropdown
|
||||
const dropdown = createSuggestionDropdown(trainedWordsList, classTokensValue, existingWords);
|
||||
addForm.appendChild(dropdown);
|
||||
|
||||
// Focus the input
|
||||
addForm.querySelector('input').focus();
|
||||
|
||||
} else {
|
||||
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
|
||||
this.title = "Edit trigger words";
|
||||
|
||||
// Hide edit controls and input form
|
||||
editControls.style.display = 'none';
|
||||
addForm.style.display = 'none';
|
||||
|
||||
// Check if we're exiting edit mode due to "Save" or "Cancel"
|
||||
if (!this.dataset.skipRestore) {
|
||||
// If canceling, restore original trigger words
|
||||
restoreOriginalTriggerWords(triggerWordsSection, originalTriggerWords);
|
||||
} else {
|
||||
// If saving, reset UI state on current trigger words
|
||||
resetTriggerWordsUIState(triggerWordsSection);
|
||||
// Reset the skip restore flag
|
||||
delete this.dataset.skipRestore;
|
||||
}
|
||||
|
||||
// If we have no trigger words, show the "No trigger word needed" text
|
||||
// and hide the empty tags container
|
||||
const currentTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
|
||||
if (noTriggerWords && currentTags.length === 0) {
|
||||
noTriggerWords.style.display = '';
|
||||
if (tagsContainer) tagsContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Remove dropdown if present
|
||||
const dropdown = triggerWordsSection.querySelector('.metadata-suggestions-dropdown');
|
||||
if (dropdown) dropdown.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Set up input for adding trigger words
|
||||
const triggerWordInput = document.querySelector('.metadata-input');
|
||||
|
||||
if (triggerWordInput) {
|
||||
// Add keydown event to input
|
||||
triggerWordInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addNewTriggerWord(this.value);
|
||||
this.value = ''; // Clear input after adding
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Set up save button
|
||||
const saveBtn = document.querySelector('.metadata-save-btn');
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', saveTriggerWords);
|
||||
}
|
||||
|
||||
// Set up delete buttons
|
||||
document.querySelectorAll('.metadata-delete-btn').forEach(btn => {
|
||||
// Remove any existing listeners to avoid duplication
|
||||
btn.removeEventListener('click', deleteTriggerWord);
|
||||
btn.addEventListener('click', deleteTriggerWord);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete trigger word event handler
|
||||
* @param {Event} e - Click event
|
||||
*/
|
||||
function deleteTriggerWord(e) {
|
||||
e.stopPropagation();
|
||||
const tag = this.closest('.trigger-word-tag');
|
||||
tag.remove();
|
||||
|
||||
// Update status of items in the trained words dropdown
|
||||
updateTrainedWordsDropdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset UI state for trigger words after saving
|
||||
* @param {HTMLElement} section - The trigger words section
|
||||
*/
|
||||
function resetTriggerWordsUIState(section) {
|
||||
const triggerWordTags = section.querySelectorAll('.trigger-word-tag');
|
||||
|
||||
triggerWordTags.forEach(tag => {
|
||||
const word = tag.dataset.word;
|
||||
const copyIcon = tag.querySelector('.trigger-word-copy');
|
||||
const deleteBtn = tag.querySelector('.metadata-delete-btn');
|
||||
|
||||
// Restore click-to-copy functionality
|
||||
tag.onclick = () => copyTriggerWord(word);
|
||||
|
||||
// Show copy icon, hide delete button
|
||||
if (copyIcon) copyIcon.style.display = '';
|
||||
if (deleteBtn) deleteBtn.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore original trigger words when canceling edit
|
||||
* @param {HTMLElement} section - The trigger words section
|
||||
* @param {Array} originalWords - Original trigger words
|
||||
*/
|
||||
function restoreOriginalTriggerWords(section, originalWords) {
|
||||
const tagsContainer = section.querySelector('.trigger-words-tags');
|
||||
const noTriggerWords = section.querySelector('.no-trigger-words');
|
||||
|
||||
if (!tagsContainer) return;
|
||||
|
||||
// Clear current tags
|
||||
tagsContainer.innerHTML = '';
|
||||
|
||||
if (originalWords.length === 0) {
|
||||
if (noTriggerWords) noTriggerWords.style.display = '';
|
||||
tagsContainer.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide "no trigger words" message
|
||||
if (noTriggerWords) noTriggerWords.style.display = 'none';
|
||||
tagsContainer.style.display = 'flex';
|
||||
|
||||
// Recreate original tags
|
||||
originalWords.forEach(word => {
|
||||
const tag = document.createElement('div');
|
||||
tag.className = 'trigger-word-tag';
|
||||
tag.dataset.word = word;
|
||||
tag.onclick = () => copyTriggerWord(word);
|
||||
tag.innerHTML = `
|
||||
<span class="trigger-word-content">${word}</span>
|
||||
<span class="trigger-word-copy">
|
||||
<i class="fas fa-copy"></i>
|
||||
</span>
|
||||
<button class="metadata-delete-btn" style="display:none;" onclick="event.stopPropagation();">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
`;
|
||||
tagsContainer.appendChild(tag);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new trigger word
|
||||
* @param {string} word - Trigger word to add
|
||||
*/
|
||||
function addNewTriggerWord(word) {
|
||||
word = word.trim();
|
||||
if (!word) return;
|
||||
|
||||
const triggerWordsSection = document.querySelector('.trigger-words');
|
||||
let tagsContainer = document.querySelector('.trigger-words-tags');
|
||||
|
||||
// Ensure tags container exists and is visible
|
||||
if (tagsContainer) {
|
||||
tagsContainer.style.display = 'flex';
|
||||
} else {
|
||||
// Create tags container if it doesn't exist
|
||||
const contentDiv = triggerWordsSection.querySelector('.trigger-words-content');
|
||||
if (contentDiv) {
|
||||
tagsContainer = document.createElement('div');
|
||||
tagsContainer.className = 'trigger-words-tags';
|
||||
contentDiv.appendChild(tagsContainer);
|
||||
}
|
||||
}
|
||||
|
||||
if (!tagsContainer) return;
|
||||
|
||||
// Hide "no trigger words" message if it exists
|
||||
const noTriggerWordsMsg = triggerWordsSection.querySelector('.no-trigger-words');
|
||||
if (noTriggerWordsMsg) {
|
||||
noTriggerWordsMsg.style.display = 'none';
|
||||
}
|
||||
|
||||
// Validation: Check length
|
||||
if (word.split(/\s+/).length > 30) {
|
||||
showToast('Trigger word should not exceed 30 words', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation: Check total number
|
||||
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
|
||||
if (currentTags.length >= 30) {
|
||||
showToast('Maximum 30 trigger words allowed', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation: Check for duplicates
|
||||
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
|
||||
if (existingWords.includes(word)) {
|
||||
showToast('This trigger word already exists', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new tag
|
||||
const newTag = document.createElement('div');
|
||||
newTag.className = 'trigger-word-tag';
|
||||
newTag.dataset.word = word;
|
||||
newTag.innerHTML = `
|
||||
<span class="trigger-word-content">${word}</span>
|
||||
<span class="trigger-word-copy" style="display:none;">
|
||||
<i class="fas fa-copy"></i>
|
||||
</span>
|
||||
<button class="metadata-delete-btn" onclick="event.stopPropagation();">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add event listener to delete button
|
||||
const deleteBtn = newTag.querySelector('.metadata-delete-btn');
|
||||
deleteBtn.addEventListener('click', deleteTriggerWord);
|
||||
|
||||
tagsContainer.appendChild(newTag);
|
||||
|
||||
// Update status of items in the trained words dropdown
|
||||
updateTrainedWordsDropdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status of items in the trained words dropdown
|
||||
*/
|
||||
function updateTrainedWordsDropdown() {
|
||||
const dropdown = document.querySelector('.metadata-suggestions-dropdown');
|
||||
if (!dropdown) return;
|
||||
|
||||
// Get all current trigger words
|
||||
const currentTags = document.querySelectorAll('.trigger-word-tag');
|
||||
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
|
||||
|
||||
// Update status of each item in dropdown
|
||||
dropdown.querySelectorAll('.metadata-suggestion-item').forEach(item => {
|
||||
const wordText = item.querySelector('.metadata-suggestion-text').textContent;
|
||||
const isAdded = existingWords.includes(wordText);
|
||||
|
||||
if (isAdded) {
|
||||
item.classList.add('already-added');
|
||||
|
||||
// Add indicator if it doesn't exist
|
||||
let indicator = item.querySelector('.added-indicator');
|
||||
if (!indicator) {
|
||||
const meta = item.querySelector('.metadata-suggestion-meta');
|
||||
indicator = document.createElement('span');
|
||||
indicator.className = 'added-indicator';
|
||||
indicator.innerHTML = '<i class="fas fa-check"></i>';
|
||||
meta.appendChild(indicator);
|
||||
}
|
||||
|
||||
// Remove click event
|
||||
item.onclick = null;
|
||||
} else {
|
||||
// Re-enable items that are no longer in the list
|
||||
item.classList.remove('already-added');
|
||||
|
||||
// Remove indicator if it exists
|
||||
const indicator = item.querySelector('.added-indicator');
|
||||
if (indicator) indicator.remove();
|
||||
|
||||
// Restore click event if not already set
|
||||
if (!item.onclick) {
|
||||
item.onclick = () => {
|
||||
const word = item.querySelector('.metadata-suggestion-text').textContent;
|
||||
addNewTriggerWord(word);
|
||||
|
||||
// Also populate the input field
|
||||
const input = document.querySelector('.metadata-input');
|
||||
if (input) input.value = word;
|
||||
|
||||
// Focus the input
|
||||
if (input) input.focus();
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save trigger words
|
||||
*/
|
||||
async function saveTriggerWords() {
|
||||
const editBtn = document.querySelector('.edit-trigger-words-btn');
|
||||
const filePath = editBtn.dataset.filePath;
|
||||
const triggerWordsSection = editBtn.closest('.trigger-words');
|
||||
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
|
||||
const words = Array.from(triggerWordTags).map(tag => tag.dataset.word);
|
||||
|
||||
try {
|
||||
// Special format for updating nested civitai.trainedWords
|
||||
await saveModelMetadata(filePath, {
|
||||
civitai: { trainedWords: words }
|
||||
});
|
||||
|
||||
// Set flag to skip restoring original words when exiting edit mode
|
||||
editBtn.dataset.skipRestore = "true";
|
||||
|
||||
// Exit edit mode without restoring original trigger words
|
||||
editBtn.click();
|
||||
|
||||
// If we saved an empty array and there's a no-trigger-words element, show it
|
||||
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
|
||||
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
|
||||
if (words.length === 0 && noTriggerWords) {
|
||||
noTriggerWords.style.display = '';
|
||||
if (tagsContainer) tagsContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
showToast('Trigger words updated successfully', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error saving trigger words:', error);
|
||||
showToast('Failed to update trigger words', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a trigger word to clipboard
|
||||
* @param {string} word - Word to copy
|
||||
*/
|
||||
window.copyTriggerWord = async function(word) {
|
||||
try {
|
||||
await copyToClipboard(word, 'Trigger word copied');
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
showToast('Copy failed', 'error');
|
||||
}
|
||||
};
|
||||
80
static/js/components/shared/utils.js
Normal file
80
static/js/components/shared/utils.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* utils.js
|
||||
* Helper functions for the Model Modal component - General version
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format file size
|
||||
* @param {number} bytes - Number of bytes
|
||||
* @returns {string} Formatted file size
|
||||
*/
|
||||
export function formatFileSize(bytes) {
|
||||
if (!bytes) return 'N/A';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render compact tags
|
||||
* @param {Array} tags - Array of tags
|
||||
* @param {string} filePath - File path for the edit button
|
||||
* @returns {string} HTML content
|
||||
*/
|
||||
export function renderCompactTags(tags, filePath = '') {
|
||||
// Remove the early return and always render the container
|
||||
const tagsList = tags || [];
|
||||
|
||||
// Display up to 5 tags, with a tooltip indicator if there are more
|
||||
const visibleTags = tagsList.slice(0, 5);
|
||||
const remainingCount = Math.max(0, tagsList.length - 5);
|
||||
|
||||
return `
|
||||
<div class="model-tags-container">
|
||||
<div class="model-tags-header">
|
||||
<div class="model-tags-compact">
|
||||
${visibleTags.map(tag => `<span class="model-tag-compact">${tag}</span>`).join('')}
|
||||
${remainingCount > 0 ?
|
||||
`<span class="model-tag-more" data-count="${remainingCount}">+${remainingCount}</span>` :
|
||||
''}
|
||||
${tagsList.length === 0 ? `<span class="model-tag-empty">No tags</span>` : ''}
|
||||
</div>
|
||||
<button class="edit-tags-btn" data-file-path="${filePath}" title="Edit tags">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
${tagsList.length > 0 ?
|
||||
`<div class="model-tags-tooltip">
|
||||
<div class="tooltip-content">
|
||||
${tagsList.map(tag => `<span class="tooltip-tag">${tag}</span>`).join('')}
|
||||
</div>
|
||||
</div>` :
|
||||
''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up tag tooltip functionality
|
||||
*/
|
||||
export function setupTagTooltip() {
|
||||
const tagsContainer = document.querySelector('.model-tags-container');
|
||||
const tooltip = document.querySelector('.model-tags-tooltip');
|
||||
|
||||
if (tagsContainer && tooltip) {
|
||||
tagsContainer.addEventListener('mouseenter', () => {
|
||||
tooltip.classList.add('visible');
|
||||
});
|
||||
|
||||
tagsContainer.addEventListener('mouseleave', () => {
|
||||
tooltip.classList.remove('visible');
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user