From c6ff8260285dc6a52db0026001e21681d5f6b3c2 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Sun, 26 Jul 2026 19:38:41 +0200 Subject: [PATCH] Add Suggest Subfolder context menu for CivitAI and CivArchive download results --- tests/test_downloads_tab_workflow_route.mjs | 149 ++++++++++++++++++ web/resolver/resolver_dialog.js | 9 ++ .../search/download_target_methods.js | 100 +++++++++++- web/resolver/search/search_panel.js | 4 + web/resolver/views/model_info_methods.js | 24 ++- 5 files changed, 282 insertions(+), 4 deletions(-) diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 4a9e0cc..f0a409a 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -378,6 +378,155 @@ test('loaded model context menu locates its exact workflow node and subgraph', ( assert.match(resolverDialogSource, /textContent: "Locate Node"/); }); +test('download result context menu offers subfolder suggestions only for CivitAI and CivArchive', () => { + const canSuggestDownloadSubfolderFromContextMenu = eval(`(${extractMethod(downloadTargetMethodsSource, 'canSuggestDownloadSubfolderFromContextMenu')})`); + + assert.equal(canSuggestDownloadSubfolderFromContextMenu({ + context_scope: 'download_table', + source: 'civitai', + }), true); + assert.equal(canSuggestDownloadSubfolderFromContextMenu({ + context_scope: 'download_table', + details_source: 'civarchive', + }), true); + assert.equal(canSuggestDownloadSubfolderFromContextMenu({ + context_scope: 'download_table', + source: 'huggingface', + }), false); + assert.equal(canSuggestDownloadSubfolderFromContextMenu({ + context_scope: 'local_model', + source: 'civitai', + }), false); + assert.match(resolverDialogSource, /data-menu-action": "suggestSubfolder"/); + assert.match(resolverDialogSource, /textContent: "Suggest Subfolder"/); +}); + +test('download result context menu reuses the existing forced subfolder suggestion', async () => { + const getContextMenuDownloadMissing = eval(`(${extractMethod(downloadTargetMethodsSource, 'getContextMenuDownloadMissing')})`); + const suggestDownloadSubfolderFromContextMenu = eval(`(${extractMethod(downloadTargetMethodsSource, 'suggestDownloadSubfolderFromContextMenu')})`); + const missing = { + node_id: 19, + widget_index: 2, + subgraph_id: '', + is_top_level: true, + }; + const categoryEl = { + closest() { + return { id: 'download-target' }; + }, + }; + const subfolderEl = {}; + const calls = []; + const dialog = { + missingModels: [missing], + contentElement: { + querySelector(selector) { + if (selector === '#download-category-19-2') return categoryEl; + if (selector === '#download-subfolder-19-2') return subfolderEl; + return null; + }, + }, + getMissingModelKey() { + return 'missing-19-2'; + }, + canSuggestDownloadSubfolderFromContextMenu() { + return true; + }, + getContextMenuDownloadMissing, + async forceSuggestedDownloadSubfolder(...args) { + calls.push(['suggest', ...args]); + return { + category: 'loras', + subfolder: 'Krea 2/Concept', + }; + }, + refreshDownloadTargetHelp(...args) { + calls.push(['refresh', ...args]); + }, + showNotification(...args) { + calls.push(['notification', ...args]); + }, + }; + + await suggestDownloadSubfolderFromContextMenu.call(dialog, { + context_scope: 'download_table', + source: 'civitai', + missing_key: 'missing-19-2', + base_model: 'Krea 2', + tags: ['concept'], + }); + + assert.equal(calls[0][0], 'suggest'); + assert.notEqual(calls[0][1], missing); + assert.equal(calls[0][1].download_source.base_model, 'Krea 2'); + assert.deepEqual(calls[0][1].download_source.tags, ['concept']); + assert.deepEqual(calls[0].slice(2), [categoryEl, subfolderEl]); + assert.equal(calls[1][0], 'refresh'); + assert.equal(calls[1][2].download_source.base_model, 'Krea 2'); + assert.deepEqual(calls[1].slice(3), [categoryEl, subfolderEl]); + assert.deepEqual(calls[2], [ + 'notification', + 'Suggested subfolder applied: Krea 2/Concept', + 'success', + ]); +}); + +test('context menu dispatches Suggest Subfolder without a silent optional handler', async () => { + const handleContextMenuAction = eval(`(${extractMethod(modelInfoMethodsSource, 'handleContextMenuAction')})`); + const model = { + context_scope: 'download_table', + source: 'civarchive', + name: 'Cutifyier v2', + }; + const calls = []; + const dialog = { + _contextMenuModel: model, + hideContextMenu() {}, + suggestDownloadSubfolderFromContextMenu(value) { + calls.push(value); + return Promise.resolve(); + }, + }; + + handleContextMenuAction.call(dialog, 'suggestSubfolder'); + await Promise.resolve(); + + assert.deepEqual(calls, [model]); +}); + +test('clicked Krea 2 concept metadata resolves to the matching LoRA subfolder', () => { + const normalizeFolderToken = eval(`(${extractMethod(downloadTargetMethodsSource, 'normalizeFolderToken')})`); + const getSuggestedLoraSubfolder = eval(`(${extractMethod(downloadTargetMethodsSource, 'getSuggestedLoraSubfolder')})`); + const dialog = { + normalizeFolderToken, + normalizeDownloadCategory(value = '') { + return String(value || '').toLowerCase(); + }, + getCachedSearchSuggestionData() { + return {}; + }, + getCompatibleCivitaiSearchResult() { + return {}; + }, + }; + const suggestion = getSuggestedLoraSubfolder.call(dialog, { + download_source: { + base_model: 'Krea 2', + tags: ['concept'], + }, + }, 'loras', [{ + value: 'KREA2/concept', + label: 'KREA2 / concept', + baseDirectory: '', + segments: ['KREA2', 'concept'], + normalizedSegments: ['krea2', 'concept'], + }]); + + assert.equal(suggestion.value, 'KREA2/concept'); + assert.equal(suggestion.matchedBaseModel, 'Krea 2'); + assert.equal(suggestion.matchedTag, 'concept'); +}); + test('queued workflow model selection survives missing data and completes after analysis', () => { const queueWorkflowModelReferenceSelection = eval(`(${extractMethod(missingBrowserMethodsSource, 'queueWorkflowModelReferenceSelection')})`); const applyPendingWorkflowModelSelection = eval(`(${extractMethod(missingBrowserMethodsSource, 'applyPendingWorkflowModelSelection')})`); diff --git a/web/resolver/resolver_dialog.js b/web/resolver/resolver_dialog.js index 272aa71..21d4ecb 100644 --- a/web/resolver/resolver_dialog.js +++ b/web/resolver/resolver_dialog.js @@ -134,6 +134,15 @@ export class ResolverManagerDialog extends ComfyDialog { }), $el("span", { textContent: "Show More" }) ]), + $el("div.mr-context-menu-item.mr-context-menu-action-suggest-subfolder", { + "data-menu-action": "suggestSubfolder", + onclick: () => this.handleContextMenuAction('suggestSubfolder') + }, [ + $el("span.mr-context-menu-item-icon", { + innerHTML: getSvgIcon('lightbulb', 'currentColor', 'mr-context-menu-item-svg') + }), + $el("span", { textContent: "Suggest Subfolder" }) + ]), $el("div.mr-context-menu-divider.mr-context-menu-divider-source", { "data-menu-divider": "source" }), diff --git a/web/resolver/search/download_target_methods.js b/web/resolver/search/download_target_methods.js index b8e78b7..ad4b71b 100644 --- a/web/resolver/search/download_target_methods.js +++ b/web/resolver/search/download_target_methods.js @@ -2091,7 +2091,7 @@ export const downloadTargetMethods = { }, async forceSuggestedDownloadSubfolder(missing, categoryEl, subfolderEl) { - if (!categoryEl || !subfolderEl) return; + if (!categoryEl || !subfolderEl) return null; const saved = this.getSavedDownloadTargetSelection(missing); const originalCategory = this.normalizeDownloadCategory(this.getDropdownValue(categoryEl)); @@ -2114,7 +2114,7 @@ export const downloadTargetMethods = { if (!suggestion) { this.showNotification?.('No subfolder suggestion available for this model.', 'info'); - return; + return null; } this.setDropdownValue(categoryEl, category, this.getCategoryDisplayName(category)); @@ -2130,6 +2130,102 @@ export const downloadTargetMethods = { ...this.getSubfolderSuggestionTrackingPatch(suggestion, 'button') }); this.syncDownloadTargetFolderContext(categoryEl, subfolderEl); + return { + category, + subfolder: suggestedSubfolder, + suggestion + }; + }, + + canSuggestDownloadSubfolderFromContextMenu(model = {}) { + const source = String(model.details_source || model.source || '') + .toLowerCase() + .replace(/[-\s]+/g, '_'); + return model.context_scope === 'download_table' + && ['civitai', 'civarchive'].includes(source); + }, + + getContextMenuDownloadMissing(model = {}) { + const missingModels = Array.isArray(this.missingModels) ? this.missingModels : []; + const missingKey = String(model.missing_key || ''); + if (missingKey) { + const keyedMatch = missingModels.find(missing => ( + String(this.getMissingModelKey?.(missing) || '') === missingKey + )); + if (keyedMatch) return keyedMatch; + } + + return missingModels.find(missing => ( + String(missing.node_id ?? '') === String(model.node_id ?? '') + && String(missing.widget_index ?? '') === String(model.widget_index ?? '') + && String(missing.subgraph_id || '') === String(model.subgraph_id || '') + && (missing.is_top_level !== false) === (model.is_top_level !== false) + )) || null; + }, + + async suggestDownloadSubfolderFromContextMenu(model = {}) { + if (!this.canSuggestDownloadSubfolderFromContextMenu(model)) { + console.warn('Model Resolver: ignored Suggest Subfolder for an unsupported context.', { + contextScope: model.context_scope || '', + source: model.details_source || model.source || '' + }); + this.showNotification?.('Subfolder suggestions are available here only for CivitAI and CivArchive results.', 'info'); + return null; + } + + const missing = this.getContextMenuDownloadMissing(model); + if (!missing) { + console.warn('Model Resolver: could not match the context-menu result to a missing model.', { + missingKey: model.missing_key || '', + nodeId: model.node_id ?? '', + widgetIndex: model.widget_index ?? '' + }); + this.showNotification?.('Could not find the model download controls.', 'info'); + return null; + } + + const categoryEl = this.contentElement?.querySelector( + `#download-category-${missing.node_id}-${missing.widget_index}` + ); + const subfolderEl = this.contentElement?.querySelector( + `#download-subfolder-${missing.node_id}-${missing.widget_index}` + ); + if (!categoryEl || !subfolderEl) { + console.warn('Model Resolver: download target controls were not found for Suggest Subfolder.', { + nodeId: missing.node_id ?? '', + widgetIndex: missing.widget_index ?? '' + }); + this.showNotification?.('Could not find the model download controls.', 'info'); + return null; + } + + const sourceMetadata = { + ...model, + source: model.details_source || model.source || '' + }; + const suggestionMissing = { + ...missing, + download_source: { + ...(missing.download_source || {}), + ...sourceMetadata + } + }; + const applied = await this.forceSuggestedDownloadSubfolder( + suggestionMissing, + categoryEl, + subfolderEl + ); + const container = categoryEl.closest?.('.mr-download-target') || this.contentElement; + this.refreshDownloadTargetHelp(container, suggestionMissing, categoryEl, subfolderEl); + if (applied?.subfolder) { + console.info('Model Resolver: applied context-menu subfolder suggestion.', { + category: applied.category, + subfolder: applied.subfolder, + source: sourceMetadata.source + }); + this.showNotification?.(`Suggested subfolder applied: ${applied.subfolder}`, 'success'); + } + return applied; }, applySearchResultSuggestion(missing) { diff --git a/web/resolver/search/search_panel.js b/web/resolver/search/search_panel.js index ac1d1af..f76c96a 100644 --- a/web/resolver/search/search_panel.js +++ b/web/resolver/search/search_panel.js @@ -1996,6 +1996,10 @@ export const searchPanelMethods = { name: modelName, filename: downloadFilename, missing_key: this.getMissingModelKey(missing), + node_id: missing.node_id, + widget_index: missing.widget_index, + subgraph_id: missing.subgraph_id || '', + is_top_level: missing.is_top_level !== false, category: rowCategory } : null; diff --git a/web/resolver/views/model_info_methods.js b/web/resolver/views/model_info_methods.js index 35e3d04..c9b3c4d 100644 --- a/web/resolver/views/model_info_methods.js +++ b/web/resolver/views/model_info_methods.js @@ -41,6 +41,7 @@ export const modelInfoMethods = { const isLocalModelContext = model?.context_scope === 'local_model' || model?.context_scope === 'local_match'; const isFolderOnlyContext = isDownloadFolderContext || isDownloadRootContext; const isSourceModelContext = !isDownloadTableContext && !isFolderOnlyContext && !isDownloadQueueContext && !isDownloadHistoryContext; + const showSuggestSubfolder = this.canSuggestDownloadSubfolderFromContextMenu?.(model) || false; const hasLocalPath = Boolean(model?.open_path || model?.folder_path || model?.download_directory || model?.directory || model?.path || model?.resolved_path); const showOpenFolder = !isDownloadTableContext && hasLocalPath; const showCompareHashes = (isLocalModelContext || isDownloadFolderContext) @@ -53,13 +54,14 @@ export const modelInfoMethods = { && model?.node_id !== ''; this.setContextMenuItemVisible('showInfo', isSourceModelContext); this.setContextMenuItemVisible('showMore', canShowMore); + this.setContextMenuItemVisible('suggestSubfolder', showSuggestSubfolder); this.setContextMenuItemVisible('source', showSourceLink); this.setContextMenuItemVisible('civitai', false); this.setContextMenuItemVisible('locateNode', showLocateNode); this.setContextMenuItemVisible('switchWorkflow', showSwitchWorkflow); this.setContextMenuItemVisible('compareHashes', showCompareHashes); this.setContextMenuItemVisible('openFolder', showOpenFolder); - this.setContextMenuDividerVisible('source', showSourceLink && (isSourceModelContext || canShowMore)); + this.setContextMenuDividerVisible('source', showSourceLink && (isSourceModelContext || canShowMore || showSuggestSubfolder)); this.setContextMenuDividerVisible( 'workflow', (showLocateNode || showSwitchWorkflow) && (isSourceModelContext || canShowMore || showSourceLink) @@ -126,6 +128,22 @@ export const modelInfoMethods = { this.showModelInfo(model); } else if (action === 'showMore') { this.showSourceModelDetails(model); + } else if (action === 'suggestSubfolder') { + if (typeof this.suggestDownloadSubfolderFromContextMenu !== 'function') { + console.error('Model Resolver: Suggest Subfolder handler is unavailable.'); + this.showNotification?.('Suggest Subfolder is unavailable. Reload ComfyUI and try again.', 'error'); + return; + } + console.info('Model Resolver: Suggest Subfolder requested from the download result context menu.', { + source: model.details_source || model.source || '', + model: model.name || model.filename || '', + baseModel: model.base_model || model.baseModel || '', + tags: model.tags || [] + }); + Promise.resolve(this.suggestDownloadSubfolderFromContextMenu(model)).catch(error => { + console.error('Model Resolver: context-menu subfolder suggestion failed:', error); + this.showNotification?.('Could not apply a subfolder suggestion.', 'error'); + }); } }, @@ -451,6 +469,7 @@ export const modelInfoMethods = { const isDownloadHistoryContext = model?.context_scope === 'download_history'; const isFolderOnlyContext = isDownloadFolderContext || isDownloadRootContext; const isSourceModelContext = !isDownloadTableContext && !isFolderOnlyContext && !isDownloadQueueContext && !isDownloadHistoryContext; + const showSuggestSubfolder = this.canSuggestDownloadSubfolderFromContextMenu?.(model) || false; const hasLocalPath = Boolean(model?.open_path || model?.folder_path || model?.download_directory || model?.directory || model?.path || model?.resolved_path); const showOpenFolder = !isDownloadTableContext && hasLocalPath; const showCompareHashes = (model?.context_scope === 'local_model' || model?.context_scope === 'local_match' || isDownloadFolderContext) @@ -459,9 +478,10 @@ export const modelInfoMethods = { const showSwitchWorkflow = (isDownloadQueueContext || isDownloadHistoryContext) && Boolean(this.canSwitchToDownloadWorkflow?.(model)); const showSourceLink = Boolean(sourceLink?.url); + this.setContextMenuItemVisible('suggestSubfolder', showSuggestSubfolder); this.setContextMenuItemVisible('source', showSourceLink); this.setContextMenuItemVisible('civitai', false); - this.setContextMenuDividerVisible('source', showSourceLink && (isSourceModelContext || canShowMore)); + this.setContextMenuDividerVisible('source', showSourceLink && (isSourceModelContext || canShowMore || showSuggestSubfolder)); this.setContextMenuDividerVisible('workflow', showSwitchWorkflow && (isSourceModelContext || canShowMore || showSourceLink)); this.setContextMenuDividerVisible('folder', (showCompareHashes || showOpenFolder) && (isSourceModelContext || canShowMore || showSourceLink || showSwitchWorkflow)); this.updateContextMenuSourceItem(sourceLink);