mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-07 14:30:15 -03:00
feat(downloads): show failure summary modal for single downloads too
This commit is contained in:
@@ -6,7 +6,7 @@ import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js';
|
||||
import { buildCivitaiUrl, extractCivitaiModelUrlParts, normalizeCivitaiPageHost } from '../utils/civitaiUtils.js';
|
||||
import { formatFileSize } from '../utils/formatters.js';
|
||||
import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js';
|
||||
|
||||
@@ -879,6 +879,26 @@ export class DownloadManager {
|
||||
this.updateTargetPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize a clickable URL for a single-download failure entry.
|
||||
* Single downloads have no pasted URL, so the modal link is derived from
|
||||
* the model/version ids (CivitAI) or the HF repo/file (HuggingFace).
|
||||
*/
|
||||
_buildSingleItemUrl({ modelId, versionId, source, repo = null, filename = null }) {
|
||||
if (source === 'huggingface' && repo) {
|
||||
const base = `https://huggingface.co/${encodeURI(repo)}`;
|
||||
return filename ? `${base}/blob/${encodeURI('main')}/${encodeURI(filename)}` : base;
|
||||
}
|
||||
if (modelId) {
|
||||
return buildCivitaiUrl({
|
||||
modelId,
|
||||
versionId,
|
||||
host: normalizeCivitaiPageHost(state?.global?.settings?.civitai_host),
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async executeDownloadWithProgress({
|
||||
modelId,
|
||||
versionId,
|
||||
@@ -897,6 +917,7 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
const displayName = versionName || `#${versionId}`;
|
||||
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, source, fileParams, closeModal: false };
|
||||
let ws = null;
|
||||
let updateProgress = () => { };
|
||||
let cancelled = false;
|
||||
@@ -985,6 +1006,26 @@ export class DownloadManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!response?.success) {
|
||||
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
||||
showDownloadBatchSummary({
|
||||
total: 1,
|
||||
completed: 0,
|
||||
failedItems: [{
|
||||
item: {
|
||||
modelId,
|
||||
versionId,
|
||||
source,
|
||||
url: this._buildSingleItemUrl({ modelId, versionId, source }),
|
||||
},
|
||||
error: response?.error || 'Unknown error',
|
||||
name: displayName,
|
||||
}],
|
||||
onRetry: () => this.executeDownloadWithProgress(retryParams),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
showToast('toast.loras.downloadCompleted', {}, 'success');
|
||||
|
||||
if (closeModal) {
|
||||
@@ -1019,7 +1060,21 @@ export class DownloadManager {
|
||||
console.log('Download cancelled by user:', downloadId);
|
||||
} else {
|
||||
console.error('Failed to download model version:', error);
|
||||
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
|
||||
showDownloadBatchSummary({
|
||||
total: 1,
|
||||
completed: 0,
|
||||
failedItems: [{
|
||||
item: {
|
||||
modelId,
|
||||
versionId,
|
||||
source,
|
||||
url: this._buildSingleItemUrl({ modelId, versionId, source }),
|
||||
},
|
||||
error: error?.message || 'Unknown error',
|
||||
name: displayName,
|
||||
}],
|
||||
onRetry: () => this.executeDownloadWithProgress(retryParams),
|
||||
});
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
@@ -1034,14 +1089,16 @@ export class DownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths }) {
|
||||
async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths, files = null }) {
|
||||
modalManager.closeModal('downloadModal');
|
||||
this.loadingManager.restoreProgressBar();
|
||||
const totalFiles = this.hfSelectedFiles.length;
|
||||
const filesToDownload = files || this.hfSelectedFiles;
|
||||
const totalFiles = filesToDownload.length;
|
||||
const updateProgress = this.loadingManager.showDownloadProgress(totalFiles);
|
||||
|
||||
let cancelled = false;
|
||||
let currentDownloadId = null;
|
||||
const failedFiles = [];
|
||||
|
||||
this.loadingManager.showCancelButton(async () => {
|
||||
if (cancelled) return;
|
||||
@@ -1060,7 +1117,7 @@ export class DownloadManager {
|
||||
for (let i = 0; i < totalFiles; i++) {
|
||||
if (cancelled) break;
|
||||
|
||||
const filename = this.hfSelectedFiles[i];
|
||||
const filename = filesToDownload[i];
|
||||
updateProgress(0, completedDownloads, filename);
|
||||
this.loadingManager.setStatus(`Downloading ${filename}...`);
|
||||
|
||||
@@ -1106,6 +1163,31 @@ export class DownloadManager {
|
||||
if (response?.success) {
|
||||
completedDownloads++;
|
||||
updateProgress(100, completedDownloads, filename);
|
||||
} else {
|
||||
failedFiles.push({
|
||||
item: {
|
||||
source: 'huggingface',
|
||||
repo: this.hfRepoId,
|
||||
filename,
|
||||
url: this._buildSingleItemUrl({ source: 'huggingface', repo: this.hfRepoId, filename }),
|
||||
},
|
||||
error: response?.error || 'Unknown error',
|
||||
name: filename,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error(`Failed to download HF file ${filename}:`, err);
|
||||
failedFiles.push({
|
||||
item: {
|
||||
source: 'huggingface',
|
||||
repo: this.hfRepoId,
|
||||
filename,
|
||||
url: this._buildSingleItemUrl({ source: 'huggingface', repo: this.hfRepoId, filename }),
|
||||
},
|
||||
error: err?.message || 'Unknown error',
|
||||
name: filename,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
ws.close();
|
||||
@@ -1115,11 +1197,27 @@ export class DownloadManager {
|
||||
if (cancelled) {
|
||||
showToast('toast.downloads.downloadStopped', {}, 'info',
|
||||
`Download cancelled. ${completedDownloads} item(s) completed.`);
|
||||
} else {
|
||||
showToast('toast.loras.downloadCompleted', {}, 'success');
|
||||
await resetAndReload(true);
|
||||
return true;
|
||||
}
|
||||
if (failedFiles.length === 0) {
|
||||
showToast('toast.loras.downloadCompleted', {}, 'success');
|
||||
await resetAndReload(true);
|
||||
return true;
|
||||
}
|
||||
showDownloadBatchSummary({
|
||||
total: totalFiles,
|
||||
completed: completedDownloads,
|
||||
failedItems: failedFiles,
|
||||
onRetry: () => this._downloadHfSingle({
|
||||
modelRoot,
|
||||
targetFolder,
|
||||
useDefaultPaths,
|
||||
files: failedFiles.map((f) => f.item.filename),
|
||||
}),
|
||||
});
|
||||
await resetAndReload(true);
|
||||
return true;
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error('Failed to download HF model:', error);
|
||||
|
||||
@@ -28,6 +28,7 @@ const {
|
||||
downloadModel: vi.fn(),
|
||||
downloadHfModel: vi.fn(),
|
||||
cancelDownload: vi.fn(),
|
||||
getPageState: vi.fn(() => ({})),
|
||||
};
|
||||
|
||||
// Shared loading manager served both via state.loadingManager and the
|
||||
@@ -314,4 +315,120 @@ describe('DownloadManager batch download summary flow', () => {
|
||||
);
|
||||
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('shows the batch summary for a single CivitAI download resolved as a failure', async () => {
|
||||
mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'rate limited' });
|
||||
|
||||
await manager.executeDownloadWithProgress({
|
||||
modelId: '111',
|
||||
versionId: 'v1',
|
||||
versionName: 'V1',
|
||||
modelRoot: '/m',
|
||||
useDefaultPaths: true,
|
||||
});
|
||||
|
||||
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
|
||||
const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
|
||||
expect(summary.total).toBe(1);
|
||||
expect(summary.completed).toBe(0);
|
||||
expect(summary.failedItems).toHaveLength(1);
|
||||
expect(summary.failedItems[0].item.modelId).toBe('111');
|
||||
expect(summary.failedItems[0].item.versionId).toBe('v1');
|
||||
expect(summary.failedItems[0].item.url).toEqual(expect.stringContaining('civitai.com/models/111'));
|
||||
expect(summary.failedItems[0].error).toBe('rate limited');
|
||||
expect(summary.failedItems[0].name).toBe('V1');
|
||||
expect(summary.onRetry).toEqual(expect.any(Function));
|
||||
expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.downloadCompleted', expect.anything(), 'success');
|
||||
});
|
||||
|
||||
it('shows the batch summary when a single download throws', async () => {
|
||||
mockApiClient.downloadModel.mockRejectedValue(new Error('network down'));
|
||||
|
||||
await manager.executeDownloadWithProgress({
|
||||
modelId: '111',
|
||||
versionId: 'v1',
|
||||
versionName: 'V1',
|
||||
modelRoot: '/m',
|
||||
useDefaultPaths: true,
|
||||
});
|
||||
|
||||
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
|
||||
const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
|
||||
expect(summary.total).toBe(1);
|
||||
expect(summary.completed).toBe(0);
|
||||
expect(summary.failedItems).toHaveLength(1);
|
||||
expect(summary.failedItems[0].error).toBe('network down');
|
||||
expect(summary.failedItems[0].item.url).toEqual(expect.stringContaining('civitai.com/models/111'));
|
||||
expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.downloadCompleted', expect.anything(), 'success');
|
||||
});
|
||||
|
||||
it('keeps the success toast and skips the summary for a successful single download', async () => {
|
||||
mockApiClient.downloadModel.mockResolvedValue({ success: true });
|
||||
|
||||
const result = await manager.executeDownloadWithProgress({
|
||||
modelId: '111',
|
||||
versionId: 'v1',
|
||||
versionName: 'V1',
|
||||
modelRoot: '/m',
|
||||
useDefaultPaths: true,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(showDownloadBatchSummaryMock).not.toHaveBeenCalled();
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.loras.downloadCompleted', {}, 'success');
|
||||
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('retries a failed single download through onRetry with the same params', async () => {
|
||||
mockApiClient.downloadModel
|
||||
.mockResolvedValueOnce({ success: false, error: 'rate limited' })
|
||||
.mockResolvedValueOnce({ success: true });
|
||||
|
||||
await manager.executeDownloadWithProgress({
|
||||
modelId: '111',
|
||||
versionId: 'v1',
|
||||
versionName: 'V1',
|
||||
modelRoot: '/m',
|
||||
useDefaultPaths: true,
|
||||
});
|
||||
|
||||
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
|
||||
const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
|
||||
expect(summary.failedItems).toHaveLength(1);
|
||||
|
||||
await summary.onRetry();
|
||||
|
||||
expect(mockApiClient.downloadModel).toHaveBeenCalledTimes(2);
|
||||
const retryCall = mockApiClient.downloadModel.mock.calls[1];
|
||||
expect(retryCall[0]).toBe('111');
|
||||
expect(retryCall[1]).toBe('v1');
|
||||
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.loras.downloadCompleted', {}, 'success');
|
||||
});
|
||||
|
||||
it('shows a summary for HF partial failure and retries only the failed files', async () => {
|
||||
manager.hfRepoId = 'user/repo';
|
||||
manager.hfSelectedFiles = ['a.safetensors', 'b.safetensors'];
|
||||
mockApiClient.downloadHfModel
|
||||
.mockResolvedValueOnce({ success: true })
|
||||
.mockResolvedValueOnce({ success: false, error: 'denied' });
|
||||
|
||||
const result = await manager._downloadHfSingle({ modelRoot: '/m', useDefaultPaths: true });
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
|
||||
const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
|
||||
expect(summary.total).toBe(2);
|
||||
expect(summary.completed).toBe(1);
|
||||
expect(summary.failedItems).toHaveLength(1);
|
||||
expect(summary.failedItems[0].name).toBe('b.safetensors');
|
||||
expect(summary.failedItems[0].item.url).toEqual(
|
||||
expect.stringContaining('huggingface.co/user/repo/blob/main/b.safetensors')
|
||||
);
|
||||
|
||||
await summary.onRetry();
|
||||
|
||||
expect(mockApiClient.downloadHfModel).toHaveBeenCalledTimes(3);
|
||||
expect(mockApiClient.downloadHfModel.mock.calls[2][0].filename).toBe('b.safetensors');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user