feat: enhance model version context with file metadata

- Rename `preview_overrides` to `version_context` to better reflect expanded purpose
- Add file_path and file_name fields to version serialization
- Update method names and parameter signatures for consistency
- Include file metadata from cache in version context building
- Maintain backward compatibility with existing preview URL functionality

The changes provide more comprehensive version information including file details while maintaining existing preview override behavior.
This commit is contained in:
Will Miao
2025-10-26 08:53:53 +08:00
parent 9ca2b9dd56
commit 795b9e8418
20 changed files with 1542 additions and 51 deletions

View File

@@ -0,0 +1,335 @@
.model-versions-tab {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-2) 0;
}
.versions-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2);
background: color-mix(in oklch, var(--lora-surface) 70%, transparent);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
}
.versions-toolbar-info h3 {
margin: 0 0 4px;
font-size: 1.05rem;
font-weight: 600;
color: var(--text-color);
}
.versions-toolbar-info p {
margin: 0;
font-size: 0.85rem;
color: var(--text-muted);
}
.versions-toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.versions-toolbar-btn {
appearance: none;
border-radius: var(--border-radius-xs);
padding: 8px 14px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.versions-toolbar-btn-primary {
background: var(--lora-accent);
color: #fff;
border-color: color-mix(in oklch, var(--lora-accent) 70%, transparent);
}
.versions-toolbar-btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
background: color-mix(in oklch, var(--lora-accent) 85%, transparent);
}
.versions-toolbar-btn-secondary {
background: transparent;
color: var(--text-muted);
border-color: var(--border-color);
}
.versions-toolbar-btn-secondary:hover:not(:disabled) {
color: var(--text-color);
}
.versions-toolbar-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.versions-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.version-divider {
height: 1px;
background: var(--border-color);
margin: var(--space-1) 0;
}
.model-version-row {
display: grid;
grid-template-columns: 124px 1fr auto;
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
background: color-mix(in oklch, var(--card-bg) 92%, var(--bg-color) 8%);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
[data-theme="dark"] .model-version-row {
background: color-mix(in oklch, var(--card-bg) 88%, black 12%);
}
.model-version-row:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.model-version-row.is-current {
border-color: var(--lora-accent);
box-shadow: 0 0 0 1px color-mix(in oklch, var(--lora-accent) 65%, transparent),
0 10px 22px rgba(0, 0, 0, 0.12);
}
.version-media {
width: 124px;
height: 88px;
border-radius: var(--border-radius-xs);
overflow: hidden;
background: rgba(0, 0, 0, 0.03);
display: flex;
align-items: center;
justify-content: center;
border: 1px solid color-mix(in oklch, var(--border-color) 70%, transparent);
}
.version-media img,
.version-media video {
width: 100%;
height: 100%;
object-fit: cover;
}
.version-media video {
background: #000;
}
.version-media-placeholder {
font-size: 0.85rem;
color: var(--text-muted);
border-style: dashed;
border-width: 1px;
}
.version-details {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.version-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 0.95rem;
color: color-mix(in oklch, var(--text-color) 92%, black 8%);
}
.version-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.version-id {
font-size: 0.75rem;
color: var(--text-muted);
font-weight: 500;
}
.version-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.version-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid transparent;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.version-badge-info {
background: color-mix(in oklch, var(--badge-update-bg) 25%, transparent);
color: var(--badge-update-bg);
border-color: color-mix(in oklch, var(--badge-update-bg) 55%, transparent);
}
.version-badge-success {
background: color-mix(in oklch, var(--lora-success) 25%, transparent);
color: var(--lora-success);
border-color: color-mix(in oklch, var(--lora-success) 50%, transparent);
}
.version-badge-muted {
background: color-mix(in oklch, var(--text-muted) 18%, transparent);
color: var(--text-muted);
border-color: color-mix(in oklch, var(--text-muted) 40%, transparent);
}
.version-badge-current {
background: color-mix(in oklch, var(--lora-accent) 22%, transparent);
color: var(--lora-accent);
border-color: color-mix(in oklch, var(--lora-accent) 55%, transparent);
}
.version-meta {
font-size: 0.8rem;
color: var(--text-muted);
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.version-meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.version-meta-primary {
font-weight: 600;
color: color-mix(in oklch, var(--text-color) 88%, var(--lora-accent) 12%);
}
.version-meta-separator {
color: color-mix(in oklch, var(--text-muted) 90%, var(--text-color) 10%);
}
.version-actions {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-end;
}
.version-action {
min-width: 128px;
padding: 7px 12px;
border-radius: var(--border-radius-xs);
border: 1px solid transparent;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.version-action-primary {
background: var(--lora-accent);
color: #fff;
border-color: color-mix(in oklch, var(--lora-accent) 65%, transparent);
}
.version-action-primary:hover {
transform: translateY(-1px);
background: color-mix(in oklch, var(--lora-accent) 85%, transparent);
}
.version-action-danger {
background: transparent;
border-color: color-mix(in oklch, var(--lora-error) 60%, transparent);
color: var(--lora-error);
}
.version-action-danger:hover {
background: color-mix(in oklch, var(--lora-error) 12%, transparent);
}
.version-action-ghost {
background: transparent;
border-color: var(--border-color);
color: var(--text-color);
}
.version-action-ghost:hover {
background: color-mix(in oklch, var(--lora-surface) 35%, transparent);
}
.version-action:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.versions-loading-state,
.versions-empty,
.versions-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: var(--space-3);
border: 1px dashed var(--lora-border);
border-radius: var(--border-radius-sm);
color: var(--text-muted);
text-align: center;
}
.versions-error {
border-style: solid;
border-color: color-mix(in oklch, var(--lora-error) 45%, transparent);
color: var(--lora-error);
}
.versions-empty i,
.versions-error i {
font-size: 1.25rem;
}
@media (max-width: 900px) {
.model-version-row {
grid-template-columns: 1fr;
align-items: stretch;
}
.version-actions {
flex-direction: row;
justify-content: flex-end;
flex-wrap: wrap;
}
.version-action {
min-width: 0;
}
}

View File

@@ -27,6 +27,7 @@
@import 'components/lora-modal/preset-tags.css';
@import 'components/lora-modal/showcase.css';
@import 'components/lora-modal/triggerwords.css';
@import 'components/lora-modal/versions.css';
@import 'components/shared/edit-metadata.css';
@import 'components/search-filter.css';
@import 'components/bulk.css';
@@ -55,4 +56,4 @@
/* 使用已有的loading-spinner样式 */
.initialization-notice .loading-spinner {
margin-bottom: var(--space-2);
}
}

View File

@@ -77,6 +77,10 @@ export function getApiEndpoints(modelType) {
relinkCivitai: `/api/lm/${modelType}/relink-civitai`,
civitaiVersions: `/api/lm/${modelType}/civitai/versions`,
refreshUpdates: `/api/lm/${modelType}/updates/refresh`,
modelUpdateStatus: `/api/lm/${modelType}/updates/status`,
modelUpdateVersions: `/api/lm/${modelType}/updates/versions`,
ignoreModelUpdate: `/api/lm/${modelType}/updates/ignore`,
ignoreVersionUpdate: `/api/lm/${modelType}/updates/ignore-version`,
// Preview management
replacePreview: `/api/lm/${modelType}/replace-preview`,

View File

@@ -592,6 +592,73 @@ export class BaseModelApiClient {
}
}
async fetchModelUpdateVersions(modelId, { refresh = false, force = false } = {}) {
try {
const params = new URLSearchParams();
if (refresh) params.append('refresh', 'true');
if (force) params.append('force', 'true');
const query = params.toString();
const requestUrl = `${this.apiConfig.endpoints.modelUpdateVersions}/${modelId}${query ? `?${query}` : ''}`;
const response = await fetch(requestUrl);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to fetch model versions');
}
return await response.json();
} catch (error) {
console.error('Error fetching model update versions:', error);
throw error;
}
}
async setModelUpdateIgnore(modelId, shouldIgnore) {
try {
const response = await fetch(this.apiConfig.endpoints.ignoreModelUpdate, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
modelId,
shouldIgnore,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to update model ignore status');
}
return await response.json();
} catch (error) {
console.error('Error updating model ignore status:', error);
throw error;
}
}
async setVersionUpdateIgnore(modelId, versionId, shouldIgnore) {
try {
const response = await fetch(this.apiConfig.endpoints.ignoreVersionUpdate, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
modelId,
versionId,
shouldIgnore,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to update version ignore status');
}
return await response.json();
} catch (error) {
console.error('Error updating version ignore status:', error);
throw error;
}
}
async fetchModelRoots() {
try {
const response = await fetch(this.apiConfig.endpoints.roots);
@@ -1162,4 +1229,4 @@ export class BaseModelApiClient {
completionMessage: translate('loras.bulkOperations.autoOrganizeProgress.complete', {}, 'Auto-organize complete')
});
}
}
}

