Files
ComfyUI-Lora-Manager/static/js/components/shared/ModelCard.js
T
Will Miao 5ab0e88abc feat(links): support ModelScope and TensorArt as model sources
A model file could only ever be linked to huggingface.co: `set_hf_url`
validated the URL with a huggingface-only regex, the agent fetched the card
from a hardcoded HF URL, and the readme processor built every relative image
path off `https://huggingface.co/{repo}/resolve/main`. ModelScope publishes the
same model-card convention (README.md + YAML frontmatter, often carrying
`base_model:` and `trigger_words:`) behind a public, key-less API, so the
enrichment pipeline could already serve it - it was the plumbing that was
HF-shaped, not the idea.

Make the external source a first-class, provider-driven concept:

- New `py/services/model_sources/` registry. A `ModelSource` owns URL
  recognition (lenient for stored values, strict for user input), the
  canonical page URL, model-card fetching, the asset base URL and the
  capability flags. `HuggingFaceSource` is the previous logic relocated;
  `ModelScopeSource` reads `/models/{o}/{n}/resolve/{master|main}/README.md`
  and falls back to `/api/v1/models/{o}/{n}/repo`. `TensorArtSource` is
  link-only on purpose: tensor.art answers plain HTTP clients with a
  Cloudflare challenge and its internal API (ap-east-1.tensorart.cloud /
  cn.tensorart.net) rejects every /v1/model/* route with "invalid
  authorization header", so it declares supports_enrichment=False rather than
  failing silently later.
- Metadata gains `source_platform` + `source_url`; `hf_url` stays as a
  read/write alias, written only for Hugging Face, so existing sidecars,
  cached rows and third-party consumers keep working. Normalisation runs at
  the scanner, the persistent cache (both directions, plus two new columns
  behind an ALTER migration) and the linking handler - which is what stops a
  user who switches sources from leaving a stale `hf_url` on a ModelScope
  model.
- The agent pipeline keys off the provider instead of `hf_url`: the fast-fail
  gate now explains *why* a model is skipped (no source / unknown source /
  source without a reachable card), the prompt context exposes
  source_url/source_id/source_label/asset_base_url while still filling the
  legacy hf_url/repo aliases, and the four README image extractors take a
  base_url (defaulting to HF) so relative paths resolve against the right
  site. Version grouping generalises to hf: / ms: / ta: keys.
- `POST /api/lm/set-hf-url` keeps its path and its legacy payload keys but
  accepts `source_url`, validates against every provider and returns the
  platform. `GET /api/lm/model-sources` lets the UI render the supported-site
  list from the server.
- Frontend: a `modelSourceHelpers` mirror of the registry drives the link
  dialog, the card/modal globe (branded "View on ModelScope/TensorArt"), the
  version-group key and the enrichment gate; the versions tab no longer sends
  ms:/ta: keys to the CivitAI API.

TensorArt stays in the list because provenance is worth keeping even when the
card is unreadable - the dialog says so plainly ("Sites that don't expose one
(currently TensorArt) can only be linked") and the context menu disables
enrichment with a matching tooltip, instead of the user getting
"Unsupported URL".

Verified against the real ModelScope API: jj3550945163/Krea-2-LORA returns a
1882-byte card whose frontmatter carries base_model/tags/trigger_words, and
relative images resolve to .../resolve/master/....

Tests: backend 2815 passed; frontend 1130 JS + 91 Vue passed; pytest
tests/i18n and a Jinja compile pass over templates/. The nine locales carry
[TODO: Translate] for the new strings, completed in the next commit.
2026-09-14 07:24:08 +08:00

1114 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
import { getModelSourceInfo, getModelSourceGroupKey, getModelSourceViewTitle, openModelSource } from '../../utils/modelSourceHelpers.js';
import { state, getCurrentPageState } from '../../state/index.js';
import { showModelModal } from './ModelModal.js';
import { hasCivitaiSource } from './utils.js';
import { bulkManager } from '../../managers/BulkManager.js';
import { modalManager } from '../../managers/ModalManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES, MODEL_CARD_DRAG_MIME_TYPE } from '../../utils/constants.js';
import { MODEL_TYPES } from '../../api/apiConfig.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { showDeleteModal } from '../../utils/modalUtils.js';
import { translate } from '../../utils/i18nHelpers.js';
import { eventManager } from '../../utils/EventManager.js';
// Helper function to get display name based on settings
function getDisplayName(model) {
const displayNameSetting = state.global.settings.model_name_display || 'model_name';
if (displayNameSetting === 'file_name') {
return model.file_name || model.model_name || 'Unknown Model';
}
return model.model_name || model.file_name || 'Unknown Model';
}
// Add global event delegation handlers using event manager
export function setupModelCardEventDelegation(modelType) {
// Remove any existing handler first
eventManager.removeHandler('click', 'modelCard-delegation');
// Register model card event delegation with event manager
eventManager.addHandler('click', 'modelCard-delegation', (event) => {
return handleModelCardEvent_internal(event, modelType);
}, {
priority: 60, // Medium priority for model card interactions
targetSelector: '#modelGrid',
skipWhenModalOpen: false // Allow model card interactions even when modals are open (for some actions)
});
}
// Event delegation handler for all model card events
function handleModelCardEvent_internal(event, modelType) {
// Find the closest card element
const card = event.target.closest('.model-card');
if (!card) return false; // Continue with other handlers
// Handle specific elements within the card
if (event.target.closest('.toggle-blur-btn')) {
event.stopPropagation();
toggleBlurContent(card);
return true; // Stop propagation
}
if (event.target.closest('.show-content-btn')) {
event.stopPropagation();
showBlurredContent(card);
return true; // Stop propagation
}
if (event.target.closest('.fa-star')) {
event.stopPropagation();
toggleFavorite(card);
return true; // Stop propagation
}
if (event.target.closest('.fa-globe')) {
event.stopPropagation();
// CivitAI wins when the model actually has CivitAI data; otherwise fall
// back to the linked external source. Relying on `from_civitai` here
// made the two sources mutually exclusive whenever one of them was
// (re)linked (#1094).
if (card.dataset.has_civitai === 'true') {
openCivitai(card.dataset.filepath);
} else if (card.dataset.source_platform === 'huggingface' && card.dataset.hf_url) {
openHuggingFace(card.dataset.hf_url);
} else if (card.dataset.source_url) {
openModelSource(card.dataset.source_url);
}
return true; // Stop propagation
}
if (event.target.closest('.fa-paper-plane')) {
event.stopPropagation();
handleSendToWorkflow(card, event.shiftKey, modelType);
return true; // Stop propagation
}
if (event.target.closest('.fa-copy')) {
event.stopPropagation();
handleCopyAction(card, modelType);
return true; // Stop propagation
}
if (event.target.closest('.fa-trash')) {
event.stopPropagation();
showDeleteModal(card.dataset.filepath);
return true; // Stop propagation
}
if (event.target.closest('.fa-image')) {
event.stopPropagation();
getModelApiClient().replaceModelPreview(card.dataset.filepath);
return true; // Stop propagation
}
if (event.target.closest('.fa-folder-open')) {
event.stopPropagation();
handleExampleImagesAccess(card, modelType);
return true; // Stop propagation
}
if (event.target.closest('.version-count-link')) {
event.stopPropagation();
handleViewLocalVersionsFromCard(card, modelType);
return true;
}
// If no specific element was clicked, handle the card click (show modal or toggle selection)
if (state.bulkMode && event.shiftKey) {
event.preventDefault(); // keep shift+click from extending a text selection
}
handleCardClick(card, modelType, event.shiftKey);
return false; // Continue with other handlers (e.g., bulk selection)
}
// 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) {
const starIcon = card.querySelector('.fa-star');
const isFavorite = starIcon.classList.contains('fas');
const newFavoriteState = !isFavorite;
try {
await getModelApiClient().saveModelMetadata(card.dataset.filepath, {
favorite: newFavoriteState
});
if (newFavoriteState) {
showToast('modelCard.favorites.added', {}, 'success');
} else {
showToast('modelCard.favorites.removed', {}, 'success');
}
} catch (error) {
console.error('Failed to update favorite status:', error);
showToast('modelCard.favorites.updateFailed', {}, 'error');
}
}
function handleSendToWorkflow(card, replaceMode, modelType) {
if (modelType === MODEL_TYPES.LORA) {
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
const folder = card.dataset.folder || '';
const loraName = folder ? `${folder}/${card.dataset.file_name}` : card.dataset.file_name;
const loraSyntax = buildLoraSyntax(loraName, usageTips);
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
const modelPath = card.dataset.filepath;
if (!modelPath) {
const message = translate('modelCard.sendToWorkflow.missingPath', {}, 'Unable to determine model path for this card');
showToast(message, {}, 'error');
return;
}
const subtype = (card.dataset.sub_type || 'checkpoint').toLowerCase();
const isDiffusionModel = subtype === 'diffusion_model';
const widgetName = isDiffusionModel ? 'unet_name' : 'ckpt_name';
const actionTypeText = translate(
isDiffusionModel ? 'uiHelpers.nodeSelector.diffusionModel' : 'uiHelpers.nodeSelector.checkpoint',
{},
isDiffusionModel ? 'Diffusion Model' : 'Checkpoint'
);
const successMessage = translate(
'uiHelpers.workflow.modelUpdated',
{},
'Model updated in workflow'
);
const failureMessage = translate(
'uiHelpers.workflow.modelFailed',
{},
'Failed to update model node'
);
const missingNodesMessage = translate(
'uiHelpers.workflow.noMatchingNodes',
{},
'No compatible nodes available in the current workflow'
);
const missingTargetMessage = translate(
'uiHelpers.workflow.noTargetNodeSelected',
{},
'No target node selected'
);
sendModelPathToWorkflow(modelPath, {
widgetName,
collectionType: MODEL_TYPES.CHECKPOINT,
actionTypeText,
successMessage,
failureMessage,
missingNodesMessage,
missingTargetMessage,
});
} else if (modelType === MODEL_TYPES.EMBEDDING) {
const folder = card.dataset.folder || '';
const name = card.dataset.file_name || '';
const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
sendEmbeddingToWorkflow(embeddingCode);
} else {
showToast('modelCard.sendToWorkflow.checkpointNotImplemented', {}, 'info');
}
}
function handleCopyAction(card, modelType) {
if (modelType === MODEL_TYPES.LORA) {
copyLoraSyntax(card);
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
// Checkpoint copy functionality - copy checkpoint name
const checkpointName = card.dataset.file_name;
const message = translate('modelCard.actions.checkpointNameCopied', {}, 'Checkpoint name copied');
copyToClipboard(checkpointName, message);
} else if (modelType === MODEL_TYPES.EMBEDDING) {
const folder = card.dataset.folder || '';
const name = card.dataset.file_name || '';
const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
const message = translate('modelCard.actions.embeddingNameCopied', {}, 'Embedding syntax copied');
copyToClipboard(embeddingCode, message);
} else {
// Other model types (VAE, upscalers, ...) - copy the file name
const fileName = card.dataset.file_name;
const message = translate('modelCard.actions.modelNameCopied', {}, 'Model name copied');
copyToClipboard(fileName, message);
}
}
function handleReplacePreview(filePath, modelType) {
apiClient.replaceModelPreview(filePath);
}
async function handleExampleImagesAccess(card, modelType) {
const modelHash = card.dataset.sha256;
try {
const response = await fetch(`/api/lm/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('modelCard.exampleImages.checkError', {}, 'error');
}
}
function handleViewLocalVersionsFromCard(card, modelType) {
const modelId = card.dataset.modelId;
const modelName = card.dataset.name;
if (!modelId) return;
// Respect version_grouping: only filter by base model when the strategy says so
const strategy = state.global?.settings?.version_grouping;
const shouldFilterByBase = strategy === 'same_base';
const baseModel = shouldFilterByBase && card.dataset.base_model !== 'Unknown'
? card.dataset.base_model
: undefined;
// Use the no-reload VLM flow via PageControls
if (window.pageControls && typeof window.pageControls.triggerVlmView === 'function') {
window.pageControls.triggerVlmView(modelId, modelName, baseModel, modelType);
}
}
function handleCardClick(card, modelType, extendSelection = false) {
const pageState = getCurrentPageState();
if (state.bulkMode) {
// Toggle selection using the bulk manager
bulkManager.toggleCardSelection(card, extendSelection);
} else if (pageState && pageState.duplicatesMode) {
// In duplicates mode, don't open modal when clicking cards
return;
} else {
// Normal behavior - show modal
showModelModalFromCard(card, modelType);
}
}
// Preview URL is not in the dataset; read it from the card's rendered media
function getCardPreviewUrl(card) {
const cardMedia = card.querySelector('.card-preview img, .card-preview video');
if (!cardMedia) return '';
return cardMedia.tagName === 'VIDEO'
? (cardMedia.dataset.src || '')
: (cardMedia.src || '');
}
async function showModelModalFromCard(card, modelType) {
// Create model metadata object
const modelMeta = {
sha256: card.dataset.sha256,
autov3: card.dataset.autov3 || '',
preview_url: getCardPreviewUrl(card),
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',
source_platform: card.dataset.source_platform || '',
source_url: card.dataset.source_url || '',
hf_url: card.dataset.hf_url || '',
base_model: card.dataset.base_model,
notes: card.dataset.notes || '',
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 || '[]'),
update_available: card.dataset.update_available === 'true',
modelDescription: card.dataset.modelDescription || '',
// LoRA specific fields
...(modelType === MODEL_TYPES.LORA && {
usage_tips: card.dataset.usage_tips,
})
};
await 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 = async () => {
// Get the model hash
const modelHash = card.dataset.sha256;
if (!modelHash) {
showToast('modelCard.exampleImages.missingHash', {}, 'error');
return;
}
// Close the modal
modalManager.closeModal('exampleAccessModal');
try {
// Use the appropriate model API client to download examples
const apiClient = getModelApiClient(modelType);
await apiClient.downloadExampleImages([modelHash]);
// Open the example images folder if successful
openExampleImagesFolder(modelHash);
} catch (error) {
console.error('Error downloading example images:', error);
// Error already shown by the API client
}
};
} else {
downloadBtn.classList.add('disabled');
const noRemoteImagesTitle = translate('modelCard.exampleImages.noRemoteImagesAvailable', {}, 'No remote example images available for this model on Civitai');
downloadBtn.setAttribute('title', noRemoteImagesTitle);
downloadBtn.onclick = null;
}
}
// Set up import button
const importBtn = modal.querySelector('#importExamplesBtn');
if (importBtn) {
importBtn.onclick = async () => {
modalManager.closeModal('exampleAccessModal');
// Get the model data from card dataset (works for both lora and checkpoint)
const modelMeta = {
sha256: card.dataset.sha256,
autov3: card.dataset.autov3 || '',
preview_url: getCardPreviewUrl(card),
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',
source_platform: card.dataset.source_platform || '',
source_url: card.dataset.source_url || '',
hf_url: card.dataset.hf_url || '',
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
await showModelModal(modelMeta, modelType);
// Reveal the import entry once the modal content has rendered
setTimeout(() => {
// Gallery mode: the import button is always visible — expand the zone
const importBtn = document.querySelector('#modelModal .gallery-import-btn');
if (importBtn) {
importBtn.click();
return;
}
// Empty state: the import area is the whole tab content — scroll to it
const importArea = document.querySelector('#modelModal .example-import-area');
if (importArea) {
importArea.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, 500);
};
}
// Show the modal
modalManager.showModal('exampleAccessModal');
}
export function createModelCard(model, modelType) {
const card = document.createElement('div');
card.className = 'model-card'; // Reuse the same class for styling
// Always draggable (move-to-folder in the sidebar). Accidental micro-drags
// from click jitter are rendered harmless by the preview-drop handlers
// below, which ignore internal card drags via MODEL_CARD_DRAG_MIME_TYPE.
card.draggable = true;
card.dataset.sha256 = model.sha256;
card.dataset.autov3 = model.autov3 || '';
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;
// Independent of `from_civitai`: a model can have both CivitAI data and an
// HF link, and the card globe must keep pointing at CivitAI when it does.
card.dataset.has_civitai = hasCivitaiSource(model.civitai) ? 'true' : 'false';
card.dataset.usage_count = String(model.usage_count);
card.dataset.notes = model.notes || '';
card.dataset.base_model = model.base_model || 'Unknown';
card.dataset.favorite = model.favorite ? 'true' : 'false';
card.dataset.exclude = model.exclude ? 'true' : 'false';
const modelSourceInfo = getModelSourceInfo(model);
card.dataset.source_url = modelSourceInfo?.url || '';
card.dataset.source_platform = modelSourceInfo?.platform || '';
// Legacy alias: only Hugging Face models expose `hf_url`.
card.dataset.hf_url = modelSourceInfo?.platform === 'huggingface' ? modelSourceInfo.url : '';
const hasUpdateAvailable = Boolean(model.update_available);
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
// Store version_count for group-by-model display
if (model.version_count !== undefined) {
card.dataset.version_count = model.version_count;
}
// To only show usage_count when sorting by usage.
const pageState = getCurrentPageState();
const isUsageSort = pageState?.sortBy?.startsWith('usage');
const hasUsageCount = isUsageSort && typeof model.usage_count === 'number';
const civitaiData = model.civitai || {};
const modelId = civitaiData?.modelId ?? civitaiData?.model_id;
if (modelId !== undefined && modelId !== null && modelId !== '') {
card.dataset.modelId = modelId;
} else {
// For externally-sourced models, derive a group key from the source
// URL for version grouping (hf:user/repo, ms:user/repo, ta:<id>).
const sourceGroupKey = getModelSourceGroupKey(model);
if (sourceGroupKey) {
card.dataset.modelId = sourceGroupKey;
}
}
// LoRA specific data
if (modelType === MODEL_TYPES.LORA) {
card.dataset.usage_tips = model.usage_tips;
}
// Set sub_type for all model types (lora/locon/dora, checkpoint/diffusion_model, embedding)
if (model.sub_type) {
card.dataset.sub_type = model.sub_type;
}
// 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 matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
if (shouldBlur) {
card.classList.add('nsfw-content');
}
if (model.skip_metadata_refresh) {
card.classList.add('skip-refresh');
}
if (model.exclude) {
card.classList.add('excluded-model');
}
// state.selectedModels resolves to the active page's set (selectedLoras
// included) - do not narrow this back to selectedLoras/LORA-only.
if (state.bulkMode && state.selectedModels.has(model.file_path)) {
card.classList.add('selected');
}
// Get the appropriate preview versions map
const previewVersionsKey = modelType;
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}${previewUrl.includes('?') ? '&' : '?'}t=${version}` : previewUrl;
// Determine NSFW warning text based on level with i18n support
let nsfwText = translate('modelCard.nsfw.matureContent', {}, 'Mature Content');
if (nsfwLevel >= NSFW_LEVELS.XXX) {
nsfwText = translate('modelCard.nsfw.xxxRated', {}, 'XXX-rated Content');
} else if (nsfwLevel >= NSFW_LEVELS.X) {
nsfwText = translate('modelCard.nsfw.xRated', {}, 'X-rated Content');
} else if (nsfwLevel >= NSFW_LEVELS.R) {
nsfwText = translate('modelCard.nsfw.rRated', {}, 'R-rated Content');
}
// Check if autoplayOnHover is enabled for video previews
const autoplayOnHover = state.global?.settings?.autoplay_on_hover || false;
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
const videoAttrs = [
'controls',
'muted',
'loop',
'playsinline',
'preload="none"',
`data-src="${versionedPreviewUrl}"`
];
if (!autoplayOnHover) {
videoAttrs.push('data-autoplay="true"');
}
// Get favorite status from model data
const isFavorite = model.favorite === true;
if (hasUpdateAvailable) {
card.classList.add('has-update');
}
// Generate action icons based on model type with i18n support
const favoriteTitle = isFavorite ?
translate('modelCard.actions.removeFromFavorites', {}, 'Remove from favorites') :
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
const hasCivitai = hasCivitaiSource(model.civitai);
const globeTitle = hasCivitai ?
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
modelSourceInfo ?
getModelSourceViewTitle(modelSourceInfo) :
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
const globeEnabled = hasCivitai || !!modelSourceInfo;
let sendTitle;
let copyTitle;
if (modelType === MODEL_TYPES.LORA) {
sendTitle = translate('modelCard.actions.sendToWorkflow', {}, 'Send to ComfyUI (Click: Append, Shift+Click: Replace)');
copyTitle = translate('modelCard.actions.copyLoRASyntax', {}, 'Copy LoRA Syntax');
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
// Checkpoint send sets the widget value directly; no append/replace modes.
sendTitle = translate('modelCard.actions.sendCheckpointToWorkflow', {}, 'Send to ComfyUI');
copyTitle = translate('modelCard.actions.copyCheckpointName', {}, 'Copy checkpoint name');
} else if (modelType === MODEL_TYPES.EMBEDDING) {
// Embedding send always appends to the prompt; no replace mode.
sendTitle = translate('modelCard.actions.sendEmbeddingToWorkflow', {}, 'Send to ComfyUI');
copyTitle = translate('modelCard.actions.copyEmbeddingName', {}, 'Copy embedding name');
} else {
sendTitle = translate('modelCard.actions.sendToWorkflow', {}, 'Send to ComfyUI');
copyTitle = translate('modelCard.actions.copyLoRASyntax', {}, 'Copy value');
}
const updateBadgeLabel = translate('modelCard.badges.update', {}, 'Update');
const updateBadgeTooltip = translate('modelCard.badges.updateAvailable', {}, 'Update available');
const actionIcons = `
<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}"
title="${favoriteTitle}">
</i>
<i class="fas fa-globe"
title="${globeTitle}"
${!globeEnabled ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
</i>
<i class="fas fa-paper-plane"
title="${sendTitle}">
</i>
<i class="fas fa-copy"
title="${copyTitle}">
</i>`;
// Generate UI text with i18n support
const toggleBlurTitle = translate('modelCard.actions.toggleBlur', {}, 'Toggle blur');
const showButtonText = translate('modelCard.actions.show', {}, 'Show');
const footerActionSetting = state.global.settings.model_card_footer_action || 'example_images';
const footerActionTitle = footerActionSetting === 'replace_preview'
? translate('modelCard.actions.replacePreview', {}, 'Replace Preview')
: translate('modelCard.actions.openExampleImages', {}, 'Open Example Images Folder');
const footerActionIcon = footerActionSetting === 'replace_preview'
? 'fas fa-image'
: 'fas fa-folder-open';
const baseModelLabel = model.base_model || 'Unknown';
const baseModelAbbreviation = getBaseModelAbbreviation(baseModelLabel);
// Sub-type display (e.g., LoRA, LyCO, DoRA, CKPT, DM, EMB)
const subType = model.sub_type || '';
const subTypeAbbreviation = getSubTypeAbbreviation(subType);
const fullSubTypeName = MODEL_SUBTYPE_DISPLAY_NAMES[subType?.toLowerCase()] || subType || '';
card.innerHTML = `
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
${isVideo ?
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
`<img draggable="false" src="${versionedPreviewUrl}" alt="${model.model_name}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
}
<div class="card-header">
${shouldBlur ?
`<button class="toggle-blur-btn" title="${toggleBlurTitle}">
<i class="fas fa-eye"></i>
</button>` : ''}
<div class="card-header-info">
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}"
title="${fullSubTypeName ? fullSubTypeName + ' | ' : ''}${baseModelLabel}">
${subTypeAbbreviation ? `<span class="model-sub-type">${subTypeAbbreviation}</span>` : ''}
${subTypeAbbreviation ? `<span class="model-separator"></span>` : ''}
<span class="model-base-type">${baseModelAbbreviation}</span>
</span>
${hasUpdateAvailable ? `
<span class="model-update-badge" title="${updateBadgeTooltip}">
<i class="fas fa-arrow-up"></i>
</span>
` : ''}
${model.skip_metadata_refresh ? `
<span class="model-skip-refresh-badge" title="${translate('modelCard.badges.skipRefresh', {}, 'Metadata refresh skipped')}">
<i class="fas fa-ban"></i>
</span>
` : ''}
${model.exclude ? `
<span class="model-excluded-badge" title="${translate('globalContextMenu.manageExcludedModels.label', {}, 'Excluded Models')}">
<i class="fas fa-eye-slash"></i>
</span>
` : ''}
</div>
<div class="card-actions">
${actionIcons}
</div>
</div>
${shouldBlur ? `
<div class="nsfw-overlay">
<div class="nsfw-warning">
<p>${nsfwText}</p>
<button class="show-content-btn">${showButtonText}</button>
</div>
</div>
` : ''}
<div class="card-footer">
<div class="model-info">
<span class="model-name" title="${getDisplayName(model).replace(/"/g, '&quot;')}">${getDisplayName(model)}</span>
<div class="version-row">
${(() => {
const autoTags = model.auto_tags || [];
const hlTags = autoTags.filter(t => t === 'HIGH' || t === 'LOW');
const hasVersionName = model.civitai?.name;
// When group_by_model is active and model has multiple versions,
// show clickable version count instead of version name (and hide badges)
const isGroupByModel = state.global.settings.group_by_model;
const versionCount = model.version_count;
const showVersionCount = isGroupByModel && versionCount > 1;
if (!hlTags.length && !hasVersionName && !showVersionCount) return '';
const density = state.global.settings.display_density || 'default';
const shortLabels = density === 'medium' || density === 'compact';
// Don't show HIGH/LOW badges when showing version count (confusing in grouped mode)
const badges = !showVersionCount ? hlTags.map(t => {
const cls = t === 'HIGH' ? 'hl-badge hl-badge--high' : 'hl-badge hl-badge--low';
const label = shortLabels ? (t === 'HIGH' ? 'H' : 'L') : t;
const titleAttr = shortLabels ? ` title="${t}"` : '';
return `<span class="${cls}"${titleAttr}>${label}</span>`;
}).join('') : '';
let versionHtml = '';
if (showVersionCount) {
const countLabel = translate('modelCard.footer.versionCount', { count: versionCount }, `${versionCount} versions`);
versionHtml = `<span class="version-count-link" title="${translate('modelCard.footer.viewAllVersions', {}, 'View all local versions')}">${countLabel}</span>`;
} else if (hasVersionName) {
versionHtml = `<span class="version-name civitai-version">${model.civitai.name}</span>`;
}
return `<span class="badge-version-unit">${badges}${versionHtml}</span>`;
})()}
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
</div>
</div>
<div class="card-actions">
<i class="${footerActionIcon}"
title="${footerActionTitle}">
</i>
</div>
</div>
</div>
`;
// Add video auto-play on hover functionality if needed
const videoElement = card.querySelector('video');
if (videoElement) {
configureModelCardVideo(videoElement, autoplayOnHover);
}
// Dropping an image/video onto the card replaces the model preview via the
// existing replace-preview endpoint (overwrites file on disk, refreshes card).
// Internal card drags (move-to-folder) are tagged with a custom MIME type by
// SidebarManager and must be ignored here entirely: no highlight, no upload.
const isInternalCardDrag = (event) =>
Boolean(event.dataTransfer?.types?.includes(MODEL_CARD_DRAG_MIME_TYPE));
const preventDragDefaults = (event) => {
event.preventDefault();
event.stopPropagation();
};
['dragenter', 'dragover'].forEach((eventName) => {
card.addEventListener(eventName, (event) => {
if (isInternalCardDrag(event)) return;
preventDragDefaults(event);
card.classList.add('drag-over');
});
});
card.addEventListener('dragleave', (event) => {
if (isInternalCardDrag(event)) return;
preventDragDefaults(event);
card.classList.remove('drag-over');
});
card.addEventListener('drop', (event) => {
if (isInternalCardDrag(event)) return;
preventDragDefaults(event);
card.classList.remove('drag-over');
const files = event.dataTransfer?.files;
if (!files || files.length === 0) return;
const file = files[0];
// Keep in sync with the accept list of the preview file picker (image/* + video/mp4).
if (!file.type.startsWith('image/') && file.type !== 'video/mp4') {
showToast('toast.api.previewDropInvalid', { name: file.name || '' }, 'error');
return;
}
const filePath = card.dataset.filepath;
if (!filePath) return;
// uploadPreview handles loading state, card refresh and error toasts internally.
getModelApiClient().uploadPreview(filePath, file);
});
return card;
}
const VIDEO_LAZY_ROOT_MARGIN = '200px 0px';
const VIDEO_LOAD_INTERVAL_MS = 120;
const VIDEO_LOAD_MAX_CONCURRENCY = 2;
let videoLazyObserver = null;
const videoLoadQueue = [];
const queuedVideoElements = new Set();
let activeVideoLoads = 0;
let queueTimer = null;
const scheduleFrame = typeof requestAnimationFrame === 'function'
? requestAnimationFrame
: (callback) => setTimeout(callback, 16);
function scheduleVideoQueueProcessing(delay = 0) {
if (queueTimer !== null) {
return;
}
queueTimer = setTimeout(() => {
queueTimer = null;
processVideoLoadQueue();
}, delay);
}
function dequeueVideoElement(videoElement) {
if (!queuedVideoElements.has(videoElement)) {
return;
}
queuedVideoElements.delete(videoElement);
const index = videoLoadQueue.indexOf(videoElement);
if (index !== -1) {
videoLoadQueue.splice(index, 1);
}
}
function processVideoLoadQueue() {
if (videoLoadQueue.length === 0) {
return;
}
while (activeVideoLoads < VIDEO_LOAD_MAX_CONCURRENCY && videoLoadQueue.length > 0) {
const videoElement = videoLoadQueue.shift();
queuedVideoElements.delete(videoElement);
if (!videoElement || !videoElement.isConnected || videoElement.dataset.loaded === 'true') {
continue;
}
activeVideoLoads++;
videoElement.dataset.loading = 'true';
scheduleFrame(() => {
try {
loadVideoSource(videoElement);
} finally {
delete videoElement.dataset.loading;
activeVideoLoads--;
if (videoLoadQueue.length > 0) {
scheduleVideoQueueProcessing(VIDEO_LOAD_INTERVAL_MS);
}
}
});
}
if (videoLoadQueue.length > 0 && queueTimer === null) {
scheduleVideoQueueProcessing(VIDEO_LOAD_INTERVAL_MS);
}
}
function enqueueVideoElement(videoElement) {
if (!videoElement || videoElement.dataset.loaded === 'true' || videoElement.dataset.loading === 'true') {
return;
}
if (!videoElement.isConnected) {
return;
}
if (queuedVideoElements.has(videoElement)) {
return;
}
queuedVideoElements.add(videoElement);
videoLoadQueue.push(videoElement);
scheduleVideoQueueProcessing();
}
function ensureVideoLazyObserver() {
if (videoLazyObserver) {
return videoLazyObserver;
}
videoLazyObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
enqueueVideoElement(entry.target);
}
});
}, {
root: null,
rootMargin: VIDEO_LAZY_ROOT_MARGIN,
threshold: 0.01
});
return videoLazyObserver;
}
function cleanupHoverHandlers(videoElement) {
const handlers = videoElement._hoverHandlers;
if (!handlers) return;
const { cardPreview, mouseEnter, mouseLeave } = handlers;
if (cardPreview) {
cardPreview.removeEventListener('mouseenter', mouseEnter);
cardPreview.removeEventListener('mouseleave', mouseLeave);
}
delete videoElement._hoverHandlers;
}
function requestSafePlay(videoElement) {
const playPromise = videoElement.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => { });
}
}
function loadVideoSource(videoElement) {
if (!videoElement) {
return false;
}
if (videoLazyObserver) {
try {
videoLazyObserver.unobserve(videoElement);
} catch (error) {
// Ignore observer errors (e.g., element already unobserved)
}
}
if (videoElement.dataset.loaded === 'true' || !videoElement.isConnected) {
return false;
}
const sourceElement = videoElement.querySelector('source');
const dataSrc = videoElement.dataset.src || sourceElement?.dataset?.src;
if (!dataSrc) {
return false;
}
// Ensure src attributes are reset before applying
videoElement.removeAttribute('src');
if (sourceElement) {
sourceElement.src = dataSrc;
} else {
videoElement.src = dataSrc;
}
videoElement.load();
videoElement.dataset.loaded = 'true';
if (videoElement.dataset.autoplay === 'true') {
videoElement.setAttribute('autoplay', '');
requestSafePlay(videoElement);
}
return true;
}
export function configureModelCardVideo(videoElement, autoplayOnHover) {
if (!videoElement) return;
dequeueVideoElement(videoElement);
cleanupHoverHandlers(videoElement);
const sourceElement = videoElement.querySelector('source');
const existingSrc = videoElement.dataset.src || sourceElement?.dataset?.src || videoElement.currentSrc;
if (existingSrc && !videoElement.dataset.src) {
videoElement.dataset.src = existingSrc;
}
if (sourceElement && !sourceElement.dataset.src) {
sourceElement.dataset.src = videoElement.dataset.src || sourceElement.src;
}
videoElement.removeAttribute('autoplay');
videoElement.removeAttribute('src');
videoElement.setAttribute('preload', 'none');
videoElement.setAttribute('muted', '');
videoElement.setAttribute('loop', '');
videoElement.setAttribute('playsinline', '');
videoElement.setAttribute('controls', '');
videoElement.dataset.loaded = 'false';
delete videoElement.dataset.loading;
if (sourceElement) {
sourceElement.removeAttribute('src');
if (videoElement.dataset.src) {
sourceElement.dataset.src = videoElement.dataset.src;
}
}
if (!autoplayOnHover) {
videoElement.dataset.autoplay = 'true';
} else {
delete videoElement.dataset.autoplay;
}
const observer = ensureVideoLazyObserver();
observer.observe(videoElement);
// Pause the video until it is either hovered or autoplay kicks in
try {
videoElement.pause();
} catch (err) {
// Ignore pause errors (e.g., if not loaded yet)
}
if (autoplayOnHover) {
const cardPreview = videoElement.closest('.card-preview');
if (cardPreview) {
const mouseEnter = () => {
dequeueVideoElement(videoElement);
loadVideoSource(videoElement);
requestSafePlay(videoElement);
};
const mouseLeave = () => {
videoElement.pause();
videoElement.currentTime = 0;
};
cardPreview.addEventListener('mouseenter', mouseEnter);
cardPreview.addEventListener('mouseleave', mouseLeave);
videoElement._hoverHandlers = { cardPreview, mouseEnter, mouseLeave };
}
}
}
// 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('.model-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();
}
}