Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1367 lines
56 KiB
JavaScript
1367 lines
56 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
|
|
|
// Collapse a widget to zero height so it's invisible but still serialized
|
|
function hideWidget(node, widget) {
|
|
widget.computeSize = () => [0, -4];
|
|
widget.type = "hidden";
|
|
node.setSize(node.computeSize());
|
|
}
|
|
|
|
// Inject modal CSS once into document.head
|
|
let _stylesInjected = false;
|
|
function injectStyles() {
|
|
if (_stylesInjected) return;
|
|
_stylesInjected = true;
|
|
const style = document.createElement("style");
|
|
style.textContent = `
|
|
.ell-modal-overlay {
|
|
position: fixed; inset: 0;
|
|
z-index: 9999;
|
|
}
|
|
.ell-modal-box {
|
|
position: fixed;
|
|
background: var(--comfy-menu-bg, #353535);
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 32px rgba(0,0,0,0.6);
|
|
display: flex; flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
.ell-modal-header {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
padding: 10px 14px;
|
|
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
|
font-weight: bold; color: var(--fg-color, #fff);
|
|
cursor: move;
|
|
flex-shrink: 0;
|
|
user-select: none;
|
|
}
|
|
.ell-modal-close {
|
|
background: none; border: none; color: var(--fg-color, #fff);
|
|
cursor: pointer; font-size: 18px; line-height: 1; padding: 2px 6px;
|
|
}
|
|
.ell-modal-body {
|
|
display: flex; flex: 1; min-height: 0; overflow: hidden;
|
|
}
|
|
.ell-tree-container {
|
|
flex: 1; overflow: auto; padding: 4px 0;
|
|
background: var(--comfy-input-bg, #1a1a1a);
|
|
font-family: system-ui, sans-serif;
|
|
font-size: 13px;
|
|
}
|
|
.ell-row {
|
|
display: flex; align-items: center;
|
|
height: 22px; line-height: 22px;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
user-select: none;
|
|
color: var(--fg-color, #ddd);
|
|
border: 1px solid transparent;
|
|
box-sizing: border-box;
|
|
}
|
|
.ell-row:hover {
|
|
background: rgba(255,255,255,0.07);
|
|
}
|
|
.ell-row-selected {
|
|
background: #0078d4;
|
|
color: #fff;
|
|
}
|
|
.ell-row-selected:hover {
|
|
background: #1084d8;
|
|
}
|
|
.ell-row-arrow {
|
|
display: inline-block;
|
|
width: 14px; text-align: center;
|
|
flex-shrink: 0;
|
|
font-size: 9px;
|
|
color: var(--input-text, #aaa);
|
|
}
|
|
.ell-row-selected .ell-row-arrow { color: #fff; }
|
|
.ell-row-icon {
|
|
display: inline-block;
|
|
width: 20px; text-align: center;
|
|
flex-shrink: 0;
|
|
}
|
|
.ell-row-label {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
padding-right: 8px;
|
|
}
|
|
.ell-modal-footer {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 8px 14px;
|
|
border-top: 1px solid var(--border-color, #4e4e4e);
|
|
flex-shrink: 0;
|
|
}
|
|
.ell-status-text {
|
|
flex: 1; min-width: 0;
|
|
font-size: 12px; color: var(--input-text, #aaa);
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.ell-select-btn {
|
|
padding: 5px 18px; border-radius: 4px;
|
|
background: var(--comfy-input-bg, #444); color: var(--fg-color, #fff);
|
|
border: 1px solid var(--border-color, #4e4e4e); cursor: pointer;
|
|
flex-shrink: 0;
|
|
}
|
|
.ell-select-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
.ell-select-btn-active {
|
|
background: #0078d4 !important;
|
|
border-color: #006cbd !important;
|
|
color: #fff !important;
|
|
}
|
|
.ell-ext-filter {
|
|
background: var(--comfy-input-bg, #1a1a1a);
|
|
color: var(--fg-color, #ddd);
|
|
border: 1px solid var(--border-color, #4e4e4e);
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
padding: 3px 6px;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
}
|
|
.ell-row-inaccessible {
|
|
opacity: 0.45;
|
|
cursor: not-allowed;
|
|
}
|
|
.ell-row-inaccessible:hover {
|
|
background: none !important;
|
|
}
|
|
.ell-resize-handle {
|
|
position: absolute; right: 0; bottom: 0;
|
|
width: 16px; height: 16px; cursor: se-resize;
|
|
background: linear-gradient(135deg,
|
|
transparent 50%,
|
|
var(--border-color, #666) 50%, var(--border-color, #666) 60%,
|
|
transparent 60%, transparent 70%,
|
|
var(--border-color, #666) 70%, var(--border-color, #666) 80%,
|
|
transparent 80%);
|
|
opacity: 0.6;
|
|
}
|
|
.ell-meta-popup {
|
|
position: fixed;
|
|
background: var(--comfy-menu-bg, #353535);
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 32px rgba(0,0,0,0.6);
|
|
display: flex; flex-direction: column;
|
|
overflow: hidden;
|
|
z-index: 10000;
|
|
font-family: system-ui, sans-serif;
|
|
font-size: 13px;
|
|
color: var(--fg-color, #ddd);
|
|
min-width: 200px;
|
|
}
|
|
.ell-meta-popup-header {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
padding: 8px 12px;
|
|
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
|
flex-shrink: 0;
|
|
}
|
|
.ell-meta-popup-title {
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--input-text, #aaa);
|
|
font-weight: bold;
|
|
}
|
|
.ell-meta-popup-close {
|
|
background: none; border: none; color: var(--fg-color, #fff);
|
|
cursor: pointer; font-size: 16px; line-height: 1; padding: 2px 4px;
|
|
}
|
|
.ell-meta-tabs {
|
|
display: flex;
|
|
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
|
flex-shrink: 0;
|
|
}
|
|
.ell-meta-tab {
|
|
flex: 1;
|
|
background: none; border: none; border-bottom: 2px solid transparent;
|
|
color: var(--input-text, #aaa); cursor: pointer;
|
|
padding: 6px 4px; font-size: 11px;
|
|
}
|
|
.ell-meta-tab:hover { color: var(--fg-color, #ddd); }
|
|
.ell-meta-tab-active {
|
|
color: #fff !important;
|
|
border-bottom-color: #0078d4 !important;
|
|
}
|
|
.ell-meta-body {
|
|
flex: 1; overflow-y: auto; padding: 8px;
|
|
background: var(--comfy-input-bg, #1a1a1a);
|
|
}
|
|
.ell-meta-grid {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr;
|
|
gap: 4px 10px;
|
|
align-items: baseline;
|
|
}
|
|
.ell-meta-key {
|
|
color: var(--input-text, #888);
|
|
font-size: 11px;
|
|
text-align: right;
|
|
white-space: nowrap;
|
|
}
|
|
.ell-meta-val {
|
|
color: var(--fg-color, #ddd);
|
|
font-size: 12px;
|
|
word-break: break-word;
|
|
}
|
|
.ell-meta-empty {
|
|
color: var(--input-text, #666);
|
|
font-size: 12px;
|
|
text-align: center;
|
|
padding: 24px 8px;
|
|
font-style: italic;
|
|
}
|
|
.ell-meta-loading {
|
|
color: var(--input-text, #888);
|
|
font-size: 12px;
|
|
text-align: center;
|
|
padding: 24px 8px;
|
|
}
|
|
.ell-meta-tag-list {
|
|
display: flex; flex-wrap: wrap; gap: 4px;
|
|
padding: 2px 0;
|
|
}
|
|
.ell-meta-tag {
|
|
background: rgba(255,255,255,0.1);
|
|
border-radius: 12px;
|
|
padding: 2px 8px;
|
|
font-size: 11px;
|
|
color: var(--fg-color, #ddd);
|
|
cursor: default;
|
|
white-space: nowrap;
|
|
}
|
|
.ell-meta-popup-header { cursor: move; }
|
|
.ell-meta-resize-handle {
|
|
position: absolute; right: 0; bottom: 0;
|
|
width: 14px; height: 14px; cursor: se-resize;
|
|
background: linear-gradient(135deg,
|
|
transparent 50%,
|
|
var(--border-color, #666) 50%, var(--border-color, #666) 60%,
|
|
transparent 60%, transparent 70%,
|
|
var(--border-color, #666) 70%, var(--border-color, #666) 80%,
|
|
transparent 80%);
|
|
opacity: 0.5;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Module-level state shared across all node instances
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Cached result of GET /external_lora/loras_folder (undefined = not fetched yet)
|
|
let _lorasFolderCache = undefined;
|
|
|
|
// Last extension filter the user chose — persists across opens and nodes
|
|
let _lastExtFilter = ".safetensors"; // default: Safetensors only
|
|
|
|
// Metadata popup state (shared across all node instances; only one popup shown at a time)
|
|
let metaPopupEl = null;
|
|
let _metaActiveTab = "overview";
|
|
let _metaFetchSeq = 0;
|
|
let _metaPopupGeo = null;
|
|
|
|
async function _fetchLorasFolder() {
|
|
if (_lorasFolderCache) return _lorasFolderCache; // only cache successes; failures retry next open
|
|
try {
|
|
const resp = await fetch("/external_lora/loras_folder");
|
|
if (!resp.ok) return null;
|
|
const data = await resp.json();
|
|
if (data.drive) {
|
|
_lorasFolderCache = { drive: data.drive, sub_path: data.sub_path || "" };
|
|
return _lorasFolderCache;
|
|
}
|
|
} catch {}
|
|
return null;
|
|
}
|
|
|
|
// Convert the filter select value to the API `extensions` param
|
|
function _extParam(filter) {
|
|
if (!filter || filter === "") return null; // backend uses _SUPPORTED_EXTENSIONS
|
|
if (filter === "*") return ["*"]; // all files
|
|
return filter.split(","); // e.g. ".pt,.pth"
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Metadata popup (module-level; shared across nodes)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function _escHtml(str) {
|
|
return String(str)
|
|
.replace(/&/g, "&").replace(/</g, "<")
|
|
.replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
|
|
function _saveMetaPopupGeo() {
|
|
if (!metaPopupEl) return;
|
|
_metaPopupGeo = {
|
|
left: parseInt(metaPopupEl.style.left, 10),
|
|
top: parseInt(metaPopupEl.style.top, 10),
|
|
width: parseInt(metaPopupEl.style.width, 10),
|
|
height: parseInt(metaPopupEl.style.height, 10),
|
|
};
|
|
}
|
|
|
|
function buildMetaPopup() {
|
|
if (metaPopupEl) return;
|
|
metaPopupEl = document.createElement("div");
|
|
metaPopupEl.className = "ell-meta-popup";
|
|
metaPopupEl.style.display = "none";
|
|
|
|
const popHeader = document.createElement("div");
|
|
popHeader.className = "ell-meta-popup-header";
|
|
const popTitle = document.createElement("span");
|
|
popTitle.className = "ell-meta-popup-title";
|
|
popTitle.textContent = "LoRA Info";
|
|
const popClose = document.createElement("button");
|
|
popClose.className = "ell-meta-popup-close";
|
|
popClose.textContent = "\u2715";
|
|
popClose.onclick = hideMetaPopup;
|
|
popHeader.appendChild(popTitle);
|
|
popHeader.appendChild(popClose);
|
|
popHeader.addEventListener("pointerdown", (e) => {
|
|
if (e.target.closest(".ell-meta-popup-close")) return;
|
|
const startX = e.clientX - metaPopupEl.offsetLeft;
|
|
const startY = e.clientY - metaPopupEl.offsetTop;
|
|
popHeader.setPointerCapture(e.pointerId);
|
|
document.body.style.userSelect = "none";
|
|
function onMove(ev) {
|
|
metaPopupEl.style.left = Math.max(40 - metaPopupEl.offsetWidth, Math.min(window.innerWidth - 40, ev.clientX - startX)) + "px";
|
|
metaPopupEl.style.top = Math.max(0, Math.min(window.innerHeight - 40, ev.clientY - startY)) + "px";
|
|
}
|
|
function onUp() {
|
|
popHeader.removeEventListener("pointermove", onMove);
|
|
popHeader.removeEventListener("pointerup", onUp);
|
|
document.body.style.userSelect = "";
|
|
_saveMetaPopupGeo();
|
|
}
|
|
popHeader.addEventListener("pointermove", onMove);
|
|
popHeader.addEventListener("pointerup", onUp);
|
|
});
|
|
|
|
const tabBar = document.createElement("div");
|
|
tabBar.className = "ell-meta-tabs";
|
|
for (const [id, label] of [["overview","Overview"],["training","Training"],["tags","Tags"],["about","About"]]) {
|
|
const tab = document.createElement("button");
|
|
tab.className = "ell-meta-tab" + (id === _metaActiveTab ? " ell-meta-tab-active" : "");
|
|
tab.textContent = label;
|
|
tab.dataset.tab = id;
|
|
tab.onclick = () => {
|
|
_metaActiveTab = id;
|
|
metaPopupEl.querySelectorAll(".ell-meta-tab").forEach(t =>
|
|
t.classList.toggle("ell-meta-tab-active", t.dataset.tab === id));
|
|
metaPopupEl.querySelectorAll(".ell-meta-panel").forEach(p =>
|
|
p.style.display = p.dataset.panel === id ? "" : "none");
|
|
};
|
|
tabBar.appendChild(tab);
|
|
}
|
|
|
|
const popBody = document.createElement("div");
|
|
popBody.className = "ell-meta-body";
|
|
for (const id of ["overview","training","tags","about"]) {
|
|
const panel = document.createElement("div");
|
|
panel.className = "ell-meta-panel";
|
|
panel.dataset.panel = id;
|
|
panel.style.display = id === _metaActiveTab ? "" : "none";
|
|
popBody.appendChild(panel);
|
|
}
|
|
|
|
metaPopupEl.appendChild(popHeader);
|
|
metaPopupEl.appendChild(tabBar);
|
|
metaPopupEl.appendChild(popBody);
|
|
const metaResizeHandle = document.createElement("div");
|
|
metaResizeHandle.className = "ell-meta-resize-handle";
|
|
metaResizeHandle.addEventListener("pointerdown", (e) => {
|
|
e.stopPropagation();
|
|
const startX = e.clientX, startY = e.clientY;
|
|
const initW = metaPopupEl.offsetWidth, initH = metaPopupEl.offsetHeight;
|
|
metaResizeHandle.setPointerCapture(e.pointerId);
|
|
document.body.style.userSelect = "none";
|
|
function onMove(ev) {
|
|
metaPopupEl.style.width = Math.max(200, initW + (ev.clientX - startX)) + "px";
|
|
metaPopupEl.style.height = Math.max(150, initH + (ev.clientY - startY)) + "px";
|
|
}
|
|
function onUp() {
|
|
metaResizeHandle.removeEventListener("pointermove", onMove);
|
|
metaResizeHandle.removeEventListener("pointerup", onUp);
|
|
document.body.style.userSelect = "";
|
|
_saveMetaPopupGeo();
|
|
}
|
|
metaResizeHandle.addEventListener("pointermove", onMove);
|
|
metaResizeHandle.addEventListener("pointerup", onUp);
|
|
});
|
|
metaPopupEl.appendChild(metaResizeHandle);
|
|
document.body.appendChild(metaPopupEl);
|
|
}
|
|
|
|
function positionMetaPopup(boxEl) {
|
|
if (!metaPopupEl) return;
|
|
if (_metaPopupGeo) {
|
|
metaPopupEl.style.left = Math.max(0, Math.min(window.innerWidth - 40, _metaPopupGeo.left)) + "px";
|
|
metaPopupEl.style.top = Math.max(0, Math.min(window.innerHeight - 40, _metaPopupGeo.top)) + "px";
|
|
metaPopupEl.style.width = (_metaPopupGeo.width || 280) + "px";
|
|
metaPopupEl.style.height = (_metaPopupGeo.height || 300) + "px";
|
|
metaPopupEl.style.opacity = "1";
|
|
return;
|
|
}
|
|
if (!boxEl) return;
|
|
const rect = boxEl.getBoundingClientRect();
|
|
const popW = 280;
|
|
const gap = 8;
|
|
const vpW = window.innerWidth;
|
|
|
|
let left;
|
|
if (rect.right + gap + popW <= vpW) {
|
|
left = rect.right + gap;
|
|
metaPopupEl.style.opacity = "1";
|
|
} else {
|
|
left = Math.max(rect.right - popW - 4, rect.left);
|
|
metaPopupEl.style.opacity = "0.96";
|
|
}
|
|
const headerH = 41, footerH = 46;
|
|
metaPopupEl.style.left = left + "px";
|
|
metaPopupEl.style.top = (rect.top + headerH) + "px";
|
|
metaPopupEl.style.width = popW + "px";
|
|
metaPopupEl.style.height = Math.max(200, rect.height - headerH - footerH) + "px";
|
|
}
|
|
|
|
function _renderMetaPanels(result) {
|
|
if (!metaPopupEl) return;
|
|
const meta = (result && result.metadata) || {};
|
|
const noMeta = !(result && result.metadata);
|
|
|
|
function grid(rows) {
|
|
if (!rows.length) return '<div class="ell-meta-empty">No data</div>';
|
|
return '<div class="ell-meta-grid">' +
|
|
rows.map(([k, v]) =>
|
|
`<span class="ell-meta-key">${_escHtml(k)}</span>` +
|
|
`<span class="ell-meta-val">${v ?? "\u2014"}</span>`
|
|
).join("") + "</div>";
|
|
}
|
|
|
|
const noEmbed = '<div class="ell-meta-empty">No embedded metadata</div>';
|
|
|
|
// --- Overview ---
|
|
const ov = [];
|
|
if (result && result.file_size_mb != null) ov.push(["Size", result.file_size_mb + " MB"]);
|
|
if (result && result.modified) ov.push(["Modified", result.modified.replace("T", " ")]);
|
|
if (meta.ss_base_model_version) ov.push(["Base model", _escHtml(meta.ss_base_model_version)]);
|
|
const dim = meta.ss_network_dim;
|
|
const alpha = meta.ss_network_alpha;
|
|
if (dim) ov.push(["Rank (dim)", _escHtml(dim)]);
|
|
if (alpha) ov.push(["Alpha", _escHtml(alpha)]);
|
|
if (dim && alpha) {
|
|
const d = parseFloat(dim), a = parseFloat(alpha);
|
|
if (d > 0) ov.push(["Eff. scale", (a / d).toFixed(3)]);
|
|
}
|
|
if (meta.ss_network_module)
|
|
ov.push(["Network", _escHtml(meta.ss_network_module.split(".").pop())]);
|
|
|
|
// --- Training ---
|
|
const tr = [];
|
|
if (!noMeta) {
|
|
if (meta.ss_steps) tr.push(["Steps", _escHtml(meta.ss_steps)]);
|
|
if (meta.ss_epoch) tr.push(["Epochs", _escHtml(meta.ss_epoch)]);
|
|
if (meta.ss_num_train_images) tr.push(["Train images", _escHtml(meta.ss_num_train_images)]);
|
|
if (meta.ss_num_reg_images) tr.push(["Reg images", _escHtml(meta.ss_num_reg_images)]);
|
|
if (meta.ss_resolution) tr.push(["Resolution", _escHtml(meta.ss_resolution)]);
|
|
if (meta.ss_unet_lr) tr.push(["UNet LR", _escHtml(meta.ss_unet_lr)]);
|
|
if (meta.ss_text_encoder_lr) tr.push(["TE LR", _escHtml(meta.ss_text_encoder_lr)]);
|
|
else if (meta.ss_learning_rate) tr.push(["LR", _escHtml(meta.ss_learning_rate)]);
|
|
}
|
|
|
|
// --- Tags ---
|
|
let tagHtml = noMeta ? noEmbed : "";
|
|
if (!noMeta) {
|
|
let tagFreq = meta.ss_tag_frequency;
|
|
if (typeof tagFreq === "string") {
|
|
try { tagFreq = JSON.parse(tagFreq); } catch { tagFreq = null; }
|
|
}
|
|
if (tagFreq && typeof tagFreq === "object" && !Array.isArray(tagFreq)) {
|
|
const merged = {};
|
|
for (const dir of Object.values(tagFreq)) {
|
|
if (dir && typeof dir === "object") {
|
|
for (const [tag, count] of Object.entries(dir)) {
|
|
merged[tag] = (merged[tag] || 0) + Number(count);
|
|
}
|
|
}
|
|
}
|
|
const sorted = Object.entries(merged).sort((a, b) => b[1] - a[1]).slice(0, 40);
|
|
tagHtml = sorted.length
|
|
? '<div class="ell-meta-tag-list">' +
|
|
sorted.map(([tag, cnt]) =>
|
|
`<span class="ell-meta-tag" title="${cnt} occurrences">${_escHtml(tag)}</span>`
|
|
).join("") + "</div>"
|
|
: '<div class="ell-meta-empty">No tags</div>';
|
|
} else {
|
|
tagHtml = '<div class="ell-meta-empty">No tag data</div>';
|
|
}
|
|
}
|
|
|
|
// --- About ---
|
|
const ab = [];
|
|
if (!noMeta) {
|
|
const title = meta["modelspec.title"] || meta.ss_output_name;
|
|
const author = meta["modelspec.author"];
|
|
const desc = meta["modelspec.description"] || meta.ss_training_comment;
|
|
const license = meta["modelspec.license"];
|
|
const tags = meta["modelspec.tags"];
|
|
if (title) ab.push(["Title", _escHtml(title)]);
|
|
if (author) ab.push(["Author", _escHtml(author)]);
|
|
if (license) ab.push(["License", _escHtml(license)]);
|
|
if (tags) ab.push(["Tags", _escHtml(tags)]);
|
|
if (desc) ab.push(["Notes",
|
|
`<span style="white-space:pre-wrap;display:block">${_escHtml(desc)}</span>`]);
|
|
}
|
|
|
|
const content = {
|
|
overview: grid(ov),
|
|
training: noMeta ? noEmbed : (tr.length ? grid(tr) : '<div class="ell-meta-empty">No training data</div>'),
|
|
tags: tagHtml,
|
|
about: noMeta ? noEmbed : (ab.length ? grid(ab) : '<div class="ell-meta-empty">No description</div>'),
|
|
};
|
|
|
|
metaPopupEl.querySelectorAll(".ell-meta-panel").forEach(p => {
|
|
p.innerHTML = content[p.dataset.panel] || "";
|
|
});
|
|
}
|
|
|
|
async function showMetaPopup(data, boxEl) {
|
|
buildMetaPopup();
|
|
positionMetaPopup(boxEl);
|
|
metaPopupEl.style.display = "flex";
|
|
|
|
// Sync tab active state in case it changed since last open
|
|
metaPopupEl.querySelectorAll(".ell-meta-tab").forEach(t =>
|
|
t.classList.toggle("ell-meta-tab-active", t.dataset.tab === _metaActiveTab));
|
|
metaPopupEl.querySelectorAll(".ell-meta-panel").forEach(p =>
|
|
p.style.display = p.dataset.panel === _metaActiveTab ? "" : "none");
|
|
|
|
// Loading state in every panel
|
|
metaPopupEl.querySelectorAll(".ell-meta-panel").forEach(p => {
|
|
p.innerHTML = '<div class="ell-meta-loading">Loading\u2026</div>';
|
|
});
|
|
|
|
const seq = ++_metaFetchSeq;
|
|
const params = new URLSearchParams({
|
|
drive: data.drive,
|
|
path: data.segments.join("/"),
|
|
});
|
|
|
|
let result = null;
|
|
try {
|
|
const resp = await fetch("/external_lora/metadata?" + params);
|
|
if (resp.ok) result = await resp.json();
|
|
} catch {}
|
|
|
|
if (seq !== _metaFetchSeq) return; // a newer file was clicked
|
|
if (!metaPopupEl || metaPopupEl.style.display === "none") return;
|
|
|
|
_renderMetaPanels(result || {});
|
|
}
|
|
|
|
function hideMetaPopup() {
|
|
if (metaPopupEl) metaPopupEl.style.display = "none";
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
app.registerExtension({
|
|
name: "ExternalLoraLoader",
|
|
|
|
async nodeCreated(node) {
|
|
// Only apply to ExternalLoraLoader nodes
|
|
if (node.comfyClass !== "ExternalLoraLoader") return;
|
|
|
|
// --- Widget references ---
|
|
const driveWidget = node.widgets.find(w => w.name === "drive");
|
|
const subPathWidget = node.widgets.find(w => w.name === "sub_path");
|
|
const loraNameWidget = node.widgets.find(w => w.name === "lora_name");
|
|
|
|
if (!driveWidget || !subPathWidget || !loraNameWidget) return;
|
|
|
|
// Render the backend STRING input as a combo so the UI keeps the same
|
|
// dropdown behavior without triggering backend combo-list validation.
|
|
loraNameWidget.type = "combo";
|
|
loraNameWidget.options = { ...(loraNameWidget.options || {}), values: ["none"] };
|
|
loraNameWidget.value = loraNameWidget.value || "none";
|
|
|
|
// nodeCreated fires BEFORE node.configure() writes saved widget values,
|
|
// so loraNameWidget.value is still the default here. onConfigure fires
|
|
// at the END of configure(), after widgets_values are applied — that is
|
|
// the first moment we can see the real saved filename and seed options
|
|
// so ComfyUI's missing-models validator finds the value in the list.
|
|
const _origOnConfigure = node.onConfigure;
|
|
node.onConfigure = function(info) {
|
|
if (_origOnConfigure) _origOnConfigure.call(this, info);
|
|
const v = loraNameWidget.value || "none";
|
|
if (v !== "none" && !loraNameWidget.options.values.includes(v)) {
|
|
loraNameWidget.options.values = ["none", v];
|
|
}
|
|
};
|
|
|
|
// --- Debounce helper ---
|
|
let debounceTimer = null;
|
|
let pathLabelWidget = null;
|
|
function scheduleRefresh() {
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(() => refreshFiles(), 500);
|
|
}
|
|
|
|
// --- refreshFiles: POST to list_files API ---
|
|
async function refreshFiles() {
|
|
const drive = driveWidget.value;
|
|
const path = (subPathWidget.value || "").replace(/\\/g, "/");
|
|
|
|
try {
|
|
const resp = await fetch("/external_lora/list_files", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ drive, path }),
|
|
});
|
|
const data = await resp.json();
|
|
|
|
if (!resp.ok) {
|
|
loraNameWidget.options.values = ["none"];
|
|
loraNameWidget.value = "none";
|
|
showTempLabel(loraNameWidget, `Server error (${resp.status})`, 2000);
|
|
} else if (data.files && data.files.length > 0) {
|
|
const prev = loraNameWidget.value;
|
|
loraNameWidget.options.values = data.files;
|
|
loraNameWidget.value = data.files.includes(prev) ? prev : "none";
|
|
} else {
|
|
loraNameWidget.options.values = ["none"];
|
|
loraNameWidget.value = "none";
|
|
pathLabelWidget.value = "No file selected";
|
|
showTempLabel(loraNameWidget, "Path not found", 2000);
|
|
}
|
|
} catch (err) {
|
|
loraNameWidget.options.values = ["none"];
|
|
loraNameWidget.value = "none";
|
|
pathLabelWidget.value = "No file selected";
|
|
showTempLabel(loraNameWidget, "Error fetching files", 2000);
|
|
} finally {
|
|
node.setDirtyCanvas(true, true);
|
|
}
|
|
}
|
|
|
|
// --- Temporary label helper ---
|
|
const _labelTimers = new WeakMap();
|
|
function showTempLabel(widget, message, durationMs) {
|
|
clearTimeout(_labelTimers.get(widget));
|
|
const origLabel = widget.label;
|
|
widget.label = message;
|
|
node.setDirtyCanvas(true, true);
|
|
_labelTimers.set(widget, setTimeout(() => {
|
|
widget.label = origLabel;
|
|
node.setDirtyCanvas(true, true);
|
|
}, durationMs));
|
|
}
|
|
|
|
// 1. Hide drive and sub_path from the canvas
|
|
hideWidget(node, driveWidget);
|
|
hideWidget(node, subPathWidget);
|
|
|
|
// 2. Inject CSS (idempotent)
|
|
injectStyles();
|
|
|
|
// 3. Path label (read-only display)
|
|
pathLabelWidget = node.addWidget("text", "selected_path", "No file selected", () => {});
|
|
setTimeout(() => {
|
|
if (pathLabelWidget.inputEl) pathLabelWidget.inputEl.readOnly = true;
|
|
}, 0);
|
|
|
|
// 4. Browse button
|
|
const browseBtn = node.addWidget("button", "Browse\u2026", null, () => openFileBrowser());
|
|
|
|
// 5. Browser navigation state
|
|
const browserState = {
|
|
drive: "", pathSegments: [], selectedFile: null,
|
|
};
|
|
|
|
// 6. Modal DOM (built lazily on first open)
|
|
let modalOverlay = null;
|
|
let modalBoxEl = null;
|
|
let treeContainerEl = null;
|
|
let statusEl = null;
|
|
let selectBtn = null;
|
|
let _boxPositioned = false;
|
|
|
|
// Last folder the user browsed to in THIS node's modal (null = not opened yet)
|
|
let _lastBrowsePath = null; // { drive: string, segments: string[] }
|
|
|
|
// Per-node extension filter — inherits module-level default on first open
|
|
let _extFilter = _lastExtFilter;
|
|
|
|
// DOM reference to the extension filter <select> (set once by buildModal)
|
|
let extFilterEl = null;
|
|
|
|
// Shared keydown handler for Escape key
|
|
let onKeyDown = null;
|
|
|
|
// Tree state (per node instance)
|
|
let treeRootData = null;
|
|
let _idCounter = 0;
|
|
|
|
function _nextId() { return ++_idCounter; }
|
|
|
|
// --- _normDrive: normalize drive strings for comparison ---
|
|
function _normDrive(d) {
|
|
return (d || "").replace(/\\/g, "/").replace(/\/+$/, "") + "/";
|
|
}
|
|
|
|
// --- clearFileSelection: recursively clears _selected on all nodes ---
|
|
function clearFileSelection(n) {
|
|
if (!n) return;
|
|
n._selected = false;
|
|
[...(n.children || []), ...(n._children || [])].forEach(clearFileSelection);
|
|
}
|
|
|
|
// --- renderTree: rebuild the Explorer-style tree DOM ---
|
|
function renderTree() {
|
|
if (!treeContainerEl || !treeRootData) return;
|
|
treeContainerEl.innerHTML = "";
|
|
|
|
function renderNode(data, depth) {
|
|
const isFile = data.type === "file";
|
|
const isDrive = data.type === "drive";
|
|
const isInaccessible = !isFile && data.accessible === false;
|
|
|
|
// Client-side extension filter (applied to files only)
|
|
if (isFile && _extFilter && _extFilter !== "*") {
|
|
const allowed = _extFilter.split(",");
|
|
const dot = data.name.lastIndexOf(".");
|
|
const ext = dot >= 0 ? data.name.slice(dot).toLowerCase() : "";
|
|
if (!allowed.includes(ext)) return;
|
|
}
|
|
|
|
const row = document.createElement("div");
|
|
row.className = "ell-row"
|
|
+ (data._selected ? " ell-row-selected" : "")
|
|
+ (isInaccessible ? " ell-row-inaccessible" : "");
|
|
row.style.paddingLeft = (depth * 16 + 4) + "px";
|
|
if (isInaccessible && data.reason) row.title = data.reason;
|
|
|
|
// Arrow: ▶ collapsed, ▼ expanded, space for files
|
|
const arrow = document.createElement("span");
|
|
arrow.className = "ell-row-arrow";
|
|
if (isFile) {
|
|
arrow.textContent = "\u00a0";
|
|
} else {
|
|
arrow.textContent = data.children !== null ? "\u25bc" : "\u25b6";
|
|
}
|
|
|
|
// Icon
|
|
const icon = document.createElement("span");
|
|
icon.className = "ell-row-icon";
|
|
if (isDrive) icon.textContent = "\uD83D\uDDB4"; // 🖴
|
|
else if (isFile) icon.textContent = "\uD83D\uDCC4"; // 📄
|
|
else icon.textContent = data.children !== null ? "\uD83D\uDCC2" : "\uD83D\uDCC1"; // 📂 / 📁
|
|
|
|
// Label
|
|
const label = document.createElement("span");
|
|
label.className = "ell-row-label";
|
|
label.textContent = data.name;
|
|
|
|
row.appendChild(arrow);
|
|
row.appendChild(icon);
|
|
row.appendChild(label);
|
|
|
|
row.addEventListener("click", (e) => { if (!isInaccessible) onNodeClick(e, data); });
|
|
if (isFile) {
|
|
row.addEventListener("dblclick", (e) => {
|
|
e.stopPropagation();
|
|
commitSelection();
|
|
});
|
|
}
|
|
|
|
treeContainerEl.appendChild(row);
|
|
|
|
// Recurse into expanded children
|
|
if (data.children) {
|
|
for (const child of data.children) {
|
|
renderNode(child, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const drive of (treeRootData.children || [])) {
|
|
renderNode(drive, 0);
|
|
}
|
|
}
|
|
|
|
// --- onNodeClick: file select or folder lazy-load + toggle ---
|
|
async function onNodeClick(event, data) {
|
|
if (data.type === "file") {
|
|
clearFileSelection(treeRootData);
|
|
data._selected = true;
|
|
browserState.drive = data.drive;
|
|
browserState.pathSegments = data.segments.slice(0, -1);
|
|
browserState.selectedFile = data.name;
|
|
if (selectBtn) {
|
|
selectBtn.disabled = false;
|
|
selectBtn.textContent = "Select";
|
|
selectBtn.classList.add("ell-select-btn-active");
|
|
}
|
|
if (statusEl) statusEl.textContent = data.name;
|
|
renderTree();
|
|
showMetaPopup(data, modalBoxEl);
|
|
return;
|
|
}
|
|
|
|
// Folder / drive — lazy load on first expand, then toggle
|
|
hideMetaPopup();
|
|
if (!data._loaded) {
|
|
if (statusEl) statusEl.textContent = "Loading\u2026";
|
|
const path = data.segments.join("/");
|
|
try {
|
|
const resp = await fetch("/external_lora/browse", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ drive: data.drive, path, extensions: _extParam(_extFilter) }),
|
|
});
|
|
if (!resp.ok) throw new Error(resp.statusText);
|
|
const json = await resp.json();
|
|
const dirs = json.dirs || [];
|
|
const files = json.files || [];
|
|
data.children = [
|
|
...dirs.map(d => ({
|
|
name: d.name, type: "dir", drive: data.drive,
|
|
segments: [...data.segments, d.name],
|
|
_loaded: false, children: null, _children: null,
|
|
_id: _nextId(), _selected: false,
|
|
accessible: d.accessible, reason: d.reason,
|
|
})),
|
|
...files.map(name => ({
|
|
name, type: "file", drive: data.drive,
|
|
segments: [...data.segments, name],
|
|
_loaded: true, children: null, _children: null,
|
|
_id: _nextId(), _selected: false
|
|
})),
|
|
];
|
|
data._children = null;
|
|
data._loaded = true;
|
|
if (statusEl) statusEl.textContent = "";
|
|
// Remember this folder as the last browsed location
|
|
_lastBrowsePath = { drive: data.drive, segments: [...data.segments] };
|
|
} catch {
|
|
if (statusEl) statusEl.textContent = "Error loading";
|
|
return;
|
|
}
|
|
} else {
|
|
// Toggle expand/collapse
|
|
if (data.children !== null) {
|
|
data._children = data.children;
|
|
data.children = null;
|
|
} else {
|
|
data.children = data._children || [];
|
|
data._children = null;
|
|
}
|
|
}
|
|
|
|
renderTree();
|
|
}
|
|
|
|
// --- autoExpandToPath: traverse tree data to saved path ---
|
|
async function autoExpandToPath(drive, segments, savedFile) {
|
|
if (!treeRootData) return;
|
|
let cur = (treeRootData.children || []).find(c => _normDrive(c.drive) === _normDrive(drive));
|
|
if (!cur) return;
|
|
|
|
for (let i = 0; i < segments.length; i++) {
|
|
if (!cur._loaded) {
|
|
const path = cur.segments.join("/");
|
|
try {
|
|
const resp = await fetch("/external_lora/browse", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ drive: cur.drive, path, extensions: _extParam(_extFilter) }),
|
|
});
|
|
if (!resp.ok) throw new Error(resp.statusText);
|
|
const json = await resp.json();
|
|
cur._loaded = true;
|
|
cur.children = [
|
|
...(json.dirs || []).map(d => ({
|
|
name: d.name, type: "dir", drive: cur.drive,
|
|
segments: [...cur.segments, d.name],
|
|
_loaded: false, children: null, _children: null,
|
|
_id: _nextId(), _selected: false,
|
|
accessible: d.accessible, reason: d.reason,
|
|
})),
|
|
...(json.files || []).map(name => ({
|
|
name, type: "file", drive: cur.drive,
|
|
segments: [...cur.segments, name],
|
|
_loaded: true, children: null, _children: null,
|
|
_id: _nextId(), _selected: false
|
|
})),
|
|
];
|
|
} catch {
|
|
break;
|
|
}
|
|
}
|
|
const next = (cur.children || []).find(c => c.name === segments[i]);
|
|
if (!next) break;
|
|
cur = next;
|
|
}
|
|
|
|
// Load the final folder so its files are visible and can be highlighted
|
|
if (!cur._loaded) {
|
|
const path = cur.segments.join("/");
|
|
try {
|
|
const resp = await fetch("/external_lora/browse", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ drive: cur.drive, path, extensions: _extParam(_extFilter) }),
|
|
});
|
|
if (resp.ok) {
|
|
const json = await resp.json();
|
|
cur._loaded = true;
|
|
cur.children = [
|
|
...(json.dirs || []).map(d => ({
|
|
name: d.name, type: "dir", drive: cur.drive,
|
|
segments: [...cur.segments, d.name],
|
|
_loaded: false, children: null, _children: null,
|
|
_id: _nextId(), _selected: false,
|
|
accessible: d.accessible, reason: d.reason,
|
|
})),
|
|
...(json.files || []).map(name => ({
|
|
name, type: "file", drive: cur.drive,
|
|
segments: [...cur.segments, name],
|
|
_loaded: true, children: null, _children: null,
|
|
_id: _nextId(), _selected: false,
|
|
})),
|
|
];
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
// Highlight saved file
|
|
if (savedFile && cur.children) {
|
|
const fileNode = cur.children.find(c => c.type === "file" && c.name === savedFile);
|
|
if (fileNode) {
|
|
clearFileSelection(treeRootData);
|
|
fileNode._selected = true;
|
|
browserState.drive = fileNode.drive;
|
|
browserState.pathSegments = fileNode.segments.slice(0, -1);
|
|
browserState.selectedFile = fileNode.name;
|
|
if (selectBtn) {
|
|
selectBtn.disabled = false;
|
|
selectBtn.textContent = "Select";
|
|
}
|
|
if (statusEl) statusEl.textContent = savedFile;
|
|
}
|
|
}
|
|
|
|
renderTree();
|
|
}
|
|
|
|
// --- positionModal() ---
|
|
function positionModal() {
|
|
const box = modalOverlay ? modalOverlay.querySelector(".ell-modal-box") : null;
|
|
if (_boxPositioned || !box) return;
|
|
const initW = Math.min(700, window.innerWidth * 0.88);
|
|
const initH = Math.min(520, window.innerHeight * 0.80);
|
|
box.style.width = initW + "px";
|
|
box.style.height = initH + "px";
|
|
box.style.left = Math.round((window.innerWidth - initW) / 2) + "px";
|
|
box.style.top = Math.round((window.innerHeight - initH) / 2) + "px";
|
|
_boxPositioned = true;
|
|
}
|
|
|
|
// --- makeDraggable(box, header) ---
|
|
function makeDraggable(box, header) {
|
|
header.addEventListener("pointerdown", (e) => {
|
|
if (e.target.closest(".ell-modal-close")) return;
|
|
const startX = e.clientX - box.offsetLeft;
|
|
const startY = e.clientY - box.offsetTop;
|
|
header.setPointerCapture(e.pointerId);
|
|
document.body.style.userSelect = "none";
|
|
function onMove(ev) {
|
|
const minV = 40;
|
|
let left = Math.max(minV - box.offsetWidth, Math.min(window.innerWidth - minV, ev.clientX - startX));
|
|
let top = Math.max(0, Math.min(window.innerHeight - minV, ev.clientY - startY));
|
|
box.style.left = left + "px";
|
|
box.style.top = top + "px";
|
|
}
|
|
function onUp() {
|
|
header.removeEventListener("pointermove", onMove);
|
|
header.removeEventListener("pointerup", onUp);
|
|
document.body.style.userSelect = "";
|
|
}
|
|
header.addEventListener("pointermove", onMove);
|
|
header.addEventListener("pointerup", onUp);
|
|
});
|
|
}
|
|
|
|
// --- makeResizable(box, handle) ---
|
|
function makeResizable(box, handle) {
|
|
handle.addEventListener("pointerdown", (e) => {
|
|
e.stopPropagation();
|
|
const startX = e.clientX, startY = e.clientY;
|
|
const initW = box.offsetWidth, initH = box.offsetHeight;
|
|
handle.setPointerCapture(e.pointerId);
|
|
document.body.style.userSelect = "none";
|
|
function onMove(ev) {
|
|
const newW = Math.min(window.innerWidth * 0.95, Math.max(480, initW + (ev.clientX - startX)));
|
|
const newH = Math.min(window.innerHeight * 0.92, Math.max(350, initH + (ev.clientY - startY)));
|
|
box.style.width = newW + "px";
|
|
box.style.height = newH + "px";
|
|
}
|
|
function onUp() {
|
|
handle.removeEventListener("pointermove", onMove);
|
|
handle.removeEventListener("pointerup", onUp);
|
|
document.body.style.userSelect = "";
|
|
}
|
|
handle.addEventListener("pointermove", onMove);
|
|
handle.addEventListener("pointerup", onUp);
|
|
});
|
|
}
|
|
|
|
// --- buildModal() ---
|
|
function buildModal() {
|
|
if (modalOverlay) return; // already built
|
|
|
|
modalOverlay = document.createElement("div");
|
|
modalOverlay.className = "ell-modal-overlay";
|
|
modalOverlay.style.display = "none";
|
|
|
|
const box = document.createElement("div");
|
|
box.className = "ell-modal-box";
|
|
modalBoxEl = box;
|
|
|
|
// Header
|
|
const header = document.createElement("div");
|
|
header.className = "ell-modal-header";
|
|
const title = document.createElement("span");
|
|
title.textContent = "Browse for LoRA";
|
|
const headerBtns = document.createElement("div");
|
|
headerBtns.style.cssText = "display:flex;align-items:center;gap:2px";
|
|
|
|
const refreshBtn = document.createElement("button");
|
|
refreshBtn.className = "ell-modal-close";
|
|
refreshBtn.title = "Refresh drives";
|
|
refreshBtn.textContent = "\u21BB";
|
|
refreshBtn.onclick = async () => {
|
|
refreshBtn.disabled = true;
|
|
if (statusEl) statusEl.textContent = "Refreshing\u2026";
|
|
await _initTree();
|
|
refreshBtn.disabled = false;
|
|
};
|
|
|
|
const closeBtn = document.createElement("button");
|
|
closeBtn.className = "ell-modal-close";
|
|
closeBtn.textContent = "\u2715";
|
|
closeBtn.onclick = closeModal;
|
|
|
|
headerBtns.appendChild(refreshBtn);
|
|
headerBtns.appendChild(closeBtn);
|
|
header.appendChild(title);
|
|
header.appendChild(headerBtns);
|
|
|
|
// Body — scrollable tree container
|
|
const body = document.createElement("div");
|
|
body.className = "ell-modal-body";
|
|
|
|
treeContainerEl = document.createElement("div");
|
|
treeContainerEl.className = "ell-tree-container";
|
|
|
|
body.appendChild(treeContainerEl);
|
|
|
|
// Footer
|
|
const footer = document.createElement("div");
|
|
footer.className = "ell-modal-footer";
|
|
|
|
// Extension filter select
|
|
extFilterEl = document.createElement("select");
|
|
extFilterEl.className = "ell-ext-filter";
|
|
[
|
|
{ value: "", label: "All LoRA types" },
|
|
{ value: ".safetensors", label: "Safetensors" },
|
|
{ value: ".ckpt", label: "Checkpoint" },
|
|
{ value: ".pt,.pth", label: "PyTorch" },
|
|
{ value: "*", label: "All files" },
|
|
].forEach(({ value, label }) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = value;
|
|
opt.textContent = label;
|
|
extFilterEl.appendChild(opt);
|
|
});
|
|
|
|
statusEl = document.createElement("span");
|
|
statusEl.className = "ell-status-text";
|
|
|
|
selectBtn = document.createElement("button");
|
|
selectBtn.className = "ell-select-btn";
|
|
selectBtn.textContent = "Select";
|
|
selectBtn.disabled = true;
|
|
selectBtn.onclick = commitSelection;
|
|
|
|
footer.appendChild(extFilterEl);
|
|
footer.appendChild(statusEl);
|
|
footer.appendChild(selectBtn);
|
|
|
|
// Resize handle
|
|
const resizeHandle = document.createElement("div");
|
|
resizeHandle.className = "ell-resize-handle";
|
|
|
|
box.appendChild(header);
|
|
box.appendChild(body);
|
|
box.appendChild(footer);
|
|
box.appendChild(resizeHandle);
|
|
modalOverlay.appendChild(box);
|
|
|
|
document.body.appendChild(modalOverlay);
|
|
|
|
// Wire up drag and resize
|
|
makeDraggable(box, header);
|
|
makeResizable(box, resizeHandle);
|
|
}
|
|
|
|
// --- openFileBrowser() ---
|
|
async function openFileBrowser() {
|
|
buildModal();
|
|
modalOverlay.style.display = "block";
|
|
positionModal();
|
|
|
|
if (onKeyDown) { document.removeEventListener("keydown", onKeyDown); onKeyDown = null; }
|
|
onKeyDown = (e) => {
|
|
if (e.key === "Escape") closeModal();
|
|
else if (e.key === "Enter" && browserState.selectedFile) commitSelection();
|
|
};
|
|
document.addEventListener("keydown", onKeyDown);
|
|
|
|
browserState.selectedFile = null;
|
|
if (selectBtn) {
|
|
selectBtn.disabled = true;
|
|
selectBtn.textContent = "Select";
|
|
selectBtn.classList.remove("ell-select-btn-active");
|
|
}
|
|
if (statusEl) statusEl.textContent = "Loading drives\u2026";
|
|
|
|
// Sync filter select to current per-node value
|
|
if (extFilterEl) {
|
|
extFilterEl.value = _extFilter;
|
|
// Wire change handler (safe to re-attach; handler is idempotent via replace)
|
|
extFilterEl.onchange = async () => {
|
|
_extFilter = extFilterEl.value;
|
|
_lastExtFilter = _extFilter;
|
|
// Re-init the tree so new folders load with the updated filter
|
|
treeRootData = null;
|
|
await _initTree();
|
|
};
|
|
}
|
|
|
|
await _initTree();
|
|
}
|
|
|
|
// Fetch drives, rebuild treeRootData, then navigate to the best-known path.
|
|
// Called on first open and on filter change.
|
|
async function _initTree() {
|
|
if (statusEl) statusEl.textContent = "Loading drives\u2026";
|
|
try {
|
|
const resp = await fetch("/external_lora/browse", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({}),
|
|
});
|
|
if (!resp.ok) throw new Error(resp.statusText);
|
|
const data = await resp.json();
|
|
const drives = data.dirs || [];
|
|
|
|
treeRootData = {
|
|
name: "root", type: "root", drive: "", segments: [],
|
|
_loaded: true, _id: _nextId(), _selected: false,
|
|
children: drives.map(d => ({
|
|
name: d.name, type: "drive", drive: d.name, segments: [],
|
|
_loaded: false, children: null, _children: null,
|
|
_id: _nextId(), _selected: false,
|
|
accessible: d.accessible, reason: d.reason,
|
|
}))
|
|
};
|
|
|
|
if (statusEl) statusEl.textContent = "";
|
|
renderTree();
|
|
|
|
// Navigation priority:
|
|
// 1. Last folder the user manually browsed to (within this session)
|
|
// 2. The folder of the currently-selected LoRA (from widget values)
|
|
// 3. ComfyUI's loras folder (first open, nothing saved)
|
|
const savedDrive = driveWidget.value || "";
|
|
const savedPath = subPathWidget.value || "";
|
|
const savedFile = loraNameWidget.value !== "none" ? loraNameWidget.value : null;
|
|
|
|
if (_lastBrowsePath && _lastBrowsePath.drive) {
|
|
await autoExpandToPath(_lastBrowsePath.drive, _lastBrowsePath.segments, savedFile);
|
|
} else if (savedDrive && (savedFile || savedPath)) {
|
|
// Navigate to the saved location even when no file is selected yet
|
|
const segs = savedPath ? savedPath.split(/[/\\]/).filter(Boolean) : [];
|
|
await autoExpandToPath(savedDrive, segs, savedFile);
|
|
} else {
|
|
const lf = await _fetchLorasFolder();
|
|
if (lf && lf.drive) {
|
|
const segs = lf.sub_path ? lf.sub_path.split("/").filter(Boolean) : [];
|
|
await autoExpandToPath(lf.drive, segs, null);
|
|
}
|
|
}
|
|
} catch {
|
|
if (statusEl) statusEl.textContent = "Failed to load drives";
|
|
}
|
|
}
|
|
|
|
function closeModal() {
|
|
if (modalOverlay) modalOverlay.style.display = "none";
|
|
hideMetaPopup();
|
|
// Remove Escape key handler to avoid memory leak
|
|
if (onKeyDown) {
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
onKeyDown = null;
|
|
}
|
|
}
|
|
|
|
function commitSelection() {
|
|
if (!browserState.selectedFile) return;
|
|
const drive = browserState.drive;
|
|
const subPath = browserState.pathSegments.join("/");
|
|
const loraName = browserState.selectedFile;
|
|
|
|
// Remember this location for the next modal open
|
|
_lastBrowsePath = { drive, segments: [...browserState.pathSegments] };
|
|
|
|
// Write back to hidden widgets
|
|
driveWidget.value = drive;
|
|
subPathWidget.value = subPath;
|
|
loraNameWidget.value = loraName;
|
|
|
|
// Ensure selected file is in options so refreshFiles preserves it even on transient error
|
|
if (!loraNameWidget.options.values || !loraNameWidget.options.values.includes(loraName)) {
|
|
loraNameWidget.options.values = ["none", loraName];
|
|
}
|
|
|
|
// Update visible path label
|
|
const displayPath = subPath
|
|
? drive + subPath + "/" + loraName
|
|
: drive + loraName;
|
|
pathLabelWidget.value = displayPath;
|
|
|
|
closeModal();
|
|
|
|
// Sync lora_name dropdown options so ComfyUI serializes it correctly
|
|
refreshFiles();
|
|
node.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
// --- Attach listeners ---
|
|
driveWidget.callback = () => scheduleRefresh();
|
|
|
|
const origSubPathCallback = subPathWidget.callback;
|
|
subPathWidget.callback = (...args) => {
|
|
if (origSubPathCallback) origSubPathCallback(...args);
|
|
scheduleRefresh();
|
|
};
|
|
|
|
// --- Clear Cache button ---
|
|
const clearCacheBtn = node.addWidget("button", "Clear Cache", null, async () => {
|
|
try {
|
|
const resp = await fetch("/external_lora/clear_cache", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
const mb = Number(data.freed_mb || 0);
|
|
const label = mb >= 1024
|
|
? `Freed ${(mb / 1024).toFixed(1)} GB`
|
|
: `Freed ${mb.toFixed(1)} MB`;
|
|
showTempLabel(clearCacheBtn, label, 2000);
|
|
refreshCacheStats(); // update stats line after clearing
|
|
} catch (err) {
|
|
console.error("[ExternalLoraLoader] Clear cache failed:", err);
|
|
showTempLabel(clearCacheBtn, "Cache clear failed", 2000);
|
|
}
|
|
});
|
|
|
|
// --- Cache stats display (sits below Clear Cache button) ---
|
|
const cacheStatsWidget = node.addWidget("text", "cache_stats", "\u2014", () => {});
|
|
setTimeout(() => {
|
|
if (cacheStatsWidget.inputEl) {
|
|
cacheStatsWidget.inputEl.readOnly = true;
|
|
cacheStatsWidget.inputEl.style.color = "#888";
|
|
cacheStatsWidget.inputEl.style.fontSize = "11px";
|
|
cacheStatsWidget.inputEl.style.textAlign = "center";
|
|
cacheStatsWidget.inputEl.style.cursor = "default";
|
|
cacheStatsWidget.inputEl.style.userSelect = "none";
|
|
cacheStatsWidget.inputEl.style.pointerEvents = "none";
|
|
}
|
|
}, 0);
|
|
|
|
const maxCacheMbWidget = node.widgets.find(w => w.name === "max_cache_mb");
|
|
|
|
async function refreshCacheStats() {
|
|
try {
|
|
const resp = await fetch("/external_lora/cache_stats");
|
|
if (!resp.ok) return;
|
|
const d = await resp.json();
|
|
const maxMb = maxCacheMbWidget ? maxCacheMbWidget.value : d.max_mb;
|
|
const availMb = maxMb - d.used_mb;
|
|
const fmtUsed = mb => mb >= 1024
|
|
? `${(mb / 1024).toFixed(1)} GB`
|
|
: `${Number(mb).toFixed(0)} MB`;
|
|
cacheStatsWidget.value =
|
|
`${fmtUsed(d.used_mb)} used \u00b7 ${Number(availMb).toFixed(0)} MB avail`;
|
|
node.setDirtyCanvas(true, true);
|
|
} catch {}
|
|
}
|
|
|
|
if (maxCacheMbWidget) {
|
|
const _origMaxCacheCallback = maxCacheMbWidget.callback;
|
|
maxCacheMbWidget.callback = (...args) => {
|
|
if (_origMaxCacheCallback) _origMaxCacheCallback(...args);
|
|
refreshCacheStats();
|
|
};
|
|
}
|
|
|
|
refreshCacheStats();
|
|
|
|
// Poll cache stats every 10 s so the display stays current as LoRAs are loaded
|
|
const _statsInterval = setInterval(refreshCacheStats, 10_000);
|
|
const _origOnRemoved = node.onRemoved;
|
|
node.onRemoved = function() {
|
|
clearInterval(_statsInterval);
|
|
if (_origOnRemoved) _origOnRemoved.call(this);
|
|
};
|
|
|
|
// --- Widget order splice ---
|
|
// Final canvas order: drive(hidden), sub_path(hidden), selected_path, Browse…, lora_name, …, Clear Cache, cache_stats
|
|
{
|
|
const _loraIdx = node.widgets.indexOf(loraNameWidget);
|
|
const _btnIdx = node.widgets.indexOf(browseBtn);
|
|
const _pathIdx = node.widgets.indexOf(pathLabelWidget);
|
|
node.widgets.splice(_btnIdx, 1);
|
|
node.widgets.splice(_pathIdx, 1);
|
|
node.widgets.splice(_loraIdx, 0, pathLabelWidget);
|
|
node.widgets.splice(_loraIdx + 1, 0, browseBtn);
|
|
node.setSize(node.computeSize());
|
|
}
|
|
|
|
// Restore path label from persisted widget values on workflow load
|
|
setTimeout(() => {
|
|
const d = driveWidget.value || "";
|
|
const p = subPathWidget.value || "";
|
|
const f = loraNameWidget.value || "none";
|
|
if (d && f !== "none") {
|
|
const displayPath = p ? d + p + "/" + f : d + f;
|
|
pathLabelWidget.value = displayPath;
|
|
browserState.drive = d;
|
|
browserState.pathSegments = p ? p.split(/[/\\]/).filter(Boolean) : [];
|
|
browserState.selectedFile = f;
|
|
}
|
|
}, 0);
|
|
|
|
// --- Initial load ---
|
|
scheduleRefresh();
|
|
}
|
|
});
|