web: refine Model Linker UI — improve context menu, tooltips, info dialog, and styles
Improve UX for model relinking: add context menu actions (Show Info, Open on CivitAI, Open containing folder), sanitize CivitAI descriptions, persist info dialog size, and adjust CSS spacing and visibility.
This commit is contained in:
@@ -764,6 +764,74 @@
|
||||
margin-top: 8px;
|
||||
display: none;
|
||||
}
|
||||
.ml-search-progress-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.ml-search-progress-item {
|
||||
padding: 6px 8px;
|
||||
background: rgba(255,255,255,0.035);
|
||||
border: 1px solid var(--ml-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.ml-search-progress-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--ml-text-muted);
|
||||
}
|
||||
.ml-search-progress-head span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ml-search-progress-head span:first-child {
|
||||
color: var(--ml-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ml-search-progress-head span:last-child {
|
||||
flex: 0 1 auto;
|
||||
max-width: 55%;
|
||||
text-align: right;
|
||||
}
|
||||
.ml-search-progress-bar {
|
||||
height: 5px;
|
||||
overflow: hidden;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-radius: 5px;
|
||||
}
|
||||
.ml-search-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--ml-accent);
|
||||
border-radius: inherit;
|
||||
transition: width 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
.ml-search-progress-fill-running {
|
||||
width: 42% !important;
|
||||
animation: ml-search-progress-slide 1.05s ease-in-out infinite;
|
||||
}
|
||||
.ml-search-progress-found .ml-search-progress-fill {
|
||||
background: #72d38a;
|
||||
}
|
||||
.ml-search-progress-none .ml-search-progress-fill {
|
||||
background: rgba(255,255,255,0.32);
|
||||
}
|
||||
.ml-search-progress-error .ml-search-progress-fill {
|
||||
background: #ff8a80;
|
||||
}
|
||||
@keyframes ml-search-progress-slide {
|
||||
0% {
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(240%);
|
||||
}
|
||||
}
|
||||
.ml-combo-section {
|
||||
position: relative;
|
||||
margin-top: 8px;
|
||||
|
||||
+199
-36
@@ -106,10 +106,14 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
popular: null,
|
||||
model_list: null,
|
||||
huggingface: null,
|
||||
civitai: null
|
||||
civitai: null,
|
||||
lora_manager_archive: null
|
||||
},
|
||||
lastAttemptSources: [],
|
||||
lastAttemptFound: null
|
||||
lastAttemptFound: null,
|
||||
lastAttemptError: null,
|
||||
sourceProgress: {},
|
||||
activeSearchRunId: null
|
||||
});
|
||||
}
|
||||
return this.searchResultCache.get(key);
|
||||
@@ -222,6 +226,69 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
return labels[source] || source;
|
||||
}
|
||||
|
||||
getSearchSourcesForSelection(selectedSource, missing = {}) {
|
||||
if (selectedSource !== 'all') {
|
||||
return this.isSourceAvailable(selectedSource) ? [selectedSource] : [];
|
||||
}
|
||||
|
||||
const sources = ['local', 'huggingface', 'civitai'];
|
||||
if (!missing.is_urn && this.isSourceAvailable('lora_manager_archive')) {
|
||||
sources.push('lora_manager_archive');
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
setSourceProgress(state, source, patch = {}) {
|
||||
state.sourceProgress = {
|
||||
...(state.sourceProgress || {}),
|
||||
[source]: {
|
||||
...(state.sourceProgress?.[source] || {}),
|
||||
...patch
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
hasActiveSearchProgress(state = {}) {
|
||||
return Object.values(state.sourceProgress || {}).some(progress => (
|
||||
progress?.status === 'pending' || progress?.status === 'running'
|
||||
));
|
||||
}
|
||||
|
||||
renderSearchProgress(state = {}) {
|
||||
const progressEntries = Object.entries(state.sourceProgress || {});
|
||||
if (!progressEntries.length) return '';
|
||||
|
||||
const statusLabels = {
|
||||
pending: 'Queued',
|
||||
running: 'Searching...',
|
||||
found: 'Found',
|
||||
none: 'No match',
|
||||
error: 'Error'
|
||||
};
|
||||
|
||||
let html = '<div class="ml-search-progress-list">';
|
||||
for (const [source, progress] of progressEntries) {
|
||||
const status = progress?.status || 'pending';
|
||||
const label = this.getSearchSourceLabel(source);
|
||||
const statusLabel = progress?.message || statusLabels[status] || status;
|
||||
const percent = status === 'pending' ? 0 : (status === 'running' ? 55 : 100);
|
||||
const safePercent = Math.max(0, Math.min(100, percent));
|
||||
html += `
|
||||
<div class="ml-search-progress-item ml-search-progress-${status}">
|
||||
<div class="ml-search-progress-head">
|
||||
<span>${this.escapeHtml(label)}</span>
|
||||
<span>${this.escapeHtml(statusLabel)}</span>
|
||||
</div>
|
||||
<div class="ml-search-progress-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${safePercent}">
|
||||
<div class="ml-search-progress-fill${status === 'running' ? ' ml-search-progress-fill-running' : ''}" style="width: ${safePercent}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update source selector buttons and helper text for one card
|
||||
*/
|
||||
@@ -4527,6 +4594,7 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
const state = this.getSearchState(missing);
|
||||
const selectedSource = state.selectedSource || 'all';
|
||||
const selectedSourceLabel = this.getSearchSourceLabel(selectedSource);
|
||||
const sourceIds = this.getSearchSourcesForSelection(selectedSource, missing);
|
||||
|
||||
// For URNs, use the CivitAI model name for searching instead of the URN itself
|
||||
// and pass the URN type as category (CivitAI expects specific type names)
|
||||
@@ -4558,24 +4626,41 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
const resultsId = `search-results-${missing.node_id}-${missing.widget_index}`;
|
||||
const resultsDiv = this.contentElement?.querySelector(`#${resultsId}`);
|
||||
const searchBtn = this.contentElement?.querySelector(`#search-${missing.node_id}-${missing.widget_index}`);
|
||||
let searchRunId = null;
|
||||
|
||||
try {
|
||||
if (!sourceIds.length) {
|
||||
throw new Error(`${selectedSourceLabel} is not available in this install`);
|
||||
}
|
||||
|
||||
searchRunId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
state.activeSearchRunId = searchRunId;
|
||||
state.lastAttemptSources = sourceIds;
|
||||
state.lastAttemptFound = null;
|
||||
state.lastAttemptError = null;
|
||||
state.sourceProgress = {};
|
||||
for (const source of sourceIds) {
|
||||
this.setSourceProgress(state, source, {
|
||||
status: 'running',
|
||||
message: 'Searching...'
|
||||
});
|
||||
}
|
||||
|
||||
if (searchBtn) {
|
||||
searchBtn.disabled = true;
|
||||
searchBtn.innerHTML = `${this.getSearchIconHtml()} Searching ${selectedSourceLabel}...`;
|
||||
}
|
||||
if (resultsDiv) {
|
||||
resultsDiv.style.display = 'block';
|
||||
resultsDiv.innerHTML = `<span class="ml-info-accent-text">Searching ${selectedSourceLabel}...</span>`;
|
||||
this.displaySearchResults(missing, state, resultsDiv);
|
||||
}
|
||||
|
||||
// For URNs, include model_id and version_id for direct download
|
||||
const tokens = this.getStoredTokens();
|
||||
const searchData = {
|
||||
const baseSearchData = {
|
||||
filename,
|
||||
category,
|
||||
is_urn: isUrn,
|
||||
sources: [selectedSource],
|
||||
civitai_session_token: tokens.civitai_session_token,
|
||||
civitai_candidate_limit: tokens.civitai_candidate_limit,
|
||||
civitai_use_trpc_search: tokens.civitai_use_trpc_search,
|
||||
@@ -4587,46 +4672,110 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
hf_use_brave_fallback: tokens.hf_use_brave_fallback
|
||||
};
|
||||
if (isUrn && missing.urn) {
|
||||
searchData.model_id = missing.urn.model_id;
|
||||
searchData.version_id = missing.urn.version_id;
|
||||
baseSearchData.model_id = missing.urn.model_id;
|
||||
baseSearchData.version_id = missing.urn.version_id;
|
||||
}
|
||||
|
||||
console.log('Model Linker: Search request:', JSON.stringify(searchData));
|
||||
|
||||
const response = await api.fetchApi('/model_linker/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(searchData)
|
||||
const attemptedSources = new Set();
|
||||
let anyFound = false;
|
||||
let hadError = false;
|
||||
|
||||
const searchPromises = sourceIds.map(async (source) => {
|
||||
const searchData = {
|
||||
...baseSearchData,
|
||||
sources: [source]
|
||||
};
|
||||
|
||||
try {
|
||||
console.log('Model Linker: Search request:', JSON.stringify(searchData));
|
||||
|
||||
const response = await api.fetchApi('/model_linker/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(searchData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Search failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Model Linker: Search response:', JSON.stringify(data));
|
||||
|
||||
if (state.activeSearchRunId !== searchRunId) {
|
||||
return { source, stale: true };
|
||||
}
|
||||
|
||||
const responseSources = Array.isArray(data.searched_sources) && data.searched_sources.length
|
||||
? data.searched_sources
|
||||
: [source];
|
||||
responseSources.forEach(responseSource => attemptedSources.add(responseSource));
|
||||
|
||||
const found = this.hasSearchResults(data);
|
||||
anyFound = anyFound || found;
|
||||
state.results = this.mergeSearchResults(state.results, data);
|
||||
state.lastAttemptSources = Array.from(attemptedSources);
|
||||
state.lastAttemptFound = anyFound;
|
||||
this.setSourceProgress(state, source, {
|
||||
status: found ? 'found' : 'none',
|
||||
message: found ? 'Found' : 'No match'
|
||||
});
|
||||
|
||||
if (data.civitai) {
|
||||
missing.civitai_search_result = {
|
||||
base_model: data.civitai.base_model,
|
||||
tags: data.civitai.tags || [],
|
||||
trained_words: data.civitai.trained_words || [],
|
||||
filename: data.civitai.filename,
|
||||
name: data.civitai.name,
|
||||
type: data.civitai.type
|
||||
};
|
||||
}
|
||||
|
||||
this.displaySearchResults(missing, state, resultsDiv);
|
||||
this.applySearchResultSuggestion(missing);
|
||||
return { source, data };
|
||||
} catch (error) {
|
||||
console.error(`Model Linker: Search error for ${source}:`, error);
|
||||
if (state.activeSearchRunId !== searchRunId) {
|
||||
return { source, stale: true, error };
|
||||
}
|
||||
|
||||
hadError = true;
|
||||
attemptedSources.add(source);
|
||||
state.lastAttemptSources = Array.from(attemptedSources);
|
||||
state.lastAttemptFound = anyFound;
|
||||
this.setSourceProgress(state, source, {
|
||||
status: 'error',
|
||||
message: error.message || 'Error'
|
||||
});
|
||||
this.displaySearchResults(missing, state, resultsDiv);
|
||||
return { source, error };
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Search failed: ${response.status}`);
|
||||
}
|
||||
await Promise.all(searchPromises);
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Model Linker: Search response:', JSON.stringify(data));
|
||||
state.results = this.mergeSearchResults(state.results, data);
|
||||
state.lastAttemptSources = Array.isArray(data.searched_sources) ? data.searched_sources : [selectedSource];
|
||||
state.lastAttemptFound = this.hasSearchResults(data);
|
||||
if (data.civitai) {
|
||||
missing.civitai_search_result = {
|
||||
base_model: data.civitai.base_model,
|
||||
tags: data.civitai.tags || [],
|
||||
trained_words: data.civitai.trained_words || [],
|
||||
filename: data.civitai.filename,
|
||||
name: data.civitai.name,
|
||||
type: data.civitai.type
|
||||
};
|
||||
if (state.activeSearchRunId === searchRunId) {
|
||||
state.activeSearchRunId = null;
|
||||
state.lastAttemptSources = attemptedSources.size ? Array.from(attemptedSources) : sourceIds;
|
||||
state.lastAttemptFound = anyFound;
|
||||
state.lastAttemptError = hadError && !anyFound
|
||||
? 'Search finished with errors. Check source statuses above.'
|
||||
: null;
|
||||
this.displaySearchResults(missing, state, resultsDiv);
|
||||
}
|
||||
this.displaySearchResults(missing, state, resultsDiv);
|
||||
this.applySearchResultSuggestion(missing);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Model Linker: Search error:', error);
|
||||
state.lastAttemptError = error.message;
|
||||
if (resultsDiv) {
|
||||
resultsDiv.innerHTML = this.renderStatusMessage(`Search failed: ${error.message}`, 'error');
|
||||
}
|
||||
} finally {
|
||||
if (!searchRunId || state.activeSearchRunId === searchRunId) {
|
||||
state.activeSearchRunId = null;
|
||||
}
|
||||
if (searchBtn) {
|
||||
searchBtn.disabled = false;
|
||||
searchBtn.innerHTML = `${this.getSearchIconHtml()} Search Again`;
|
||||
@@ -4763,19 +4912,33 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
const civitaiResult = results.civitai ? (Array.isArray(results.civitai) ? results.civitai[0] : results.civitai) : null;
|
||||
const loraManagerArchiveResult = results.lora_manager_archive ? (Array.isArray(results.lora_manager_archive) ? results.lora_manager_archive[0] : results.lora_manager_archive) : null;
|
||||
const hasResults = popular || modelListResult || hfResult || civitaiResult || loraManagerArchiveResult;
|
||||
const progressHtml = this.renderSearchProgress(state);
|
||||
const hasActiveProgress = this.hasActiveSearchProgress(state);
|
||||
|
||||
if (!hasResults) {
|
||||
if (hasActiveProgress) {
|
||||
container.innerHTML = progressHtml;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state?.lastAttemptError) {
|
||||
container.innerHTML = `${progressHtml}${this.renderStatusMessage(state.lastAttemptError, 'error')}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const searchedLabel = (state?.lastAttemptSources || []).map(source => this.getSearchSourceLabel(source)).join(', ');
|
||||
container.innerHTML = this.renderStatusMessage(
|
||||
container.innerHTML = `${progressHtml}${this.renderStatusMessage(
|
||||
searchedLabel ? `No matches found in ${searchedLabel}.` : 'No matches found online for this model.',
|
||||
'warning'
|
||||
);
|
||||
)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="ml-search-results-stack">';
|
||||
let html = `${progressHtml}<div class="ml-search-results-stack">`;
|
||||
|
||||
if (state?.lastAttemptFound === false) {
|
||||
if (!hasActiveProgress && state?.lastAttemptError) {
|
||||
html += this.renderStatusMessage(state.lastAttemptError, 'error');
|
||||
} else if (!hasActiveProgress && state?.lastAttemptFound === false) {
|
||||
const searchedLabel = (state.lastAttemptSources || []).map(source => this.getSearchSourceLabel(source)).join(', ');
|
||||
html += this.renderStatusMessage(`No new matches found in ${searchedLabel}. Existing results are kept below.`, 'warning');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user