feat: add missing models browser UI and logic
- Implement CSS styles for missing models browser interface (.ml-missing-browser and related classes) - Add JavaScript methods for handling missing models: * getMissingModelKey, getMissingFilename, getBestLocalMatch * getMissingModelSummaryStats, getMissingSourceStatus * renderMissingSourcesSummary, hasRenderableSearchState - Replace missing models rendering logic with new implementation - Support for exact/partial match statistics and source status indicators
This commit is contained in:
@@ -222,6 +222,307 @@
|
||||
color: var(--ml-text-muted);
|
||||
}
|
||||
|
||||
.ml-missing-browser {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 1.1fr) minmax(320px, 0.9fr);
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
background: rgba(255,255,255,0.015);
|
||||
}
|
||||
.ml-missing-list-pane,
|
||||
.ml-missing-detail-pane {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ml-missing-list-pane {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
border-right: 1px solid rgba(255,255,255,0.08);
|
||||
background: rgba(20,20,20,0.92);
|
||||
}
|
||||
.ml-missing-list-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.07);
|
||||
}
|
||||
.ml-missing-list-title {
|
||||
font-size: 14px;
|
||||
font-weight: 750;
|
||||
color: var(--ml-text);
|
||||
}
|
||||
.ml-missing-list-meta {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--ml-text-muted);
|
||||
}
|
||||
.ml-missing-list-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ml-missing-stat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.055);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
color: var(--ml-text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ml-missing-stat-exact {
|
||||
color: #72d38a;
|
||||
}
|
||||
.ml-missing-stat-partial {
|
||||
color: #f1cf64;
|
||||
}
|
||||
.ml-missing-list-head,
|
||||
#model-linker-modal .ml-missing-list-row {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(220px, 1.7fr) minmax(66px, 112px) minmax(150px, 1fr) 106px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.ml-missing-list-head {
|
||||
padding: 8px 10px 8px 6px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
background: rgba(255,255,255,0.035);
|
||||
color: var(--ml-text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ml-missing-list {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
#model-linker-modal .ml-missing-list-row {
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
padding: 7px 10px 7px 6px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.055);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--ml-text);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
transition: background 0.14s ease, box-shadow 0.14s ease;
|
||||
}
|
||||
#model-linker-modal .ml-missing-list-row:hover {
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
#model-linker-modal .ml-missing-list-row.is-selected {
|
||||
background: rgba(114,211,138,0.09);
|
||||
box-shadow: inset 3px 0 0 rgba(114,211,138,0.92);
|
||||
}
|
||||
.ml-missing-row-index {
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: rgba(255,255,255,0.55);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
justify-self: stretch;
|
||||
}
|
||||
.ml-missing-row-model,
|
||||
.ml-missing-row-match {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.ml-missing-row-model {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
.ml-missing-row-name,
|
||||
.ml-missing-row-node,
|
||||
.ml-missing-row-match span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ml-missing-row-name {
|
||||
color: var(--ml-text);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ml-missing-row-node {
|
||||
grid-column: 1;
|
||||
color: var(--ml-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.ml-missing-row-type {
|
||||
justify-self: start;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
padding: 2px 7px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.065);
|
||||
color: #d4d4d4;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.ml-missing-row-match {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
column-gap: 6px;
|
||||
row-gap: 2px;
|
||||
color: var(--ml-text-muted);
|
||||
}
|
||||
.ml-missing-row-match strong {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 18px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.065);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ml-missing-row-match span {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: normal;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.ml-missing-row-match-exact strong {
|
||||
color: #72d38a;
|
||||
background: rgba(114,211,138,0.12);
|
||||
}
|
||||
.ml-missing-row-match-partial strong {
|
||||
color: #f1cf64;
|
||||
background: rgba(241,207,100,0.13);
|
||||
}
|
||||
.ml-missing-row-match-none strong {
|
||||
color: rgba(255,255,255,0.42);
|
||||
}
|
||||
.ml-missing-row-sources {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.ml-missing-source-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.035);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
color: rgba(255,255,255,0.42);
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.ml-missing-source-icon {
|
||||
display: block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: 0 0 12px;
|
||||
}
|
||||
.ml-missing-source-found {
|
||||
color: #72d38a;
|
||||
border-color: rgba(114,211,138,0.55);
|
||||
background: rgba(114,211,138,0.11);
|
||||
}
|
||||
.ml-missing-source-none {
|
||||
color: rgba(255,255,255,0.48);
|
||||
}
|
||||
.ml-missing-source-running,
|
||||
.ml-missing-source-pending {
|
||||
color: #8fd0ff;
|
||||
border-color: rgba(143,208,255,0.55);
|
||||
background: rgba(143,208,255,0.1);
|
||||
}
|
||||
.ml-missing-source-error {
|
||||
color: #ff8a80;
|
||||
border-color: rgba(255,138,128,0.55);
|
||||
background: rgba(255,138,128,0.1);
|
||||
}
|
||||
.ml-missing-detail-pane {
|
||||
overflow-y: auto;
|
||||
background: rgba(18,18,18,0.9);
|
||||
}
|
||||
.ml-missing-detail-pane .ml-card {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.ml-missing-detail-pane .ml-card:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
.ml-missing-detail-pane .ml-columns {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.ml-missing-detail-pane .ml-column {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.ml-missing-browser {
|
||||
grid-template-columns: 1fr;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ml-missing-list-pane {
|
||||
max-height: 46vh;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.ml-missing-detail-pane {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.ml-missing-list-head {
|
||||
display: none;
|
||||
}
|
||||
#model-linker-modal .ml-missing-list-row {
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
gap: 7px;
|
||||
}
|
||||
.ml-missing-row-index {
|
||||
grid-row: 1 / span 4;
|
||||
align-self: start;
|
||||
margin-top: 1px;
|
||||
text-align: center;
|
||||
}
|
||||
.ml-missing-row-model,
|
||||
.ml-missing-row-type,
|
||||
.ml-missing-row-match,
|
||||
.ml-missing-row-sources {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* Card Styles */
|
||||
.ml-card {
|
||||
background: var(--ml-card-bg);
|
||||
|
||||
+311
-127
@@ -28,6 +28,7 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
this.searchProgressTimers = new Map();
|
||||
this.cachedAnalysisData = null;
|
||||
this.cachedWorkflowSignature = null;
|
||||
this.selectedMissingModelKey = null;
|
||||
this.boundHandleOutsideClick = this.handleOutsideClick.bind(this);
|
||||
this.activeTabStorageKey = 'model_linker_active_tab';
|
||||
this.activeTab = this.restoreActiveTab(); // Default tab
|
||||
@@ -471,6 +472,7 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
huggingface: 'huggingface',
|
||||
civitai: 'civitai',
|
||||
'lora-archive': 'archive',
|
||||
lora_manager_archive: 'archive',
|
||||
'lora-manager-archive': 'archive',
|
||||
local: 'database',
|
||||
'workflow-url': 'link',
|
||||
@@ -3924,7 +3926,297 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getMissingModelKey(missing = {}) {
|
||||
const nodeId = missing.node_id ?? '';
|
||||
const widgetIndex = missing.widget_index ?? '';
|
||||
const subgraphId = missing.subgraph_id || '';
|
||||
const scope = missing.is_top_level !== false ? 'T' : 'F';
|
||||
return `${nodeId}:${widgetIndex}:${subgraphId}:${scope}`;
|
||||
}
|
||||
|
||||
getMissingFilename(missing = {}) {
|
||||
return missing.original_path?.split('/').pop()?.split('\\').pop() || missing.name || 'Missing model';
|
||||
}
|
||||
|
||||
getBestLocalMatch(missing = {}, minConfidence = 0) {
|
||||
const matches = Array.isArray(missing.matches) ? missing.matches : [];
|
||||
return matches
|
||||
.filter(match => Number(match.confidence) >= minConfidence)
|
||||
.sort((a, b) => Number(b.confidence || 0) - Number(a.confidence || 0))[0] || null;
|
||||
}
|
||||
|
||||
getMissingModelSummaryStats(missingModels = []) {
|
||||
return missingModels.reduce((stats, missing) => {
|
||||
const best = this.getBestLocalMatch(missing, 70);
|
||||
if (best?.confidence === 100) {
|
||||
stats.exact += 1;
|
||||
} else if (best) {
|
||||
stats.partial += 1;
|
||||
} else {
|
||||
stats.none += 1;
|
||||
}
|
||||
return stats;
|
||||
}, { exact: 0, partial: 0, none: 0 });
|
||||
}
|
||||
|
||||
getMissingSourceStatus(missing = {}, source = '') {
|
||||
const state = this.searchResultCache.get(this.getMissingSearchKey(missing));
|
||||
const progress = state?.sourceProgress?.[source];
|
||||
if (progress?.status) return progress.status;
|
||||
|
||||
const results = state?.results || {};
|
||||
const resultKeys = {
|
||||
local: 'model_list',
|
||||
huggingface: 'huggingface',
|
||||
civitai: 'civitai',
|
||||
lora_manager_archive: 'lora_manager_archive'
|
||||
};
|
||||
if (resultKeys[source] && results[resultKeys[source]]) {
|
||||
return 'found';
|
||||
}
|
||||
|
||||
const downloadSource = missing.download_source || {};
|
||||
const mappedDownloadSource = {
|
||||
model_list: 'local',
|
||||
popular: 'local',
|
||||
workflow: 'civitai'
|
||||
}[downloadSource.source] || downloadSource.source;
|
||||
if (mappedDownloadSource === source && downloadSource.url) {
|
||||
return 'found';
|
||||
}
|
||||
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
renderMissingSourcesSummary(missing = {}) {
|
||||
const sourceItems = [
|
||||
{ source: 'local' },
|
||||
{ source: 'huggingface' },
|
||||
{ source: 'civitai' }
|
||||
];
|
||||
if (this.isSourceAvailable('lora_manager_archive')) {
|
||||
sourceItems.push({ source: 'lora_manager_archive' });
|
||||
}
|
||||
|
||||
return sourceItems.map(item => {
|
||||
const status = this.getMissingSourceStatus(missing, item.source);
|
||||
const statusClass = String(status || 'idle').replace(/[^a-z0-9_-]/gi, '');
|
||||
const label = this.getSearchSourceLabel(item.source);
|
||||
const statusLabels = {
|
||||
pending: 'Queued',
|
||||
running: 'Searching',
|
||||
found: 'Found',
|
||||
none: 'No match',
|
||||
error: 'Error',
|
||||
idle: 'Not searched'
|
||||
};
|
||||
const title = `${label}: ${statusLabels[status] || status}`;
|
||||
const iconName = this.getSearchSourceIconName(item.source);
|
||||
const iconHtml = getSvgIcon(iconName, 'currentColor', 'ml-missing-source-icon');
|
||||
return `<span class="ml-missing-source-dot ml-missing-source-${statusClass}" title="${this.escapeHtml(title)}" aria-label="${this.escapeHtml(title)}">${iconHtml}</span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
hasRenderableSearchState(state = {}) {
|
||||
return Boolean(
|
||||
(state.lastAttemptSources || []).length
|
||||
|| Object.keys(state.sourceProgress || {}).length
|
||||
|| this.hasSearchResults(state.results || {})
|
||||
|| state.lastAttemptError
|
||||
);
|
||||
}
|
||||
|
||||
renderMissingModelsBrowser(missingModels, selectedKey, totalMissing, activeCount, hasAny100Match) {
|
||||
const stats = this.getMissingModelSummaryStats(missingModels);
|
||||
const detailIndex = missingModels.findIndex(missing => this.getMissingModelKey(missing) === selectedKey);
|
||||
const detailMissing = detailIndex >= 0 ? missingModels[detailIndex] : null;
|
||||
const activeHint = activeCount > 0
|
||||
? `${activeCount} downloading`
|
||||
: (hasAny100Match ? 'Auto-link ready for exact matches' : 'Review matches or search online');
|
||||
|
||||
let html = `
|
||||
<div class="ml-missing-browser">
|
||||
<section class="ml-missing-list-pane" aria-label="Missing model list">
|
||||
<div class="ml-missing-list-toolbar">
|
||||
<div>
|
||||
<div class="ml-missing-list-title">${totalMissing} missing model${totalMissing === 1 ? '' : 's'}</div>
|
||||
<div class="ml-missing-list-meta">${this.escapeHtml(activeHint)}</div>
|
||||
</div>
|
||||
<div class="ml-missing-list-stats">
|
||||
<span class="ml-missing-stat ml-missing-stat-exact">${stats.exact} exact</span>
|
||||
<span class="ml-missing-stat ml-missing-stat-partial">${stats.partial} partial</span>
|
||||
<span class="ml-missing-stat ml-missing-stat-none">${stats.none} no match</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-missing-list-head">
|
||||
<span>ID</span>
|
||||
<span>Missing Model</span>
|
||||
<span>Type</span>
|
||||
<span>Best Local Match</span>
|
||||
<span>Sources</span>
|
||||
</div>
|
||||
<div class="ml-missing-list">
|
||||
`;
|
||||
|
||||
missingModels.forEach((missing, index) => {
|
||||
const key = this.getMissingModelKey(missing);
|
||||
const isSelected = key === selectedKey;
|
||||
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 || '';
|
||||
const matchDisplay = matchName || 'Local match';
|
||||
const matchClass = confidence === 100 ? 'exact' : (bestMatch ? 'partial' : 'none');
|
||||
const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown';
|
||||
const nodeLabel = missing.subgraph_name || missing.node_type || 'Node';
|
||||
|
||||
html += `
|
||||
<button type="button"
|
||||
class="ml-missing-list-row ${isSelected ? 'is-selected' : ''}"
|
||||
data-missing-key="${this.escapeHtml(key)}">
|
||||
<span class="ml-missing-row-index">${index + 1}</span>
|
||||
<span class="ml-missing-row-model">
|
||||
<span class="ml-missing-row-name" title="${this.escapeHtml(filename)}">${this.escapeHtml(formattedFilename.display)}</span>
|
||||
<span class="ml-missing-row-node">${this.escapeHtml(nodeLabel)} #${this.escapeHtml(String(missing.node_id || ''))}</span>
|
||||
</span>
|
||||
<span class="ml-missing-row-type">${this.escapeHtml(typeLabel)}</span>
|
||||
<span class="ml-missing-row-match ml-missing-row-match-${matchClass}">
|
||||
${bestMatch ? `<strong>${confidence.toFixed(confidence % 1 ? 1 : 0)}%</strong><span title="${this.escapeHtml(matchDisplay)}">${this.escapeHtml(matchDisplay)}</span>` : '<strong>--</strong><span>No local match</span>'}
|
||||
</span>
|
||||
<span class="ml-missing-row-sources">${this.renderMissingSourcesSummary(missing)}</span>
|
||||
</button>
|
||||
`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</div>
|
||||
</section>
|
||||
<section class="ml-missing-detail-pane" aria-label="Missing model details">
|
||||
${detailMissing ? this.renderMissingModel(detailMissing, detailIndex) : this.renderStatusMessage('Select a missing model to inspect details.', 'info')}
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
return html;
|
||||
}
|
||||
|
||||
wireMissingModelsBrowser(container, data, sortedMissingModels) {
|
||||
container.querySelectorAll('.ml-missing-list-row').forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
const key = row.dataset.missingKey;
|
||||
if (!key || key === this.selectedMissingModelKey) return;
|
||||
this.selectedMissingModelKey = key;
|
||||
this.displayMissingModels(container, data);
|
||||
});
|
||||
});
|
||||
|
||||
const selectedMissing = sortedMissingModels.find(missing => this.getMissingModelKey(missing) === this.selectedMissingModelKey);
|
||||
if (!selectedMissing) return;
|
||||
|
||||
const selectedIndex = sortedMissingModels.indexOf(selectedMissing);
|
||||
this.wireMissingModelDetail(container, selectedMissing, selectedIndex);
|
||||
}
|
||||
|
||||
wireMissingModelDetail(container, missing, missingIndex) {
|
||||
this.wireLocalMatchButtons(container, missing, missingIndex);
|
||||
this.wireDownloadSearchPanel(container, missing);
|
||||
|
||||
const locateId = `locate-${missing.node_id}-${missing.widget_index}`;
|
||||
const locateBtn = container.querySelector(`#${locateId}`);
|
||||
if (locateBtn && missing.is_top_level !== false) {
|
||||
locateBtn.addEventListener('click', () => {
|
||||
this.locateNodeInGraph(missing.node_id);
|
||||
});
|
||||
}
|
||||
|
||||
const comboId = `combo-${missing.node_id}-${missing.widget_index}`;
|
||||
const comboInput = container.querySelector(`#combo-input-${comboId}`);
|
||||
const comboList = container.querySelector(`#combo-list-${comboId}`);
|
||||
const comboRefresh = container.querySelector(`#combo-refresh-${comboId}`);
|
||||
|
||||
if (comboList) {
|
||||
this.enableWheelScrollChaining(comboList);
|
||||
}
|
||||
|
||||
const allModels = 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 || '';
|
||||
|
||||
const populateComboOptions = (filterText, highlightIdx = -1) => {
|
||||
if (!comboList) return;
|
||||
const f = (filterText || '').toLowerCase();
|
||||
const filtered = f
|
||||
? allModels.filter(m => buildLabel(m).toLowerCase().includes(f))
|
||||
: allModels.slice();
|
||||
|
||||
let html = '';
|
||||
for (let i = 0; i < filtered.length; i++) {
|
||||
const m = filtered[i];
|
||||
const label = buildLabel(m);
|
||||
const folder = getFolder(m);
|
||||
const isHighlighted = i === highlightIdx;
|
||||
const folderDisplay = folder ? folder.replace(/\\/g, '/').replace(/:/, '') : '';
|
||||
html += `<div data-idx="${allModels.indexOf(m)}" class="ml-combo-option ${isHighlighted ? 'is-highlighted' : ''}">`;
|
||||
html += `<div class="ml-combo-option-row">`;
|
||||
html += `<code>${this.escapeHtml(label)}</code>`;
|
||||
html += `</div>`;
|
||||
if (folderDisplay) {
|
||||
html += `<div class="ml-combo-folder" title="${this.escapeHtml(folderDisplay)}">Folder ${this.escapeHtml(folderDisplay)}</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
comboList.innerHTML = html;
|
||||
|
||||
comboList.querySelectorAll('div[data-idx]').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const idx = parseInt(el.dataset.idx, 10);
|
||||
if (!Number.isNaN(idx) && idx >= 0 && idx < allModels.length) {
|
||||
const chosenModel = allModels[idx];
|
||||
if (chosenModel) {
|
||||
this.queueResolution(missing, chosenModel);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (comboList) {
|
||||
populateComboOptions('');
|
||||
}
|
||||
|
||||
if (comboInput) {
|
||||
const debouncedFilter = this.debounce(() => {
|
||||
populateComboOptions(comboInput.value);
|
||||
}, 200);
|
||||
comboInput.addEventListener('input', debouncedFilter);
|
||||
comboInput.addEventListener('focus', () => {
|
||||
if (comboList) comboList.style.display = 'block';
|
||||
populateComboOptions(comboInput.value);
|
||||
});
|
||||
comboInput.addEventListener('blur', () => {
|
||||
setTimeout(() => {
|
||||
if (comboList) comboList.style.display = 'none';
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
if (comboRefresh) {
|
||||
comboRefresh.addEventListener('click', async () => {
|
||||
this.allModels = null;
|
||||
await this.ensureAllModelsLoaded();
|
||||
populateComboOptions(comboInput?.value || '');
|
||||
});
|
||||
}
|
||||
|
||||
const state = this.searchResultCache.get(this.getMissingSearchKey(missing));
|
||||
const searchResultsDiv = container.querySelector(`#search-results-${missing.node_id}-${missing.widget_index}`);
|
||||
if (state && searchResultsDiv && this.hasRenderableSearchState(state)) {
|
||||
searchResultsDiv.style.display = 'block';
|
||||
this.displaySearchResults(missing, state, searchResultsDiv);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display missing models in the dialog
|
||||
*/
|
||||
@@ -3965,20 +4257,6 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
return;
|
||||
}
|
||||
|
||||
// Summary header with count
|
||||
let html = `
|
||||
<div class="ml-missing-summary">
|
||||
<div class="ml-missing-summary-title">
|
||||
<span class="ml-missing-summary-count">${totalMissing} Missing Model${totalMissing > 1 ? 's' : ''}</span>
|
||||
<span class="ml-missing-summary-meta">Compact relinking and download view</span>
|
||||
</div>
|
||||
<div class="ml-missing-summary-meta">
|
||||
${activeCount > 0 ? `${activeCount} downloading` : (hasAny100Match ? 'Auto-Link ready for exact matches' : 'Review matches or search online')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
html += '<div class="ml-stack-md">';
|
||||
|
||||
// Skip rendering if active tab is not "missing"
|
||||
if (this.activeTab !== 'missing') {
|
||||
container.innerHTML = '';
|
||||
@@ -3986,7 +4264,7 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
}
|
||||
|
||||
// Sort missing models: those with 100% confidence matches first, then others
|
||||
const sortedMissingModels = missingModels.sort((a, b) => {
|
||||
const sortedMissingModels = [...missingModels].sort((a, b) => {
|
||||
const aMatches = a.matches || [];
|
||||
const bMatches = b.matches || [];
|
||||
|
||||
@@ -4011,122 +4289,28 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
|
||||
for (let mi = 0; mi < sortedMissingModels.length; mi++) {
|
||||
sortedMissingModels[mi].__displayIndex = mi;
|
||||
html += this.renderMissingModel(sortedMissingModels[mi], mi);
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
const selectedStillExists = sortedMissingModels.some(missing => (
|
||||
this.getMissingModelKey(missing) === this.selectedMissingModelKey
|
||||
));
|
||||
if (!selectedStillExists) {
|
||||
this.selectedMissingModelKey = sortedMissingModels.length
|
||||
? this.getMissingModelKey(sortedMissingModels[0])
|
||||
: null;
|
||||
}
|
||||
|
||||
// Attach event listeners for resolve buttons (use sorted order)
|
||||
// Note: We need to match the exact same logic as renderMissingModel to find which buttons were rendered
|
||||
sortedMissingModels.forEach((missing, missingIndex) => {
|
||||
this.wireLocalMatchButtons(container, missing, missingIndex);
|
||||
|
||||
this.wireDownloadSearchPanel(container, missing);
|
||||
|
||||
// Wire locate chip (only for top-level nodes)
|
||||
const locateId = `locate-${missing.node_id}-${missing.widget_index}`;
|
||||
const locateBtn = container.querySelector(`#${locateId}`);
|
||||
if (locateBtn && missing.is_top_level !== false) {
|
||||
locateBtn.addEventListener('click', () => {
|
||||
this.locateNodeInGraph(missing.node_id);
|
||||
});
|
||||
}
|
||||
|
||||
// Wire up all-models search + dropdown (combo-style)
|
||||
const comboId = `combo-${missing.node_id}-${missing.widget_index}`;
|
||||
const comboInput = container.querySelector(`#combo-input-${comboId}`);
|
||||
const comboList = container.querySelector(`#combo-list-${comboId}`);
|
||||
const comboRefresh = container.querySelector(`#combo-refresh-${comboId}`);
|
||||
|
||||
if (comboList) {
|
||||
this.enableWheelScrollChaining(comboList);
|
||||
}
|
||||
|
||||
const allModels = 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 || '';
|
||||
|
||||
// Populate dropdown with filtered models
|
||||
const populateComboOptions = (filterText, highlightIdx = -1) => {
|
||||
if (!comboList) return;
|
||||
const f = (filterText || '').toLowerCase();
|
||||
const filtered = f
|
||||
? allModels.filter(m => buildLabel(m).toLowerCase().includes(f))
|
||||
: allModels.slice(); // Copy to avoid mutation
|
||||
|
||||
let html = '';
|
||||
for (let i = 0; i < filtered.length; i++) {
|
||||
const m = filtered[i];
|
||||
const label = buildLabel(m);
|
||||
const folder = getFolder(m);
|
||||
const isHighlighted = i === highlightIdx;
|
||||
const folderDisplay = folder ? folder.replace(/\\/g, '/').replace(/:/, '') : '';
|
||||
html += `<div data-idx="${allModels.indexOf(m)}" class="ml-combo-option ${isHighlighted ? 'is-highlighted' : ''}">`;
|
||||
html += `<div class="ml-combo-option-row">`;
|
||||
html += `<code>${label}</code>`;
|
||||
html += `</div>`;
|
||||
if (folderDisplay) {
|
||||
html += `<div class="ml-combo-folder" title="${folderDisplay}">📁 ${folderDisplay}</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
comboList.innerHTML = html;
|
||||
|
||||
// Add click listeners to options
|
||||
comboList.querySelectorAll('div[data-idx]').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const idx = parseInt(el.dataset.idx, 10);
|
||||
if (!isNaN(idx) && idx >= 0 && idx < allModels.length) {
|
||||
const chosenModel = allModels[idx];
|
||||
if (chosenModel) {
|
||||
this.queueResolution(missing, chosenModel);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Initial populate
|
||||
if (comboList) {
|
||||
populateComboOptions('');
|
||||
}
|
||||
|
||||
// Filter input with debounce
|
||||
if (comboInput) {
|
||||
const debouncedFilter = this.debounce(() => {
|
||||
populateComboOptions(comboInput.value);
|
||||
}, 200);
|
||||
comboInput.addEventListener('input', debouncedFilter);
|
||||
|
||||
// Show dropdown on focus
|
||||
comboInput.addEventListener('focus', () => {
|
||||
if (comboList) comboList.style.display = 'block';
|
||||
populateComboOptions(comboInput.value);
|
||||
});
|
||||
|
||||
// Close on blur (with delay to allow click)
|
||||
comboInput.addEventListener('blur', () => {
|
||||
setTimeout(() => {
|
||||
if (comboList) comboList.style.display = 'none';
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh button - reload all models
|
||||
if (comboRefresh) {
|
||||
comboRefresh.addEventListener('click', async () => {
|
||||
this.allModels = null; // Force reload
|
||||
await this.ensureAllModelsLoaded();
|
||||
populateComboOptions(comboInput?.value || '');
|
||||
});
|
||||
}
|
||||
});
|
||||
this.missingModels = sortedMissingModels;
|
||||
container.innerHTML = this.renderMissingModelsBrowser(
|
||||
sortedMissingModels,
|
||||
this.selectedMissingModelKey,
|
||||
totalMissing,
|
||||
activeCount,
|
||||
hasAny100Match
|
||||
);
|
||||
this.wireMissingModelsBrowser(container, data, sortedMissingModels);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single missing model entry
|
||||
*/
|
||||
renderMissingModel(missing, missingIndex = 0) {
|
||||
const allMatches = missing.matches || [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user