feat: improve model scan cache override, combo UX, and filename handling

- Add force_rescan end-to-end so callers can bypass the short-lived local model scan cache: backend entry in __init__.py and core/resolver.py, lifecycle_graph_methods forwards it through the workflow analysis body, and download_target_methods passes ?force=1 from the refresh flow.
- Rewrite combo dropdown lifecycle in missing_browser_methods with show/hide helpers, pointer-capture tracking, and a capture-phase outside-click listener so the list stays open during drag/hover and closes reliably otherwise.
- Animate refresh buttons (CSS keyframes + JS Web Animations) with reduced-motion fallback.
- Encode selected model path into the selected-bar data-model attribute and wire contextmenu so users can open the containing folder from the selection.
- Fix XSS risk in selected-bar label by escaping label HTML; change queue/selected Remove buttons to danger style.
- Increase max missing-browser model column width clamp from 430 to 820 and stop truncating missing-filename display; show full filename with tooltip instead.
- Update huggingface-author-index.json metadata timestamp/counts (1 file changed, 1 line).
This commit is contained in:
Dariusz L
2026-06-19 03:57:40 +02:00
parent 5f1e814751
commit 4f91accbab
8 changed files with 217 additions and 34 deletions
+13 -1
View File
@@ -207,6 +207,12 @@ class ModelResolverExtension:
data = await request.json()
workflow_json = data.get("workflow")
analysis_id = str(data.get("analysis_id") or "").strip()
force_rescan = data.get("force_rescan", False)
force_rescan = (
force_rescan
if isinstance(force_rescan, bool)
else str(force_rescan).lower() in {"1", "true", "yes"}
)
if not workflow_json:
return web.json_response(
@@ -240,6 +246,7 @@ class ModelResolverExtension:
0.0,
10,
update_analysis_progress if analysis_id else None,
force_rescan=force_rescan,
)
# Filter out LoraManager lorAs that already exist locally (exists=True)
@@ -529,7 +536,12 @@ class ModelResolverExtension:
async def get_models(request):
"""Get list of all available models."""
try:
models = get_model_files()
force_rescan = str(
request.query.get("force")
or request.query.get("force_rescan")
or ""
).lower() in {"1", "true", "yes"}
models = get_model_files(force_rescan=force_rescan)
return web.json_response(models)
except Exception as e:
self.logger.error(
+3 -1
View File
@@ -326,6 +326,7 @@ def analyze_and_find_matches(
similarity_threshold: float = 0.0,
max_matches_per_model: int = 10,
progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
force_rescan: bool = False,
) -> Dict[str, Any]:
"""
Main entry point: analyze workflow and find matches for missing models.
@@ -334,6 +335,7 @@ def analyze_and_find_matches(
workflow_json: Complete workflow JSON dictionary
similarity_threshold: Minimum similarity score (0.0 to 1.0) for matches
max_matches_per_model: Maximum number of matches to return per missing model
force_rescan: If True, bypass the short-lived local model scan cache
Returns:
Dictionary with analysis results:
@@ -389,7 +391,7 @@ def analyze_and_find_matches(
# Analyze workflow to find all model references
# Get available models
available_models = get_model_files()
available_models = get_model_files(force_rescan=force_rescan)
available_models_by_category = {}
for model in available_models:
model_category = model.get("category", "")
File diff suppressed because one or more lines are too long
+45
View File
@@ -1011,6 +1011,7 @@
font-weight: 700;
color: var(--mr-text);
margin: 0;
min-width: 0;
word-break: break-word;
letter-spacing: -0.01em;
}
@@ -1024,7 +1025,10 @@
}
.mr-card-title-primary {
display: block;
min-width: 0;
color: var(--mr-text-soft);
overflow-wrap: anywhere;
word-break: normal;
}
.mr-card-title-secondary {
display: block;
@@ -1577,6 +1581,33 @@
border-color: var(--mr-missing-control-focus-border, rgba(114,211,138,0.30));
box-shadow: 0 0 0 2px var(--mr-missing-control-focus-ring, rgba(114,211,138,0.08));
}
@keyframes mr-refresh-spin {
to {
transform: rotate(360deg);
}
}
.mr-refresh-spin-target,
#model-resolver-modal .mr-refresh-spin-target {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
}
.mr-btn.mr-is-refreshing > svg,
#model-resolver-modal .mr-btn.mr-is-refreshing > svg,
.mr-btn.mr-is-refreshing .mr-refresh-spin-target,
#model-resolver-modal .mr-btn.mr-is-refreshing .mr-refresh-spin-target {
animation: mr-refresh-spin 0.62s linear infinite;
transform-origin: center;
}
@media (prefers-reduced-motion: reduce) {
.mr-btn.mr-is-refreshing > svg,
#model-resolver-modal .mr-btn.mr-is-refreshing > svg,
.mr-btn.mr-is-refreshing .mr-refresh-spin-target,
#model-resolver-modal .mr-btn.mr-is-refreshing .mr-refresh-spin-target {
animation: mr-refresh-spin 0.9s linear 1;
}
}
.mr-btn-primary,
#model-resolver-modal .mr-btn-primary {
background: var(--mr-accent);
@@ -4591,6 +4622,10 @@
width: 14px;
height: 14px;
}
#model-resolver-modal .mr-options-shell .mr-btn.mr-btn-primary,
#model-resolver-modal .mr-options-shell .mr-btn.mr-btn-primary:hover:not(:disabled) {
color: #101010;
}
.mr-options-field {
display: flex;
flex-direction: column;
@@ -5690,6 +5725,16 @@
border-color: rgba(47, 128, 237, 0.48);
color: #ffffff;
}
#model-resolver-modal .mr-missing-detail-pane .model-resolver-selected .mr-btn-danger {
background: rgba(239, 68, 68, 0.16);
border-color: rgba(239, 68, 68, 0.34);
color: #fee2e2;
}
#model-resolver-modal .mr-missing-detail-pane .model-resolver-selected .mr-btn-danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.24);
border-color: rgba(239, 68, 68, 0.48);
color: #ffffff;
}
#model-resolver-modal .mr-missing-detail-pane .mr-search-progress-list-compact {
gap: 7px;
margin: 10px 0 8px;
+33 -4
View File
@@ -176,7 +176,7 @@ export const queueMethods = {
html += `<div class="mr-queue-item-title">${this.escapeHtml(nodeLabel)} #${this.escapeHtml(String(r.node_id))}</div>`;
html += `<div class="mr-queue-item-meta"><span>Original</span><code>${this.escapeHtml(orig)}</code></div>`;
html += `<div class="mr-queue-item-selection"><span>Selected</span><code>${this.escapeHtml(label)}</code></div>`;
html += `<div class="mr-queue-item-actions"><button id="${rmId}" class="mr-btn mr-btn-secondary mr-btn-sm">Remove</button></div>`;
html += `<div class="mr-queue-item-actions"><button id="${rmId}" class="mr-btn mr-btn-danger mr-btn-sm">Remove</button></div>`;
html += `</div>`;
}
html += '</div>';
@@ -338,20 +338,49 @@ export const queueMethods = {
if (!selection) {
selectedBar.style.display = 'none';
selectedBar.innerHTML = '';
selectedBar.removeAttribute('data-model');
selectedBar.removeAttribute('data-tooltip');
selectedBar.oncontextmenu = null;
return;
}
// Build selected bar content
const label = selection.resolved_model?.relative_path || selection.resolved_model?.filename || selection.resolved_path || '';
const selectedPath = selection.resolved_model?.path || selection.resolved_path || '';
const selectedContext = selectedPath
? {
context_scope: 'download_folder',
open_folder_label: 'Open Containing Folder',
name: selection.resolved_model?.filename || label,
path: selectedPath,
resolved_path: selectedPath,
category: selection.category,
original_path: selection.original_path
}
: null;
const selectedContextAttrs = selectedContext
? ` data-model="${this.escapeHtml(encodeURIComponent(JSON.stringify(selectedContext)))}" oncontextmenu="window.MLOpenContextMenu(event, this)" data-tooltip="Right-click to open containing folder"`
: '';
if (selectedContext) {
selectedBar.dataset.model = encodeURIComponent(JSON.stringify(selectedContext));
selectedBar.dataset.tooltip = 'Right-click to open containing folder';
selectedBar.oncontextmenu = (event) => {
window.MLOpenContextMenu?.(event, selectedBar);
};
} else {
selectedBar.removeAttribute('data-model');
selectedBar.removeAttribute('data-tooltip');
selectedBar.oncontextmenu = null;
}
const applyBtnId = `selected-apply-${nodeId}-${widgetIndex}`;
const removeBtnId = `selected-remove-${nodeId}-${widgetIndex}`;
selectedBar.innerHTML = `<div class="mr-selected-bar-inner">`;
selectedBar.innerHTML = `<div class="mr-selected-bar-inner"${selectedContextAttrs}>`;
selectedBar.innerHTML += `<span class="mr-selected-label">✓ Selected:</span>`;
selectedBar.innerHTML += `<code class="mr-selected-code">${label}</code>`;
selectedBar.innerHTML += `<code class="mr-selected-code">${this.escapeHtml(label)}</code>`;
selectedBar.innerHTML += `<span class="mr-selected-actions">`;
selectedBar.innerHTML += `<button id="${applyBtnId}" class="mr-btn mr-btn-primary mr-btn-sm" data-tooltip="Apply this selected local match to the workflow.">Apply</button>`;
selectedBar.innerHTML += `<button id="${removeBtnId}" class="mr-btn mr-btn-secondary mr-btn-sm">Remove</button>`;
selectedBar.innerHTML += `<button id="${removeBtnId}" class="mr-btn mr-btn-danger mr-btn-sm">Remove</button>`;
selectedBar.innerHTML += `</span>`;
selectedBar.innerHTML += `</div>`;
selectedBar.style.display = 'block';
@@ -8,10 +8,11 @@ export const downloadTargetMethods = {
/**
* Ensure all models are loaded for the dropdown.
*/
async ensureAllModelsLoaded() {
if (this.allModels && this.allModels.length) return;
async ensureAllModelsLoaded(options = {}) {
const force = options === true || Boolean(options?.force);
if (!force && this.allModels && this.allModels.length) return;
try {
const resp = await api.fetchApi('/model_resolver/models');
const resp = await api.fetchApi(`/model_resolver/models${force ? '?force=1' : ''}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const models = await resp.json();
const list = Array.isArray(models) ? models : [];
@@ -113,7 +113,7 @@ export const lifecycleGraphMethods = {
/**
* Load workflow data and display missing models
*/
async loadWorkflowData(workflow = null, { force = false } = {}) {
async loadWorkflowData(workflow = null, { force = false, forceRescan = force } = {}) {
if (!this.contentElement) return;
this._workflowDataLoadToken = null;
@@ -180,7 +180,7 @@ export const lifecycleGraphMethods = {
const response = await api.fetchApi('/model_resolver/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow, analysis_id: analysisId })
body: JSON.stringify({ workflow, analysis_id: analysisId, force_rescan: Boolean(forceRescan) })
});
if (this._analysisProgressToken === analysisId) {
this._analysisProgressToken = null;
+116 -22
View File
@@ -218,7 +218,7 @@ export const missingBrowserMethods = {
}
return {
modelPx: clamp(modelPx, 180, 430),
modelPx: clamp(modelPx, 180, 820),
typePx: clamp(typePx, 66, 120)
};
},
@@ -255,7 +255,7 @@ export const missingBrowserMethods = {
<span class="mr-missing-stat mr-missing-stat-none">${stats.none} no match</span>
</div>
<button id="mr-refresh-missing-analysis" type="button" class="mr-btn mr-btn-secondary mr-btn-sm mr-missing-refresh-btn" data-tooltip="Re-analyze workflow and refresh local matches">
${getSvgIcon('refreshCw')} Refresh
<span class="mr-refresh-spin-target">${getSvgIcon('refreshCw')}</span> Refresh
</button>
</div>
</div>
@@ -278,7 +278,6 @@ export const missingBrowserMethods = {
const isSelected = key === selectedKey;
const isBatchSelected = this.batchSelectedMissingKeys?.has(key);
const filename = this.getMissingFilename(missing);
const formattedFilename = this.formatFilename(filename, 46);
const bestMatch = this.getBestLocalMatch(missing, 70);
const confidence = bestMatch ? Number(bestMatch.confidence || 0) : 0;
const matchName = bestMatch?.model?.relative_path || bestMatch?.filename || bestMatch?.path || '';
@@ -302,7 +301,7 @@ export const missingBrowserMethods = {
</span>
<span class="mr-missing-row-index">${index + 1}</span>
<span class="mr-missing-row-model">
<span class="mr-missing-row-name" data-tooltip="${this.escapeHtml(filename)}">${this.escapeHtml(formattedFilename.display)}</span>
<span class="mr-missing-row-name" data-tooltip="${this.escapeHtml(filename)}">${this.escapeHtml(filename)}</span>
${rowNodeHtml}
</span>
<span class="mr-missing-row-type ${typeColorClass}">${this.escapeHtml(typeLabel)}</span>
@@ -598,17 +597,62 @@ export const missingBrowserMethods = {
const comboInput = container.querySelector(`#combo-input-${comboId}`);
const comboList = container.querySelector(`#combo-list-${comboId}`);
const comboRefresh = container.querySelector(`#combo-refresh-${comboId}`);
const comboSection = comboInput?.closest('.mr-combo-section')
|| comboList?.closest('.mr-combo-section')
|| comboRefresh?.closest('.mr-combo-section');
if (comboList) {
this.enableWheelScrollChaining(comboList);
}
const allModels = Array.isArray(this.allModels) ? this.allModels : [];
const getAllModels = () => Array.isArray(this.allModels) ? this.allModels : [];
const buildLabel = (m) => `${m.category ? m.category + ': ' : ''}${m.relative_path || m.filename || ''}`;
const getFolder = (m) => m.path || m.base_directory || '';
let comboListPointerActive = false;
let outsideComboPointerHandler = null;
const isInsideCombo = (target) => (
Boolean(target) && (
Boolean(comboSection?.contains(target))
|| Boolean(comboInput?.contains(target))
|| Boolean(comboList?.contains(target))
|| Boolean(comboRefresh?.contains(target))
)
);
const stopOutsideComboPointerListener = () => {
if (!outsideComboPointerHandler) return;
window.removeEventListener('pointerdown', outsideComboPointerHandler, true);
outsideComboPointerHandler = null;
};
const startOutsideComboPointerListener = () => {
if (outsideComboPointerHandler) return;
outsideComboPointerHandler = (event) => {
if (!comboList?.isConnected) {
stopOutsideComboPointerListener();
return;
}
if (isInsideCombo(event.target)) {
return;
}
hideComboList();
};
window.addEventListener('pointerdown', outsideComboPointerHandler, true);
};
const showComboList = () => {
if (!comboList) return;
comboList.classList.remove('mr-is-hidden');
comboList.classList.add('mr-is-visible');
startOutsideComboPointerListener();
};
const hideComboList = () => {
if (!comboList) return;
comboList.classList.remove('mr-is-visible');
comboList.classList.add('mr-is-hidden');
stopOutsideComboPointerListener();
};
const populateComboOptions = (filterText, highlightIdx = -1) => {
if (!comboList) return;
const allModels = getAllModels();
const f = (filterText || '').toLowerCase();
const filtered = f
? allModels.filter(m => buildLabel(m).toLowerCase().includes(f))
@@ -639,6 +683,7 @@ export const missingBrowserMethods = {
const chosenModel = allModels[idx];
if (chosenModel) {
this.queueResolution(missing, chosenModel);
hideComboList();
}
}
});
@@ -646,6 +691,16 @@ export const missingBrowserMethods = {
};
if (comboList) {
comboList.addEventListener('pointerdown', () => {
comboListPointerActive = true;
const releaseComboPointer = () => {
setTimeout(() => {
comboListPointerActive = false;
}, 0);
};
window.addEventListener('pointerup', releaseComboPointer, { once: true, capture: true });
window.addEventListener('pointercancel', releaseComboPointer, { once: true, capture: true });
}, { capture: true });
populateComboOptions('');
}
@@ -655,27 +710,40 @@ export const missingBrowserMethods = {
}, 200);
comboInput.addEventListener('input', debouncedFilter);
comboInput.addEventListener('focus', () => {
if (comboList) {
comboList.classList.remove('mr-is-hidden');
comboList.classList.add('mr-is-visible');
}
showComboList();
populateComboOptions(comboInput.value);
});
comboInput.addEventListener('blur', () => {
setTimeout(() => {
if (comboList) {
comboList.classList.remove('mr-is-visible');
comboList.classList.add('mr-is-hidden');
if (!comboList) return;
if (comboListPointerActive || comboList.matches(':hover')) {
return;
}
hideComboList();
}, 200);
});
}
if (comboRefresh) {
comboRefresh.addEventListener('click', async () => {
this.allModels = null;
await this.ensureAllModelsLoaded();
populateComboOptions(comboInput?.value || '');
const minRefreshFeedback = new Promise(resolve => setTimeout(resolve, 420));
const refreshAnimation = this.startRefreshButtonAnimation(comboRefresh);
try {
comboRefresh.disabled = true;
comboRefresh.classList.add('mr-btn-is-disabled', 'mr-is-refreshing');
this.allModels = null;
await this.ensureAllModelsLoaded({ force: true });
await minRefreshFeedback;
populateComboOptions(comboInput?.value || '');
} catch (error) {
await minRefreshFeedback;
console.warn('Model Resolver: could not refresh local model list', error);
this.showNotification('Failed to refresh local model list', 'error');
} finally {
refreshAnimation?.cancel();
comboRefresh.disabled = false;
comboRefresh.classList.remove('mr-btn-is-disabled', 'mr-is-refreshing');
}
});
}
@@ -690,6 +758,29 @@ export const missingBrowserMethods = {
this.restoreDownloadProgressForMissing?.(missing);
},
startRefreshButtonAnimation(button) {
if (!button) return null;
const target = button.querySelector('.mr-refresh-spin-target') || button.querySelector('svg');
if (!target || typeof target.animate !== 'function') return null;
const existing = target.getAnimations?.() || [];
existing.forEach(animation => animation.cancel());
target.style.transformOrigin = 'center';
return target.animate(
[
{ transform: 'rotate(0deg)' },
{ transform: 'rotate(360deg)' }
],
{
duration: 620,
easing: 'linear',
iterations: Infinity
}
);
},
/**
* Display missing models in the dialog
*/
@@ -803,23 +894,27 @@ export const missingBrowserMethods = {
async refreshMissingAnalysis(button = null) {
if (button?.disabled) return;
const minRefreshFeedback = new Promise(resolve => setTimeout(resolve, 420));
const refreshAnimation = this.startRefreshButtonAnimation(button);
try {
if (button) {
button.disabled = true;
button.classList.add('mr-btn-is-disabled');
button.classList.add('mr-btn-is-disabled', 'mr-is-refreshing');
}
this.showNotification('Refreshing missing models and local matches...', 'info');
this.allModels = null;
this.invalidateLoadedModelsCacheForActiveWorkflow?.();
await minRefreshFeedback;
await this.loadWorkflowData(null, { force: true });
} catch (error) {
console.error('Model Resolver: missing analysis refresh failed:', error);
this.showNotification('Refresh failed: ' + error.message, 'error');
} finally {
if (button) {
refreshAnimation?.cancel();
button.disabled = false;
button.classList.remove('mr-btn-is-disabled');
button.classList.remove('mr-btn-is-disabled', 'mr-is-refreshing');
}
}
},
@@ -835,8 +930,7 @@ export const missingBrowserMethods = {
const perfectMatches = filteredMatches.filter(m => m.confidence === 100);
const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70);
// Format the missing filename for display
const missingFilename = this.formatFilename(missing.original_path, 60);
const missingFilename = this.getMissingFilename(missing);
// Determine node info for the chip
const nodeDisplay = this.getMissingNodeDisplay(missing);
@@ -850,7 +944,7 @@ export const missingBrowserMethods = {
html += `<div class="mr-card-title-wrap">`;
const titleMetaParts = [];
let titlePrimaryHtml = `<span class="mr-card-title-primary" data-tooltip="${this.escapeHtml(missingFilename.full)}">${missingFilename.display}</span>`;
let titlePrimaryHtml = `<span class="mr-card-title-primary" data-tooltip="${this.escapeHtml(missingFilename)}">${this.escapeHtml(missingFilename)}</span>`;
let titleSecondaryHtml = '';
const modelId = missing.urn_model_id || missing.urn?.model_id;
@@ -859,7 +953,7 @@ export const missingBrowserMethods = {
const urnLoadingId = `urn-loading-${missing.node_id}-${missing.widget_index}`;
if (missing.is_urn) {
titleMetaParts.push(`<span class="mr-card-title-eyebrow" data-tooltip="${this.escapeHtml(missingFilename.full)}">${missingFilename.display}</span>`);
titleMetaParts.push(`<span class="mr-card-title-eyebrow" data-tooltip="${this.escapeHtml(missingFilename)}">${this.escapeHtml(missingFilename)}</span>`);
}
if (missing.is_urn && !missing.civitai_info) {
@@ -923,7 +1017,7 @@ export const missingBrowserMethods = {
html += `<div class="mr-combo-row">`;
html += `<label class="mr-combo-label">Model</label>`;
html += `<input id="combo-input-${comboId}" class="mr-combo-input" type="text" placeholder="Type to filter local models...">`;
html += `<button id="combo-refresh-${comboId}" type="button" aria-label="Reload local model list" data-tooltip="Reload local model list" class="mr-btn mr-btn-secondary mr-btn-sm mr-btn-icon-only mr-combo-refresh-btn">${getSvgIcon('refreshCw', 'currentColor', 'mr-combo-refresh-icon')}</button>`;
html += `<button id="combo-refresh-${comboId}" type="button" aria-label="Reload local model list" data-tooltip="Reload local model list" class="mr-btn mr-btn-secondary mr-btn-sm mr-btn-icon-only mr-combo-refresh-btn"><span class="mr-refresh-spin-target">${getSvgIcon('refreshCw', 'currentColor', 'mr-combo-refresh-icon')}</span></button>`;
html += `</div>`;
html += `<div id="combo-list-${comboId}" class="mr-combo-list"></div>`;
html += `</div>`;