mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
7c4c8b8f30
The Add button silently returned when no parameter or value was provided, looking clickable but doing nothing. Keep it disabled until both inputs are filled, validate the numeric value, surface save failures via toast without clearing user input, and confirm additions vs overwrites with success toasts. Includes translations for all locales.
1421 lines
58 KiB
JavaScript
1421 lines
58 KiB
JavaScript
import { showToast, openCivitai, sendLoraToWorkflow, sendEmbeddingToWorkflow, sendModelPathToWorkflow, buildLoraSyntax, copyToClipboard } from '../../utils/uiHelpers.js';
|
|
import { modalManager } from '../../managers/ModalManager.js';
|
|
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
|
import {
|
|
scrollToTop,
|
|
loadExampleImages
|
|
} from './showcase/ShowcaseView.js';
|
|
import { setupTabSwitching } from './ModelDescription.js';
|
|
import {
|
|
setupModelNameEditing,
|
|
setupBaseModelEditing,
|
|
setupFileNameEditing,
|
|
setupVersionNameEditing
|
|
} from './ModelMetadata.js';
|
|
import { setupTagEditMode } from './ModelTags.js';
|
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
|
import { renderCompactTags, setupTagTooltip, formatFileSize, escapeAttribute, escapeHtml } from './utils.js';
|
|
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
|
|
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
|
import { initVersionsTab } from './ModelVersionsTab.js';
|
|
import { loadRecipesForModel } from './RecipeTab.js';
|
|
import { translate } from '../../utils/i18nHelpers.js';
|
|
import { showDeleteModal } from '../../utils/modalUtils.js';
|
|
import { state } from '../../state/index.js';
|
|
|
|
function getModalFilePath(fallback = '') {
|
|
const modalElement = document.getElementById('modelModal');
|
|
if (modalElement && modalElement.dataset && modalElement.dataset.filePath) {
|
|
return modalElement.dataset.filePath;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
const COMMERCIAL_ICON_CONFIG = [
|
|
{
|
|
key: 'image',
|
|
icon: 'photo-off.svg',
|
|
titleKey: 'modals.model.license.noImageSell',
|
|
fallback: 'No selling generated content'
|
|
},
|
|
{
|
|
key: 'rentcivit',
|
|
icon: 'brush-off.svg',
|
|
titleKey: 'modals.model.license.noRentCivit',
|
|
fallback: 'No Civitai generation'
|
|
},
|
|
{
|
|
key: 'rent',
|
|
icon: 'world-off.svg',
|
|
titleKey: 'modals.model.license.noRent',
|
|
fallback: 'No generation services'
|
|
},
|
|
{
|
|
key: 'sell',
|
|
icon: 'shopping-cart-off.svg',
|
|
titleKey: 'modals.model.license.noSell',
|
|
fallback: 'No selling models'
|
|
}
|
|
];
|
|
|
|
let navigationKeyHandler = null;
|
|
let navigationModelType = null;
|
|
let navigationInProgress = false;
|
|
|
|
function hasLicenseField(license, field) {
|
|
return Object.prototype.hasOwnProperty.call(license || {}, field);
|
|
}
|
|
|
|
function indentMarkup(markup, spaces) {
|
|
if (!markup) {
|
|
return '';
|
|
}
|
|
const padding = ' '.repeat(spaces);
|
|
return markup
|
|
.split('\n')
|
|
.map(line => (line ? padding + line : line))
|
|
.join('\n');
|
|
}
|
|
|
|
function splitAggregateCommercialValue(value) {
|
|
const trimmed = String(value ?? '').trim();
|
|
const looksAggregate = trimmed.includes(',') || (trimmed.startsWith('{') && trimmed.endsWith('}'));
|
|
if (!looksAggregate) {
|
|
return [value];
|
|
}
|
|
|
|
let inner = trimmed;
|
|
if (inner.startsWith('{') && inner.endsWith('}')) {
|
|
inner = inner.slice(1, -1);
|
|
}
|
|
|
|
const parts = inner
|
|
.split(',')
|
|
.map(part => part.trim())
|
|
.filter(Boolean);
|
|
|
|
return parts.length ? parts : [value];
|
|
}
|
|
|
|
function normalizeCommercialValues(value) {
|
|
if (!value && value !== '') {
|
|
return ['Sell'];
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
const flattened = [];
|
|
value.forEach(item => {
|
|
if (item === null || item === undefined) {
|
|
return;
|
|
}
|
|
if (typeof item === 'string') {
|
|
flattened.push(...splitAggregateCommercialValue(item));
|
|
return;
|
|
}
|
|
flattened.push(String(item));
|
|
});
|
|
if (flattened.length > 0) {
|
|
return flattened;
|
|
}
|
|
if (value.length === 0) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
if (typeof value === 'string') {
|
|
return splitAggregateCommercialValue(value);
|
|
}
|
|
|
|
if (value && typeof value[Symbol.iterator] === 'function') {
|
|
const result = [];
|
|
for (const item of value) {
|
|
if (item === null || item === undefined) {
|
|
continue;
|
|
}
|
|
if (typeof item === 'string') {
|
|
result.push(...splitAggregateCommercialValue(item));
|
|
continue;
|
|
}
|
|
result.push(String(item));
|
|
}
|
|
if (result.length > 0) {
|
|
return result;
|
|
}
|
|
}
|
|
|
|
return ['Sell'];
|
|
}
|
|
|
|
function sanitiseCommercialValue(value) {
|
|
if (!value && value !== '') {
|
|
return '';
|
|
}
|
|
return String(value)
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[\s_-]+/g, '')
|
|
.replace(/[^a-z]/g, '');
|
|
}
|
|
|
|
function resolveCommercialRestrictions(value) {
|
|
const normalizedValues = normalizeCommercialValues(value);
|
|
const allowed = new Set();
|
|
normalizedValues.forEach(item => {
|
|
const cleaned = sanitiseCommercialValue(item);
|
|
if (!cleaned) {
|
|
return;
|
|
}
|
|
allowed.add(cleaned);
|
|
});
|
|
|
|
if (allowed.has('sell')) {
|
|
allowed.add('rent');
|
|
allowed.add('rentcivit');
|
|
allowed.add('image');
|
|
}
|
|
if (allowed.has('rent')) {
|
|
allowed.add('rentcivit');
|
|
}
|
|
|
|
const disallowed = [];
|
|
COMMERCIAL_ICON_CONFIG.forEach(config => {
|
|
if (!allowed.has(config.key)) {
|
|
disallowed.push(config);
|
|
}
|
|
});
|
|
return disallowed;
|
|
}
|
|
|
|
function createLicenseIconMarkup(icon, label) {
|
|
const safeLabel = escapeAttribute(label);
|
|
const iconPath = `/loras_static/images/tabler/${icon}`;
|
|
return `<span class="license-icon" role="img" aria-label="${safeLabel}" title="${safeLabel}" style="--license-icon-image: url('${iconPath}')"></span>`;
|
|
}
|
|
|
|
function renderLicenseIcons(modelData) {
|
|
const license = modelData?.civitai?.model;
|
|
if (!license) {
|
|
return '';
|
|
}
|
|
|
|
const icons = [];
|
|
if (hasLicenseField(license, 'allowNoCredit') && license.allowNoCredit === false) {
|
|
const label = translate('modals.model.license.creditRequired', {}, 'Creator credit required');
|
|
icons.push(createLicenseIconMarkup('user-check.svg', label));
|
|
}
|
|
|
|
if (hasLicenseField(license, 'allowCommercialUse')) {
|
|
const restrictions = resolveCommercialRestrictions(license.allowCommercialUse);
|
|
restrictions.forEach(({ icon, titleKey, fallback }) => {
|
|
const label = translate(titleKey, {}, fallback);
|
|
icons.push(createLicenseIconMarkup(icon, label));
|
|
});
|
|
}
|
|
|
|
if (hasLicenseField(license, 'allowDerivatives') && license.allowDerivatives === false) {
|
|
const label = translate('modals.model.license.noDerivatives', {}, 'No sharing merges');
|
|
icons.push(createLicenseIconMarkup('exchange-off.svg', label));
|
|
}
|
|
|
|
if (hasLicenseField(license, 'allowDifferentLicense') && license.allowDifferentLicense === false) {
|
|
const label = translate('modals.model.license.noReLicense', {}, 'Same permissions required');
|
|
icons.push(createLicenseIconMarkup('rotate-2.svg', label));
|
|
}
|
|
|
|
if (!icons.length) {
|
|
return '';
|
|
}
|
|
|
|
const containerLabel = translate('modals.model.license.restrictionsLabel', {}, 'License restrictions');
|
|
const safeContainerLabel = escapeAttribute(containerLabel);
|
|
return `<div class="license-restrictions" aria-label="${safeContainerLabel}" role="group">
|
|
${icons.join('\n ')}
|
|
</div>`;
|
|
}
|
|
|
|
// ── Set 2 (new CivitAI-style) permission icons ──
|
|
|
|
const NEW_LICENSE_ICON_CONFIG = [
|
|
{
|
|
key: 'commercial',
|
|
icon: 'currency-dollar.svg',
|
|
allowedFn: (license) => {
|
|
const uses = license.allowCommercialUse || [];
|
|
return uses.includes('Image') || uses.includes('Sell');
|
|
},
|
|
labelAllowed: 'Commercial use allowed',
|
|
labelDenied: 'No commercial use'
|
|
},
|
|
{
|
|
key: 'genServices',
|
|
icon: 'brush.svg',
|
|
allowedFn: (license) => {
|
|
const uses = license.allowCommercialUse || [];
|
|
return uses.includes('RentCivit') || uses.includes('Rent');
|
|
},
|
|
labelAllowed: 'Generation services allowed',
|
|
labelDenied: 'No generation services'
|
|
},
|
|
{
|
|
key: 'credit',
|
|
icon: 'user.svg',
|
|
allowedFn: (license) => !!license.allowNoCredit,
|
|
labelAllowed: 'No credit required',
|
|
labelDenied: 'Creator credit required'
|
|
},
|
|
{
|
|
key: 'derivatives',
|
|
icon: 'git-merge.svg',
|
|
allowedFn: (license) => !!license.allowDerivatives,
|
|
labelAllowed: 'Merges allowed',
|
|
labelDenied: 'No merges allowed'
|
|
},
|
|
{
|
|
key: 'relicense',
|
|
icon: 'license.svg',
|
|
allowedFn: (license) => !!license.allowDifferentLicense,
|
|
labelAllowed: 'Different permissions allowed on merges',
|
|
labelDenied: 'Same permissions required on merges'
|
|
}
|
|
];
|
|
|
|
function createNewLicenseIconMarkup(icon, allowed, label) {
|
|
const safeLabel = escapeAttribute(label);
|
|
const iconPath = `/loras_static/images/tabler/${icon}`;
|
|
const stateClass = allowed ? 'allowed' : 'denied';
|
|
return `<span class="license-icon-new ${stateClass}" role="img" aria-label="${safeLabel}" title="${safeLabel}" style="--license-icon-image: url('${iconPath}')"></span>`;
|
|
}
|
|
|
|
function renderNewLicenseIcons(modelData) {
|
|
const license = modelData?.civitai?.model;
|
|
if (!license) {
|
|
return '';
|
|
}
|
|
|
|
const icons = [];
|
|
NEW_LICENSE_ICON_CONFIG.forEach((config) => {
|
|
if (config.key === 'credit' && !hasLicenseField(license, 'allowNoCredit')) {
|
|
return;
|
|
}
|
|
if (config.key === 'derivatives' && !hasLicenseField(license, 'allowDerivatives')) {
|
|
return;
|
|
}
|
|
if (config.key === 'relicense' && !hasLicenseField(license, 'allowDifferentLicense')) {
|
|
return;
|
|
}
|
|
if ((config.key === 'commercial' || config.key === 'genServices') && !hasLicenseField(license, 'allowCommercialUse')) {
|
|
return;
|
|
}
|
|
const allowed = config.allowedFn(license);
|
|
const label = allowed ? config.labelAllowed : config.labelDenied;
|
|
icons.push(createNewLicenseIconMarkup(config.icon, allowed, label));
|
|
});
|
|
|
|
if (!icons.length) {
|
|
return '';
|
|
}
|
|
|
|
const containerLabel = translate('modals.model.license.restrictionsLabel', {}, 'License permissions');
|
|
const safeContainerLabel = escapeAttribute(containerLabel);
|
|
return `<div class="license-permissions" aria-label="${safeContainerLabel}" role="group">
|
|
${icons.join('\n ')}
|
|
</div>`;
|
|
}
|
|
|
|
/**
|
|
* 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 async function showModelModal(model, modelType) {
|
|
const modalId = 'modelModal';
|
|
const modalTitle = model.model_name;
|
|
cleanupNavigationShortcuts();
|
|
detachModalHandlers(modalId);
|
|
|
|
// Fetch complete civitai metadata
|
|
let completeCivitaiData = model.civitai || {};
|
|
if (model.file_path) {
|
|
try {
|
|
const fullMetadata = await getModelApiClient().fetchModelMetadata(model.file_path);
|
|
completeCivitaiData = fullMetadata || model.civitai || {};
|
|
} catch (error) {
|
|
console.warn('Failed to fetch complete metadata, using existing data:', error);
|
|
// Continue with existing data if fetch fails
|
|
}
|
|
}
|
|
|
|
// Update model with complete civitai data
|
|
const modelWithFullData = {
|
|
...model,
|
|
civitai: completeCivitaiData
|
|
};
|
|
const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || '');
|
|
const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A');
|
|
// De-emphasized hash display: a borderless full-width footnote line below
|
|
// the info grid — sha256 middle-truncated (first 10 + last 6), autov3 in
|
|
// full (12 chars); the full value is copied via data-hash.
|
|
const modelSha256 = modelWithFullData.sha256 || '';
|
|
const modelAutov3 = modelWithFullData.autov3 || '';
|
|
const truncatedSha256 = modelSha256.length > 16
|
|
? `${modelSha256.slice(0, 10)}\u2026${modelSha256.slice(-6)}`
|
|
: modelSha256;
|
|
const copyHashTitle = translate('modals.model.actions.copyHash', {}, 'Copy hash');
|
|
const hashEntries = [];
|
|
if (modelSha256) {
|
|
hashEntries.push(`
|
|
<span class="hash-entry">
|
|
<span class="hash-kind">SHA256</span>
|
|
<span class="model-hash-value" title="${escapeAttribute(modelSha256)}">${escapeHtml(truncatedSha256)}</span>
|
|
<button class="hash-copy-btn" data-action="copy-hash" data-hash="${escapeAttribute(modelSha256)}" title="${copyHashTitle}">
|
|
<i class="fas fa-copy"></i>
|
|
</button>
|
|
</span>`);
|
|
}
|
|
if (modelAutov3) {
|
|
hashEntries.push(`
|
|
<span class="hash-entry">
|
|
<span class="hash-kind">AutoV3</span>
|
|
<span class="model-hash-value" title="${escapeAttribute(modelAutov3)}">${escapeHtml(modelAutov3)}</span>
|
|
<button class="hash-copy-btn" data-action="copy-hash" data-hash="${escapeAttribute(modelAutov3)}" title="${copyHashTitle}">
|
|
<i class="fas fa-copy"></i>
|
|
</button>
|
|
</span>`);
|
|
}
|
|
const hashesMarkup = modelSha256 && hashEntries.length ? `
|
|
<div class="hash-footnote" aria-label="${translate('modals.model.metadata.hashes', {}, 'Hashes')}">${hashEntries.join('<span class="hash-sep">·</span>')}
|
|
</div>` : '';
|
|
const useNewIcons = state.global.settings.use_new_license_icons !== false;
|
|
const licenseIcons = useNewIcons
|
|
? renderNewLicenseIcons(modelWithFullData)
|
|
: renderLicenseIcons(modelWithFullData);
|
|
const viewOnCivitaiAction = modelWithFullData.from_civitai ? `
|
|
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${escapedFilePathAttr}">
|
|
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
|
|
</div>`.trim() : '';
|
|
const escapedHfUrl = modelWithFullData.hf_url ? escapeAttribute(modelWithFullData.hf_url) : '';
|
|
const viewOnHuggingFaceAction = escapedHfUrl ? `
|
|
<div class="civitai-view" title="${translate('modals.model.actions.viewOnHuggingFace', {}, 'View on Hugging Face')}" data-action="view-huggingface" data-hf-url="${escapedHfUrl}">
|
|
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnHuggingFaceText', {}, 'View on Hugging Face')}
|
|
</div>`.trim() : '';
|
|
const creatorInfoAction = modelWithFullData.civitai?.creator ? `
|
|
<div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}">
|
|
${modelWithFullData.civitai.creator.image ?
|
|
`<div class="creator-avatar">
|
|
<img src="${modelWithFullData.civitai.creator.image}" alt="${modelWithFullData.civitai.creator.username}" onerror="this.onerror=null; this.src='/loras_static/icons/user-placeholder.png';">
|
|
</div>` :
|
|
`<div class="creator-avatar creator-placeholder">
|
|
<i class="fas fa-user"></i>
|
|
</div>`
|
|
}
|
|
<span class="creator-username">${modelWithFullData.civitai.creator.username}</span>
|
|
</div>`.trim() : '';
|
|
const creatorActionItems = [];
|
|
if (viewOnCivitaiAction) {
|
|
creatorActionItems.push(indentMarkup(viewOnCivitaiAction, 24));
|
|
}
|
|
if (viewOnHuggingFaceAction) {
|
|
creatorActionItems.push(indentMarkup(viewOnHuggingFaceAction, 24));
|
|
}
|
|
if (creatorInfoAction) {
|
|
creatorActionItems.push(indentMarkup(creatorInfoAction, 24));
|
|
}
|
|
const creatorActionsMarkup = creatorActionItems.length
|
|
? [
|
|
' <div class="creator-actions">',
|
|
creatorActionItems.join('\n'),
|
|
' </div>'
|
|
].join('\n')
|
|
: '';
|
|
const headerActionItems = [];
|
|
|
|
// Add send to ComfyUI button for all model types
|
|
const sendToWorkflowTitle = translate('modals.model.actions.sendToWorkflow', {}, 'Send to ComfyUI');
|
|
const sendToWorkflowButton = `
|
|
<button class="modal-send-btn" data-action="send-to-workflow" data-model-type="${modelType}" title="${sendToWorkflowTitle}">
|
|
<i class="fas fa-paper-plane"></i>
|
|
<span>${translate('modals.model.actions.sendToWorkflowText', {}, 'Send to ComfyUI')}</span>
|
|
</button>
|
|
`.trim();
|
|
headerActionItems.push(indentMarkup(sendToWorkflowButton, 20));
|
|
|
|
if (creatorActionsMarkup) {
|
|
headerActionItems.push(creatorActionsMarkup);
|
|
}
|
|
if (licenseIcons) {
|
|
headerActionItems.push(indentMarkup(licenseIcons.trim(), 20));
|
|
}
|
|
|
|
// Destructive action stays last (rightmost). The license icons' auto
|
|
// margin right-anchors the [license][delete] cluster as one group.
|
|
const deleteModelTitle = translate('modals.model.actions.deleteModelWithShortcut', {}, 'Delete model (Del)');
|
|
const deleteModelButton = `
|
|
<button class="modal-delete-btn" data-action="delete-model" title="${deleteModelTitle}" aria-label="${deleteModelTitle}">
|
|
<i class="fas fa-trash" aria-hidden="true"></i>
|
|
</button>
|
|
`.trim();
|
|
headerActionItems.push(indentMarkup(deleteModelButton, 20));
|
|
|
|
const headerActionsMarkup = headerActionItems.length
|
|
? [
|
|
' <div class="modal-header-actions">',
|
|
headerActionItems.join('\n'),
|
|
' </div>'
|
|
].join('\n')
|
|
: '';
|
|
const hasUpdateAvailable = Boolean(modelWithFullData.update_available);
|
|
const updateAvailabilityState = { hasUpdateAvailable };
|
|
const updateBadgeTooltip = translate('modelCard.badges.updateAvailable', {}, 'Update available');
|
|
|
|
// Prepare LoRA specific data with complete civitai data
|
|
const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ?
|
|
modelWithFullData.civitai.trainedWords : [];
|
|
|
|
// Generate model type specific content
|
|
let typeSpecificContent;
|
|
if (modelType === 'loras') {
|
|
typeSpecificContent = renderLoraSpecificContent(modelWithFullData, escapedWords);
|
|
} else if (modelType === 'embeddings') {
|
|
typeSpecificContent = renderEmbeddingSpecificContent(modelWithFullData, escapedWords);
|
|
} else {
|
|
typeSpecificContent = '';
|
|
}
|
|
|
|
// Generate tabs based on model type
|
|
const examplesText = translate('modals.model.tabs.examples', {}, 'Examples');
|
|
const descriptionText = translate('modals.model.tabs.description', {}, 'Model Description');
|
|
const recipesText = translate('modals.model.tabs.recipes', {}, 'Recipes');
|
|
const versionsText = translate('modals.model.tabs.versions', {}, 'Versions');
|
|
const versionsBadgeLabel = translate('modelCard.badges.update', {}, 'Update');
|
|
const versionsTabBadge = hasUpdateAvailable
|
|
? `<span class="tab-badge tab-badge--update">${versionsBadgeLabel}</span>`
|
|
: '';
|
|
const versionsTabClasses = ['tab-btn'];
|
|
if (hasUpdateAvailable) {
|
|
versionsTabClasses.push('tab-btn--has-update');
|
|
}
|
|
const versionsTabButton = `<button class="${versionsTabClasses.join(' ')}" data-tab="versions">
|
|
<span class="tab-label">${versionsText}</span>
|
|
${versionsTabBadge}
|
|
</button>`.trim();
|
|
|
|
const supportsRecipesTab = modelType === 'loras' || modelType === 'checkpoints';
|
|
|
|
const tabsContent = supportsRecipesTab ?
|
|
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
|
|
<button class="tab-btn" data-tab="description">${descriptionText}</button>
|
|
${versionsTabButton}
|
|
<button class="tab-btn" data-tab="recipes">${recipesText}</button>` :
|
|
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
|
|
<button class="tab-btn" data-tab="description">${descriptionText}</button>
|
|
${versionsTabButton}`;
|
|
|
|
const loadingExampleImagesText = translate('modals.model.loading.exampleImages', {}, 'Loading example images...');
|
|
const loadingDescriptionText = translate('modals.model.loading.description', {}, 'Loading model description...');
|
|
const loadingRecipesText = translate('modals.model.loading.recipes', {}, 'Loading recipes...');
|
|
const loadingExamplesText = translate('modals.model.loading.examples', {}, 'Loading examples...');
|
|
|
|
const loadingVersionsText = translate('modals.model.loading.versions', {}, 'Loading versions...');
|
|
// Use CivitAI modelId, or derive HF group key for HF-only models
|
|
let civitaiModelId = modelWithFullData.civitai?.modelId || '';
|
|
if (!civitaiModelId && modelWithFullData.hf_url) {
|
|
const match = modelWithFullData.hf_url.match(/https?:\/\/huggingface\.co\/([^/]+\/[^/]+)/);
|
|
if (match) {
|
|
civitaiModelId = 'hf:' + match[1];
|
|
}
|
|
}
|
|
const civitaiVersionId = modelWithFullData.civitai?.id || '';
|
|
const navAriaLabel = translate('modals.model.navigation.label', {}, 'Model navigation');
|
|
const previousTitle = translate('modals.model.navigation.previousWithShortcut', {}, 'Previous model (←)');
|
|
const nextTitle = translate('modals.model.navigation.nextWithShortcut', {}, 'Next model (→)');
|
|
const navigationControls = `
|
|
<div class="modal-nav-controls" role="group" aria-label="${navAriaLabel}">
|
|
<button class="modal-nav-btn" data-action="nav-prev" title="${previousTitle}" aria-label="${previousTitle}">
|
|
<i class="fas fa-chevron-left" aria-hidden="true"></i>
|
|
</button>
|
|
<button class="modal-nav-btn" data-action="nav-next" title="${nextTitle}" aria-label="${nextTitle}">
|
|
<i class="fas fa-chevron-right" aria-hidden="true"></i>
|
|
</button>
|
|
</div>`.trim();
|
|
|
|
const tabPanesContent = supportsRecipesTab ?
|
|
`<div id="showcase-tab" class="tab-pane active">
|
|
<div class="example-images-loading">
|
|
<i class="fas fa-spinner fa-spin"></i> ${loadingExampleImagesText}
|
|
</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> ${loadingDescriptionText}
|
|
</div>
|
|
<div class="model-description-content hidden">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="versions-tab" class="tab-pane">
|
|
<div class="model-versions-tab" data-model-id="${civitaiModelId}" data-model-type="${modelType}" data-current-version-id="${civitaiVersionId}">
|
|
<div class="versions-loading-state">
|
|
<i class="fas fa-spinner fa-spin"></i> ${loadingVersionsText}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="recipes-tab" class="tab-pane">
|
|
<div class="recipes-loading">
|
|
<i class="fas fa-spinner fa-spin"></i> ${loadingRecipesText}
|
|
</div>
|
|
</div>` :
|
|
`<div id="showcase-tab" class="tab-pane active">
|
|
<div class="recipes-loading">
|
|
<i class="fas fa-spinner fa-spin"></i> ${loadingExamplesText}
|
|
</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> ${loadingDescriptionText}
|
|
</div>
|
|
<div class="model-description-content hidden">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="versions-tab" class="tab-pane">
|
|
<div class="model-versions-tab" data-model-id="${civitaiModelId}" data-model-type="${modelType}" data-current-version-id="${civitaiVersionId}">
|
|
<div class="versions-loading-state">
|
|
<i class="fas fa-spinner fa-spin"></i> ${loadingVersionsText}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
const content = `
|
|
<div class="modal-content">
|
|
<button class="close" onclick="modalManager.closeModal('${modalId}')">×</button>
|
|
<header class="modal-header">
|
|
<div class="modal-header-row">
|
|
<div class="model-name-header">
|
|
<h2 class="model-name-content">${modalTitle}</h2>
|
|
<button class="edit-model-name-btn" title="${translate('modals.model.actions.editModelName', {}, 'Edit model name')}">
|
|
<i class="fas fa-pencil-alt"></i>
|
|
</button>
|
|
</div>
|
|
|
|
${navigationControls}
|
|
</div>
|
|
|
|
${headerActionsMarkup}
|
|
|
|
${renderCompactTags(modelWithFullData.tags || [], modelWithFullData.file_path)}
|
|
</header>
|
|
|
|
<div class="modal-body">
|
|
<div class="info-section">
|
|
<div class="info-grid">
|
|
<div class="info-item">
|
|
<label>${translate('modals.model.metadata.version', {}, 'Version')}</label>
|
|
<div class="version-name-wrapper">
|
|
<span class="version-name-content">${modelWithFullData.civitai?.name || 'N/A'}</span>
|
|
<button class="edit-version-name-btn" title="${translate('modals.model.actions.editVersionName', {}, 'Edit version name')}">
|
|
<i class="fas fa-pencil-alt"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="info-item">
|
|
<label>${translate('modals.model.metadata.fileName', {}, 'File Name')}</label>
|
|
<div class="file-name-wrapper">
|
|
<span id="file-name" class="file-name-content">${modelWithFullData.file_name || 'N/A'}</span>
|
|
<button class="edit-file-name-btn" title="${translate('modals.model.actions.editFileName', {}, 'Edit file name')}">
|
|
<i class="fas fa-pencil-alt"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="info-item">
|
|
<div class="location-wrapper">
|
|
<label>${translate('modals.model.metadata.location', {}, 'Location')}</label>
|
|
<span class="file-path" title="${translate('modals.model.actions.openFileLocation', {}, 'Open file location')}"
|
|
data-action="open-file-location"
|
|
data-filepath="${escapedFilePathAttr}">
|
|
${escapedFolderPath}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="info-item base-size">
|
|
<div class="base-wrapper">
|
|
<label>${translate('modals.model.metadata.baseModel', {}, 'Base Model')}</label>
|
|
<div class="base-model-display">
|
|
<span class="base-model-content">${modelWithFullData.base_model || translate('modals.model.metadata.unknown', {}, 'Unknown')}</span>
|
|
<button class="edit-base-model-btn" title="${translate('modals.model.actions.editBaseModel', {}, 'Edit base model')}">
|
|
<i class="fas fa-pencil-alt"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="size-wrapper">
|
|
<label>${translate('modals.model.metadata.size', {}, 'Size')}</label>
|
|
<span>${formatFileSize(modelWithFullData.file_size)}</span>
|
|
</div>
|
|
</div>
|
|
${hashesMarkup}
|
|
${typeSpecificContent}
|
|
<div class="info-item notes">
|
|
<div class="notes-header">
|
|
<label>${translate('modals.model.metadata.additionalNotes', {}, 'Additional Notes')} <i class="fas fa-info-circle notes-hint" title="${translate('modals.model.metadata.notesHint', {}, 'Press Enter to save, Shift+Enter for new line')}"></i></label>
|
|
<button class="notes-toggle-btn" style="display:none" title="${translate('modals.model.notes.showMore', {}, 'Show more')}">
|
|
<i class="fas fa-chevron-down"></i>
|
|
</button>
|
|
</div>
|
|
<div class="editable-field">
|
|
<div class="notes-content" contenteditable="true" spellcheck="false">${modelWithFullData.notes || translate('modals.model.metadata.addNotesPlaceholder', {}, 'Add your notes here...')}</div>
|
|
</div>
|
|
</div>
|
|
<div class="info-item full-width">
|
|
<label>${translate('modals.model.metadata.aboutThisVersion', {}, 'About this version')}</label>
|
|
<div class="description-text">${modelWithFullData.civitai?.description || 'N/A'}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="showcase-section" data-model-hash="${modelWithFullData.sha256 || ''}" data-model-name="${escapeAttribute(modelWithFullData.file_name || modelWithFullData.model_name || '')}" data-model-type="${modelType}" data-filepath="${escapedFilePathAttr}">
|
|
<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>
|
|
`;
|
|
|
|
function updateVersionsTabBadge(hasUpdate) {
|
|
const modalElement = document.getElementById(modalId);
|
|
if (!modalElement) return;
|
|
|
|
const tabButton = modalElement.querySelector('.tab-btn[data-tab="versions"]');
|
|
if (!tabButton) return;
|
|
|
|
tabButton.classList.toggle('tab-btn--has-update', hasUpdate);
|
|
|
|
let badge = tabButton.querySelector('.tab-badge--update');
|
|
if (hasUpdate) {
|
|
if (!badge) {
|
|
badge = document.createElement('span');
|
|
badge.className = 'tab-badge tab-badge--update';
|
|
badge.textContent = versionsBadgeLabel;
|
|
badge.title = updateBadgeTooltip;
|
|
tabButton.appendChild(badge);
|
|
} else {
|
|
badge.textContent = versionsBadgeLabel;
|
|
badge.title = updateBadgeTooltip;
|
|
}
|
|
} else if (badge) {
|
|
badge.remove();
|
|
}
|
|
}
|
|
|
|
function updateCardUpdateAvailability(hasUpdate) {
|
|
const filePath = modelWithFullData.file_path;
|
|
if (!filePath) return;
|
|
|
|
let updatedViaScroller = false;
|
|
if (state.virtualScroller?.updateSingleItem) {
|
|
updatedViaScroller = state.virtualScroller.updateSingleItem(filePath, {
|
|
update_available: hasUpdate,
|
|
});
|
|
}
|
|
|
|
if (updatedViaScroller) {
|
|
return;
|
|
}
|
|
|
|
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
|
? window.CSS.escape(filePath)
|
|
: filePath.replace(/["\\]/g, '\\$&');
|
|
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
|
if (!card) return;
|
|
|
|
card.dataset.update_available = hasUpdate ? 'true' : 'false';
|
|
card.classList.toggle('has-update', hasUpdate);
|
|
|
|
const headerInfo = card.querySelector('.card-header-info');
|
|
if (!headerInfo) return;
|
|
|
|
let badge = headerInfo.querySelector('.model-update-badge');
|
|
if (hasUpdate) {
|
|
if (!badge) {
|
|
badge = document.createElement('span');
|
|
badge.className = 'model-update-badge';
|
|
badge.title = updateBadgeTooltip;
|
|
badge.textContent = versionsBadgeLabel;
|
|
headerInfo.appendChild(badge);
|
|
}
|
|
} else if (badge) {
|
|
badge.remove();
|
|
}
|
|
}
|
|
|
|
function handleUpdateStatusChange(hasUpdate) {
|
|
if (updateAvailabilityState.hasUpdateAvailable === hasUpdate) {
|
|
return;
|
|
}
|
|
updateAvailabilityState.hasUpdateAvailable = hasUpdate;
|
|
updateVersionsTabBadge(hasUpdate);
|
|
updateCardUpdateAvailability(hasUpdate);
|
|
}
|
|
|
|
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;
|
|
}
|
|
cleanupNavigationShortcuts();
|
|
};
|
|
|
|
modalManager.showModal(modalId, content, null, onCloseCallback);
|
|
const activeModalElement = document.getElementById(modalId);
|
|
if (activeModalElement) {
|
|
activeModalElement.dataset.filePath = modelWithFullData.file_path || '';
|
|
// Store usage_tips for LoRA models
|
|
if (modelType === 'loras' && modelWithFullData.usage_tips) {
|
|
activeModalElement.dataset.usageTips = modelWithFullData.usage_tips;
|
|
}
|
|
// Store sub_type for checkpoint models
|
|
if (modelType === 'checkpoints' && modelWithFullData.sub_type) {
|
|
activeModalElement.dataset.subType = modelWithFullData.sub_type;
|
|
}
|
|
// Store folder for embedding models
|
|
if (modelType === 'embeddings' && modelWithFullData.folder) {
|
|
activeModalElement.dataset.folder = modelWithFullData.folder;
|
|
}
|
|
// Show the back-to-top button once the modal content is scrolled
|
|
const modalContent = activeModalElement.querySelector('.modal-content');
|
|
const backToTopBtn = activeModalElement.querySelector('.back-to-top');
|
|
if (modalContent && backToTopBtn) {
|
|
modalContent.addEventListener('scroll', () => {
|
|
backToTopBtn.classList.toggle('visible', modalContent.scrollTop > 300);
|
|
});
|
|
}
|
|
}
|
|
updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable);
|
|
const versionsTabController = initVersionsTab({
|
|
modalId,
|
|
modelType,
|
|
modelId: civitaiModelId,
|
|
currentVersionId: civitaiVersionId,
|
|
currentBaseModel: modelWithFullData.base_model,
|
|
modelName: model.model_name,
|
|
onUpdateStatusChange: handleUpdateStatusChange,
|
|
});
|
|
setupEditableFields(modelWithFullData.file_path, modelType);
|
|
setupTabSwitching({
|
|
onTabChange: async (tab) => {
|
|
if (tab === 'versions') {
|
|
await versionsTabController.load();
|
|
}
|
|
},
|
|
});
|
|
versionsTabController.load({ eager: true });
|
|
setupTagTooltip();
|
|
setupTagEditMode(modelType);
|
|
setupModelNameEditing(modelWithFullData.file_path);
|
|
setupVersionNameEditing(modelWithFullData.file_path);
|
|
setupBaseModelEditing(modelWithFullData.file_path);
|
|
setupFileNameEditing(modelWithFullData.file_path);
|
|
setupEventHandlers(modelWithFullData.file_path, modelType);
|
|
setupNavigationShortcuts(modelType);
|
|
updateNavigationControls();
|
|
|
|
// Model-specific setup
|
|
if (modelType === 'loras' || modelType === 'embeddings') {
|
|
setupTriggerWordsEditMode();
|
|
}
|
|
|
|
if (modelType === 'loras') {
|
|
loadRecipesForModel({
|
|
modelKind: 'lora',
|
|
displayName: modelWithFullData.model_name,
|
|
sha256: modelWithFullData.sha256,
|
|
});
|
|
} else if (modelType === 'checkpoints') {
|
|
loadRecipesForModel({
|
|
modelKind: 'checkpoint',
|
|
displayName: modelWithFullData.model_name,
|
|
sha256: modelWithFullData.sha256,
|
|
});
|
|
}
|
|
|
|
// Load example images asynchronously - merge regular and custom images
|
|
const regularImages = modelWithFullData.civitai?.images || [];
|
|
const customImages = modelWithFullData.civitai?.customImages || [];
|
|
// Combine images - regular images first, then custom images
|
|
const allImages = [...regularImages, ...customImages];
|
|
loadExampleImages(allImages, modelWithFullData.sha256, modelWithFullData.preview_url || '');
|
|
}
|
|
|
|
function renderLoraSpecificContent(lora, escapedWords) {
|
|
return `
|
|
<div class="info-item usage-tips">
|
|
<label>${translate('modals.model.metadata.usageTips', {}, 'Usage Tips')}</label>
|
|
<div class="editable-field">
|
|
<div class="preset-controls">
|
|
<select id="preset-selector">
|
|
<option value="">${translate('modals.model.usageTips.addPresetParameter', {}, 'Add preset parameter...')}</option>
|
|
<option value="strength_min">${translate('modals.model.usageTips.strengthMin', {}, 'Strength Min')}</option>
|
|
<option value="strength_max">${translate('modals.model.usageTips.strengthMax', {}, 'Strength Max')}</option>
|
|
<option value="strength_range">${translate('modals.model.usageTips.strengthRange', {}, 'Strength Range')}</option>
|
|
<option value="strength">${translate('modals.model.usageTips.strength', {}, 'Strength')}</option>
|
|
<option value="clip_strength">${translate('modals.model.usageTips.clipStrength', {}, 'Clip Strength')}</option>
|
|
<option value="clip_skip">${translate('modals.model.usageTips.clipSkip', {}, 'Clip Skip')}</option>
|
|
</select>
|
|
<!-- autofill opt-out attrs prevent password managers / email-alias extensions from attaching popups -->
|
|
<input type="number" id="preset-value" step="0.01" placeholder="${translate('modals.model.usageTips.valuePlaceholder', {}, 'Value')}" style="display:none;" autocomplete="off" data-1p-ignore data-lpignore="true" data-bwignore data-form-type="other">
|
|
<button class="add-preset-btn" disabled>${translate('modals.model.usageTips.add', {}, 'Add')}</button>
|
|
</div>
|
|
<div class="preset-tags">
|
|
${renderPresetTags(parsePresets(lora.usage_tips))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
${renderTriggerWords(escapedWords, lora.file_path)}
|
|
`;
|
|
}
|
|
|
|
function renderEmbeddingSpecificContent(embedding, escapedWords) {
|
|
return `${renderTriggerWords(escapedWords, embedding.file_path)}`;
|
|
}
|
|
|
|
function detachModalHandlers(modalId) {
|
|
const modalElement = document.getElementById(modalId);
|
|
if (modalElement && modalElement._clickHandler) {
|
|
modalElement.removeEventListener('click', modalElement._clickHandler);
|
|
delete modalElement._clickHandler;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets up event handlers using event delegation for LoRA modal
|
|
* @param {string} filePath - Path to the model file
|
|
* @param {string} modelType - Current model type
|
|
*/
|
|
function setupEventHandlers(filePath, modelType) {
|
|
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;
|
|
case 'view-civitai':
|
|
openCivitai(target.dataset.filepath);
|
|
break;
|
|
case 'view-huggingface':
|
|
if (target.dataset.hfUrl) {
|
|
window.open(target.dataset.hfUrl, '_blank', 'noopener,noreferrer');
|
|
}
|
|
break;
|
|
case 'view-creator':
|
|
const username = target.dataset.username;
|
|
if (username) {
|
|
const host = state.global.settings.civitai_host || 'civitai.com';
|
|
window.open(`https://${host}/user/${username}`, '_blank');
|
|
}
|
|
break;
|
|
case 'open-file-location':
|
|
const filePath = target.dataset.filepath || getModalFilePath();
|
|
if (filePath) {
|
|
openFileLocation(filePath);
|
|
}
|
|
break;
|
|
case 'nav-prev':
|
|
handleDirectionalNavigation('prev', modelType);
|
|
break;
|
|
case 'nav-next':
|
|
handleDirectionalNavigation('next', modelType);
|
|
break;
|
|
case 'send-to-workflow':
|
|
handleSendToWorkflow(target, modelType);
|
|
break;
|
|
case 'delete-model':
|
|
handleDeleteModel();
|
|
break;
|
|
case 'copy-hash':
|
|
if (target.dataset.hash) {
|
|
copyToClipboard(target.dataset.hash, 'Hash copied to clipboard');
|
|
}
|
|
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 (notes and usage tips) in the model modal
|
|
* @param {string} filePath - The full file path of the model
|
|
* @param {string} modelType - Type of model ('loras' or 'checkpoints' or 'embeddings')
|
|
*/
|
|
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();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Setup adaptive expand/collapse for notes
|
|
setupNotesExpand();
|
|
|
|
// LoRA specific field setup
|
|
if (modelType === 'loras') {
|
|
setupLoraSpecificFields(filePath);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adaptive expand/collapse for the Additional Notes section.
|
|
* Measures content height synchronously after render (before first paint,
|
|
* so no visual flash). If notes fit within ~4 lines, no toggle is shown.
|
|
* If they exceed the threshold, the field collapses with a gradient fade
|
|
* and a "Show more" button appears.
|
|
*/
|
|
function setupNotesExpand() {
|
|
const notesContainer = document.querySelector('.info-item.notes');
|
|
if (!notesContainer) return;
|
|
|
|
const notesField = notesContainer.querySelector('.editable-field');
|
|
const notesContent = notesContainer.querySelector('.notes-content');
|
|
const toggleBtn = notesContainer.querySelector('.notes-toggle-btn');
|
|
|
|
if (!notesField || !notesContent || !toggleBtn) return;
|
|
|
|
const placeholderText = translate('modals.model.metadata.addNotesPlaceholder', {}, 'Add your notes here...');
|
|
const content = notesContent.textContent || '';
|
|
const isEmpty = !content.trim() || content === placeholderText;
|
|
|
|
if (isEmpty) {
|
|
return;
|
|
}
|
|
|
|
// CSS default has no constraints, so scrollHeight reflects full content
|
|
const contentHeight = notesContent.scrollHeight;
|
|
const collapsedThreshold = 95; // ~4 lines
|
|
|
|
if (contentHeight <= collapsedThreshold) {
|
|
return;
|
|
}
|
|
|
|
// Long content — collapse and show toggle
|
|
notesField.classList.add('collapsed');
|
|
toggleBtn.style.display = 'inline-flex';
|
|
toggleBtn.title = translate('modals.model.notes.showMore', {}, 'Show more');
|
|
|
|
const toggleIcon = toggleBtn.querySelector('i');
|
|
|
|
toggleBtn.addEventListener('click', function onClick() {
|
|
const isCollapsed = notesField.classList.contains('collapsed');
|
|
if (isCollapsed) {
|
|
notesField.classList.remove('collapsed');
|
|
toggleBtn.title = translate('modals.model.notes.showLess', {}, 'Show less');
|
|
toggleIcon.className = 'fas fa-chevron-up';
|
|
notesField.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
} else {
|
|
notesField.classList.add('collapsed');
|
|
toggleBtn.title = translate('modals.model.notes.showMore', {}, 'Show more');
|
|
toggleIcon.className = 'fas fa-chevron-down';
|
|
}
|
|
});
|
|
}
|
|
|
|
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');
|
|
const resolveFilePath = () => getModalFilePath(filePath);
|
|
|
|
if (!presetSelector || !presetValue || !addPresetBtn || !presetTags) return;
|
|
|
|
// Add button stays disabled until both a parameter and a value are provided
|
|
const updateAddPresetButtonState = () => {
|
|
addPresetBtn.disabled = !(presetSelector.value && presetValue.value.trim());
|
|
};
|
|
|
|
presetSelector.addEventListener('change', function () {
|
|
const selected = this.value;
|
|
if (selected) {
|
|
presetValue.style.display = 'inline-block';
|
|
if (selected === 'strength_range') {
|
|
presetValue.type = 'text';
|
|
presetValue.placeholder = 'e.g. 0.8-1.2';
|
|
presetValue.removeAttribute('min');
|
|
presetValue.removeAttribute('max');
|
|
presetValue.removeAttribute('step');
|
|
} else {
|
|
presetValue.type = 'number';
|
|
presetValue.placeholder = translate('modals.model.usageTips.valuePlaceholder', {}, 'Value');
|
|
presetValue.min = selected.includes('strength') ? -10 : 0;
|
|
presetValue.max = selected.includes('strength') ? 10 : 10;
|
|
presetValue.step = 0.5;
|
|
if (selected === 'clip_skip') {
|
|
presetValue.step = 1;
|
|
}
|
|
}
|
|
// Add auto-focus
|
|
setTimeout(() => presetValue.focus(), 0);
|
|
} else {
|
|
presetValue.style.display = 'none';
|
|
}
|
|
updateAddPresetButtonState();
|
|
});
|
|
|
|
presetValue.addEventListener('input', updateAddPresetButtonState);
|
|
|
|
addPresetBtn.addEventListener('click', async function () {
|
|
const key = presetSelector.value;
|
|
const value = presetValue.value.trim();
|
|
|
|
// Unreachable via UI while the button is disabled; kept as a safety net
|
|
if (!key || !value) return;
|
|
|
|
const currentPath = resolveFilePath();
|
|
if (!currentPath) return;
|
|
const escapedCurrentPath = window.CSS && typeof window.CSS.escape === 'function'
|
|
? window.CSS.escape(currentPath)
|
|
: currentPath.replace(/["\\]/g, '\\$&');
|
|
const escapedFilePath = window.CSS && typeof window.CSS.escape === 'function'
|
|
? window.CSS.escape(filePath)
|
|
: filePath.replace(/["\\]/g, '\\$&');
|
|
const loraCard = document.querySelector(`.model-card[data-filepath="${escapedCurrentPath}"]`) ||
|
|
document.querySelector(`.model-card[data-filepath="${escapedFilePath}"]`);
|
|
const currentPresets = parsePresets(loraCard?.dataset.usage_tips);
|
|
|
|
let isUpdate;
|
|
if (key === 'strength_range') {
|
|
const rangeMatch = value.match(/^(-?\d*\.?\d+)\s*[-~]\s*(-?\d*\.?\d+)$/);
|
|
if (rangeMatch) {
|
|
isUpdate = 'strength_min' in currentPresets || 'strength_max' in currentPresets;
|
|
currentPresets['strength_min'] = parseFloat(rangeMatch[1]);
|
|
currentPresets['strength_max'] = parseFloat(rangeMatch[2]);
|
|
} else {
|
|
showToast('modals.model.usageTips.invalidRange', {}, 'error', 'Invalid range format. Use x.x-y.y');
|
|
return;
|
|
}
|
|
} else {
|
|
const numericValue = parseFloat(value);
|
|
if (!Number.isFinite(numericValue)) {
|
|
showToast('modals.model.usageTips.invalidValue', {}, 'error', 'Please enter a valid number');
|
|
return;
|
|
}
|
|
isUpdate = key in currentPresets;
|
|
currentPresets[key] = numericValue;
|
|
}
|
|
const newPresetsJson = JSON.stringify(currentPresets);
|
|
|
|
try {
|
|
await getModelApiClient().saveModelMetadata(currentPath, { usage_tips: newPresetsJson });
|
|
} catch (error) {
|
|
console.error('Failed to save preset parameter:', error);
|
|
showToast('modals.model.usageTips.saveFailed', {}, 'error', 'Failed to save preset parameter');
|
|
return;
|
|
}
|
|
|
|
presetTags.innerHTML = renderPresetTags(currentPresets);
|
|
showToast(
|
|
isUpdate ? 'modals.model.usageTips.updated' : 'modals.model.usageTips.added',
|
|
{},
|
|
'success',
|
|
isUpdate ? 'Preset parameter updated' : 'Preset parameter added'
|
|
);
|
|
|
|
presetSelector.value = '';
|
|
presetValue.value = '';
|
|
presetValue.style.display = 'none';
|
|
addPresetBtn.disabled = true;
|
|
});
|
|
|
|
// Add keydown event for preset value
|
|
presetValue.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
addPresetBtn.click();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Save model notes using the current modal file path.
|
|
*/
|
|
async function saveNotes() {
|
|
const filePath = getModalFilePath();
|
|
if (!filePath) {
|
|
return;
|
|
}
|
|
const content = document.querySelector('.notes-content').textContent;
|
|
try {
|
|
await getModelApiClient().saveModelMetadata(filePath, { notes: content });
|
|
|
|
showToast('modals.model.notes.saved', {}, 'success');
|
|
} catch (error) {
|
|
showToast('modals.model.notes.saveFailed', {}, 'error');
|
|
}
|
|
}
|
|
|
|
function shouldIgnoreNavigationKey(event) {
|
|
const target = event.target;
|
|
if (!target) return false;
|
|
const tagName = target.tagName ? target.tagName.toLowerCase() : '';
|
|
return target.isContentEditable || ['input', 'textarea', 'select', 'button'].includes(tagName);
|
|
}
|
|
|
|
function updateNavigationControls() {
|
|
const modalElement = document.getElementById('modelModal');
|
|
if (!modalElement) return;
|
|
|
|
const prevBtn = modalElement.querySelector('[data-action="nav-prev"]');
|
|
const nextBtn = modalElement.querySelector('[data-action="nav-next"]');
|
|
if (!prevBtn || !nextBtn) return;
|
|
|
|
const scroller = state.virtualScroller;
|
|
if (!scroller || typeof scroller.getNavigationState !== 'function') {
|
|
prevBtn.disabled = true;
|
|
nextBtn.disabled = true;
|
|
return;
|
|
}
|
|
|
|
const { hasPrev, hasNext } = scroller.getNavigationState(modalElement.dataset.filePath || '');
|
|
prevBtn.disabled = navigationInProgress || !hasPrev;
|
|
nextBtn.disabled = navigationInProgress || !hasNext;
|
|
}
|
|
|
|
function cleanupNavigationShortcuts() {
|
|
if (navigationKeyHandler) {
|
|
document.removeEventListener('keydown', navigationKeyHandler);
|
|
navigationKeyHandler = null;
|
|
}
|
|
navigationModelType = null;
|
|
navigationInProgress = false;
|
|
}
|
|
|
|
function setupNavigationShortcuts(modelType) {
|
|
const modalElement = document.getElementById('modelModal');
|
|
if (!modalElement) return;
|
|
|
|
cleanupNavigationShortcuts();
|
|
navigationModelType = modelType;
|
|
|
|
navigationKeyHandler = (event) => {
|
|
if (shouldIgnoreNavigationKey(event)) return;
|
|
|
|
if (event.key === 'ArrowLeft') {
|
|
event.preventDefault();
|
|
handleDirectionalNavigation('prev', navigationModelType);
|
|
} else if (event.key === 'ArrowRight') {
|
|
event.preventDefault();
|
|
handleDirectionalNavigation('next', navigationModelType);
|
|
} else if (event.key === 'Delete') {
|
|
event.preventDefault();
|
|
handleDeleteModel();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', navigationKeyHandler);
|
|
}
|
|
|
|
/**
|
|
* Open the shared delete confirmation for the model currently shown in the
|
|
* modal. Showing the delete modal replaces this modal (ModalManager only
|
|
* keeps one modal open), which also unregisters these shortcuts.
|
|
*/
|
|
function handleDeleteModel() {
|
|
const filePath = getModalFilePath();
|
|
if (!filePath) return;
|
|
showDeleteModal(filePath);
|
|
}
|
|
|
|
async function handleDirectionalNavigation(direction, modelType) {
|
|
if (navigationInProgress) return;
|
|
|
|
const modalElement = document.getElementById('modelModal');
|
|
const scroller = state.virtualScroller;
|
|
const filePath = modalElement?.dataset?.filePath || '';
|
|
|
|
if (!modalElement || !filePath || !scroller || typeof scroller.getAdjacentItemByFilePath !== 'function') {
|
|
return;
|
|
}
|
|
|
|
navigationInProgress = true;
|
|
updateNavigationControls();
|
|
|
|
try {
|
|
const adjacent = await scroller.getAdjacentItemByFilePath(filePath, direction);
|
|
if (!adjacent || !adjacent.item) {
|
|
const toastKey = direction === 'prev' ? 'modals.model.navigation.noPrevious' : 'modals.model.navigation.noNext';
|
|
const toastFallback = direction === 'prev' ? 'No previous model available' : 'No next model available';
|
|
showToast(toastKey, {}, 'info', toastFallback);
|
|
return;
|
|
}
|
|
|
|
navigationModelType = modelType || navigationModelType;
|
|
await showModelModal(adjacent.item, navigationModelType || modelType);
|
|
} finally {
|
|
navigationInProgress = false;
|
|
updateNavigationControls();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Call backend to open file location and select the file
|
|
* @param {string} filePath
|
|
*/
|
|
async function openFileLocation(filePath) {
|
|
try {
|
|
const resp = await fetch('/api/lm/open-file-location', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ 'file_path': filePath })
|
|
});
|
|
if (!resp.ok) throw new Error('Failed to open file location');
|
|
|
|
const data = await resp.json();
|
|
|
|
if (data.mode === 'clipboard' && data.path) {
|
|
try {
|
|
await navigator.clipboard.writeText(data.path);
|
|
showToast('modals.model.openFileLocation.copied', { path: data.path }, 'success');
|
|
} catch (clipboardErr) {
|
|
console.warn('Clipboard API not available:', clipboardErr);
|
|
showToast('modals.model.openFileLocation.clipboardFallback', { path: data.path }, 'info');
|
|
}
|
|
} else {
|
|
showToast('modals.model.openFileLocation.success', {}, 'success');
|
|
}
|
|
} catch (err) {
|
|
showToast('modals.model.openFileLocation.failed', {}, 'error');
|
|
}
|
|
}
|
|
|
|
async function handleSendToWorkflow(target, modelType) {
|
|
const filePath = getModalFilePath();
|
|
if (!filePath) {
|
|
showToast('modals.model.sendToWorkflow.noFilePath', {}, 'error');
|
|
return;
|
|
}
|
|
|
|
// Get the current model data from the modal
|
|
const modalElement = document.getElementById('modelModal');
|
|
const currentFileName = modalElement?.querySelector('#file-name')?.textContent || '';
|
|
|
|
if (modelType === 'loras') {
|
|
// For LoRA: Build syntax from usage tips and send
|
|
const usageTipsData = modalElement?.dataset?.usageTips;
|
|
const usageTips = usageTipsData ? JSON.parse(usageTipsData) : {};
|
|
const loraSyntax = buildLoraSyntax(currentFileName, usageTips);
|
|
await sendLoraToWorkflow(loraSyntax, false, 'lora');
|
|
} else if (modelType === 'checkpoints') {
|
|
// For Checkpoint: Send model path
|
|
const subtype = (modalElement?.dataset?.subType || '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'
|
|
);
|
|
|
|
await sendModelPathToWorkflow(filePath, {
|
|
widgetName,
|
|
collectionType: MODEL_TYPES.CHECKPOINT,
|
|
actionTypeText,
|
|
successMessage,
|
|
failureMessage,
|
|
missingNodesMessage,
|
|
missingTargetMessage,
|
|
});
|
|
} else if (modelType === 'embeddings') {
|
|
const folder = modalElement?.dataset?.folder || '';
|
|
const name = currentFileName.replace(/\.[^.]+$/, '');
|
|
const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
|
|
await sendEmbeddingToWorkflow(embeddingCode);
|
|
}
|
|
}
|
|
|
|
// Export the model modal API
|
|
const modelModal = {
|
|
show: showModelModal,
|
|
scrollToTop
|
|
};
|
|
|
|
export { modelModal };
|