feat: add in-app LM Remote configuration
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
import {
|
||||
buildConfigDraft,
|
||||
buildConnectionDraft,
|
||||
didEffectiveRemoteUrlChange,
|
||||
isConfigWritable,
|
||||
mappingsToRows,
|
||||
} from "./remote_config_utils.js";
|
||||
|
||||
// Explicit /api paths work with both current and legacy ComfyUI apiURL helpers.
|
||||
const CONFIG_ENDPOINT = "/api/lm-remote/config";
|
||||
const TEST_ENDPOINT = "/api/lm-remote/test-connection";
|
||||
|
||||
let activeDialog = null;
|
||||
let pageEffectiveRemoteUrl;
|
||||
|
||||
function element(tag, className, text) {
|
||||
const value = document.createElement(tag);
|
||||
if (className) value.className = className;
|
||||
if (text != null) value.textContent = String(text);
|
||||
return value;
|
||||
}
|
||||
|
||||
function labeledInput(labelText, input) {
|
||||
const label = element("label", "lmrc-field");
|
||||
label.append(element("span", "lmrc-label", labelText), input);
|
||||
return label;
|
||||
}
|
||||
|
||||
function parseError(response, payload) {
|
||||
const error = new Error(
|
||||
payload?.error || `Request failed with HTTP ${response.status}.`
|
||||
);
|
||||
error.field = payload?.field || "";
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function requestJson(path, options = {}) {
|
||||
const response = await api.fetchApi(path, options);
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
// The HTTP status below still gives the user a useful failure.
|
||||
}
|
||||
if (!response.ok || payload?.success === false) {
|
||||
throw parseError(response, payload);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function trapFocus(event, panel) {
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = Array.from(
|
||||
panel.querySelectorAll(
|
||||
'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
).filter((item) => item.offsetParent !== null);
|
||||
if (!focusable.length) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export function openRemoteConfigDialog({ onSaved } = {}) {
|
||||
if (activeDialog) {
|
||||
activeDialog.panel.focus();
|
||||
return activeDialog;
|
||||
}
|
||||
|
||||
const opener = document.activeElement;
|
||||
|
||||
const overlay = element("div", "lmrc-overlay");
|
||||
overlay.setAttribute("role", "dialog");
|
||||
overlay.setAttribute("aria-modal", "true");
|
||||
overlay.setAttribute("aria-labelledby", "lmrc-dialog-title");
|
||||
const panel = element("section", "lmrc-dialog");
|
||||
panel.tabIndex = -1;
|
||||
overlay.appendChild(panel);
|
||||
|
||||
const header = element("header", "lmrc-header");
|
||||
const heading = element("div");
|
||||
const title = element("h2", "", "Configure LM Remote");
|
||||
title.id = "lmrc-dialog-title";
|
||||
heading.append(
|
||||
title,
|
||||
element(
|
||||
"p",
|
||||
"",
|
||||
"Connect this ComfyUI instance to LoRA Manager. New requests use saved changes immediately."
|
||||
)
|
||||
);
|
||||
const closeButton = element("button", "lmrc-icon-button");
|
||||
closeButton.type = "button";
|
||||
closeButton.title = "Close";
|
||||
closeButton.setAttribute("aria-label", "Close configuration");
|
||||
closeButton.appendChild(element("i", "pi pi-times"));
|
||||
header.append(heading, closeButton);
|
||||
|
||||
const form = element("form", "lmrc-form");
|
||||
form.noValidate = true;
|
||||
const connectionSection = element("section", "lmrc-section");
|
||||
connectionSection.appendChild(element("h3", "", "Connection"));
|
||||
const connectionGrid = element("div", "lmrc-connection-grid");
|
||||
|
||||
const remoteUrlInput = element("input", "lmrc-input");
|
||||
remoteUrlInput.type = "url";
|
||||
remoteUrlInput.placeholder = "http://manager.local:8188";
|
||||
remoteUrlInput.autocomplete = "url";
|
||||
remoteUrlInput.spellcheck = false;
|
||||
const remoteField = labeledInput("Remote URL", remoteUrlInput);
|
||||
const remoteHint = element("small", "lmrc-hint");
|
||||
remoteField.appendChild(remoteHint);
|
||||
|
||||
const timeoutInput = element("input", "lmrc-input");
|
||||
timeoutInput.type = "number";
|
||||
timeoutInput.min = "1";
|
||||
timeoutInput.max = "300";
|
||||
timeoutInput.step = "1";
|
||||
const timeoutField = labeledInput("Timeout (seconds)", timeoutInput);
|
||||
const timeoutHint = element("small", "lmrc-hint");
|
||||
timeoutField.appendChild(timeoutHint);
|
||||
connectionGrid.append(remoteField, timeoutField);
|
||||
connectionSection.appendChild(connectionGrid);
|
||||
|
||||
const mappingSection = element("section", "lmrc-section");
|
||||
const mappingHeading = element("div", "lmrc-section-heading");
|
||||
const mappingCopy = element("div");
|
||||
mappingCopy.append(
|
||||
element("h3", "", "Path mappings"),
|
||||
element(
|
||||
"p",
|
||||
"",
|
||||
"Optional remote-to-local prefixes when both machines mount models at different paths."
|
||||
)
|
||||
);
|
||||
const addMappingButton = element("button", "lmrc-button lmrc-secondary", "Add mapping");
|
||||
addMappingButton.type = "button";
|
||||
mappingHeading.append(mappingCopy, addMappingButton);
|
||||
const mappingRows = element("div", "lmrc-mapping-rows");
|
||||
mappingSection.append(mappingHeading, mappingRows);
|
||||
|
||||
const notices = element("div", "lmrc-notices");
|
||||
const status = element("div", "lmrc-status");
|
||||
status.setAttribute("role", "status");
|
||||
status.setAttribute("aria-live", "polite");
|
||||
|
||||
const footer = element("footer", "lmrc-footer");
|
||||
const leftActions = element("div", "lmrc-footer-group");
|
||||
const testButton = element("button", "lmrc-button lmrc-secondary", "Test connection");
|
||||
testButton.type = "button";
|
||||
const reloadButton = element("button", "lmrc-button lmrc-secondary", "Reload saved");
|
||||
reloadButton.type = "button";
|
||||
leftActions.append(testButton, reloadButton);
|
||||
const rightActions = element("div", "lmrc-footer-group");
|
||||
const cancelButton = element("button", "lmrc-button lmrc-secondary", "Close");
|
||||
cancelButton.type = "button";
|
||||
const saveButton = element("button", "lmrc-button lmrc-primary", "Save");
|
||||
saveButton.type = "submit";
|
||||
rightActions.append(cancelButton, saveButton);
|
||||
footer.append(leftActions, rightActions);
|
||||
|
||||
form.append(connectionSection, mappingSection, notices, status, footer);
|
||||
panel.append(header, form);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
let loaded = null;
|
||||
let busy = false;
|
||||
let formLocked = false;
|
||||
let dismissLocked = false;
|
||||
|
||||
function updateDisabledState() {
|
||||
const overrides = loaded?.overrides || {};
|
||||
remoteUrlInput.disabled = formLocked || Boolean(overrides.remote_url);
|
||||
timeoutInput.disabled = formLocked || Boolean(overrides.timeout);
|
||||
for (const control of mappingRows.querySelectorAll("input, button")) {
|
||||
control.disabled = formLocked;
|
||||
}
|
||||
closeButton.disabled = dismissLocked;
|
||||
cancelButton.disabled = dismissLocked;
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (dismissLocked) return;
|
||||
if (activeDialog?.overlay !== overlay) return;
|
||||
overlay.remove();
|
||||
activeDialog = null;
|
||||
if (opener?.isConnected && typeof opener.focus === "function") {
|
||||
try {
|
||||
opener.focus({ preventScroll: true });
|
||||
} catch {
|
||||
opener.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(message = "", kind = "", action = null) {
|
||||
status.replaceChildren();
|
||||
if (message) status.appendChild(element("span", "", message));
|
||||
if (action) {
|
||||
const actionButton = element(
|
||||
"button",
|
||||
"lmrc-button lmrc-secondary lmrc-status-action",
|
||||
action.label
|
||||
);
|
||||
actionButton.type = "button";
|
||||
actionButton.addEventListener("click", action.onClick);
|
||||
status.appendChild(actionButton);
|
||||
}
|
||||
status.className = `lmrc-status${kind ? ` ${kind}` : ""}`;
|
||||
}
|
||||
|
||||
function setBusy(
|
||||
value,
|
||||
message = "",
|
||||
{ lockForm = false, lockDismiss = false } = {}
|
||||
) {
|
||||
busy = value;
|
||||
formLocked = Boolean(value && lockForm);
|
||||
dismissLocked = Boolean(value && lockDismiss);
|
||||
testButton.disabled = value || !loaded;
|
||||
reloadButton.disabled = value;
|
||||
saveButton.disabled = value || !loaded || !isConfigWritable(loaded);
|
||||
addMappingButton.disabled = value || !loaded;
|
||||
form.setAttribute("aria-busy", String(value));
|
||||
updateDisabledState();
|
||||
if (message) setStatus(message, "busy");
|
||||
}
|
||||
|
||||
function addMappingRow(remote = "", local = "") {
|
||||
const row = element("div", "lmrc-mapping-row");
|
||||
const remoteInput = element("input", "lmrc-input");
|
||||
remoteInput.type = "text";
|
||||
remoteInput.placeholder = "/data/models/loras";
|
||||
remoteInput.value = remote;
|
||||
remoteInput.setAttribute("aria-label", "Remote path prefix");
|
||||
const arrow = element("i", "pi pi-arrow-right lmrc-mapping-arrow");
|
||||
arrow.setAttribute("aria-hidden", "true");
|
||||
const localInput = element("input", "lmrc-input");
|
||||
localInput.type = "text";
|
||||
localInput.placeholder = "/mnt/nas/models/loras";
|
||||
localInput.value = local;
|
||||
localInput.setAttribute("aria-label", "Local path prefix");
|
||||
const remove = element("button", "lmrc-icon-button");
|
||||
remove.type = "button";
|
||||
remove.title = "Remove path mapping";
|
||||
remove.setAttribute("aria-label", remove.title);
|
||||
remove.appendChild(element("i", "pi pi-trash"));
|
||||
remove.addEventListener("click", () => row.remove());
|
||||
row.append(remoteInput, arrow, localInput, remove);
|
||||
mappingRows.appendChild(row);
|
||||
updateDisabledState();
|
||||
return row;
|
||||
}
|
||||
|
||||
function readMappingRows() {
|
||||
return Array.from(mappingRows.querySelectorAll(".lmrc-mapping-row")).map(
|
||||
(row) => {
|
||||
const inputs = row.querySelectorAll("input");
|
||||
return { remote: inputs[0]?.value || "", local: inputs[1]?.value || "" };
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function renderNotices(payload) {
|
||||
notices.replaceChildren();
|
||||
const source = payload.storage?.source;
|
||||
if (source === "legacy") {
|
||||
notices.appendChild(
|
||||
element(
|
||||
"div",
|
||||
"lmrc-notice",
|
||||
"Loaded the package config. Saving migrates it to ComfyUI user data."
|
||||
)
|
||||
);
|
||||
} else if (source === "user") {
|
||||
notices.appendChild(
|
||||
element("div", "lmrc-notice", "Stored in ComfyUI user data.")
|
||||
);
|
||||
} else if (source === "explicit") {
|
||||
notices.appendChild(
|
||||
element(
|
||||
"div",
|
||||
"lmrc-notice",
|
||||
"Stored in the file selected by LM_REMOTE_CONFIG."
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!isConfigWritable(payload)) {
|
||||
notices.appendChild(
|
||||
element(
|
||||
"div",
|
||||
"lmrc-notice warning",
|
||||
"Configuration storage is not writable. Fix its file or directory permissions, then use Reload saved to check again."
|
||||
)
|
||||
);
|
||||
}
|
||||
for (const warning of payload.warnings || []) {
|
||||
notices.appendChild(element("div", "lmrc-notice warning", warning));
|
||||
}
|
||||
}
|
||||
|
||||
function applyPayload(payload) {
|
||||
loaded = payload;
|
||||
const configured = payload.configured || {};
|
||||
const effective = payload.effective || configured;
|
||||
const overrides = payload.overrides || {};
|
||||
remoteUrlInput.value = configured.remote_url || "";
|
||||
timeoutInput.value = String(configured.timeout ?? 30);
|
||||
remoteHint.textContent = overrides.remote_url
|
||||
? `Managed by ${overrides.remote_url}. Effective: ${effective.remote_url}`
|
||||
: "The base URL of the standalone LoRA Manager.";
|
||||
timeoutHint.textContent = overrides.timeout
|
||||
? `Managed by ${overrides.timeout}. Effective: ${effective.timeout} seconds.`
|
||||
: "Used for Manager API and proxy requests.";
|
||||
mappingRows.replaceChildren();
|
||||
const rows = mappingsToRows(configured.path_mappings);
|
||||
rows.forEach((row) => addMappingRow(row.remote, row.local));
|
||||
renderNotices(payload);
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
setBusy(true, "Loading configuration…", { lockForm: true });
|
||||
try {
|
||||
const payload = await requestJson(CONFIG_ENDPOINT);
|
||||
if (pageEffectiveRemoteUrl === undefined) {
|
||||
pageEffectiveRemoteUrl = String(payload?.effective?.remote_url || "");
|
||||
}
|
||||
applyPayload(payload);
|
||||
setStatus("");
|
||||
(form.querySelector("input:not([disabled])") || panel).focus();
|
||||
} catch (error) {
|
||||
loaded = null;
|
||||
setBusy(false);
|
||||
setStatus(error.message || "Could not load LM Remote configuration.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function showFormError(error) {
|
||||
setStatus(error.message, "error");
|
||||
const target =
|
||||
error.field === "remote_url"
|
||||
? remoteUrlInput
|
||||
: error.field === "timeout"
|
||||
? timeoutInput
|
||||
: null;
|
||||
if (target) {
|
||||
queueMicrotask(() => {
|
||||
if (target.isConnected && !target.disabled) target.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addMappingButton.addEventListener("click", () => {
|
||||
addMappingRow().querySelector("input")?.focus();
|
||||
});
|
||||
reloadButton.addEventListener("click", () => {
|
||||
if (!busy) load();
|
||||
});
|
||||
testButton.addEventListener("click", async () => {
|
||||
if (busy || !loaded) return;
|
||||
try {
|
||||
const draft = buildConnectionDraft({
|
||||
remoteUrl: remoteUrlInput.value,
|
||||
timeout: timeoutInput.value,
|
||||
effective: loaded.effective,
|
||||
overrides: loaded.overrides,
|
||||
});
|
||||
setBusy(true, "Testing connection…", { lockForm: true });
|
||||
const payload = await requestJson(TEST_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(draft),
|
||||
});
|
||||
setStatus(`Connected in ${payload.latency_ms} ms.`, "success");
|
||||
} catch (error) {
|
||||
showFormError(error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (busy || !loaded || !isConfigWritable(loaded)) return;
|
||||
try {
|
||||
const config = buildConfigDraft({
|
||||
remoteUrl: remoteUrlInput.value,
|
||||
timeout: timeoutInput.value,
|
||||
mappingRows: readMappingRows(),
|
||||
});
|
||||
setBusy(true, "Saving configuration…", {
|
||||
lockForm: true,
|
||||
lockDismiss: true,
|
||||
});
|
||||
const payload = await requestJson(CONFIG_ENDPOINT, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ revision: loaded.revision, config }),
|
||||
});
|
||||
applyPayload(payload);
|
||||
if (
|
||||
didEffectiveRemoteUrlChange(
|
||||
{ effective: { remote_url: pageEffectiveRemoteUrl } },
|
||||
payload
|
||||
)
|
||||
) {
|
||||
setStatus(
|
||||
"Saved. Reload the ComfyUI page to load Manager assets and reconnect live updates. Save workflow changes first.",
|
||||
"success",
|
||||
{
|
||||
label: "Reload ComfyUI page",
|
||||
onClick: () => window.location.reload(),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setStatus(
|
||||
"Saved and applied to new requests. No ComfyUI restart required.",
|
||||
"success"
|
||||
);
|
||||
}
|
||||
onSaved?.(payload);
|
||||
} catch (error) {
|
||||
if (error.status === 409 && error.payload?.latest) {
|
||||
setStatus(`${error.message} Use Reload saved to continue.`, "error");
|
||||
} else {
|
||||
showFormError(error);
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
closeButton.addEventListener("click", close);
|
||||
cancelButton.addEventListener("click", close);
|
||||
overlay.addEventListener("mousedown", (event) => {
|
||||
if (event.target === overlay) close();
|
||||
});
|
||||
overlay.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
} else {
|
||||
trapFocus(event, panel);
|
||||
}
|
||||
});
|
||||
|
||||
activeDialog = { overlay, panel, close };
|
||||
panel.focus();
|
||||
load();
|
||||
return activeDialog;
|
||||
}
|
||||
Reference in New Issue
Block a user