feat: add in-app LM Remote configuration
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user