View File

@@ -9,7 +9,8 @@ import { translate } from '../../utils/i18nHelpers.js';
/**
* Set up tab switching functionality
*/
export function setupTabSwitching() {
export function setupTabSwitching(options = {}) {
const { onTabChange } = options;
const tabButtons = document.querySelectorAll('.showcase-tabs .tab-btn');
tabButtons.forEach(button => {
@@ -31,6 +32,14 @@ export function setupTabSwitching() {
if (button.dataset.tab === 'description') {
await loadModelDescription();
}
if (typeof onTabChange === 'function') {
try {
await onTabChange(button.dataset.tab);
} catch (error) {
console.error('Error handling tab change:', error);
}
}
});
});
}
@@ -176,4 +185,4 @@ export async function setupModelDescriptionEditing(filePath) {
descContainer.classList.remove('editing');
editBtn.classList.remove('visible');
}
}
}

View File

@@ -17,6 +17,7 @@ import { getModelApiClient } from '../../api/modelApiFactory.js';
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
import { parsePresets, renderPresetTags } from './PresetTags.js';
import { initVersionsTab } from './ModelVersionsTab.js';
import { loadRecipesForLora } from './RecipeTab.js';
import { translate } from '../../utils/i18nHelpers.js';
@@ -65,19 +66,26 @@ export async function showModelModal(model, modelType) {
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 tabsContent = modelType === 'loras' ?
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
<button class="tab-btn" data-tab="description">${descriptionText}</button>
<button class="tab-btn" data-tab="recipes">${recipesText}</button>` :
<button class="tab-btn" data-tab="recipes">${recipesText}</button>
<button class="tab-btn" data-tab="versions">${versionsText}</button>` :
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
<button class="tab-btn" data-tab="description">${descriptionText}</button>`;
<button class="tab-btn" data-tab="description">${descriptionText}</button>
<button class="tab-btn" data-tab="versions">${versionsText}</button>`;
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...');
const civitaiModelId = modelWithFullData.civitai?.modelId || '';
const civitaiVersionId = modelWithFullData.civitai?.id || '';
const tabPanesContent = modelType === 'loras' ?
`<div id="showcase-tab" class="tab-pane active">
<div class="example-images-loading">
@@ -99,6 +107,14 @@ export async function showModelModal(model, modelType) {
<div class="recipes-loading">
<i class="fas fa-spinner fa-spin"></i> ${loadingRecipesText}
</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="showcase-tab" class="tab-pane active">
<div class="recipes-loading">
@@ -114,6 +130,14 @@ export async function showModelModal(model, modelType) {
<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 = `
@@ -232,9 +256,22 @@ export async function showModelModal(model, modelType) {
};
modalManager.showModal(modalId, content, null, onCloseCallback);
const versionsTabController = initVersionsTab({
modalId,
modelType,
modelId: civitaiModelId,
currentVersionId: civitaiVersionId,
});
setupEditableFields(modelWithFullData.file_path, modelType);
setupShowcaseScroll(modalId);
setupTabSwitching();
setupTabSwitching({
onTabChange: async (tab) => {
if (tab === 'versions') {
await versionsTabController.load();
}
},
});
versionsTabController.load({ eager: true });
setupTagTooltip();
setupTagEditMode(modelType);
setupModelNameEditing(modelWithFullData.file_path);

View File

@@ -0,0 +1,563 @@
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { downloadManager } from '../../managers/DownloadManager.js';
import { showToast } from '../../utils/uiHelpers.js';
import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js';
import { formatFileSize } from './utils.js';
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
function escapeHtml(value) {
if (value == null) {
return '';
}
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function isVideoUrl(url) {
if (!url || typeof url !== 'string') {
return false;
}
try {
const parsed = new URL(url, window.location.origin);
const pathname = parsed.pathname || '';
const extension = pathname.slice(pathname.lastIndexOf('.')).toLowerCase();
return VIDEO_EXTENSIONS.includes(extension);
} catch (error) {
const normalized = url.split('?')[0].toLowerCase();
return VIDEO_EXTENSIONS.some(ext => normalized.endsWith(ext));
}
}
function formatDateLabel(value) {
if (!value) {
return null;
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return null;
}
return parsed.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
function buildMetaMarkup(version) {
const segments = [];
if (version.baseModel) {
segments.push(
`<span class="version-meta-primary">${escapeHtml(version.baseModel)}</span>`
);
}
const releaseLabel = formatDateLabel(version.releasedAt);
if (releaseLabel) {
segments.push(escapeHtml(releaseLabel));
}
if (typeof version.sizeBytes === 'number' && version.sizeBytes > 0) {
segments.push(escapeHtml(formatFileSize(version.sizeBytes)));
}
if (!segments.length) {
return escapeHtml(
translate('modals.model.versions.labels.noDetails', {}, 'No additional details')
);
}
return segments
.map(segment => `<span class="version-meta-item">${segment}</span>`)
.join('<span class="version-meta-separator">•</span>');
}
function buildBadge(label, tone) {
return `<span class="version-badge version-badge-${tone}">${escapeHtml(label)}</span>`;
}
function getAutoplaySetting() {
try {
return Boolean(state?.global?.settings?.autoplay_on_hover);
} catch (error) {
return false;
}
}
function renderMediaMarkup(version) {
if (!version.previewUrl) {
const placeholderText = translate('modals.model.versions.media.placeholder', {}, 'No preview');
return `<div class="version-media version-media-placeholder">${escapeHtml(placeholderText)}</div>`;
}
if (isVideoUrl(version.previewUrl)) {
const autoplayOnHover = getAutoplaySetting();
return `
<div class="version-media">
<video
src="${escapeHtml(version.previewUrl)}"
${autoplayOnHover ? '' : 'controls'}
muted
loop
playsinline
preload="metadata"
data-autoplay-on-hover="${autoplayOnHover ? 'true' : 'false'}"
></video>
</div>
`;
}
return `
<div class="version-media">
<img src="${escapeHtml(version.previewUrl)}" alt="${escapeHtml(version.name || 'preview')}">
</div>
`;
}
function renderRow(version, options) {
const { latestLibraryVersionId, currentVersionId } = options;
const isCurrent = currentVersionId && version.versionId === currentVersionId;
const isNewer =
typeof latestLibraryVersionId === 'number' &&
version.versionId > latestLibraryVersionId;
const badges = [];
if (isCurrent) {
badges.push(buildBadge(translate('modals.model.versions.badges.current', {}, 'Current Version'), 'current'));
}
if (version.isInLibrary) {
badges.push(buildBadge(translate('modals.model.versions.badges.inLibrary', {}, 'In Library'), 'success'));
} else if (isNewer && !version.shouldIgnore) {
badges.push(buildBadge(translate('modals.model.versions.badges.newer', {}, 'Newer Version'), 'info'));
}
if (version.shouldIgnore) {
badges.push(buildBadge(translate('modals.model.versions.badges.ignored', {}, 'Ignored'), 'muted'));
}
const downloadLabel = translate('modals.model.versions.actions.download', {}, 'Download');
const deleteLabel = translate('modals.model.versions.actions.delete', {}, 'Delete');
const ignoreLabel = translate(
version.shouldIgnore
? 'modals.model.versions.actions.unignore'
: 'modals.model.versions.actions.ignore',
{},
version.shouldIgnore ? 'Unignore' : 'Ignore'
);
const actions = [];
if (!version.isInLibrary) {
actions.push(
`<button class="version-action version-action-primary" data-version-action="download">${escapeHtml(downloadLabel)}</button>`
);
} else if (version.filePath) {
actions.push(
`<button class="version-action version-action-danger" data-version-action="delete">${escapeHtml(deleteLabel)}</button>`
);
}
actions.push(
`<button class="version-action version-action-ghost" data-version-action="toggle-ignore" data-ignore-state="${
version.shouldIgnore ? 'ignored' : 'active'
}">${escapeHtml(ignoreLabel)}</button>`
);
return `
<div class="model-version-row${isCurrent ? ' is-current' : ''}" data-version-id="${escapeHtml(version.versionId)}">
${renderMediaMarkup(version)}
<div class="version-details">
<div class="version-title">
<span class="version-name">${escapeHtml(version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version'))}</span>
<span class="version-id">#${escapeHtml(version.versionId)}</span>
</div>
<div class="version-badges">${badges.join('')}</div>
<div class="version-meta">
${buildMetaMarkup(version)}
</div>
</div>
<div class="version-actions">
${actions.join('')}
</div>
</div>
`;
}
function setupMediaHoverInteractions(container) {
const autoplayOnHover = getAutoplaySetting();
if (!autoplayOnHover) {
return;
}
container.querySelectorAll('.version-media video').forEach(video => {
if (video.dataset.autoplayOnHover !== 'true') {
return;
}
const play = () => {
try {
video.currentTime = 0;
const promise = video.play();
if (promise && typeof promise.catch === 'function') {
promise.catch(() => {});
}
} catch (error) {
console.debug('Failed to autoplay preview video:', error);
}
};
const stop = () => {
video.pause();
video.currentTime = 0;
};
video.addEventListener('mouseenter', play);
video.addEventListener('focus', play);
video.addEventListener('mouseleave', stop);
video.addEventListener('blur', stop);
});
}
function getLatestLibraryVersionId(record) {
if (!record || !Array.isArray(record.inLibraryVersionIds) || !record.inLibraryVersionIds.length) {
return null;
}
return Math.max(...record.inLibraryVersionIds);
}
function renderToolbar(record) {
const ignoreText = record.shouldIgnore
? translate('modals.model.versions.actions.resumeModelUpdates', {}, 'Resume updates for this model')
: translate('modals.model.versions.actions.ignoreModelUpdates', {}, 'Ignore updates for this model');
const viewLocalText = translate('modals.model.versions.actions.viewLocalVersions', {}, 'View all local versions');
const infoText = translate(
'modals.model.versions.copy',
{ count: record.versions.length },
'Track and manage every version of this model in one place.'
);
return `
<header class="versions-toolbar">
<div class="versions-toolbar-info">
<h3>${translate('modals.model.versions.heading', {}, 'Model versions')}</h3>
<p>${escapeHtml(infoText)}</p>
</div>
<div class="versions-toolbar-actions">
<button class="versions-toolbar-btn versions-toolbar-btn-primary" data-versions-action="toggle-model-ignore">
${escapeHtml(ignoreText)}
</button>
<button class="versions-toolbar-btn versions-toolbar-btn-secondary" data-versions-action="view-local" title="${escapeHtml(translate('modals.model.versions.actions.viewLocalTooltip', {}, 'Coming soon'))}" disabled>
${escapeHtml(viewLocalText)}
</button>
</div>
</header>
`;
}
function renderEmptyState(container) {
const message = translate('modals.model.versions.empty', {}, 'No version history available for this model yet.');
container.innerHTML = `
<div class="versions-empty">
<i class="fas fa-info-circle"></i>
<p>${escapeHtml(message)}</p>
</div>
`;
}
function renderErrorState(container, message) {
const fallback = translate('modals.model.versions.error', {}, 'Failed to load versions.');
container.innerHTML = `
<div class="versions-error">
<i class="fas fa-exclamation-triangle"></i>
<p>${escapeHtml(message || fallback)}</p>
</div>
`;
}
export function initVersionsTab({
modalId,
modelType,
modelId,
currentVersionId,
}) {
const pane = document.querySelector(`#${modalId} #versions-tab`);
const container = pane ? pane.querySelector('.model-versions-tab') : null;
const normalizedCurrentVersionId =
typeof currentVersionId === 'number'
? currentVersionId
: currentVersionId
? Number(currentVersionId)
: null;
if (!container) {
return {
async load() {},
async refresh() {},
};
}
let controller = {
isLoading: false,
hasLoaded: false,
record: null,
};
let apiClient;
function ensureClient() {
if (apiClient) {
return apiClient;
}
try {
apiClient = getModelApiClient(modelType);
} catch (error) {
apiClient = getModelApiClient();
}
return apiClient;
}
function showLoading() {
const loadingText = translate('modals.model.loading.versions', {}, 'Loading versions...');
container.innerHTML = `
<div class="versions-loading-state">
<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(loadingText)}
</div>
`;
}
function render(record) {
controller.record = record;
controller.hasLoaded = true;
if (!record || !Array.isArray(record.versions) || record.versions.length === 0) {
renderEmptyState(container);
return;
}
const latestLibraryVersionId = getLatestLibraryVersionId(record);
let dividerInserted = false;
const sortedVersions = [...record.versions].sort(
(a, b) => Number(b.versionId) - Number(a.versionId)
);
const rowsMarkup = sortedVersions
.map(version => {
const isNewer =
typeof latestLibraryVersionId === 'number' &&
version.versionId > latestLibraryVersionId;
let markup = '';
if (
!dividerInserted &&
typeof latestLibraryVersionId === 'number' &&
!isNewer
) {
dividerInserted = true;
markup += '<div class="version-divider" role="presentation"></div>';
}
markup += renderRow(version, {
latestLibraryVersionId,
currentVersionId: normalizedCurrentVersionId,
});
return markup;
})
.join('');
container.innerHTML = `
${renderToolbar(record)}
<div class="versions-list">
${rowsMarkup}
</div>
`;
setupMediaHoverInteractions(container);
}
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;
}
if (controller.hasLoaded && !forceRefresh) {
return;
}
controller.isLoading = true;
if (!eager) {
showLoading();
}
try {
const client = ensureClient();
const response = await client.fetchModelUpdateVersions(modelId, {
refresh: true,
});
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;
}
}
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 handleDeleteVersion(button, versionId) {
if (!controller.record) {
return;
}
const version = controller.record.versions.find(item => item.versionId === versionId);
if (!version?.filePath) {
return;
}
const confirmText = translate(
'modals.model.versions.confirm.delete',
{},
'Delete this version from your library?'
);
if (!window.confirm(confirmText)) {
return;
}
button.disabled = true;
try {
const client = ensureClient();
await client.deleteModel(version.filePath);
showToast(
translate('modals.model.versions.toast.versionDeleted', {}, 'Version deleted'),
{},
'success'
);
await refresh();
} catch (error) {
console.error('Failed to delete version:', error);
showToast(error?.message || 'Failed to delete version', {}, 'error');
} finally {
button.disabled = false;
}
}
function handleDownloadVersion(versionId) {
if (!controller.record) {
return;
}
downloadManager.openForModelVersion(modelType, modelId, versionId);
}
container.addEventListener('click', async event => {
const toolbarAction = event.target.closest('[data-versions-action]');
if (toolbarAction) {
const action = toolbarAction.dataset.versionsAction;
if (action === 'toggle-model-ignore') {
event.preventDefault();
await handleToggleModelIgnore(toolbarAction);
}
return;
}
const actionButton = event.target.closest('[data-version-action]');
if (!actionButton) {
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();
handleDownloadVersion(versionId);
break;
case 'delete':
event.preventDefault();
await handleDeleteVersion(actionButton, versionId);
break;
case 'toggle-ignore':
event.preventDefault();
await handleToggleVersionIgnore(actionButton, versionId);
break;
default:
break;
}
});
return {
load: options => loadVersions(options),
refresh,
};
}

View File

@@ -140,6 +140,14 @@ export class DownloadManager {
this.loadDefaultPathSetting();
}
async retrieveVersionsForModel(modelId, source = null) {
this.versions = await this.apiClient.fetchCivitaiVersions(modelId, source);
if (!this.versions || !this.versions.length) {
throw new Error(translate('modals.download.errors.noVersions'));
}
return this.versions;
}
async validateAndFetchVersions() {
const url = document.getElementById('modelUrl').value.trim();
const errorElement = document.getElementById('urlError');
@@ -152,12 +160,8 @@ export class DownloadManager {
throw new Error(translate('modals.download.errors.invalidUrl'));
}
this.versions = await this.apiClient.fetchCivitaiVersions(this.modelId, this.source);
if (!this.versions.length) {
throw new Error(translate('modals.download.errors.noVersions'));
}
await this.retrieveVersionsForModel(this.modelId, this.source);
// If we have a version ID from URL, pre-select it
if (this.modelVersionId) {
this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId);
@@ -171,6 +175,27 @@ export class DownloadManager {
}
}
async fetchVersionsForCurrentModel() {
const errorElement = document.getElementById('urlError');
if (errorElement) {
errorElement.textContent = '';
}
try {
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions'));
await this.retrieveVersionsForModel(this.modelId, this.source);
if (this.modelVersionId) {
this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId);
}
this.showVersionStep();
} catch (error) {
if (errorElement) {
errorElement.textContent = error.message;
}
} finally {
this.loadingManager.hide();
}
}
extractModelId(url) {
const versionMatch = url.match(/modelVersionId=(\d+)/i);
this.modelVersionId = versionMatch ? versionMatch[1] : null;
@@ -191,6 +216,26 @@ export class DownloadManager {
return null;
}
async openForModelVersion(modelType, modelId, versionId = null) {
try {
this.apiClient = getModelApiClient(modelType);
} catch (error) {
this.apiClient = getModelApiClient();
}
this.showDownloadModal();
this.modelId = modelId ? modelId.toString() : null;
this.modelVersionId = versionId ? versionId.toString() : null;
this.source = null;
if (!this.modelId) {
return;
}
await this.fetchVersionsForCurrentModel();
}
showVersionStep() {
document.getElementById('urlStep').style.display = 'none';
document.getElementById('versionStep').style.display = 'block';