mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
feat(recipes): reconnect suggestions, undo, and base-model family tolerance
Enhance the deleted-LoRA reconnect flow in the recipe modal: - Suggest local reconnect candidates when the panel opens, ranked by identity (same hash / same CivitAI version) then filename/name similarity, with a hard filter on confident base-model mismatches; the input gets a Combobox backed by the same endpoint as you type. - Snapshot the pre-reconnect entry and offer a permanent restore: reconnected entries show an undo icon at the right end of the info row, with the original filename in the tooltip. - Relax the manual reconnect base-model guard to a three-tier check: exact/unknown labels pass silently, same-architecture families (e.g. Pony <-> Illustrious) pass with a warning toast, and only cross-architecture mismatches stay hard-rejected.
This commit is contained in:
@@ -729,6 +729,9 @@
|
||||
|
||||
.recipe-lora-item {
|
||||
display: flex;
|
||||
/* The reconnect panel is a full-width child that wraps below the
|
||||
thumbnail + content row. */
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
padding: 10px var(--space-2);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -887,6 +890,29 @@
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Restore icon for manually reconnected entries: its presence on the info
|
||||
row doubles as the "was reconnected" marker. */
|
||||
.lora-undo-reconnect {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
opacity: 0.55;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.95em;
|
||||
line-height: 1;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.lora-undo-reconnect:hover,
|
||||
.lora-undo-reconnect:focus-visible {
|
||||
opacity: 1;
|
||||
color: var(--lora-accent);
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.local-badge,
|
||||
.missing-badge,
|
||||
.invalid-hash-badge {
|
||||
@@ -968,13 +994,17 @@
|
||||
|
||||
/* LoRA reconnect container: an inline extension of the item, not a nested
|
||||
card — a dashed separator reads lighter than another bordered box inside
|
||||
an already bordered item. */
|
||||
an already bordered item. It is a direct child of .recipe-lora-item and
|
||||
spans the full row (thumbnail column included). */
|
||||
.lora-reconnect-container {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex-basis: 100%;
|
||||
/* Flex items default to min-width:auto — never let content force the
|
||||
panel wider than the row. */
|
||||
min-width: 0;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
padding-top: 10px;
|
||||
margin-top: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@@ -1015,7 +1045,7 @@
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.reconnect-error {
|
||||
@@ -1029,6 +1059,89 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.reconnect-suggestions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.reconnect-suggestions:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reconnect-suggestions-loading,
|
||||
.reconnect-suggestions-empty {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.reconnect-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
/* Buttons default to content-box: without this, width:100% + padding +
|
||||
border overflows the panel by 18px and forces a horizontal scrollbar. */
|
||||
box-sizing: border-box;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--lora-surface, var(--bg-color));
|
||||
color: var(--text-color);
|
||||
font-size: 0.95em;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.reconnect-suggestion:hover,
|
||||
.reconnect-suggestion:focus-visible {
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.reconnect-suggestion-preview {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.reconnect-suggestion-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.reconnect-suggestion-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reconnect-suggestion-secondary {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.7;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reconnect-suggestion-reason {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--lora-accent);
|
||||
font-size: 0.85em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reconnect-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -28,8 +28,14 @@ export class Combobox {
|
||||
* @param {string[]} [options.presets=[]] Static preset values shown in dropdown.
|
||||
* @param {(inputValue: string) => Promise<string[]>} [options.fetchOptions]
|
||||
* Async function returning dynamic suggestions for the current input.
|
||||
* @param {string} [options.placeholder] Placeholder text for the empty state.
|
||||
* @param {string} [options.placeholder] Placeholder text for the input and the
|
||||
* dropdown empty state (see emptyText to override the latter).
|
||||
* @param {string} [options.emptyText] Text for the dropdown empty state;
|
||||
* defaults to `placeholder`, then 'No options'. Unlike `placeholder`
|
||||
* it never touches the input element.
|
||||
* @param {(value: string) => void} [options.onSelect] Callback when an option is chosen.
|
||||
* @param {(value: string) => void} [options.onCommit] Callback when Enter is
|
||||
* pressed without a highlighted option (free-text commit).
|
||||
*/
|
||||
constructor(inputElement, options = {}) {
|
||||
if (!inputElement || inputElement.tagName !== 'INPUT') {
|
||||
@@ -41,7 +47,9 @@ export class Combobox {
|
||||
this.presets = Array.isArray(options.presets) ? [...options.presets] : [];
|
||||
this.fetchOptions = typeof options.fetchOptions === 'function' ? options.fetchOptions : null;
|
||||
this.placeholder = options.placeholder || '';
|
||||
this.emptyText = options.emptyText || '';
|
||||
this.onSelect = typeof options.onSelect === 'function' ? options.onSelect : null;
|
||||
this.onCommit = typeof options.onCommit === 'function' ? options.onCommit : null;
|
||||
|
||||
// Internal state
|
||||
this._isOpen = false;
|
||||
@@ -109,19 +117,24 @@ export class Combobox {
|
||||
// ---- event wiring ----
|
||||
|
||||
_bindEvents() {
|
||||
this.input.addEventListener('focus', () => {
|
||||
// Keep references so destroy() can detach input listeners — callers
|
||||
// may destroy a Combobox while its input stays in the DOM.
|
||||
this._focusHandler = () => {
|
||||
if (this._suppressInputOpen) return;
|
||||
this._open();
|
||||
});
|
||||
};
|
||||
this.input.addEventListener('focus', this._focusHandler);
|
||||
|
||||
this.input.addEventListener('input', () => {
|
||||
this._inputHandler = () => {
|
||||
if (this._suppressInputOpen) return;
|
||||
this._open(); // no-op if already open
|
||||
this._refresh(); // re-filter by current input value
|
||||
this._scheduleFetch();
|
||||
});
|
||||
};
|
||||
this.input.addEventListener('input', this._inputHandler);
|
||||
|
||||
this.input.addEventListener('keydown', (event) => this._onKeyDown(event));
|
||||
this._keyDownHandler = (event) => this._onKeyDown(event);
|
||||
this.input.addEventListener('keydown', this._keyDownHandler);
|
||||
|
||||
// Click an option (delegated)
|
||||
this.panel.addEventListener('click', (event) => {
|
||||
@@ -167,6 +180,9 @@ export class Combobox {
|
||||
event.preventDefault();
|
||||
this._open();
|
||||
this._setActiveIndex(0);
|
||||
} else if (event.key === 'Enter' && typeof this.onCommit === 'function') {
|
||||
event.preventDefault();
|
||||
this.onCommit(this.input.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -184,11 +200,17 @@ export class Combobox {
|
||||
|
||||
case 'Enter':
|
||||
// Only intercept Enter to pick an option when one is actively
|
||||
// highlighted; otherwise let the input's default behavior
|
||||
// (form submit / free-text commit) proceed.
|
||||
// highlighted; otherwise commit the free-text value (when an
|
||||
// onCommit handler is registered) and let the input's default
|
||||
// behavior proceed otherwise.
|
||||
if (this._activeIndex >= 0 && this._activeIndex < this._renderedOptions.length) {
|
||||
event.preventDefault();
|
||||
this._choose(this._renderedOptions[this._activeIndex]);
|
||||
} else if (typeof this.onCommit === 'function') {
|
||||
event.preventDefault();
|
||||
const value = this.input.value;
|
||||
this._close();
|
||||
this.onCommit(value);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -254,7 +276,7 @@ export class Combobox {
|
||||
if (items.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'lm-combobox-empty';
|
||||
empty.textContent = this.placeholder ? this.placeholder : 'No options';
|
||||
empty.textContent = this.emptyText || this.placeholder || 'No options';
|
||||
this.panel.appendChild(empty);
|
||||
this._activeIndex = -1;
|
||||
return;
|
||||
@@ -333,11 +355,19 @@ export class Combobox {
|
||||
if (this.panel && this.panel.parentNode) {
|
||||
this.panel.parentNode.removeChild(this.panel);
|
||||
}
|
||||
this.input.removeEventListener('focus', this._focusHandler);
|
||||
this.input.removeEventListener('input', this._inputHandler);
|
||||
this.input.removeEventListener('keydown', this._keyDownHandler);
|
||||
document.removeEventListener('mousedown', this._outsideClickHandler);
|
||||
window.removeEventListener('resize', this._resizeHandler);
|
||||
window.removeEventListener('scroll', this._resizeHandler, true);
|
||||
}
|
||||
|
||||
/** Whether the dropdown panel is currently open. */
|
||||
isOpen() {
|
||||
return this._isOpen;
|
||||
}
|
||||
|
||||
_choose(value) {
|
||||
this.input.value = value;
|
||||
this._close();
|
||||
|
||||
@@ -12,6 +12,7 @@ import { openMediaViewer } from './shared/MediaViewer.js';
|
||||
import { showRecipeDeleteConfirmation } from './RecipeCard.js';
|
||||
import { renderCompactTags, setupTagTooltip } from './shared/utils.js';
|
||||
import { setupTagEditMode } from './shared/ModelTags.js';
|
||||
import { Combobox } from './Combobox.js';
|
||||
|
||||
const ALLOWED_GEN_PARAM_KEYS = new Set([
|
||||
'prompt',
|
||||
@@ -171,7 +172,10 @@ class RecipeModal {
|
||||
reconnectContainers.forEach(container => {
|
||||
if (container.classList.contains('active') &&
|
||||
!container.contains(event.target) &&
|
||||
!event.target.closest('.lora-reconnect')) {
|
||||
!event.target.closest('.lora-reconnect') &&
|
||||
// The Combobox dropdown lives on document.body — clicks on
|
||||
// its options are part of the reconnect interaction.
|
||||
!event.target.closest('.lm-combobox-panel')) {
|
||||
this.hideReconnectInput(container);
|
||||
}
|
||||
});
|
||||
@@ -236,6 +240,7 @@ class RecipeModal {
|
||||
this.navigationKeyHandler = null;
|
||||
}
|
||||
this.navigationInProgress = false;
|
||||
this._destroyAllReconnectComboboxes();
|
||||
}
|
||||
|
||||
setupNavigationShortcuts() {
|
||||
@@ -878,6 +883,9 @@ class RecipeModal {
|
||||
}
|
||||
|
||||
if (lorasListElement && loras.length > 0) {
|
||||
// The list innerHTML below discards every reconnect container;
|
||||
// tear down their Combobox panels (appended to document.body) first.
|
||||
this._destroyAllReconnectComboboxes();
|
||||
lorasListElement.innerHTML = loras.map(lora => {
|
||||
const existsLocally = lora.inLibrary;
|
||||
const isDeleted = lora.isDeleted;
|
||||
@@ -941,6 +949,24 @@ class RecipeModal {
|
||||
? ` role="button" tabindex="0" aria-label="${escapeHtml(translate('recipes.resources.openLoraDetails', { name: lora.modelName }, `View ${lora.modelName} in the LoRA library`))}"`
|
||||
: '';
|
||||
|
||||
// A reconnect snapshot marks a manually reconnected entry.
|
||||
// The restore icon on the info row doubles as that marker;
|
||||
// its tooltip names the previous association.
|
||||
let undoReconnectIcon = '';
|
||||
if (existsLocally && lora.reconnectSnapshot) {
|
||||
const previousName = lora.reconnectSnapshot.file_name || lora.reconnectSnapshot.modelName || '';
|
||||
const undoLabel = translate('recipes.resources.undoReconnect', {}, 'Undo');
|
||||
const undoTooltip = previousName
|
||||
? translate('recipes.resources.undoReconnectTooltipNamed', { name: previousName }, `Restore to ${previousName} (the association before reconnecting)`)
|
||||
: translate('recipes.resources.undoReconnectTooltip', {}, 'Restore the association this entry had before reconnecting');
|
||||
undoReconnectIcon = `
|
||||
<button type="button" class="lora-undo-reconnect" data-lora-index="${loraIndex}"
|
||||
title="${escapeHtml(undoTooltip)}" aria-label="${escapeHtml(undoTooltip)}">
|
||||
<i class="fas fa-rotate-left" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="${loraItemClass}" data-lora-index="${loraIndex}"${rowA11yAttributes}>
|
||||
<div class="recipe-lora-thumbnail">
|
||||
@@ -958,24 +984,26 @@ class RecipeModal {
|
||||
${lora.modelVersionName ? `<div class="recipe-lora-version">${lora.modelVersionName}</div>` : ''}
|
||||
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
|
||||
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
|
||||
${undoReconnectIcon}
|
||||
</div>
|
||||
${actionsRow}
|
||||
${isDeleted || lora.hashInvalid ? `
|
||||
<div class="lora-reconnect-container" data-lora-index="${loraIndex}">
|
||||
<div class="reconnect-instructions">
|
||||
<p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p>
|
||||
<small>${escapeHtml(translate('recipes.resources.reconnectExample', {}, 'Example: <lora:name:1> or just the name'))}</small>
|
||||
</div>
|
||||
<div class="reconnect-form">
|
||||
<input type="text" class="reconnect-input" placeholder="${escapeHtml(translate('recipes.resources.reconnectPlaceholder', {}, 'Enter LoRA name or syntax'))}">
|
||||
<div class="reconnect-actions">
|
||||
<button class="reconnect-cancel-btn">${escapeHtml(translate('common.cancel', {}, 'Cancel'))}</button>
|
||||
<button class="reconnect-confirm-btn">${escapeHtml(translate('recipes.resources.reconnect', {}, 'Reconnect'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="reconnect-error" role="alert"></p>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
${isDeleted || lora.hashInvalid ? `
|
||||
<div class="lora-reconnect-container" data-lora-index="${loraIndex}">
|
||||
<div class="reconnect-instructions">
|
||||
<p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p>
|
||||
<small>${escapeHtml(translate('recipes.resources.reconnectExample', {}, 'Example: <lora:name:1> or just the name'))}</small>
|
||||
</div>
|
||||
<div class="reconnect-form">
|
||||
<input type="text" class="reconnect-input" placeholder="${escapeHtml(translate('recipes.resources.reconnectPlaceholder', {}, 'Enter LoRA name or syntax'))}">
|
||||
<div class="reconnect-actions">
|
||||
<button class="reconnect-cancel-btn">${escapeHtml(translate('common.cancel', {}, 'Cancel'))}</button>
|
||||
<button class="reconnect-confirm-btn">${escapeHtml(translate('recipes.resources.reconnect', {}, 'Reconnect'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reconnect-suggestions"></div>
|
||||
<p class="reconnect-error" role="alert"></p>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -988,6 +1016,7 @@ class RecipeModal {
|
||||
|
||||
this.recipeLorasSyntax = '';
|
||||
} else if (lorasListElement) {
|
||||
this._destroyAllReconnectComboboxes();
|
||||
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
|
||||
this.recipeLorasSyntax = '';
|
||||
}
|
||||
@@ -1665,12 +1694,17 @@ class RecipeModal {
|
||||
this.clearReconnectError(input.closest('.lora-reconnect-container'));
|
||||
});
|
||||
input.addEventListener('keydown', (e) => {
|
||||
const container = input.closest('.lora-reconnect-container');
|
||||
// When a Combobox is attached it owns Enter (pick a highlighted
|
||||
// option, or commit free text via onCommit) and, while its
|
||||
// dropdown is open, Escape (close the dropdown first).
|
||||
const combobox = this._reconnectComboboxes && this._reconnectComboboxes.get(container);
|
||||
if (e.key === 'Enter') {
|
||||
const container = input.closest('.lora-reconnect-container');
|
||||
if (combobox) return;
|
||||
const loraIndex = container.getAttribute('data-lora-index');
|
||||
this.reconnectLora(loraIndex, input.value);
|
||||
} else if (e.key === 'Escape') {
|
||||
const container = input.closest('.lora-reconnect-container');
|
||||
if (combobox && combobox.isOpen()) return;
|
||||
this.hideReconnectInput(container);
|
||||
}
|
||||
});
|
||||
@@ -1680,7 +1714,7 @@ class RecipeModal {
|
||||
showReconnectInput(loraIndex) {
|
||||
// Hide any currently active reconnect containers
|
||||
document.querySelectorAll('.lora-reconnect-container.active').forEach(active => {
|
||||
active.classList.remove('active');
|
||||
this.hideReconnectInput(active);
|
||||
});
|
||||
|
||||
// Show the reconnect container for this lora
|
||||
@@ -1690,6 +1724,8 @@ class RecipeModal {
|
||||
this.clearReconnectError(container);
|
||||
const input = container.querySelector('.reconnect-input');
|
||||
input.focus();
|
||||
this._attachReconnectCombobox(container, loraIndex);
|
||||
this._loadReconnectSuggestions(container, loraIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1700,6 +1736,139 @@ class RecipeModal {
|
||||
const input = container.querySelector('.reconnect-input');
|
||||
if (input) input.value = '';
|
||||
}
|
||||
if (container) {
|
||||
this._destroyReconnectCombobox(container);
|
||||
// Invalidate any in-flight suggestions fetch for this panel
|
||||
this._reconnectSuggestionsToken = (this._reconnectSuggestionsToken || 0) + 1;
|
||||
const suggestions = container.querySelector('.reconnect-suggestions');
|
||||
if (suggestions) suggestions.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
_attachReconnectCombobox(container, loraIndex) {
|
||||
if (!this._reconnectComboboxes) {
|
||||
this._reconnectComboboxes = new Map();
|
||||
}
|
||||
if (this._reconnectComboboxes.has(container)) {
|
||||
return;
|
||||
}
|
||||
const input = container.querySelector('.reconnect-input');
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
const combobox = new Combobox(input, {
|
||||
fetchOptions: async (value) => {
|
||||
const suggestions = await this._fetchReconnectSuggestions(loraIndex, value);
|
||||
return suggestions.map(suggestion => suggestion.target_name);
|
||||
},
|
||||
// emptyText only labels the dropdown empty state; the input keeps
|
||||
// its own translated placeholder from the markup.
|
||||
emptyText: translate('recipes.resources.reconnectSuggestionsEmpty', {}, 'No matching LoRAs in your local library'),
|
||||
onCommit: (value) => {
|
||||
this.reconnectLora(loraIndex, value);
|
||||
},
|
||||
});
|
||||
this._reconnectComboboxes.set(container, combobox);
|
||||
}
|
||||
|
||||
_destroyReconnectCombobox(container) {
|
||||
const combobox = this._reconnectComboboxes && this._reconnectComboboxes.get(container);
|
||||
if (combobox) {
|
||||
combobox.destroy();
|
||||
this._reconnectComboboxes.delete(container);
|
||||
}
|
||||
}
|
||||
|
||||
_destroyAllReconnectComboboxes() {
|
||||
if (!this._reconnectComboboxes) {
|
||||
return;
|
||||
}
|
||||
this._reconnectComboboxes.forEach(combobox => combobox.destroy());
|
||||
this._reconnectComboboxes.clear();
|
||||
this._reconnectSuggestionsToken = (this._reconnectSuggestionsToken || 0) + 1;
|
||||
}
|
||||
|
||||
async _fetchReconnectSuggestions(loraIndex, query) {
|
||||
const suffix = query ? `?query=${encodeURIComponent(query)}` : '';
|
||||
const response = await fetch(`/api/lm/recipe/${this.recipeId}/lora/${loraIndex}/reconnect-suggestions${suffix}`);
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
const result = await response.json();
|
||||
return result && result.success && Array.isArray(result.suggestions) ? result.suggestions : [];
|
||||
}
|
||||
|
||||
async _loadReconnectSuggestions(container, loraIndex) {
|
||||
const listElement = container.querySelector('.reconnect-suggestions');
|
||||
if (!listElement) {
|
||||
return;
|
||||
}
|
||||
const token = (this._reconnectSuggestionsToken || 0) + 1;
|
||||
this._reconnectSuggestionsToken = token;
|
||||
listElement.innerHTML = `<div class="reconnect-suggestions-loading">${escapeHtml(translate('recipes.resources.reconnectSuggestionsLoading', {}, 'Searching local library...'))}</div>`;
|
||||
try {
|
||||
const suggestions = await this._fetchReconnectSuggestions(loraIndex);
|
||||
// Stale guard: panel closed or another item opened while fetching
|
||||
if (token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
|
||||
return;
|
||||
}
|
||||
this._renderReconnectSuggestions(container, suggestions, loraIndex);
|
||||
} catch (error) {
|
||||
console.error('Error fetching reconnect suggestions:', error);
|
||||
if (token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
|
||||
return;
|
||||
}
|
||||
this._renderReconnectSuggestions(container, [], loraIndex);
|
||||
}
|
||||
}
|
||||
|
||||
_renderReconnectSuggestions(container, suggestions, loraIndex) {
|
||||
const listElement = container.querySelector('.reconnect-suggestions');
|
||||
if (!listElement) {
|
||||
return;
|
||||
}
|
||||
listElement.innerHTML = '';
|
||||
if (!suggestions.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'reconnect-suggestions-empty';
|
||||
empty.textContent = translate('recipes.resources.reconnectSuggestionsEmpty', {}, 'No matching LoRAs in your local library');
|
||||
listElement.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
const reasonLabels = {
|
||||
same_hash: translate('recipes.resources.reconnectMatchSameHash', {}, 'Same hash'),
|
||||
same_version: translate('recipes.resources.reconnectMatchSameVersion', {}, 'Same model version'),
|
||||
similar_filename: translate('recipes.resources.reconnectMatchSimilarFilename', {}, 'Similar filename'),
|
||||
similar_name: translate('recipes.resources.reconnectMatchSimilarName', {}, 'Similar name'),
|
||||
};
|
||||
suggestions.forEach(suggestion => {
|
||||
// The filename (stem) is what the match scored on and what gets
|
||||
// submitted — show it as the primary label, with the base model
|
||||
// as secondary context. The model name is omitted: it played no
|
||||
// part in the match and only adds noise.
|
||||
const stem = suggestion.target_name || suggestion.file_name || '';
|
||||
const secondaryParts = [];
|
||||
if (suggestion.base_model) {
|
||||
secondaryParts.push(suggestion.base_model);
|
||||
}
|
||||
const secondary = secondaryParts.join(' · ');
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'reconnect-suggestion';
|
||||
row.title = stem;
|
||||
row.innerHTML = `
|
||||
<img class="reconnect-suggestion-preview" src="${escapeHtml(suggestion.preview_url || '/loras_static/images/no-preview.png')}" alt="" loading="lazy" onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
<span class="reconnect-suggestion-info">
|
||||
<span class="reconnect-suggestion-name">${escapeHtml(stem)}</span>
|
||||
${secondary ? `<span class="reconnect-suggestion-secondary">${escapeHtml(secondary)}</span>` : ''}
|
||||
</span>
|
||||
<span class="reconnect-suggestion-reason">${escapeHtml(reasonLabels[suggestion.match_reason] || suggestion.match_reason || '')}</span>
|
||||
`;
|
||||
row.addEventListener('click', () => {
|
||||
this.reconnectLora(loraIndex, suggestion.target_name);
|
||||
});
|
||||
listElement.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
showReconnectError(container, message) {
|
||||
@@ -1761,6 +1930,19 @@ class RecipeModal {
|
||||
// Show success message
|
||||
showToast('toast.recipes.reconnectedSuccessfully', {}, 'success');
|
||||
|
||||
// Same-architecture-family reconnects (e.g. Pony ↔ Illustrious)
|
||||
// succeed but carry structured mismatch data — warn the user.
|
||||
if (result.base_model_mismatch) {
|
||||
showToast(
|
||||
'toast.recipes.reconnectBaseModelMismatch',
|
||||
{
|
||||
recipe: result.base_model_mismatch.recipe_base_model,
|
||||
lora: result.base_model_mismatch.lora_base_model,
|
||||
},
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
|
||||
// Refresh modal to show updated content
|
||||
setTimeout(() => {
|
||||
this.showRecipeDetails(this.currentRecipe);
|
||||
@@ -1780,6 +1962,47 @@ class RecipeModal {
|
||||
}
|
||||
}
|
||||
|
||||
async restoreLora(loraIndex) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading('Restoring LoRA...');
|
||||
|
||||
const response = await fetch('/api/lm/recipe/lora/restore', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipe_id: this.recipeId,
|
||||
lora_index: loraIndex
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Swap the entry back to its pre-reconnect state
|
||||
this.currentRecipe.loras[loraIndex] = result.updated_lora;
|
||||
|
||||
showToast('toast.recipes.loraRestored', {}, 'success');
|
||||
|
||||
setTimeout(() => {
|
||||
this.showRecipeDetails(this.currentRecipe);
|
||||
}, 500);
|
||||
|
||||
state.virtualScroller.updateSingleItem(this.listFilePath || this.currentRecipe.file_path, {
|
||||
loras: this.currentRecipe.loras
|
||||
});
|
||||
} else {
|
||||
showToast('toast.recipes.loraRestoreFailed', { message: result.error }, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error restoring LoRA:', error);
|
||||
showToast('toast.recipes.loraRestoreFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
|
||||
renderCheckpoint(checkpoint) {
|
||||
const existsLocally = !!checkpoint.inLibrary;
|
||||
const localPath = checkpoint.localPath || '';
|
||||
@@ -2042,7 +2265,8 @@ class RecipeModal {
|
||||
|
||||
renderLoraItemActions(lora, loraIndex, { existsLocally, isDeleted }) {
|
||||
// In-library LoRAs need no remediation: the badge and the local path
|
||||
// already tell the full story.
|
||||
// already tell the full story. (The restore affordance for manually
|
||||
// reconnected entries lives on the info row, not here.)
|
||||
if (existsLocally) {
|
||||
return '';
|
||||
}
|
||||
@@ -2113,6 +2337,17 @@ class RecipeModal {
|
||||
this.showReconnectInput(button.dataset.loraIndex);
|
||||
});
|
||||
});
|
||||
|
||||
lorasListElement.querySelectorAll('.lora-undo-reconnect').forEach(button => {
|
||||
if (button.dataset.wired === 'true') {
|
||||
return;
|
||||
}
|
||||
button.dataset.wired = 'true';
|
||||
button.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.restoreLora(button.dataset.loraIndex);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user