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
+321
View File
@@ -58,6 +58,17 @@
white-space: nowrap;
}
.lmri-header-actions,
.lmri-inline-actions {
display: flex;
align-items: center;
gap: 6px;
}
.lmri-inline-actions {
margin-top: 10px;
}
.lmri-icon-button,
.lmri-name,
.lmri-candidate,
@@ -347,3 +358,313 @@
grid-template-columns: 1fr;
}
}
.lmrc-overlay {
--lmrc-bg: var(--comfy-menu-bg, #18181b);
--lmrc-panel: var(--comfy-input-bg, #242428);
--lmrc-border: var(--border-color, #3a3a40);
--lmrc-text: var(--fg-color, #f4f4f5);
--lmrc-muted: var(--descrip-text, #a1a1aa);
--lmrc-accent: var(--primary-color, #7c3aed);
position: fixed;
z-index: 12000;
inset: 0;
display: grid;
padding: 20px;
overflow-y: auto;
place-items: center;
background: rgb(0 0 0 / 66%);
color: var(--lmrc-text);
font: 13px/1.45 Inter, system-ui, sans-serif;
backdrop-filter: blur(3px);
}
.lmrc-overlay *,
.lmrc-overlay *::before,
.lmrc-overlay *::after {
box-sizing: border-box;
}
.lmrc-dialog {
display: flex;
width: min(720px, calc(100vw - 32px));
max-height: min(820px, calc(100vh - 40px));
flex-direction: column;
overflow: hidden;
border: 1px solid var(--lmrc-border);
border-radius: 14px;
outline: none;
background: var(--lmrc-bg);
box-shadow: 0 24px 80px rgb(0 0 0 / 55%);
}
.lmrc-header {
display: flex;
flex: 0 0 auto;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 18px 20px 15px;
border-bottom: 1px solid var(--lmrc-border);
}
.lmrc-header h2,
.lmrc-section h3 {
margin: 0;
color: var(--lmrc-text);
}
.lmrc-header h2 {
font-size: 18px;
}
.lmrc-header p,
.lmrc-section-heading p {
margin: 4px 0 0;
color: var(--lmrc-muted);
}
.lmrc-form {
min-height: 0;
flex: 1 1 auto;
padding: 16px 20px 18px;
overflow-y: auto;
}
.lmrc-section {
margin-bottom: 18px;
}
.lmrc-section h3 {
font-size: 13px;
font-weight: 650;
}
.lmrc-connection-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(130px, 0.7fr);
gap: 12px;
margin-top: 9px;
}
.lmrc-field {
display: flex;
min-width: 0;
flex-direction: column;
gap: 5px;
}
.lmrc-label {
color: var(--lmrc-muted);
font-size: 11px;
font-weight: 600;
}
.lmrc-input {
width: 100%;
min-width: 0;
height: 36px;
padding: 7px 9px;
border: 1px solid var(--lmrc-border);
border-radius: 7px;
outline: none;
background: var(--lmrc-panel);
color: var(--lmrc-text);
font: inherit;
}
.lmrc-input:focus {
border-color: color-mix(in srgb, var(--lmrc-accent) 78%, white 8%);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--lmrc-accent) 22%, transparent);
}
.lmrc-input:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.lmrc-hint {
min-height: 16px;
color: var(--lmrc-muted);
font-size: 10px;
overflow-wrap: anywhere;
}
.lmrc-section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.lmrc-section-heading p {
max-width: 500px;
font-size: 11px;
}
.lmrc-mapping-rows {
display: flex;
flex-direction: column;
gap: 7px;
margin-top: 10px;
}
.lmrc-mapping-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 20px minmax(0, 1fr) 32px;
align-items: center;
gap: 7px;
}
.lmrc-mapping-arrow {
color: var(--lmrc-muted);
text-align: center;
}
.lmrc-icon-button,
.lmrc-button,
.lmrc-settings-button {
border: 1px solid var(--lmrc-border, var(--border-color, #3a3a40));
border-radius: 7px;
background: var(--lmrc-panel, var(--comfy-input-bg, #242428));
color: var(--lmrc-text, var(--fg-color, #f4f4f5));
font: inherit;
cursor: pointer;
}
.lmrc-icon-button {
display: grid;
flex: 0 0 32px;
width: 32px;
height: 32px;
padding: 0;
place-items: center;
}
.lmrc-button,
.lmrc-settings-button {
min-height: 34px;
padding: 7px 11px;
}
.lmrc-settings-button {
width: 100%;
text-align: left;
}
.lmrc-icon-button:hover,
.lmrc-button:hover:not(:disabled),
.lmrc-settings-button:hover {
border-color: color-mix(in srgb, var(--lmrc-accent, #7c3aed) 75%, var(--lmrc-border, #3a3a40));
}
.lmrc-button:disabled,
.lmrc-icon-button:disabled {
cursor: default;
opacity: 0.5;
}
.lmrc-primary {
border-color: color-mix(in srgb, var(--lmrc-accent) 72%, white 7%);
background: color-mix(in srgb, var(--lmrc-accent) 54%, var(--lmrc-panel));
}
.lmrc-notices {
display: flex;
flex-direction: column;
gap: 6px;
}
.lmrc-notice,
.lmrc-status {
padding: 8px 10px;
border: 1px solid var(--lmrc-border);
border-radius: 7px;
background: color-mix(in srgb, var(--lmrc-panel) 72%, transparent);
color: var(--lmrc-muted);
font-size: 11px;
}
.lmrc-notice.warning,
.lmrc-status.error {
border-color: #7f1d1d;
color: #fecaca;
}
.lmrc-status {
min-height: 35px;
margin-top: 10px;
}
.lmrc-status-action {
margin-left: 10px;
}
.lmrc-status:empty {
display: none;
}
.lmrc-status.success {
border-color: #166534;
color: #bbf7d0;
}
.lmrc-status.busy {
color: var(--lmrc-text);
}
.lmrc-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid var(--lmrc-border);
}
.lmrc-footer-group {
display: flex;
gap: 7px;
}
@media (max-width: 620px) {
.lmrc-overlay {
padding: 8px;
place-items: start center;
}
.lmrc-dialog {
width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
}
.lmrc-connection-grid,
.lmrc-mapping-row {
grid-template-columns: 1fr;
}
.lmrc-mapping-arrow {
transform: rotate(90deg);
}
.lmrc-mapping-row .lmrc-icon-button {
justify-self: end;
}
.lmrc-footer {
align-items: stretch;
flex-direction: column;
}
.lmrc-footer-group {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.lmrc-status-action {
display: block;
width: 100%;
margin: 8px 0 0;
}
}
+56 -4
View File
@@ -11,9 +11,12 @@ import {
normalizeLoraIdentifier,
normalizeUsageTips,
} from "./lora_manager_sidebar_utils.js";
import { openRemoteConfigDialog } from "./remote_config_dialog.js";
const TAB_ID = "lm-remote-lora-info";
const COMMAND_ID = "LMRemote.OpenLoraInfo";
const CONFIG_COMMAND_ID = "LMRemote.Configure";
const CONFIG_SETTING_ID = "LMRemote.Connection.Configure";
const AUTO_OPEN_SETTING = "LMRemote.LoraInfo.AutoOpen";
const STYLE_ID = "lm-remote-lora-info-style";
const NODE_SELECTION_HOOK = Symbol.for("lmRemote.loraInfo.nodeSelectionHook");
@@ -45,6 +48,26 @@ function ensureStyles() {
document.head.appendChild(link);
}
function openConfiguration() {
ensureStyles();
openRemoteConfigDialog({
onSaved: () => {
if (activeName) lookupActiveName();
},
});
}
function configureSettingControl() {
const button = createElement(
"button",
"lmrc-settings-button",
"Configure LM Remote…"
);
button.type = "button";
button.addEventListener("click", openConfiguration);
return button;
}
function selectedNodeLabel() {
if (!selectedNode) return "";
return (
@@ -197,7 +220,12 @@ function renderError(content) {
const retry = createElement("button", "lmri-button", "Try again");
retry.type = "button";
retry.addEventListener("click", () => lookupActiveName());
panel.appendChild(retry);
const configure = createElement("button", "lmri-button", "Configure");
configure.type = "button";
configure.addEventListener("click", openConfiguration);
const actions = createElement("div", "lmri-inline-actions");
actions.append(retry, configure);
panel.appendChild(actions);
appendSearchActions(panel, activeName);
content.appendChild(panel);
}
@@ -361,7 +389,15 @@ function renderSidebar() {
refresh.appendChild(createElement("i", "pi pi-refresh"));
refresh.disabled = !activeName || lookupState.status === "loading";
refresh.addEventListener("click", () => lookupActiveName());
header.append(heading, refresh);
const configure = createElement("button", "lmri-icon-button");
configure.type = "button";
configure.title = "Configure LM Remote";
configure.setAttribute("aria-label", configure.title);
configure.appendChild(createElement("i", "pi pi-cog"));
configure.addEventListener("click", openConfiguration);
const headerActions = createElement("div", "lmri-header-actions");
headerActions.append(configure, refresh);
header.append(heading, headerActions);
sidebarRoot.appendChild(header);
if (selectedNames.length > 1) {
@@ -424,7 +460,9 @@ async function fallbackListLookup(name, signal) {
search: term,
fuzzy_search: "true",
});
const response = await api.fetchApi(`/lm/loras/list?${params}`, { signal });
const response = await api.fetchApi(`/api/lm/loras/list?${params}`, {
signal,
});
if (!response.ok) throw new Error(await responseError(response));
const payload = await response.json();
const result = matchModelItems(name, payload.items);
@@ -437,7 +475,7 @@ async function fallbackListLookup(name, signal) {
async function resolveManagerCard(name, signal) {
const response = await api.fetchApi(
`/lm/loras/resolve?name=${encodeURIComponent(name)}`,
`/api/lm/loras/resolve?name=${encodeURIComponent(name)}`,
{ signal }
);
if (response.status === 404) {
@@ -657,6 +695,14 @@ function registerSidebarTab() {
app.registerExtension({
name: "LoraManager.RemoteLoraInfoSidebar",
settings: [
{
id: CONFIG_SETTING_ID,
name: "Remote LoRA Manager",
type: configureSettingControl,
defaultValue: "",
category: ["LM Remote", "Connection", "Configure"],
tooltip: "Set the remote URL, timeout, and path mappings.",
},
{
id: AUTO_OPEN_SETTING,
name: "Open LoRA Info when selecting a LoRA loader",
@@ -666,6 +712,12 @@ app.registerExtension({
},
],
commands: [
{
id: CONFIG_COMMAND_ID,
label: "Configure LM Remote",
icon: "pi pi-cog",
function: openConfiguration,
},
{
id: COMMAND_ID,
label: "Open LoRA Info",
+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;
}
+132
View File
@@ -0,0 +1,132 @@
export class RemoteConfigFormError extends Error {
constructor(field, message) {
super(message);
this.name = "RemoteConfigFormError";
this.field = field;
}
}
function normalizeTimeout(value) {
const text = String(value ?? "").trim();
if (!/^\d+$/.test(text)) {
throw new RemoteConfigFormError("timeout", "Timeout must be a whole number.");
}
const timeout = Number(text);
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 300) {
throw new RemoteConfigFormError(
"timeout",
"Timeout must be between 1 and 300 seconds."
);
}
return timeout;
}
function normalizeRemoteUrl(value, allowEmpty) {
const remoteUrl = String(value ?? "").trim();
if (!remoteUrl) {
if (allowEmpty) return "";
throw new RemoteConfigFormError(
"remote_url",
"Enter a remote LoRA Manager URL."
);
}
let parsed;
try {
parsed = new URL(remoteUrl);
} catch {
throw new RemoteConfigFormError("remote_url", "Enter a valid remote URL.");
}
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new RemoteConfigFormError(
"remote_url",
"Remote URL must use http:// or https://."
);
}
if (!parsed.hostname) {
throw new RemoteConfigFormError(
"remote_url",
"Remote URL must include a host name."
);
}
if (parsed.username || parsed.password) {
throw new RemoteConfigFormError(
"remote_url",
"Credentials are not allowed in the remote URL."
);
}
if (parsed.search || parsed.hash) {
throw new RemoteConfigFormError(
"remote_url",
"Remote URL cannot contain a query or fragment."
);
}
return remoteUrl.replace(/\/+$/, "");
}
function buildMappings(rows) {
const pathMappings = new Map();
for (const row of rows || []) {
const remote = String(row?.remote ?? "").trim();
const local = String(row?.local ?? "").trim();
if (!remote && !local) continue;
if (!remote || !local) {
throw new RemoteConfigFormError(
"path_mappings",
"Each path mapping needs both a remote and local path."
);
}
const normalizedRemote = remote.replace(/\\/g, "/").replace(/\/+$/, "") || "/";
if (pathMappings.has(normalizedRemote)) {
throw new RemoteConfigFormError(
"path_mappings",
`Duplicate remote path prefix: ${normalizedRemote}`
);
}
pathMappings.set(normalizedRemote, local);
}
return Object.fromEntries(pathMappings);
}
export function mappingsToRows(pathMappings) {
if (!pathMappings || typeof pathMappings !== "object") return [];
return Object.entries(pathMappings).map(([remote, local]) => ({
remote: String(remote),
local: String(local),
}));
}
export function buildConfigDraft({ remoteUrl, timeout, mappingRows }) {
return {
remote_url: normalizeRemoteUrl(remoteUrl, true),
timeout: normalizeTimeout(timeout),
path_mappings: buildMappings(mappingRows),
};
}
export function buildConnectionDraft({
remoteUrl,
timeout,
effective,
overrides,
}) {
return {
remote_url: normalizeRemoteUrl(
overrides?.remote_url ? effective?.remote_url : remoteUrl,
false
),
timeout: normalizeTimeout(
overrides?.timeout ? effective?.timeout : timeout
),
};
}
export function didEffectiveRemoteUrlChange(previousPayload, nextPayload) {
const previous = String(previousPayload?.effective?.remote_url || "");
const next = String(nextPayload?.effective?.remote_url || "");
return previous !== next;
}
export function isConfigWritable(payload) {
return payload?.storage?.writable !== false;
}