`;
}
function renderEmptyState(container) {
const message = translate('modals.model.versions.empty', {}, 'No version history available for this model yet.');
container.innerHTML = `
${escapeHtml(message)}
`;
}
function renderFilteredEmptyState(baseModelLabel) {
const message = translate(
'modals.model.versions.filters.empty',
{ baseModel: baseModelLabel },
'No versions match the current base model filter.'
);
return `
${escapeHtml(message)}
`;
}
function renderErrorState(container, message) {
const fallback = translate('modals.model.versions.error', {}, 'Failed to load versions.');
container.innerHTML = `
`;
setupMediaHoverInteractions(container);
if (updateStatusChangeHandler) {
const resolvedFlag = resolveUpdateAvailability(
record,
currentBaseModel,
normalizedCurrentVersionId
);
if (resolvedFlag !== lastNotifiedUpdateState) {
lastNotifiedUpdateState = resolvedFlag;
updateStatusChangeHandler(resolvedFlag, record);
}
}
}
async function loadVersions({ forceRefresh = false, eager = false } = {}) {
if (controller.isLoading) {
return;
}
if (!modelId) {
renderErrorState(container, translate('modals.model.versions.missingModelId', {}, 'This model is missing a Civitai model id.'));
return;
}
// HF group keys (e.g. "hf:user/repo") are not real CivitAI model IDs —
// skip the remote API call and show a helpful message instead.
const isHfGroupKey = typeof modelId === 'string' && modelId.startsWith('hf:');
if (isHfGroupKey) {
controller.isLoading = false;
controller.hasLoaded = true;
controller.record = null;
const hfMsg = translate(
'modals.model.versions.hfGroupInfo',
{},
'This is a HuggingFace model group. Open the library to see all versions in the grid.'
);
container.innerHTML = `
${escapeHtml(hfMsg)}
`;
return;
}
if (controller.hasLoaded && !forceRefresh) {
return;
}
controller.isLoading = true;
if (!eager) {
showLoading();
}
try {
const client = ensureClient();
const response = await client.fetchModelUpdateVersions(modelId, {
refresh: false,
});
if (!response?.success) {
throw new Error(response?.error || 'Request failed');
}
render(response.record);
} catch (error) {
console.error('Failed to load model versions:', error);
renderErrorState(container, error?.message);
} finally {
controller.isLoading = false;
}
}
async function refresh() {
await loadVersions({ forceRefresh: true });
}
async function handleToggleModelIgnore(button) {
if (!controller.record) {
return;
}
const client = ensureClient();
const nextValue = !controller.record.shouldIgnore;
button.disabled = true;
try {
const response = await client.setModelUpdateIgnore(modelId, nextValue);
if (!response?.success) {
throw new Error(response?.error || 'Request failed');
}
render(response.record);
const toastKey = nextValue
? 'modals.model.versions.toast.modelIgnored'
: 'modals.model.versions.toast.modelResumed';
const toastMessage = translate(
toastKey,
{},
nextValue ? 'Updates ignored for this model' : 'Update tracking resumed'
);
showToast(toastMessage, {}, 'success');
} catch (error) {
console.error('Failed to update model ignore state:', error);
showToast(error?.message || 'Failed to update ignore preference', {}, 'error');
} finally {
button.disabled = false;
}
}
function handleToggleVersionDisplayMode() {
displayMode =
displayMode === DISPLAY_FILTER_MODES.SAME_BASE
? DISPLAY_FILTER_MODES.ANY
: DISPLAY_FILTER_MODES.SAME_BASE;
if (!controller.record) {
return;
}
render(controller.record);
}
function handleViewLocalVersions() {
if (!controller.record || !modelId) {
return;
}
// Determine base model filter based on current display mode
const baseModelInfo = getCurrentVersionBaseModel(controller.record, normalizedCurrentVersionId);
const isFilteringActive =
displayMode === DISPLAY_FILTER_MODES.SAME_BASE &&
Boolean(baseModelInfo.normalized);
// Write filter params to sessionStorage (page-scoped)
setSessionItem('vlm_model_id', String(modelId));
setSessionItem('vlm_model_name', modelName || String(modelId));
setSessionItem('vlm_page_type', modelType);
if (isFilteringActive) {
// Use raw (non-normalized) base model for exact backend matching
setSessionItem('vlm_base_model', baseModelInfo.raw);
} else {
removeSessionItem('vlm_base_model');
}
// Close the modal and navigate via no-reload VLM flow
modalManager.closeModal(modalId);
if (window.pageControls && typeof window.pageControls.triggerVlmView === 'function') {
window.pageControls.triggerVlmView(
modelId,
modelName || String(modelId),
isFilteringActive ? baseModelInfo.raw : undefined,
modelType
);
}
}
async function handleToggleVersionIgnore(button, versionId) {
if (!controller.record) {
return;
}
const client = ensureClient();
const targetVersion = controller.record.versions.find(v => v.versionId === versionId);
const nextValue = targetVersion ? !targetVersion.shouldIgnore : true;
button.disabled = true;
try {
const response = await client.setVersionUpdateIgnore(
modelId,
versionId,
nextValue
);
if (!response?.success) {
throw new Error(response?.error || 'Request failed');
}
render(response.record);
const updatedVersion = response.record.versions.find(v => v.versionId === versionId);
const toastKey = updatedVersion?.shouldIgnore
? 'modals.model.versions.toast.versionIgnored'
: 'modals.model.versions.toast.versionUnignored';
const toastMessage = translate(
toastKey,
{},
updatedVersion?.shouldIgnore ? 'Updates ignored for this version' : 'Version re-enabled'
);
showToast(toastMessage, {}, 'success');
} catch (error) {
console.error('Failed to toggle version ignore state:', error);
showToast(error?.message || 'Failed to update version preference', {}, 'error');
} finally {
button.disabled = false;
}
}
async function performDeleteVersion({
triggerButton,
confirmButton,
closeModal,
version,
}) {
if (!version?.filePath) {
console.warn('Missing file path for deletion.');
return;
}
if (triggerButton) {
triggerButton.disabled = true;
}
let confirmOriginalText = '';
if (confirmButton) {
confirmOriginalText = confirmButton.textContent;
confirmButton.disabled = true;
}
let deletionSucceeded = false;
try {
const client = ensureClient();
await client.deleteModel(version.filePath);
deletionSucceeded = true;
showToast(
translate('modals.model.versions.toast.versionDeleted', {}, 'Version deleted'),
{},
'success'
);
} catch (error) {
console.error('Failed to delete version:', error);
showToast(error?.message || 'Failed to delete version', {}, 'error');
} finally {
if (triggerButton && document.body.contains(triggerButton)) {
triggerButton.disabled = false;
}
if (
confirmButton &&
document.body.contains(confirmButton) &&
!deletionSucceeded
) {
confirmButton.disabled = false;
if (confirmOriginalText) {
confirmButton.textContent = confirmOriginalText;
}
}
}
if (!deletionSucceeded) {
return;
}
if (typeof closeModal === 'function') {
closeModal();
}
await refresh();
}
function showDeleteVersionModal(version, triggerButton) {
const modalRecord = modalManager?.getModal?.('deleteModal');
if (!modalRecord?.element) {
return false;
}
const deleteLabel = translate('modals.model.versions.actions.delete', {}, 'Delete');
const cancelLabel = translate('common.actions.cancel', {}, 'Cancel');
const title = translate('modals.model.versions.actions.delete', {}, 'Delete');
const confirmMessage = translate(
'modals.model.versions.confirm.delete',
{},
'Delete this version from your library?'
);
const versionName =
version.name ||
translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version');
const metaMarkup = buildMetaMarkup(version);
const previewMarkup = renderDeletePreview(version, versionName);
const modalElement = modalRecord.element;
const originalMarkup = modalElement.innerHTML;
const content = `
${escapeHtml(title)}
${escapeHtml(confirmMessage)}
${previewMarkup}
${escapeHtml(versionName)}
${
version.baseModel
? `
${escapeHtml(version.baseModel)}
`
: ''
}
${metaMarkup ? `
${metaMarkup}
` : ''}
`;
const cleanupHandlers = [];
modalManager.showModal(
'deleteModal',
content,
null,
() => {
cleanupHandlers.forEach(handler => {
try {
handler();
} catch (error) {
console.error('Failed to cleanup delete modal handler:', error);
}
});
cleanupHandlers.length = 0;
modalElement.innerHTML = originalMarkup;
delete modalElement.dataset.versionId;
}
);
modalElement.dataset.versionId = String(version.versionId ?? '');
const cancelButton = modalElement.querySelector('.cancel-btn');
const confirmButton = modalElement.querySelector('.delete-btn');
const closeModal = () => modalManager.closeModal('deleteModal');
if (cancelButton) {
const handleCancel = event => {
event.preventDefault();
closeModal();
};
cancelButton.addEventListener('click', handleCancel);
cleanupHandlers.push(() => {
cancelButton.removeEventListener('click', handleCancel);
});
}
if (confirmButton) {
const handleConfirm = async event => {
event.preventDefault();
await performDeleteVersion({
triggerButton,
confirmButton,
closeModal,
version,
});
};
confirmButton.addEventListener('click', handleConfirm);
cleanupHandlers.push(() => {
confirmButton.removeEventListener('click', handleConfirm);
});
}
return true;
}
async function handleDeleteVersion(button, versionId) {
if (!controller.record) {
return;
}
const version = controller.record.versions.find(item => item.versionId === versionId);
if (!version) {
console.warn('Target version missing from record for delete:', versionId);
return;
}
if (showDeleteVersionModal(version, button)) {
return;
}
const confirmText = translate(
'modals.model.versions.confirm.delete',
{},
'Delete this version from your library?'
);
if (!window.confirm(confirmText)) {
return;
}
await performDeleteVersion({
triggerButton: button,
version,
});
}
async function resolveDownloadPathFromCurrentVersion() {
if (!normalizedCurrentVersionId || !controller.record?.versions) {
return null;
}
const currentVersion = controller.record.versions.find(
v => v.versionId === normalizedCurrentVersionId && v.isInLibrary && v.filePath
);
if (!currentVersion?.filePath) {
return null;
}
try {
const client = ensureClient();
const rootsData = await client.fetchModelRoots();
const roots = rootsData?.roots;
if (!Array.isArray(roots) || roots.length === 0) {
return null;
}
const normalizedFilePath = currentVersion.filePath.replace(/\\/g, '/');
let matchedRoot = null;
let relativePath = null;
for (const root of roots) {
const normalizedRoot = root.replace(/\\/g, '/');
if (normalizedFilePath.startsWith(normalizedRoot)) {
matchedRoot = root;
relativePath = normalizedFilePath.slice(normalizedRoot.length);
if (relativePath.startsWith('/')) {
relativePath = relativePath.slice(1);
}
break;
}
}
if (!matchedRoot || !relativePath) {
return null;
}
const lastSlash = relativePath.lastIndexOf('/');
const targetFolder = lastSlash > 0 ? relativePath.slice(0, lastSlash) : '';
return { modelRoot: matchedRoot, targetFolder };
} catch (error) {
console.debug('Failed to resolve download path from current version:', error);
return null;
}
}
async function handleDownloadVersion(button, versionId) {
if (!controller.record) {
return;
}
const version = controller.record.versions.find(item => item.versionId === versionId);
if (!version) {
console.warn('Target version missing from record for download:', versionId);
return;
}
button.disabled = true;
try {
const pathInfo = await resolveDownloadPathFromCurrentVersion();
const success = await downloadManager.downloadVersionWithDefaults(modelType, modelId, versionId, {
versionName: version.name || `#${version.versionId}`,
modelRoot: pathInfo?.modelRoot || '',
targetFolder: pathInfo?.targetFolder || '',
});
if (success) {
await refresh();
}
} catch (error) {
console.error('Failed to start direct download for version:', error);
} finally {
if (document.body.contains(button)) {
button.disabled = false;
}
}
}
container.addEventListener('click', async event => {
const toolbarAction = event.target.closest('[data-versions-action]');
if (toolbarAction) {
const action = toolbarAction.dataset.versionsAction;
switch (action) {
case 'toggle-model-ignore':
event.preventDefault();
await handleToggleModelIgnore(toolbarAction);
break;
case 'toggle-version-display-mode':
event.preventDefault();
handleToggleVersionDisplayMode();
break;
case 'view-local':
event.preventDefault();
handleViewLocalVersions();
break;
default:
break;
}
return;
}
const actionButton = event.target.closest('[data-version-action]');
if (actionButton) {
// Check if browser extension has already handled this action
if (actionButton.dataset.lmExtensionHandled === 'true') {
return;
}
const row = actionButton.closest('.model-version-row');
if (!row) {
return;
}
const versionId = Number(row.dataset.versionId);
const action = actionButton.dataset.versionAction;
switch (action) {
case 'download':
event.preventDefault();
await handleDownloadVersion(actionButton, versionId);
break;
case 'delete':
event.preventDefault();
await handleDeleteVersion(actionButton, versionId);
break;
case 'toggle-ignore':
event.preventDefault();
await handleToggleVersionIgnore(actionButton, versionId);
break;
default:
break;
}
return;
}
const row = event.target.closest('.model-version-row.is-clickable');
const civitaiLink = event.target.closest('.version-civitai-link');
if (civitaiLink) {
event.preventDefault();
openCivitaiUrl(civitaiLink.href);
return;
}
if (!row) {
return;
}
if (event.target.closest('button')) {
return;
}
if (event.target.closest('.version-actions')) {
return;
}
if (event.target.closest('a')) {
return;
}
const targetUrl = row.dataset.civitaiUrl;
if (!targetUrl) {
return;
}
event.preventDefault();
openCivitaiUrl(targetUrl);
});
// Listen for extension-triggered refresh requests
container.addEventListener('lm:refreshVersions', async () => {
await refresh();
});
return {
load: options => loadVersions(options),
refresh,
};
}