Compare commits

...

10 Commits

Author SHA1 Message Date
Will Miao
00177a06d0 fix(ui): keep autocomplete text widget at max-height on node resize in Vue mode 2026-06-08 10:49:04 +08:00
Will Miao
568daa351e Revert "Merge pull request #959 from id-fa/fix/lora-loader-list-scroll-nodes2"
This reverts commit 01dac57c35, reversing
changes made to 62f9e3f44a.
2026-06-07 17:25:30 +08:00
Will Miao
5a4664fa12 Merge pull request #936 from 1756141021/feat/batch-url-download
feat: batch URL download for LoRA models
2026-06-06 20:22:52 +08:00
Will Miao
dd5b213adc fix(ui): make autocomplete text widget scrollable in Nodes 2.0 mode
In Vue/Node 2.0 mode, the AutocompleteTextWidget's textarea wheel events were intercepted by TransformPane @wheel.capture before reaching the @wheel handler, causing canvas zoom instead of text scrolling.

- Add lm-wheel-scrollable class in Vue mode to hook into the window capture-phase handler (enableListWheelScroll) which scrolls the textarea manually before TransformPane can react.
- Add maxHeight prop and container max-height for Lora Loader/Stacker/WanVideo nodes (modelType === 'loras'), matching canvas mode's height cap. Prompt/Text nodes remain uncapped.
2026-06-06 08:12:09 +08:00
Will Miao
d9ee9b3155 fix(utils): catch MemoryError in read_safetensors_metadata for non-safetensors files 2026-06-06 07:35:36 +08:00
pixelpaws
01dac57c35 Merge pull request #959 from id-fa/fix/lora-loader-list-scroll-nodes2
fix(ui): make Lora Loader list scrollable in Nodes 2.0 mode
2026-06-06 07:33:19 +08:00
id-fa
7f92d09239 fix(ui): make Lora Loader list scrollable in Nodes 2.0 mode
In Nodes 2.0 / Vue node mode the Lora Loader list could not be capped
and the node grew to show every row, unlike classic mode which fixes the
list area to 12 rows. The Vue layout engine measures the rendered DOM, so
CSS variables and computeLayoutSize alone were ignored.

- Physically cap the container via max-height so the rendered element is
  bounded to the 12-row height; extra rows scroll (overflow: auto).
- Report the capped height through computeSize / computeLayoutSize /
  getHeight / getMinHeight so the node background matches the list.
- Add enableListWheelScroll: a window capture-phase wheel hook that scrolls
  the hovered list instead of letting ComfyUI zoom the canvas, which fires
  on the document/canvas in capture and beat a container-level listener.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:29:01 +09:00
Will Miao
62f9e3f44a fix(scripts): use platformdirs for cross-platform settings path resolution
Both restore_suffixed_filenames.py and migrate_legacy_metadata.py
hardcoded Path.home() / '.config' / APP_NAME for finding settings.json,
which only works on Linux. On Windows this resolves to the wrong path
(~/.config/ instead of %LOCALAPPDATA%).

Replace the hand-rolled fallback with platformdirs.user_config_dir(),
which correctly resolves to the OS-appropriate config directory on all
platforms (Windows: %%LOCALAPPDATA%%, macOS: ~/Library/Application Support,
Linux: ~/.config). The portable mode check (settings.json in repo root
with use_portable_settings: true) is preserved unchanged.
2026-06-04 07:17:53 +08:00
willmiao
e55895786d docs: auto-update supporters list in README 2026-06-03 14:30:44 +00:00
hein
4e3ede23b7 feat: batch URL download for LoRA models
Add multi-URL batch download support to the download modal.
Users can paste multiple CivitAI URLs (one per line) in a textarea,
preview all parsed models in a compact list, optionally change versions
per model, select a unified download path, and batch download sequentially.

Single URL behavior is preserved unchanged.

Changes:
- Replace single-line input with textarea for multi-URL input
- Add batch preview step with compact list (thumbnail, version, size)
- Per-item version editing via existing version selector
- Batch download with WebSocket progress tracking (reuses existing infra)
- URL deduplication by model ID, preserving paste order
- Invalid URLs shown inline with remove option
- Fix: prevent click listener accumulation in showVersionStep

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 11:37:36 +08:00
11 changed files with 690 additions and 95 deletions

File diff suppressed because one or more lines are too long

View File

@@ -81,7 +81,7 @@ def read_safetensors_metadata(file_path: str) -> dict[str, Any]:
return {}
header = json.loads(header_bytes.decode("utf-8"))
return header.get("__metadata__", {})
except (OSError, json.JSONDecodeError, UnicodeDecodeError, struct.error):
except (OSError, json.JSONDecodeError, UnicodeDecodeError, struct.error, MemoryError, Exception):
return {}

