feat: add in-app LM Remote configuration

This commit is contained in:
ethanfel
2026-08-24 22:26:28 +02:00
parent de13f8dd8d
commit 5bb3b3a349
15 changed files with 3336 additions and 195 deletions
+461
View File
@@ -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;
}