diff --git a/tests/test_download_action_handlers.mjs b/tests/test_download_action_handlers.mjs
index 6b0ad48..e7e156e 100644
--- a/tests/test_download_action_handlers.mjs
+++ b/tests/test_download_action_handlers.mjs
@@ -62,6 +62,7 @@ function createDownloadDialog(queueList, calls) {
cancelDownload: id => calls.push(['cancel', id]),
pauseDownload: id => calls.push(['pause', id]),
resumeDownload: id => calls.push(['resume', id]),
+ retryDownload: id => calls.push(['retry', id]),
openContainingFolder: context => calls.push(['folder', context.widget_name]),
switchToDownloadWorkflow: context => calls.push(['workflow', context.widget_name]),
setQueueDownloadsTab: () => {},
@@ -83,6 +84,7 @@ test('queue download controls trigger each action once and keep more as click-on
'mr-download-queue-cancel',
'mr-download-queue-pause',
'mr-download-queue-resume',
+ 'mr-download-queue-retry',
'mr-download-queue-open-folder',
'mr-download-queue-switch-workflow',
'mr-download-queue-more',
@@ -103,12 +105,14 @@ test('queue download controls trigger each action once and keep more as click-on
dispatchPointerAndClick(window, buttons[2]);
dispatchPointerAndClick(window, buttons[3]);
dispatchPointerAndClick(window, buttons[4]);
- buttons[5].dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true }));
+ dispatchPointerAndClick(window, buttons[5]);
+ buttons[6].dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true }));
assert.deepEqual(calls, [
['cancel', 'download-1'],
['pause', 'download-1'],
['resume', 'download-1'],
+ ['retry', 'download-1'],
['folder', 'test-model'],
['workflow', 'test-model'],
]);
@@ -124,6 +128,7 @@ test('progress download controls support pending buttons and fallback download i
'cancel-download-btn-pending',
'pause-download-btn',
'resume-download-btn',
+ 'retry-download-btn',
'mr-download-queue-open-folder',
'mr-download-queue-switch-workflow',
'mr-download-queue-more',
@@ -145,16 +150,19 @@ test('progress download controls support pending buttons and fallback download i
buttons[3].dataset.downloadId = '';
buttons[4].dataset.downloadId = '';
buttons[5].dataset.downloadId = '';
+ buttons[6].dataset.downloadId = '';
dispatchPointerAndClick(window, buttons[0]);
dispatchPointerAndClick(window, buttons[1]);
dispatchPointerAndClick(window, buttons[2]);
dispatchPointerAndClick(window, buttons[3]);
dispatchPointerAndClick(window, buttons[4]);
+ dispatchPointerAndClick(window, buttons[5]);
assert.deepEqual(calls, [
['cancel', 'fallback-download'],
['pause', 'fallback-download'],
['resume', 'fallback-download'],
+ ['retry', 'fallback-download'],
['folder', 'test-model'],
['workflow', 'test-model'],
]);
diff --git a/tests/test_download_progress_methods.mjs b/tests/test_download_progress_methods.mjs
index a5ee28b..38bff54 100644
--- a/tests/test_download_progress_methods.mjs
+++ b/tests/test_download_progress_methods.mjs
@@ -13,6 +13,8 @@ test('download progress labels cover active, paused, checking, and terminal stat
assert.equal(context.getDownloadProgressStatusLabel('downloading', 42.5, true), 'Finalizing');
assert.equal(context.getDownloadProgressStatusLabel('starting'), 'Starting');
assert.equal(context.getDownloadProgressStatusLabel('paused'), 'Paused');
+ assert.equal(context.getDownloadProgressStatusLabel('error'), 'Interrupted');
+ assert.equal(context.getDownloadProgressStatusLabel('refresh_error'), 'Interrupted');
assert.equal(context.getDownloadProgressStatusLabel('completed_checking'), 'Checking');
assert.equal(context.getDownloadProgressStatusLabel('cancelled'), 'cancelled');
assert.equal(context.getDownloadProgressStatusLabel(''), 'Download');
@@ -26,6 +28,10 @@ test('download progress status distinguishes active and terminal states', () =>
assert.equal(isActive('paused'), true);
assert.equal(isActive('cancelling'), false);
assert.equal(isActive('completed'), false);
+ assert.equal(isActive('error', true), true);
+ assert.equal(isActive('error', false), false);
+ assert.equal(isActive('refresh_error', true), true);
+ assert.equal(isActive('refresh_error', false), false);
assert.equal(isActive('custom', true), true);
assert.equal(isActive('custom', false), false);
});
diff --git a/web/css/resolver-shell.css b/web/css/resolver-shell.css
index f56b669..08a47ad 100644
--- a/web/css/resolver-shell.css
+++ b/web/css/resolver-shell.css
@@ -769,6 +769,29 @@
border-radius: var(--mr-missing-chip-radius);
}
+.mr-download-queue-item.mr-download-queue-item-error {
+ border-color: rgba(239, 68, 68, 0.35);
+}
+
+.mr-download-queue-status.mr-download-queue-status-error {
+ color: var(--mr-color-danger-bright, #ff4d4f);
+ background: var(--mr-badge-error-bg, rgba(239, 68, 68, 0.16));
+ border-color: var(--mr-badge-error-border, rgba(239, 68, 68, 0.34));
+}
+
+.mr-download-queue-progress .mr-progress-fill.mr-progress-fill-error {
+ background: var(--mr-color-danger-bright, #ff4d4f);
+ box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
+}
+
+.mr-download-queue-stat.mr-download-queue-stat-error {
+ max-width: 220px;
+ overflow: hidden;
+ color: var(--mr-color-danger-bright, #ff4d4f);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
.mr-download-history-toolbar {
display: flex;
justify-content: flex-end;
diff --git a/web/resolver/actions/download_progress_methods.js b/web/resolver/actions/download_progress_methods.js
index 7811b8d..3ce38de 100644
--- a/web/resolver/actions/download_progress_methods.js
+++ b/web/resolver/actions/download_progress_methods.js
@@ -18,12 +18,13 @@ export const downloadProgressMethods = {
}
if (status === 'starting') return 'Starting';
if (status === 'paused') return 'Paused';
+ if (status === 'error' || status === 'refresh_error') return 'Interrupted';
if (status === 'completed_checking') return 'Checking';
return String(status || '').replace(/_/g, ' ') || 'Download';
},
isDownloadProgressStatus(status = '', isActive = false) {
- const terminalStatuses = new Set(['cancelling', 'cancelled', 'error', 'refresh_error', 'completed_checking', 'completed']);
+ const terminalStatuses = new Set(['cancelling', 'cancelled', 'completed_checking', 'completed']);
return status === 'starting'
|| status === 'downloading'
|| status === 'paused'
diff --git a/web/resolver/actions/queue_methods.js b/web/resolver/actions/queue_methods.js
index b6e86f6..b129972 100644
--- a/web/resolver/actions/queue_methods.js
+++ b/web/resolver/actions/queue_methods.js
@@ -1160,6 +1160,7 @@ export const queueMethods = {
cancel: '.mr-download-queue-cancel',
pause: '.mr-download-queue-pause',
resume: '.mr-download-queue-resume',
+ retry: '.mr-download-queue-retry',
openFolder: '.mr-download-queue-open-folder',
switchWorkflow: '.mr-download-queue-switch-workflow',
more: '.mr-download-queue-more',
@@ -1169,6 +1170,7 @@ export const queueMethods = {
onCancel: downloadId => this.cancelDownload(downloadId),
onPause: downloadId => this.pauseDownload(downloadId),
onResume: downloadId => this.resumeDownload(downloadId),
+ onRetry: downloadId => this.retryDownload?.(downloadId),
onOpenFolder: context => this.openContainingFolder?.(context),
onSwitchWorkflow: context => this.switchToDownloadWorkflow?.(context),
moreBinding: 'click',
@@ -1229,9 +1231,10 @@ export const queueMethods = {
const backend = String(progress.download_backend || info.downloadBackend || '').toLowerCase();
const isAria2 = backend === 'aria2';
const isPaused = status === 'paused';
+ const isError = status === 'error' || status === 'refresh_error';
const statusLabel = status === 'downloading'
? (presentation.isFinalizing ? 'Finalizing' : '')
- : (status === 'starting' ? 'Starting' : (isPaused ? 'Paused' : status.replace(/_/g, ' ')));
+ : (status === 'starting' ? 'Starting' : (isPaused ? 'Paused' : (isError ? 'Interrupted' : status.replace(/_/g, ' '))));
const sizeTextBase = presentation.isFinalizing && logicalTotal
? `Finalizing file: ${logicalDownloaded} / ${logicalTotal}`
: (presentation.totalSize ? `${downloaded} / ${total}` : downloaded);
@@ -1260,21 +1263,27 @@ export const queueMethods = {
``
);
- html += `
`;
+ html += `
`;
html += ``;
html += `
`;
- html += `
`;
+ html += `
`;
html += `
`;
html += `${this.escapeHtml(sizeText)}`;
html += ``;
- if (speedText) {
+ if (speedText && !isError) {
html += `${renderIcon('download', 'mr-download-queue-stat-icon')}${this.escapeHtml(speedText)}`;
}
- if (etaText) {
+ if (etaText && !isError) {
html += `${this.escapeHtml(etaText)}`;
}
- if (connections > 0) {
+ if (connections > 0 && !isError) {
html += `${renderIcon('network', 'mr-download-queue-stat-icon')}${connections}`;
}
- if (!speedText && !etaText) {
+ if (isError) {
+ const errorMessage = progress.error || info.statusSnapshot?.message || 'Download interrupted';
+ html += `${this.escapeHtml(errorMessage)}`;
+ } else if (!speedText && !etaText) {
html += `${this.escapeHtml(statusLabel)}`;
}
html += ``;
diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js
index 44e1e53..1ad1676 100644
--- a/web/resolver/actions/resolve_download_methods.js
+++ b/web/resolver/actions/resolve_download_methods.js
@@ -1132,6 +1132,7 @@ export const resolveDownloadMethods = {
cancel: '.cancel-download-btn, .cancel-download-btn-pending, .mr-download-queue-cancel',
pause: '.pause-download-btn, .mr-download-queue-pause',
resume: '.resume-download-btn, .mr-download-queue-resume',
+ retry: '.retry-download-btn, .mr-download-queue-retry',
openFolder: '.mr-download-queue-open-folder',
switchWorkflow: '.mr-download-queue-switch-workflow',
more: '.mr-download-queue-more',
@@ -1140,6 +1141,7 @@ export const resolveDownloadMethods = {
onCancel: targetDownloadId => this.cancelDownload(targetDownloadId),
onPause: targetDownloadId => this.pauseDownload(targetDownloadId),
onResume: targetDownloadId => this.resumeDownload(targetDownloadId),
+ onRetry: targetDownloadId => this.retryDownload?.(targetDownloadId),
onOpenFolder: context => this.openContainingFolder?.(context),
onSwitchWorkflow: context => this.switchToDownloadWorkflow?.(context),
onMore: (event, button) => {
@@ -1943,6 +1945,8 @@ export const resolveDownloadMethods = {
downloadDirectory: '',
baseDirectory,
sourceUrl: url,
+ pathMetadata,
+ downloadMetadata,
workflowKey,
workflowRouteKey,
workflowLabel,
@@ -1995,6 +1999,17 @@ export const resolveDownloadMethods = {
}
const downloadId = data.download_id;
+ if (missing) {
+ for (const [existingId, existingInfo] of Object.entries(this.activeDownloads || {})) {
+ if (existingInfo?.missing === missing) {
+ const existingStatus = String(existingInfo.lastStatus || existingInfo.lastProgress?.status || '').toLowerCase();
+ if (existingStatus === 'error' || existingStatus === 'refresh_error') {
+ delete this.activeDownloads[existingId];
+ this.removeActiveDownloadRecovery?.(existingId);
+ }
+ }
+ }
+ }
this.activeDownloads[downloadId] = {
missing,
progressDiv,
@@ -2006,6 +2021,8 @@ export const resolveDownloadMethods = {
downloadDirectory: data.directory || '',
baseDirectory,
sourceUrl: url,
+ pathMetadata,
+ downloadMetadata,
workflowKey,
workflowRouteKey,
workflowLabel,
@@ -2049,6 +2066,8 @@ export const resolveDownloadMethods = {
downloadDirectory: '',
baseDirectory,
sourceUrl: url,
+ pathMetadata,
+ downloadMetadata,
workflowKey,
workflowRouteKey,
workflowLabel,
@@ -2253,11 +2272,13 @@ export const resolveDownloadMethods = {
status: 'error',
message: progress.error || 'Download failed',
type: 'error',
- isActive: false
+ isActive: true
});
+ info.lastStatus = 'error';
+ info.lastProgress = progress;
+ info.statusSnapshot = errorSnapshot;
this.renderDownloadSnapshot(downloadId, errorSnapshot, { progressDiv, downloadBtn });
- delete this.activeDownloads[downloadId];
- this.removeActiveDownloadRecovery?.(downloadId);
+ this.persistActiveDownloadRecovery?.(downloadId, this.activeDownloads[downloadId]);
this.updateDownloadAllButtonState();
this.updateQueuePanel?.();
this.refreshLocalMatchesUiForMissing?.(missing);
@@ -2279,18 +2300,20 @@ export const resolveDownloadMethods = {
const snapshot = this.rememberDownloadUiState(
downloadId,
info,
- { ...(info.lastProgress || {}), status: 'error' },
+ { ...(info.lastProgress || {}), status: 'error', error: error.message },
{
status: 'error',
message: 'Connection lost - download may have failed',
type: 'error',
- isActive: false
+ isActive: true
}
);
+ info.lastStatus = 'error';
+ info.lastProgress = { ...(info.lastProgress || {}), status: 'error', error: error.message };
+ info.statusSnapshot = snapshot;
this.renderDownloadSnapshot(downloadId, snapshot);
+ this.persistActiveDownloadRecovery?.(downloadId, this.activeDownloads[downloadId]);
}
- delete this.activeDownloads[downloadId];
- this.removeActiveDownloadRecovery?.(downloadId);
this.updateDownloadAllButtonState();
this.updateQueuePanel?.();
}
@@ -2314,6 +2337,40 @@ export const resolveDownloadMethods = {
*/
cancelDownload(downloadId) {
const info = this.activeDownloads[downloadId];
+ const currentStatus = String(
+ info?.pendingDownloadStatus
+ || info?.lastStatus
+ || info?.lastProgress?.status
+ || ''
+ ).toLowerCase();
+
+ if (currentStatus === 'error' || currentStatus === 'refresh_error') {
+ delete this.activeDownloads[downloadId];
+ this.removeActiveDownloadRecovery?.(downloadId);
+ if (info) {
+ const elements = this.resolveDownloadUiElements?.(info) || {};
+ const cancelledSnapshot = this.rememberDownloadUiState(
+ downloadId,
+ info,
+ { ...(info.lastProgress || {}), status: 'cancelled' },
+ {
+ status: 'cancelled',
+ message: 'Download dismissed',
+ type: 'info',
+ isActive: false
+ }
+ );
+ this.renderDownloadSnapshot?.(downloadId, cancelledSnapshot, elements);
+ this.refreshLocalMatchesUiForMissing?.(info.missing);
+ }
+ this.updateDownloadAllButtonState?.();
+ this.updateQueuePanel?.();
+ this.fetchJson(`/model_resolver/cancel/${downloadId}`, {
+ method: 'POST'
+ }, 'Cancel download').catch(() => {});
+ return;
+ }
+
if (info) {
this.setPendingDownloadStatus(info, 'cancelling', 30000);
const snapshot = this.rememberDownloadUiState(
@@ -2432,8 +2489,62 @@ export const resolveDownloadMethods = {
});
},
+ async retryDownload(downloadId) {
+ const info = this.activeDownloads?.[downloadId];
+ if (!info) return;
+
+ const missing = info.missing;
+ const sourceUrl = info.sourceUrl || missing?.download_source?.url || '';
+ const filename = info.filename || missing?.download_source?.filename || '';
+ const category = info.category || missing?.category || 'checkpoints';
+ const subfolder = info.subfolder || '';
+ const baseDirectory = info.baseDirectory || '';
+ const pathMetadata = info.pathMetadata || (missing && missing.download_source
+ ? this.getDownloadPathMetadata?.(missing, missing.download_source)
+ : null);
+ const downloadMetadata = info.downloadMetadata || (missing && missing.download_source
+ ? this.getDownloadMetadata?.(missing, missing.download_source, {
+ filename,
+ category,
+ url: sourceUrl,
+ pathMetadata
+ })
+ : null);
+
+ if (!sourceUrl) {
+ this.showNotification('Cannot retry download: missing download URL', 'error');
+ return;
+ }
+
+ delete this.activeDownloads[downloadId];
+ this.removeActiveDownloadRecovery?.(downloadId);
+
+ const elements = this.resolveDownloadUiElements?.(info) || {};
+ const btn = elements.downloadBtn || info.downloadBtn || null;
+
+ await this.executeDownloadRequest(missing, {
+ url: sourceUrl,
+ filename,
+ category,
+ subfolder,
+ baseDirectory,
+ pathMetadata,
+ downloadMetadata,
+ btn
+ });
+ },
+
resumeDownload(downloadId) {
const info = this.activeDownloads[downloadId];
+ const currentStatus = String(
+ info?.pendingDownloadStatus
+ || info?.lastStatus
+ || info?.lastProgress?.status
+ || ''
+ ).toLowerCase();
+ if (currentStatus === 'error' || currentStatus === 'refresh_error') {
+ return this.retryDownload(downloadId);
+ }
const previousProgress = info?.lastProgress ? { ...info.lastProgress } : null;
if (info) {
this.setPendingDownloadStatus(info, 'downloading');
diff --git a/web/resolver/utils/download_action_handlers.js b/web/resolver/utils/download_action_handlers.js
index de67168..00c32a1 100644
--- a/web/resolver/utils/download_action_handlers.js
+++ b/web/resolver/utils/download_action_handlers.js
@@ -27,6 +27,7 @@ export function bindDownloadActionHandlers(root, {
onCancel = null,
onPause = null,
onResume = null,
+ onRetry = null,
onOpenFolder = null,
onSwitchWorkflow = null,
onMore = null,
@@ -47,6 +48,7 @@ export function bindDownloadActionHandlers(root, {
bindIdAction(selectors.cancel, onCancel);
bindIdAction(selectors.pause, onPause);
bindIdAction(selectors.resume, onResume);
+ bindIdAction(selectors.retry, onRetry);
bindActionButtons(root, selectors.openFolder, (button) => {
const context = resolveContext(button);