Add Suggest Subfolder context menu for CivitAI and CivArchive download results

This commit is contained in:
Dariusz L
2026-07-26 19:38:41 +02:00
parent d1382a00b4
commit c6ff826028
5 changed files with 282 additions and 4 deletions
+149
View File
@@ -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')})`);
+9
View File
@@ -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"
}),
+98 -2
View File
@@ -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) {
+4
View File
@@ -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;
+22 -2
View File
@@ -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);