View File

@@ -34,6 +34,8 @@ import sys
from pathlib import Path
from typing import Any
from platformdirs import user_config_dir
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
@@ -53,10 +55,7 @@ def resolve_settings_path() -> Path:
if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
return portable
config_home = os.environ.get("XDG_CONFIG_HOME")
if config_home:
return Path(config_home).expanduser() / APP_NAME / "settings.json"
return Path.home() / ".config" / APP_NAME / "settings.json"
return Path(user_config_dir(APP_NAME, appauthor=False)) / "settings.json"
def load_json(path: Path) -> dict[str, Any]:

View File

@@ -39,6 +39,8 @@ import sys
from pathlib import Path
from typing import Any
from platformdirs import user_config_dir
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
@@ -68,10 +70,7 @@ def resolve_settings_path() -> Path:
if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
return portable
config_home = os.environ.get("XDG_CONFIG_HOME")
if config_home:
return Path(config_home).expanduser() / APP_NAME / "settings.json"
return Path.home() / ".config" / APP_NAME / "settings.json"
return Path(user_config_dir(APP_NAME, appauthor=False)) / "settings.json"
def _load_json(path: Path) -> dict[str, Any]:

View File

@@ -668,4 +668,143 @@
[data-theme="dark"] .file-tag.size {
background: oklch(0.5 0.08 160 / 0.15);
color: oklch(0.65 0.08 160);
}
/* Textarea for multi-URL input */
#modelUrl {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
font-family: monospace;
font-size: 0.9em;
resize: vertical;
line-height: 1.5;
}
/* Batch Preview List */
.batch-preview-list {
max-height: 400px;
overflow-y: auto;
margin: var(--space-2) 0;
display: flex;
flex-direction: column;
gap: 1px;
background: var(--border-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
}
.batch-preview-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--bg-color);
}
.batch-preview-item:first-child {
border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0;
}
.batch-preview-item:last-child {
border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm);
}
.batch-preview-item:only-child {
border-radius: var(--border-radius-sm);
}
.batch-preview-thumbnail {
width: 48px;
height: 48px;
flex-shrink: 0;
border-radius: var(--border-radius-xs);
overflow: hidden;
background: var(--lora-surface);
}
.batch-preview-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.batch-preview-icon {
width: 48px;
height: 48px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: #e74c3c;
font-size: 1.2em;
}
.batch-preview-info {
flex: 1;
min-width: 0;
}
.batch-preview-name {
font-weight: 500;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.batch-preview-meta {
display: flex;
gap: 8px;
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
margin-top: 2px;
}
.batch-preview-error-text {
color: #e74c3c;
opacity: 1;
}
.batch-preview-local-badge {
color: var(--lora-accent);
opacity: 1;
}
.batch-preview-local {
opacity: 0.6;
}
.batch-preview-change-version {
flex-shrink: 0;
font-size: 0.85em;
padding: 4px 10px;
}
.batch-preview-remove {
flex-shrink: 0;
background: none;
border: none;
color: var(--text-color);
opacity: 0.5;
cursor: pointer;
padding: 4px 8px;
font-size: 1em;
}
.batch-preview-remove:hover {
opacity: 1;
color: #e74c3c;
}
.batch-preview-error {
background: oklch(0.5 0.15 25 / 0.05);
}
[data-theme="dark"] .batch-preview-item {
background: var(--lora-surface);
}

View File

@@ -22,6 +22,11 @@ export class DownloadManager {
this.apiClient = null;
this.useDefaultPath = false;
// Batch mode state
this.batchModels = [];
this.isBatchMode = false;
this.editingBatchIndex = -1;
this.loadingManager = new LoadingManager();
this.folderTreeManager = new FolderTreeManager();
this.folderClickHandler = null;
@@ -37,6 +42,8 @@ export class DownloadManager {
this.handleConfirmFileSelection = this.confirmFileSelection.bind(this);
this.handleCloseModal = this.closeModal.bind(this);
this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this);
this.handleBackToUrlFromBatch = this.backToUrlFromBatch.bind(this);
this.handleNextFromBatch = this.nextFromBatch.bind(this);
}
showDownloadModal() {
@@ -86,6 +93,10 @@ export class DownloadManager {
document.getElementById('backToVersionFromFilesBtn').addEventListener('click', this.handleBackToVersionFromFiles);
document.getElementById('confirmFileSelection').addEventListener('click', this.handleConfirmFileSelection);
// Batch preview buttons
document.getElementById('backToUrlFromBatchBtn').addEventListener('click', this.handleBackToUrlFromBatch);
document.getElementById('nextFromBatchBtn').addEventListener('click', this.handleNextFromBatch);
// Default path toggle handler
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
}
@@ -138,6 +149,9 @@ export class DownloadManager {
this.selectedFile = null;
this.selectedFolder = '';
this.batchModels = [];
this.isBatchMode = false;
this.editingBatchIndex = -1;
// Clear folder tree selection
if (this.folderTreeManager) {
@@ -157,30 +171,99 @@ export class DownloadManager {
}
async validateAndFetchVersions() {
const url = document.getElementById('modelUrl').value.trim();
const rawText = document.getElementById('modelUrl').value.trim();
const errorElement = document.getElementById('urlError');
const urls = rawText.split('\n').map(l => l.trim()).filter(Boolean);
try {
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions'));
this.modelId = this.extractModelId(url);
if (!this.modelId) {
throw new Error(translate('modals.download.errors.invalidUrl'));
}
await this.retrieveVersionsForModel(this.modelId, this.source);
// If we have a version ID from URL, pre-select it
if (this.modelVersionId) {
this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId);
}
this.showVersionStep();
} catch (error) {
errorElement.textContent = error.message;
} finally {
this.loadingManager.hide();
if (urls.length === 0) {
errorElement.textContent = translate('modals.download.errors.invalidUrl');
return;
}
if (urls.length === 1) {
this.isBatchMode = false;
try {
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions'));
this.modelId = this.extractModelId(urls[0]);
if (!this.modelId) {
throw new Error(translate('modals.download.errors.invalidUrl'));
}
await this.retrieveVersionsForModel(this.modelId, this.source);
if (this.modelVersionId) {
this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId);
}
this.showVersionStep();
} catch (error) {
errorElement.textContent = error.message;
} finally {
this.loadingManager.hide();
}
return;
}
// Multi-URL batch mode
this.isBatchMode = true;
this.batchModels = [];
errorElement.textContent = '';
const seen = new Set();
const parsed = [];
for (const url of urls) {
const result = DownloadManager.parseModelUrl(url);
if (!result.modelId) {
parsed.push({ url, error: translate('modals.download.errors.invalidUrl') });
continue;
}
if (seen.has(result.modelId)) continue;
seen.add(result.modelId);
parsed.push({ url, ...result, error: null });
}
if (parsed.length === 0) {
errorElement.textContent = translate('modals.download.errors.invalidUrl');
return;
}
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions'));
let fetched = 0;
const total = parsed.filter(p => !p.error).length;
this.batchModels = new Array(parsed.length);
const fetchPromises = parsed.map(async (item, index) => {
if (item.error) {
this.batchModels[index] = { ...item, versions: [], selectedVersion: null };
return;
}
try {
const versions = await this.apiClient.fetchCivitaiVersions(item.modelId, item.source);
fetched++;
this.loadingManager.setStatus(`${fetched}/${total}`);
let selectedVersion = null;
if (versions && versions.length > 0) {
if (item.modelVersionId) {
selectedVersion = versions.find(v => v.id.toString() === item.modelVersionId) || versions[0];
} else {
selectedVersion = versions[0];
}
}
this.batchModels[index] = { ...item, versions: versions || [], selectedVersion };
} catch (err) {
this.batchModels[index] = { ...item, versions: [], selectedVersion: null, error: err.message };
}
});
await Promise.all(fetchPromises);
this.loadingManager.hide();
this.showBatchPreviewStep();
}
async fetchVersionsForCurrentModel() {
@@ -204,25 +287,30 @@ export class DownloadManager {
}
}
extractModelId(url) {
static parseModelUrl(url) {
const civarchiveMatch = url.match(/https?:\/\/(?:www\.)?(?:civitaiarchive|civarchive)\.com\/models\/(\d+)/i);
if (civarchiveMatch) {
const versionMatch = url.match(/modelVersionId=(\d+)/i);
this.modelVersionId = versionMatch ? versionMatch[1] : null;
this.source = 'civarchive';
return civarchiveMatch[1];
return {
modelId: civarchiveMatch[1],
modelVersionId: versionMatch ? versionMatch[1] : null,
source: 'civarchive',
};
}
const { modelId, modelVersionId } = extractCivitaiModelUrlParts(url);
if (modelId) {
this.modelVersionId = modelVersionId;
this.source = null;
return modelId;
return { modelId, modelVersionId, source: null };
}
this.modelVersionId = null;
this.source = null;
return null;
return { modelId: null, modelVersionId: null, source: null };
}
extractModelId(url) {
const result = DownloadManager.parseModelUrl(url);
this.modelVersionId = result.modelVersionId;
this.source = result.source;
return result.modelId;
}
async openForModelVersion(modelType, modelId, versionId = null) {
@@ -250,7 +338,10 @@ export class DownloadManager {
document.getElementById('versionStep').style.display = 'block';
const versionList = document.getElementById('versionList');
versionList.innerHTML = this.versions.map(version => {
const newList = versionList.cloneNode(false);
versionList.parentNode.replaceChild(newList, versionList);
newList.innerHTML = this.versions.map(version => {
const firstImage = version.images?.find(img => !img.url.endsWith('.mp4'));
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
@@ -326,7 +417,7 @@ export class DownloadManager {
}).join('');
// Add click handlers for version selection and file badge
versionList.addEventListener('click', (event) => {
newList.addEventListener('click', (event) => {
const badge = event.target.closest('.file-select-badge');
if (badge) {
event.stopPropagation();
@@ -452,18 +543,30 @@ export class DownloadManager {
}
async proceedToLocation() {
if (!this.currentVersion) {
showToast('toast.loras.pleaseSelectVersion', {}, 'error');
// If editing a batch item's version, save and return to batch preview
if (this.isBatchMode && this.editingBatchIndex >= 0) {
if (this.currentVersion) {
this.batchModels[this.editingBatchIndex].selectedVersion = this.currentVersion;
}
this.editingBatchIndex = -1;
document.getElementById('versionStep').style.display = 'none';
this.showBatchPreviewStep();
return;
}
const existsLocally = this.currentVersion.existsLocally;
if (existsLocally) {
showToast('toast.loras.versionExists', {}, 'info');
return;
// In single-URL mode, validate version selection
if (!this.isBatchMode) {
if (!this.currentVersion) {
showToast('toast.loras.pleaseSelectVersion', {}, 'error');
return;
}
if (this.currentVersion.existsLocally) {
showToast('toast.loras.versionExists', {}, 'info');
return;
}
}
document.getElementById('versionStep').style.display = 'none';
document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none');
document.getElementById('locationStep').style.display = 'block';
await this.proceedToLocationContent();
}
@@ -700,14 +803,123 @@ export class DownloadManager {
this.updateTargetPath();
}
showBatchPreviewStep() {
document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none');
document.getElementById('batchPreviewStep').style.display = 'block';
const validCount = this.batchModels.filter(m => !m.error && m.selectedVersion).length;
document.getElementById('downloadModalTitle').textContent =
translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) +
` (${validCount})`;
const list = document.getElementById('batchPreviewList');
list.innerHTML = this.batchModels.map((item, index) => {
if (item.error) {
return `
<div class="batch-preview-item batch-preview-error" data-index="${index}">
<div class="batch-preview-icon">
<i class="fas fa-exclamation-triangle"></i>
</div>
<div class="batch-preview-info">
<div class="batch-preview-name">${item.url}</div>
<div class="batch-preview-meta batch-preview-error-text">${item.error}</div>
</div>
<button class="batch-preview-remove" data-index="${index}" title="${translate('common.actions.remove', {}, 'Remove')}">
<i class="fas fa-times"></i>
</button>
</div>
`;
}
const ver = item.selectedVersion;
const firstImage = ver?.images?.find(img => !img.url.endsWith('.mp4'));
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
const fileSize = ver?.modelSizeKB
? (ver.modelSizeKB / 1024).toFixed(1)
: (ver?.files?.[0]?.sizeKB ? (ver.files[0].sizeKB / 1024).toFixed(1) : '?');
const existsLocally = ver?.existsLocally;
return `
<div class="batch-preview-item ${existsLocally ? 'batch-preview-local' : ''}" data-index="${index}">
<div class="batch-preview-thumbnail">
<img src="${thumbnailUrl}" alt="">
</div>
<div class="batch-preview-info">
<div class="batch-preview-name">${ver?.name || `Model #${item.modelId}`}</div>
<div class="batch-preview-meta">
${ver?.baseModel ? `<span>${ver.baseModel}</span>` : ''}
<span>${fileSize} MB</span>
${existsLocally ? `<span class="batch-preview-local-badge"><i class="fas fa-check"></i> ${translate('modals.download.inLibrary')}</span>` : ''}
</div>
</div>
${item.versions.length > 1 ? `
<button class="batch-preview-change-version secondary-btn" data-index="${index}">
${translate('common.actions.change', {}, 'Change')}
</button>
` : ''}
</div>
`;
}).join('');
list.onclick = (e) => {
const removeBtn = e.target.closest('.batch-preview-remove');
if (removeBtn) {
const idx = parseInt(removeBtn.dataset.index);
this.batchModels.splice(idx, 1);
this.showBatchPreviewStep();
return;
}
const changeBtn = e.target.closest('.batch-preview-change-version');
if (changeBtn) {
const idx = parseInt(changeBtn.dataset.index);
this.openBatchVersionEditor(idx);
}
};
const nextBtn = document.getElementById('nextFromBatchBtn');
nextBtn.disabled = validCount === 0;
nextBtn.classList.toggle('disabled', validCount === 0);
}
openBatchVersionEditor(index) {
this.editingBatchIndex = index;
const item = this.batchModels[index];
this.versions = item.versions;
this.currentVersion = item.selectedVersion;
document.getElementById('batchPreviewStep').style.display = 'none';
this.showVersionStep();
}
backToUrlFromBatch() {
document.getElementById('batchPreviewStep').style.display = 'none';
document.getElementById('urlStep').style.display = 'block';
}
nextFromBatch() {
const validModels = this.batchModels.filter(m => !m.error && m.selectedVersion);
if (validModels.length === 0) return;
this.proceedToLocation();
}
backToUrl() {
document.getElementById('versionStep').style.display = 'none';
document.getElementById('urlStep').style.display = 'block';
if (this.isBatchMode && this.editingBatchIndex >= 0) {
this.editingBatchIndex = -1;
this.showBatchPreviewStep();
} else {
document.getElementById('urlStep').style.display = 'block';
}
}
backToVersions() {
document.getElementById('locationStep').style.display = 'none';
document.getElementById('versionStep').style.display = 'block';
if (this.isBatchMode) {
document.getElementById('batchPreviewStep').style.display = 'block';
} else {
document.getElementById('versionStep').style.display = 'block';
}
}
closeModal() {
@@ -727,34 +939,120 @@ export class DownloadManager {
return;
}
// Determine target folder and use_default_paths parameter
let targetFolder = '';
let useDefaultPaths = false;
if (this.useDefaultPath) {
useDefaultPaths = true;
targetFolder = ''; // Not needed when using default paths
} else {
targetFolder = this.folderTreeManager.getSelectedPath();
}
const fileParams = this.selectedFile ? {
type: 'Model',
format: this.selectedFile.metadata?.format || 'SafeTensor',
size: this.selectedFile.metadata?.size || 'full',
fp: this.selectedFile.metadata?.fp,
} : null;
if (!this.isBatchMode) {
const fileParams = this.selectedFile ? {
type: 'Model',
format: this.selectedFile.metadata?.format || 'SafeTensor',
size: this.selectedFile.metadata?.size || 'full',
fp: this.selectedFile.metadata?.fp,
} : null;
return this.executeDownloadWithProgress({
modelId: this.modelId,
versionId: this.currentVersion.id,
versionName: this.currentVersion.name,
modelRoot,
targetFolder,
useDefaultPaths,
source: this.source,
fileParams,
closeModal: true,
return this.executeDownloadWithProgress({
modelId: this.modelId,
versionId: this.currentVersion.id,
versionName: this.currentVersion.name,
modelRoot,
targetFolder,
useDefaultPaths,
source: this.source,
fileParams,
closeModal: true,
});
}
// Batch download mode
const downloadItems = this.batchModels.filter(m => !m.error && m.selectedVersion && !m.selectedVersion.existsLocally);
if (downloadItems.length === 0) {
showToast('toast.loras.downloadCompleted', {}, 'info');
modalManager.closeModal('downloadModal');
return;
}
modalManager.closeModal('downloadModal');
const batchDownloadId = Date.now().toString();
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`);
const loadingManager = state.loadingManager || this.loadingManager;
const updateProgress = loadingManager.showDownloadProgress(downloadItems.length);
let completedDownloads = 0;
let failedDownloads = 0;
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'download_id') return;
if (data.status === 'progress' && data.download_id?.startsWith(batchDownloadId)) {
const current = downloadItems[completedDownloads + failedDownloads];
const name = current?.selectedVersion?.name || `#${completedDownloads + failedDownloads + 1}`;
const metrics = {
bytesDownloaded: data.bytes_downloaded,
totalBytes: data.total_bytes,
bytesPerSecond: data.bytes_per_second,
};
updateProgress(data.progress, completedDownloads, name, metrics);
}
};
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = reject;
});
for (let i = 0; i < downloadItems.length; i++) {
const item = downloadItems[i];
const ver = item.selectedVersion;
const name = ver?.name || `Model #${item.modelId}`;
updateProgress(0, completedDownloads, name);
loadingManager.setStatus(`${i + 1}/${downloadItems.length}: ${name}`);
try {
const response = await this.apiClient.downloadModel(
item.modelId,
ver.id,
modelRoot,
targetFolder,
useDefaultPaths,
batchDownloadId,
item.source
);
if (!response.success) {
failedDownloads++;
} else {
completedDownloads++;
updateProgress(100, completedDownloads, '');
}
} catch (err) {
console.error(`Failed to download ${name}:`, err);
failedDownloads++;
}
}
ws.close();
loadingManager.hide();
if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else {
showToast('toast.loras.downloadPartialSuccess', {
completed: completedDownloads,
total: downloadItems.length,
}, 'warning');
}
await resetAndReload(true);
}
async downloadVersionWithDefaults(modelType, modelId, versionId, {

View File

@@ -10,7 +10,7 @@
<div class="download-step" id="urlStep">
<div class="input-group">
<label for="modelUrl" id="modelUrlLabel">{{ t('modals.download.url') }}:</label>
<input type="text" id="modelUrl" placeholder="{{ t('modals.download.placeholder') }}" />
<textarea id="modelUrl" rows="5" placeholder="{{ t('modals.download.placeholder') }}"></textarea>
<div class="error-message" id="urlError"></div>
</div>
<div class="modal-actions">
@@ -18,7 +18,18 @@
</div>
</div>
<!-- Step 2: Version Selection -->
<!-- Step 2: Batch Preview (multi-URL mode) -->
<div class="download-step" id="batchPreviewStep" style="display: none;">
<div class="batch-preview-list" id="batchPreviewList">
<!-- Batch items will be inserted here dynamically -->
</div>
<div class="modal-actions">
<button class="secondary-btn" id="backToUrlFromBatchBtn">{{ t('common.actions.back') }}</button>
<button class="primary-btn" id="nextFromBatchBtn">{{ t('common.actions.next') }}</button>
</div>
</div>
<!-- Step 3: Version Selection (single-URL or per-item editing) -->
<div class="download-step" id="versionStep" style="display: none;">
<div class="version-list" id="versionList">
<!-- Versions will be inserted here dynamically -->

View File

@@ -5,7 +5,9 @@
ref="textareaRef"
:placeholder="placeholder"
:spellcheck="spellcheck ?? false"
:class="['text-input', { 'vue-dom-mode': isVueDomMode }]"
:class="['text-input', { 'vue-dom-mode': isVueDomMode, 'lm-wheel-scrollable': isVueDomMode }]"
:style="maxHeight && isVueDomMode ? { maxHeight: maxHeight + 'px' } : undefined"
data-capture-wheel="true"
@input="onInput"
@wheel="onWheel"
/>
@@ -47,6 +49,7 @@ const props = defineProps<{
placeholder?: string
showPreview?: boolean
spellcheck?: boolean
maxHeight?: number
}>()
// Reactive ref for Vue DOM mode

View File

@@ -546,6 +546,27 @@ function normalizeAutocompleteWidgetValues(node: any, info: any) {
}
}
function applyAutocompleteTextLayoutFix(
widget: any,
container: HTMLElement | undefined,
isVueMode: boolean
): void {
if (isVueMode) {
;(widget as any).computeLayoutSize = undefined
widget.computeSize = (width?: number) =>
[width ?? 200, AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT - 4]
if (container) {
container.style.minHeight = `${AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT}px`
}
} else {
delete (widget as any).computeLayoutSize
delete (widget as any).computeSize
if (container) {
container.style.minHeight = ''
}
}
}
// Listen for Vue DOM mode setting changes and dispatch custom event
const initVueDomModeListener = () => {
if (app.ui?.settings?.addEventListener) {
@@ -554,7 +575,47 @@ const initVueDomModeListener = () => {
// before we read it (the event may fire before internal state updates)
requestAnimationFrame(() => {
const isVueDomMode = app.ui?.settings?.getSettingValue?.('Comfy.VueNodes.Enabled') ?? false
// Dispatch custom event for Vue components to listen to
if (app.graph?.nodes) {
for (const node of app.graph.nodes) {
const textWidget = node.widgets?.find(
(w: any) => w.type === 'AUTOCOMPLETE_TEXT_LORAS'
)
if (!textWidget) continue
const container = (textWidget as any).element as HTMLElement | undefined
applyAutocompleteTextLayoutFix(textWidget, container, isVueDomMode)
}
}
requestAnimationFrame(() => {
for (const nodeEl of document.querySelectorAll('[data-node-id]')) {
const grid = nodeEl.querySelector('[data-testid="node-widgets"]') as HTMLElement | null
if (!grid) continue
const nodeId = nodeEl.getAttribute('data-node-id')
const node = app.graph?.getNodeById(nodeId as any)
if (!node) continue
const rows: string[] = []
let needsFix = false
for (const w of node.widgets ?? []) {
if (w.type === 'LORA_MANAGER_AUTOCOMPLETE_METADATA') {
rows.push('min-content')
} else if (w.name === 'loras') {
rows.push('auto')
} else if (w.name === 'text' && w.type === 'AUTOCOMPLETE_TEXT_LORAS') {
rows.push(isVueDomMode ? 'min-content' : 'auto')
needsFix = true
} else {
rows.push('auto')
}
}
if (needsFix) {
grid.style.gridTemplateRows = rows.join(' ')
}
}
})
app.canvas?.setDirty(true, true)
document.dispatchEvent(new CustomEvent('lora-manager:vue-mode-change', {
detail: { isVueDomMode }
}))
@@ -655,13 +716,16 @@ function createAutocompleteTextWidgetFactory(
// Get spellcheck setting from ComfyUI settings (default: false)
const spellcheck = app.ui?.settings?.getSettingValue?.('Comfy.TextareaWidget.Spellcheck') ?? false
const maxHeight = modelType === 'loras' ? AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT : undefined
const vueApp = createApp(AutocompleteTextWidget, {
widget,
node,
modelType,
placeholder: inputOptions.placeholder || widgetName,
showPreview: true,
spellcheck
spellcheck,
maxHeight
})
vueApp.use(PrimeVue, {
@@ -673,6 +737,19 @@ function createAutocompleteTextWidgetFactory(
const appKey = instanceId
vueApps.set(appKey, vueApp)
if (maxHeight) {
container.style.maxHeight = `${maxHeight}px`
container.style.minHeight = `${maxHeight}px`
}
if (modelType === 'loras') {
applyAutocompleteTextLayoutFix(
widget,
container,
typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode
)
}
widget.onRemove = createVueWidgetCleanup(vueApp, () => {
vueApps.delete(appKey)
})

View File

@@ -2118,14 +2118,14 @@ to { transform: rotate(360deg);
padding: 20px 0;
}
.autocomplete-text-widget[data-v-5514bf46] {
.autocomplete-text-widget[data-v-8555b560] {
background: transparent;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.input-wrapper[data-v-5514bf46] {
.input-wrapper[data-v-8555b560] {
position: relative;
flex: 1;
display: flex;
@@ -2133,7 +2133,7 @@ to { transform: rotate(360deg);
}
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */
.text-input[data-v-5514bf46] {
.text-input[data-v-8555b560] {
flex: 1;
width: 100%;
background-color: var(--comfy-input-bg, #222);
@@ -2150,7 +2150,7 @@ to { transform: rotate(360deg);
}
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
.text-input.vue-dom-mode[data-v-5514bf46] {
.text-input.vue-dom-mode[data-v-8555b560] {
background-color: var(--color-charcoal-400, #313235);
color: #fff;
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
@@ -2159,12 +2159,12 @@ to { transform: rotate(360deg);
font-size: 12px;
font-family: inherit;
}
.text-input[data-v-5514bf46]:focus {
.text-input[data-v-8555b560]:focus {
outline: none;
}
/* Clear button styles */
.clear-button[data-v-5514bf46] {
.clear-button[data-v-8555b560] {
position: absolute;
right: 6px;
bottom: 6px; /* Changed from top to bottom */
@@ -2187,31 +2187,31 @@ to { transform: rotate(360deg);
}
/* Show clear button when hovering over input wrapper */
.input-wrapper:hover .clear-button[data-v-5514bf46] {
.input-wrapper:hover .clear-button[data-v-8555b560] {
opacity: 0.7;
pointer-events: auto;
}
.clear-button[data-v-5514bf46]:hover {
.clear-button[data-v-8555b560]:hover {
opacity: 1;
background: rgba(255, 100, 100, 0.8);
}
.clear-button svg[data-v-5514bf46] {
.clear-button svg[data-v-8555b560] {
width: 12px;
height: 12px;
}
/* Vue DOM mode adjustments for clear button */
.text-input.vue-dom-mode ~ .clear-button[data-v-5514bf46] {
.text-input.vue-dom-mode ~ .clear-button[data-v-8555b560] {
right: 8px;
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
width: 20px;
height: 20px;
background: rgba(107, 114, 128, 0.6);
}
.text-input.vue-dom-mode ~ .clear-button[data-v-5514bf46]:hover {
.text-input.vue-dom-mode ~ .clear-button[data-v-8555b560]:hover {
background: oklch(62% 0.18 25);
}
.text-input.vue-dom-mode ~ .clear-button svg[data-v-5514bf46] {
.text-input.vue-dom-mode ~ .clear-button svg[data-v-8555b560] {
width: 14px;
height: 14px;
}`));
@@ -14783,7 +14783,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
modelType: {},
placeholder: {},
showPreview: { type: Boolean },
spellcheck: { type: Boolean }
spellcheck: { type: Boolean },
maxHeight: {}
},
setup(__props) {
const props = __props;
@@ -14913,10 +14914,12 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
ref: textareaRef,
placeholder: __props.placeholder,
spellcheck: __props.spellcheck ?? false,
class: normalizeClass(["text-input", { "vue-dom-mode": isVueDomMode.value }]),
class: normalizeClass(["text-input", { "vue-dom-mode": isVueDomMode.value, "lm-wheel-scrollable": isVueDomMode.value }]),
style: normalizeStyle(__props.maxHeight && isVueDomMode.value ? { maxHeight: __props.maxHeight + "px" } : void 0),
"data-capture-wheel": "true",
onInput,
onWheel
}, null, 42, _hoisted_3),
}, null, 46, _hoisted_3),
showClearButton.value ? (openBlock(), createElementBlock("button", {
key: 0,
type: "button",
@@ -14949,7 +14952,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
};
}
});
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-5514bf46"]]);
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-8555b560"]]);
function createVueWidgetCleanup(vueApp, onCleanup) {
let didUnmount = false;
return () => {
@@ -15713,13 +15716,66 @@ function normalizeAutocompleteWidgetValues(node, info) {
info.widgets_values = repairedValues;
}
}
function applyAutocompleteTextLayoutFix(widget, container, isVueMode) {
if (isVueMode) {
widget.computeLayoutSize = void 0;
widget.computeSize = (width) => [width ?? 200, AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT - 4];
if (container) {
container.style.minHeight = `${AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT}px`;
}
} else {
delete widget.computeLayoutSize;
delete widget.computeSize;
if (container) {
container.style.minHeight = "";
}
}
}
const initVueDomModeListener = () => {
var _a2, _b;
if ((_b = (_a2 = app$1.ui) == null ? void 0 : _a2.settings) == null ? void 0 : _b.addEventListener) {
app$1.ui.settings.addEventListener("Comfy.VueNodes.Enabled.change", () => {
requestAnimationFrame(() => {
var _a3, _b2, _c;
var _a3, _b2, _c, _d, _e2, _f;
const isVueDomMode = ((_c = (_b2 = (_a3 = app$1.ui) == null ? void 0 : _a3.settings) == null ? void 0 : _b2.getSettingValue) == null ? void 0 : _c.call(_b2, "Comfy.VueNodes.Enabled")) ?? false;
if ((_d = app$1.graph) == null ? void 0 : _d.nodes) {
for (const node of app$1.graph.nodes) {
const textWidget = (_e2 = node.widgets) == null ? void 0 : _e2.find(
(w2) => w2.type === "AUTOCOMPLETE_TEXT_LORAS"
);
if (!textWidget) continue;
const container = textWidget.element;
applyAutocompleteTextLayoutFix(textWidget, container, isVueDomMode);
}
}
requestAnimationFrame(() => {
var _a4;
for (const nodeEl of document.querySelectorAll("[data-node-id]")) {
const grid = nodeEl.querySelector('[data-testid="node-widgets"]');
if (!grid) continue;
const nodeId = nodeEl.getAttribute("data-node-id");
const node = (_a4 = app$1.graph) == null ? void 0 : _a4.getNodeById(nodeId);
if (!node) continue;
const rows = [];
let needsFix = false;
for (const w2 of node.widgets ?? []) {
if (w2.type === "LORA_MANAGER_AUTOCOMPLETE_METADATA") {
rows.push("min-content");
} else if (w2.name === "loras") {
rows.push("auto");
} else if (w2.name === "text" && w2.type === "AUTOCOMPLETE_TEXT_LORAS") {
rows.push(isVueDomMode ? "min-content" : "auto");
needsFix = true;
} else {
rows.push("auto");
}
}
if (needsFix) {
grid.style.gridTemplateRows = rows.join(" ");
}
}
});
(_f = app$1.canvas) == null ? void 0 : _f.setDirty(true, true);
document.dispatchEvent(new CustomEvent("lora-manager:vue-mode-change", {
detail: { isVueDomMode }
}));
@@ -15799,13 +15855,15 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
);
widget.metadataWidget = metadataWidget;
const spellcheck = ((_c = (_b = (_a2 = app$1.ui) == null ? void 0 : _a2.settings) == null ? void 0 : _b.getSettingValue) == null ? void 0 : _c.call(_b, "Comfy.TextareaWidget.Spellcheck")) ?? false;
const maxHeight = modelType === "loras" ? AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT : void 0;
const vueApp = createApp(AutocompleteTextWidget, {
widget,
node,
modelType,
placeholder: inputOptions.placeholder || widgetName,
showPreview: true,
spellcheck
spellcheck,
maxHeight
});
vueApp.use(PrimeVue, {
unstyled: true,
@@ -15814,6 +15872,17 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
vueApp.mount(container);
const appKey = instanceId;
vueApps.set(appKey, vueApp);
if (maxHeight) {
container.style.maxHeight = `${maxHeight}px`;
container.style.minHeight = `${maxHeight}px`;
}
if (modelType === "loras") {
applyAutocompleteTextLayoutFix(
widget,
container,
typeof LiteGraph !== "undefined" && LiteGraph.vueNodesMode
);
}
widget.onRemove = createVueWidgetCleanup(vueApp, () => {
vueApps.delete(appKey);
});

File diff suppressed because one or more lines are too long