diff --git a/py/utils/metadata_manager.py b/py/utils/metadata_manager.py index 694a4c0c..15ab4b1f 100644 --- a/py/utils/metadata_manager.py +++ b/py/utils/metadata_manager.py @@ -206,6 +206,20 @@ class MetadataManager: model_type="checkpoint", from_civitai=True ) + elif model_class.__name__ == "EmbeddingMetadata": + metadata = model_class( + file_name=base_name, + model_name=base_name, + file_path=normalize_path(file_path), + size=os.path.getsize(real_path), + modified=datetime.now().timestamp(), + sha256=sha256, + base_model="Unknown", + preview_url=normalize_path(preview_url), + tags=[], + modelDescription="", + from_civitai=True + ) else: # Default to LoraMetadata metadata = model_class( file_name=base_name, diff --git a/standalone.py b/standalone.py index 8890e05f..d55f4c31 100644 --- a/standalone.py +++ b/standalone.py @@ -279,23 +279,50 @@ class StandaloneLoraManager(LoraManager): # Record route mapping config.add_route_mapping(real_root, preview_path) added_targets.add(os.path.normpath(real_root)) + + # Add static routes for each embedding root + for idx, root in enumerate(getattr(config, "embeddings_roots", []), start=1): + if not os.path.exists(root): + logger.warning(f"Embedding root path does not exist: {root}") + continue + + preview_path = f'/embeddings_static/root{idx}/preview' + + real_root = root + for target, link in config._path_mappings.items(): + if os.path.normpath(link) == os.path.normpath(root): + real_root = target + break + + display_root = real_root.replace('\\', '/') + app.router.add_static(preview_path, real_root) + logger.info(f"Added static route {preview_path} -> {display_root}") + + config.add_route_mapping(real_root, preview_path) + added_targets.add(os.path.normpath(real_root)) # Add static routes for symlink target paths that aren't already covered link_idx = { 'lora': 1, - 'checkpoint': 1 + 'checkpoint': 1, + 'embedding': 1 } for target_path, link_path in config._path_mappings.items(): norm_target = os.path.normpath(target_path) if norm_target not in added_targets: - # Determine if this is a checkpoint or lora link based on path + # Determine if this is a checkpoint, lora, or embedding link based on path is_checkpoint = any(os.path.normpath(cp_root) in os.path.normpath(link_path) for cp_root in config.base_models_roots) is_checkpoint = is_checkpoint or any(os.path.normpath(cp_root) in norm_target for cp_root in config.base_models_roots) - + is_embedding = any(os.path.normpath(emb_root) in os.path.normpath(link_path) for emb_root in getattr(config, "embeddings_roots", [])) + is_embedding = is_embedding or any(os.path.normpath(emb_root) in norm_target for emb_root in getattr(config, "embeddings_roots", [])) + if is_checkpoint: route_path = f'/checkpoints_static/link_{link_idx["checkpoint"]}/preview' link_idx["checkpoint"] += 1 + elif is_embedding: + route_path = f'/embeddings_static/link_{link_idx["embedding"]}/preview' + link_idx["embedding"] += 1 else: route_path = f'/loras_static/link_{link_idx["lora"]}/preview' link_idx["lora"] += 1 diff --git a/static/js/components/ContextMenu/EmbeddingContextMenu.js b/static/js/components/ContextMenu/EmbeddingContextMenu.js new file mode 100644 index 00000000..10f1dec0 --- /dev/null +++ b/static/js/components/ContextMenu/EmbeddingContextMenu.js @@ -0,0 +1,68 @@ +import { BaseContextMenu } from './BaseContextMenu.js'; +import { ModelContextMenuMixin } from './ModelContextMenuMixin.js'; +import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js'; +import { showToast } from '../../utils/uiHelpers.js'; +import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js'; + +export class EmbeddingContextMenu extends BaseContextMenu { + constructor() { + super('embeddingContextMenu', '.embedding-card'); + this.nsfwSelector = document.getElementById('nsfwLevelSelector'); + this.modelType = 'embedding'; + this.resetAndReload = resetAndReload; + + // Initialize NSFW Level Selector events + if (this.nsfwSelector) { + this.initNSFWSelector(); + } + } + + // Implementation needed by the mixin + async saveModelMetadata(filePath, data) { + return getModelApiClient().saveModelMetadata(filePath, data); + } + + handleMenuAction(action) { + // First try to handle with common actions + if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) { + return; + } + + const apiClient = getModelApiClient(); + + // Otherwise handle embedding-specific actions + switch(action) { + case 'details': + // Show embedding details + this.currentCard.click(); + break; + case 'replace-preview': + // Add new action for replacing preview images + apiClient.replaceModelPreview(this.currentCard.dataset.filepath); + break; + case 'delete': + showDeleteModal(this.currentCard.dataset.filepath); + break; + case 'copyname': + // Copy embedding name + if (this.currentCard.querySelector('.fa-copy')) { + this.currentCard.querySelector('.fa-copy').click(); + } + break; + case 'refresh-metadata': + // Refresh metadata from CivitAI + apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath); + break; + case 'move': + // Move to folder (placeholder) + showToast('Move to folder feature coming soon', 'info'); + break; + case 'exclude': + showExcludeModal(this.currentCard.dataset.filepath); + break; + } + } +} + +// Mix in shared methods +Object.assign(EmbeddingContextMenu.prototype, ModelContextMenuMixin); diff --git a/static/js/components/ContextMenu/index.js b/static/js/components/ContextMenu/index.js index 6b7f165b..af539a53 100644 --- a/static/js/components/ContextMenu/index.js +++ b/static/js/components/ContextMenu/index.js @@ -1,4 +1,5 @@ export { LoraContextMenu } from './LoraContextMenu.js'; export { RecipeContextMenu } from './RecipeContextMenu.js'; export { CheckpointContextMenu } from './CheckpointContextMenu.js'; +export { EmbeddingContextMenu } from './EmbeddingContextMenu.js'; export { ModelContextMenuMixin } from './ModelContextMenuMixin.js'; \ No newline at end of file diff --git a/static/js/components/Header.js b/static/js/components/Header.js index f95e3053..a4722a28 100644 --- a/static/js/components/Header.js +++ b/static/js/components/Header.js @@ -26,6 +26,7 @@ export class HeaderManager { const path = window.location.pathname; if (path.includes('/loras/recipes')) return 'recipes'; if (path.includes('/checkpoints')) return 'checkpoints'; + if (path.includes('/embeddings')) return 'embeddings'; if (path.includes('/statistics')) return 'statistics'; if (path.includes('/loras')) return 'loras'; return 'unknown'; diff --git a/static/js/components/controls/EmbeddingsControls.js b/static/js/components/controls/EmbeddingsControls.js new file mode 100644 index 00000000..c5b618e4 --- /dev/null +++ b/static/js/components/controls/EmbeddingsControls.js @@ -0,0 +1,57 @@ +// EmbeddingsControls.js - Specific implementation for the Embeddings page +import { PageControls } from './PageControls.js'; +import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js'; +import { showToast } from '../../utils/uiHelpers.js'; +import { downloadManager } from '../../managers/DownloadManager.js'; + +/** + * EmbeddingsControls class - Extends PageControls for Embedding-specific functionality + */ +export class EmbeddingsControls extends PageControls { + constructor() { + // Initialize with 'embeddings' page type + super('embeddings'); + + // Register API methods specific to the Embeddings page + this.registerEmbeddingsAPI(); + } + + /** + * Register Embedding-specific API methods + */ + registerEmbeddingsAPI() { + const embeddingsAPI = { + // Core API functions + loadMoreModels: async (resetPage = false, updateFolders = false) => { + return await getModelApiClient().loadMoreWithVirtualScroll(resetPage, updateFolders); + }, + + resetAndReload: async (updateFolders = false) => { + return await resetAndReload(updateFolders); + }, + + refreshModels: async (fullRebuild = false) => { + return await getModelApiClient().refreshModels(fullRebuild); + }, + + // Add fetch from Civitai functionality for embeddings + fetchFromCivitai: async () => { + return await getModelApiClient().fetchCivitaiMetadata(); + }, + + // Add show download modal functionality + showDownloadModal: () => { + downloadManager.showDownloadModal(); + }, + + // No clearCustomFilter implementation is needed for embeddings + // as custom filters are currently only used for LoRAs + clearCustomFilter: async () => { + showToast('No custom filter to clear', 'info'); + } + }; + + // Register the API + this.registerAPI(embeddingsAPI); + } +} diff --git a/static/js/components/controls/index.js b/static/js/components/controls/index.js index c767c62f..97f1ca91 100644 --- a/static/js/components/controls/index.js +++ b/static/js/components/controls/index.js @@ -2,13 +2,14 @@ import { PageControls } from './PageControls.js'; import { LorasControls } from './LorasControls.js'; import { CheckpointsControls } from './CheckpointsControls.js'; +import { EmbeddingsControls } from './EmbeddingsControls.js'; // Export the classes -export { PageControls, LorasControls, CheckpointsControls }; +export { PageControls, LorasControls, CheckpointsControls, EmbeddingsControls }; /** * Factory function to create the appropriate controls based on page type - * @param {string} pageType - The type of page ('loras' or 'checkpoints') + * @param {string} pageType - The type of page ('loras', 'checkpoints', or 'embeddings') * @returns {PageControls} - The appropriate controls instance */ export function createPageControls(pageType) { @@ -16,6 +17,8 @@ export function createPageControls(pageType) { return new LorasControls(); } else if (pageType === 'checkpoints') { return new CheckpointsControls(); + } else if (pageType === 'embeddings') { + return new EmbeddingsControls(); } else { console.error(`Unknown page type: ${pageType}`); return null; diff --git a/static/js/components/shared/ModelModal.js b/static/js/components/shared/ModelModal.js index ba7f807a..d069531b 100644 --- a/static/js/components/shared/ModelModal.js +++ b/static/js/components/shared/ModelModal.js @@ -33,7 +33,15 @@ export function showModelModal(model, modelType) { model.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : []; // Generate model type specific content - const typeSpecificContent = modelType === 'loras' ? renderLoraSpecificContent(model, escapedWords) : ''; + // const typeSpecificContent = modelType === 'loras' ? renderLoraSpecificContent(model, escapedWords) : ''; + let typeSpecificContent; + if (modelType === 'loras') { + typeSpecificContent = renderLoraSpecificContent(model, escapedWords); + } else if (modelType === 'embeddings') { + typeSpecificContent = renderEmbeddingSpecificContent(model, escapedWords); + } else { + typeSpecificContent = ''; + } // Generate tabs based on model type const tabsContent = modelType === 'loras' ? @@ -248,6 +256,10 @@ function renderLoraSpecificContent(lora, escapedWords) { `; } +function renderEmbeddingSpecificContent(embedding, escapedWords) { + return `${renderTriggerWords(escapedWords, embedding.file_path)}`; +} + /** * Sets up event handlers using event delegation for LoRA modal * @param {string} filePath - Path to the model file diff --git a/static/js/embeddings.js b/static/js/embeddings.js new file mode 100644 index 00000000..f32fd670 --- /dev/null +++ b/static/js/embeddings.js @@ -0,0 +1,55 @@ +import { appCore } from './core.js'; +import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js'; +import { createPageControls } from './components/controls/index.js'; +import { EmbeddingContextMenu } from './components/ContextMenu/index.js'; +import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js'; +import { MODEL_TYPES } from './api/apiConfig.js'; + +// Initialize the Embeddings page +class EmbeddingsPageManager { + constructor() { + // Initialize page controls + this.pageControls = createPageControls(MODEL_TYPES.EMBEDDING); + + // Initialize the ModelDuplicatesManager + this.duplicatesManager = new ModelDuplicatesManager(this, MODEL_TYPES.EMBEDDING); + + // Expose only necessary functions to global scope + this._exposeRequiredGlobalFunctions(); + } + + _exposeRequiredGlobalFunctions() { + // Minimal set of functions that need to remain global + window.confirmDelete = confirmDelete; + window.closeDeleteModal = closeDeleteModal; + window.confirmExclude = confirmExclude; + window.closeExcludeModal = closeExcludeModal; + + // Expose duplicates manager + window.modelDuplicatesManager = this.duplicatesManager; + } + + async initialize() { + // Initialize page-specific components + this.pageControls.restoreFolderFilter(); + this.pageControls.initFolderTagsVisibility(); + + // Initialize context menu + new EmbeddingContextMenu(); + + // Initialize common page features + appCore.initializePageFeatures(); + + console.log('Embeddings Manager initialized'); + } +} + +// Initialize everything when DOM is ready +document.addEventListener('DOMContentLoaded', async () => { + // Initialize core application + await appCore.initialize(); + + // Initialize embeddings page + const embeddingsPage = new EmbeddingsPageManager(); + await embeddingsPage.initialize(); +}); diff --git a/static/js/managers/FilterManager.js b/static/js/managers/FilterManager.js index 4bfe3de4..1ac8c44c 100644 --- a/static/js/managers/FilterManager.js +++ b/static/js/managers/FilterManager.js @@ -72,6 +72,8 @@ export class FilterManager { tagsEndpoint = '/api/recipes/top-tags?limit=20'; } else if (this.currentPage === 'checkpoints') { tagsEndpoint = '/api/checkpoints/top-tags?limit=20'; + } else if (this.currentPage === 'embeddings') { + tagsEndpoint = '/api/embeddings/top-tags?limit=20'; } const response = await fetch(tagsEndpoint); @@ -147,6 +149,8 @@ export class FilterManager { apiEndpoint = '/api/recipes/base-models'; } else if (this.currentPage === 'checkpoints') { apiEndpoint = '/api/checkpoints/base-models'; + } else if (this.currentPage === 'embeddings') { + apiEndpoint = '/api/embeddings/base-models'; } else { return; } @@ -160,11 +164,7 @@ export class FilterManager { data.base_models.forEach(model => { const tag = document.createElement('div'); - // Add base model classes only for the loras page - const baseModelClass = (this.currentPage === 'loras' && BASE_MODEL_CLASSES[model.name]) - ? BASE_MODEL_CLASSES[model.name] - : ''; - tag.className = `filter-tag base-model-tag ${baseModelClass}`; + tag.className = `filter-tag base-model-tag`; tag.dataset.baseModel = model.name; tag.innerHTML = `${model.name} ${model.count}`; diff --git a/static/js/managers/SearchManager.js b/static/js/managers/SearchManager.js index 819bfaa9..81663791 100644 --- a/static/js/managers/SearchManager.js +++ b/static/js/managers/SearchManager.js @@ -313,7 +313,7 @@ export class SearchManager { loraName: options.loraName || false, loraModel: options.loraModel || false }; - } else if (this.currentPage === 'loras') { + } else if (this.currentPage === 'loras' || this.currentPage === 'embeddings') { pageState.searchOptions = { filename: options.filename || false, modelname: options.modelname || false, diff --git a/templates/components/header.html b/templates/components/header.html index 0c8d7386..d506f6e6 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -16,8 +16,11 @@ Checkpoints + + Embeddings + - Statistics + Stats @@ -83,6 +86,10 @@