feat: implement embeddings functionality with context menus, controls, and page management

This commit is contained in:
Will Miao
2025-07-25 23:15:33 +08:00
parent 12d1857b13
commit e4ce384023
12 changed files with 263 additions and 13 deletions

View File

@@ -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,

View File

@@ -280,22 +280,49 @@ class StandaloneLoraManager(LoraManager):
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

View File

@@ -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);

View File

@@ -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';

View File

@@ -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';

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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

55
static/js/embeddings.js Normal file
View File

@@ -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();
});

View File

@@ -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} <span class="tag-count">${model.count}</span>`;

View File

@@ -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,

View File

@@ -16,8 +16,11 @@
<a href="/checkpoints" class="nav-item" id="checkpointsNavItem">
<i class="fas fa-check-circle"></i> Checkpoints
</a>
<a href="/embeddings" class="nav-item" id="embeddingsNavItem">
<i class="fas fa-code"></i> Embeddings
</a>
<a href="/statistics" class="nav-item" id="statisticsNavItem">
<i class="fas fa-chart-bar"></i> Statistics
<i class="fas fa-chart-bar"></i> Stats
</a>
</nav>
@@ -83,6 +86,10 @@
<div class="search-option-tag active" data-option="filename">Filename</div>
<div class="search-option-tag active" data-option="modelname">Checkpoint Name</div>
<div class="search-option-tag active" data-option="tags">Tags</div>
{% elif request.path == '/embeddings' %}
<div class="search-option-tag active" data-option="filename">Filename</div>
<div class="search-option-tag active" data-option="modelname">Embedding Name</div>
<div class="search-option-tag active" data-option="tags">Tags</div>
{% else %}
<!-- Default options for LoRAs page -->
<div class="search-option-tag active" data-option="filename">Filename</div>
@@ -147,6 +154,8 @@
searchInput.placeholder = 'Search recipes...';
} else if (currentPath === '/checkpoints') {
searchInput.placeholder = 'Search checkpoints...';
} else if (currentPath === '/embeddings') {
searchInput.placeholder = 'Search embeddings...';
} else {
searchInput.placeholder = 'Search...';
}
@@ -156,6 +165,7 @@
const lorasNavItem = document.getElementById('lorasNavItem');
const recipesNavItem = document.getElementById('recipesNavItem');
const checkpointsNavItem = document.getElementById('checkpointsNavItem');
const embeddingsNavItem = document.getElementById('embeddingsNavItem');
const statisticsNavItem = document.getElementById('statisticsNavItem');
if (currentPath === '/loras') {
@@ -164,6 +174,8 @@
recipesNavItem.classList.add('active');
} else if (currentPath === '/checkpoints') {
checkpointsNavItem.classList.add('active');
} else if (currentPath === '/embeddings') {
embeddingsNavItem.classList.add('active');
} else if (currentPath === '/statistics') {
statisticsNavItem.classList.add('active');
}