diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e87659 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +node_modules/ diff --git a/README.md b/README.md index 4c49896..b57a263 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,19 @@ All nodes appear under the **Lora Manager** category in the ComfyUI node menu, w | **WanVideo Lora Select (Remote)** | Select LoRAs for WanVideo with block-level control. | | **WanVideo Lora Select From Text (Remote)** | Select WanVideo LoRAs from text syntax. | +## LoRA Info Sidebar + +Selecting a LoRA loader opens the **LoRA Info** sidebar and follows the node's current selection. It supports the stock ComfyUI loader, LM Remote nodes, and third-party loaders that expose standard `lora_name`, numbered LoRA, stack, or `` values. + +- If the selected LoRA is indexed by the remote LoRA Manager, the sidebar shows its preview, file details, base model, trigger words, tags, usage tips, and direct model links. +- If a node contains multiple active LoRAs, use the selector at the top of the sidebar to switch cards. +- If no Manager card exists, the sidebar offers name searches on LoRA Manager, Civitai, Civitai Red, and CivArchive. +- Duplicate filenames are not guessed: the sidebar asks you to choose the matching Manager path. + +ComfyUI does not currently expose an extension API for adding custom tabs to the built-in Properties panel, so this feature uses its supported custom-sidebar API. It follows ComfyUI's configured sidebar location, including a right-side layout like Templates. + +Auto-open is enabled by default. Disable it under **Settings > LM Remote > LoRA Info > Auto-open** if you prefer to open **LoRA Info** manually from the sidebar or command palette. + ## How It Works ### Reverse Proxy @@ -105,7 +118,7 @@ An aiohttp middleware is registered at startup that intercepts requests to LoRA - `/api/lm/*` -- all REST API endpoints (except send_sync routes below) - `/extensions/ComfyUI-Lora-Manager/*` -- widget JS files and Vue widget bundle - `/loras_static/*`, `/locales/*`, `/example_images_static/*` -- static assets -- `/loras`, `/checkpoints`, `/embeddings`, `/loras/recipes`, `/statistics` -- web UI pages +- `/loras`, `/checkpoints`, `/embeddings`, `/loras/recipes`, `/community`, `/statistics` -- web UI pages - `/ws/fetch-progress`, `/ws/download-progress`, `/ws/init-progress` -- WebSocket connections **Handled locally** (events broadcast to local browser via `send_sync`): @@ -135,8 +148,8 @@ After installation and configuration: 1. Restart ComfyUI 2. Check logs for: `[LM-Remote] Proxy routes registered -> http://192.168.1.3:8188` 3. Open ComfyUI -- the LoRA Manager web UI should load (proxied from remote) -4. Add a "Lora Loader (Remote, LoraManager)" node to a workflow -5. Select a LoRA -- trigger words should populate from remote metadata +4. Add a stock or remote LoRA loader and click the node -- **LoRA Info** should open +5. Select a LoRA -- its Manager card (or external search links) should appear and remote trigger words should populate where supported 6. Run the workflow -- the LoRA loads from local shared storage ## License diff --git a/package.json b/package.json new file mode 100644 index 0000000..3196334 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "comfyui-lm-remote", + "private": true, + "type": "module", + "scripts": { + "test": "node --test tests/frontend/*.test.js" + } +} diff --git a/proxy.py b/proxy.py index 78bc180..150786c 100644 --- a/proxy.py +++ b/proxy.py @@ -38,6 +38,7 @@ _PROXY_PAGE_ROUTES = { "/checkpoints", "/embeddings", "/loras/recipes", + "/community", "/statistics", } diff --git a/tests/frontend/lora_manager_sidebar_utils.test.js b/tests/frontend/lora_manager_sidebar_utils.test.js new file mode 100644 index 0000000..81ec9ef --- /dev/null +++ b/tests/frontend/lora_manager_sidebar_utils.test.js @@ -0,0 +1,195 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildExternalLinks, + extractLoraNames, + getSelectedGraphNodes, + matchModelItems, + normalizeLoraIdentifier, + normalizeUsageTips, +} from "../../web/comfyui/lora_manager_sidebar_utils.js"; + +test("normalizes loader paths and weight extensions", () => { + assert.equal( + normalizeLoraIdentifier("Styles\\Portrait.safetensors"), + "styles/portrait" + ); +}); + +test("formats Manager usage presets and hides empty JSON", () => { + assert.deepEqual(normalizeUsageTips("{}"), []); + assert.deepEqual( + normalizeUsageTips('{"strength_min":0.7,"clipStrength":1}'), + [ + { label: "Strength min", value: "0.7" }, + { label: "Clip Strength", value: "1" }, + ] + ); + assert.deepEqual(normalizeUsageTips("Use at low strength"), [ + { label: "Note", value: "Use at low strength" }, + ]); +}); + +test("extracts stock and numbered LoRA loader widgets", () => { + const node = { + comfyClass: "Power Lora Loader", + widgets: [ + { name: "lora_name", value: "styles/portrait.safetensors" }, + { name: "lora_01", value: "characters/alice.safetensors" }, + { name: "strength_model", value: 0.8 }, + ], + }; + + assert.deepEqual(extractLoraNames(node), [ + "styles/portrait.safetensors", + "characters/alice.safetensors", + ]); +}); + +test("treats active Manager entries as authoritative over synchronized text", () => { + const node = { + comfyClass: "Lora Loader (Remote, LoraManager)", + lorasWidget: { + value: [ + { name: "one", active: true }, + { name: "two", active: false }, + ], + }, + widgets: [{ name: "text", value: " " }], + }; + + assert.deepEqual(extractLoraNames(node), ["one"]); +}); + +test("extracts LoRA syntax from text loaders without a Manager widget", () => { + const node = { + comfyClass: "LoRA Text Loader", + widgets: [{ name: "text", value: " " }], + }; + + assert.deepEqual(extractLoraNames(node), ["one", "three"]); +}); + +test("extracts third-party generic selectors and keyed LoRA maps", () => { + const node = { + type: "ThirdPartyLoraLoader", + widgets: [ + { name: "model", value: "styles/four.safetensors" }, + { + name: "loras", + value: { + "five.safetensors": 0.7, + "disabled.safetensors": false, + }, + }, + ], + }; + + assert.deepEqual(extractLoraNames(node), [ + "styles/four.safetensors", + "five.safetensors", + ]); +}); + +test("supports dynamic stack widget names and their enable switches", () => { + const node = { + type: "LoRAStackDynamic", + widgets: [ + { name: "input_mode", value: "text" }, + { name: "lora_count", value: 2 }, + { name: "lora_name_1", value: "stale-one.safetensors" }, + { name: "lora_name_text_1", value: "one.safetensors" }, + { name: "enabled_1", value: true }, + { name: "lora_name_2", value: "stale-two.safetensors" }, + { name: "lora_name_text_2", value: "two.safetensors" }, + { name: "enabled_2", value: false }, + { name: "lora_name_text_3", value: "three.safetensors" }, + { name: "enabled_3", value: true }, + ], + }; + + assert.deepEqual(extractLoraNames(node), ["one.safetensors"]); + + node.widgets.find((widget) => widget.name === "input_mode").value = "dropdown"; + node.widgets.find((widget) => widget.name === "lora_count").value = 1; + assert.deepEqual(extractLoraNames(node), ["stale-one.safetensors"]); +}); + +test("reads current selectedItems with selected_nodes fallback", () => { + const selected = { id: 4, type: "LoraLoader", widgets: [] }; + assert.deepEqual( + getSelectedGraphNodes({ selectedItems: new Set([selected]) }), + [selected] + ); + assert.deepEqual( + getSelectedGraphNodes({ selected_nodes: { 4: selected } }), + [selected] + ); +}); + +test("prefers exact relative paths and reports ambiguous basenames", () => { + const items = [ + { + file_name: "portrait.safetensors", + model_name: "Portrait", + folder: "styles", + file_path: "/models/loras/styles/portrait.safetensors", + }, + { + file_name: "portrait.safetensors", + model_name: "Portrait Alt", + folder: "people", + file_path: "/models/loras/people/portrait.safetensors", + }, + ]; + + const exact = matchModelItems("styles/portrait.safetensors", items); + assert.equal(exact.found, true); + assert.equal(exact.model.folder, "styles"); + + const ambiguous = matchModelItems("portrait.safetensors", items); + assert.equal(ambiguous.ambiguous, true); + assert.equal(ambiguous.candidates.length, 2); + + const absolute = matchModelItems( + "/models/loras/people/portrait.safetensors", + items + ); + assert.equal(absolute.found, true); + assert.equal(absolute.model.folder, "people"); +}); + +test("builds exact Civitai mirrors and hash-based CivArchive search", () => { + const links = buildExternalLinks("portrait", { + model_name: "Portrait", + sha256: "abc123", + civitai: { modelId: 42, id: 84 }, + }); + + assert.equal( + links.civitai, + "https://civitai.com/models/42?modelVersionId=84" + ); + assert.equal( + links.civitaiRed, + "https://civitai.red/models/42?modelVersionId=84" + ); + assert.equal(links.civArchive, "https://civarchive.com/search?q=abc123"); +}); + +test("builds encoded name searches when the Manager has no card", () => { + const links = buildExternalLinks("Krea 2 portrait"); + assert.equal( + links.civitai, + "https://civitai.com/search/models?query=Krea%202%20portrait" + ); + assert.equal( + links.civitaiRed, + "https://civitai.red/search/models?query=Krea%202%20portrait" + ); + assert.equal( + links.civArchive, + "https://civarchive.com/search?q=Krea%202%20portrait" + ); +}); diff --git a/web/comfyui/lora_manager_sidebar.css b/web/comfyui/lora_manager_sidebar.css new file mode 100644 index 0000000..33911eb --- /dev/null +++ b/web/comfyui/lora_manager_sidebar.css @@ -0,0 +1,349 @@ +.lmri-root { + --lmri-bg: var(--comfy-menu-bg, #18181b); + --lmri-panel: var(--comfy-input-bg, #242428); + --lmri-border: var(--border-color, #3a3a40); + --lmri-text: var(--fg-color, #f4f4f5); + --lmri-muted: var(--descrip-text, #a1a1aa); + --lmri-accent: var(--primary-color, #7c3aed); + box-sizing: border-box; + width: 100%; + height: 100%; + min-height: 0; + overflow-y: auto; + background: var(--lmri-bg); + color: var(--lmri-text); + font: 13px/1.45 Inter, system-ui, sans-serif; +} + +.lmri-root *, +.lmri-root *::before, +.lmri-root *::after { + box-sizing: border-box; +} + +.lmri-header { + position: sticky; + z-index: 2; + top: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 14px 11px; + border-bottom: 1px solid var(--lmri-border); + background: color-mix(in srgb, var(--lmri-bg) 94%, transparent); + backdrop-filter: blur(10px); +} + +.lmri-header h1, +.lmri-card h2, +.lmri-notice h3, +.lmri-empty h3 { + margin: 0; + color: var(--lmri-text); +} + +.lmri-header h1 { + font-size: 15px; + font-weight: 650; +} + +.lmri-header p { + max-width: 240px; + margin: 2px 0 0; + overflow: hidden; + color: var(--lmri-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lmri-icon-button, +.lmri-name, +.lmri-candidate, +.lmri-button { + border: 1px solid var(--lmri-border); + color: var(--lmri-text); + font: inherit; + cursor: pointer; +} + +.lmri-icon-button { + display: grid; + flex: 0 0 30px; + width: 30px; + height: 30px; + place-items: center; + border-radius: 8px; + background: var(--lmri-panel); +} + +.lmri-icon-button:disabled { + cursor: default; + opacity: 0.45; +} + +.lmri-name-selector { + display: flex; + gap: 6px; + padding: 10px 12px 2px; + overflow-x: auto; +} + +.lmri-name { + flex: 0 0 auto; + max-width: 190px; + padding: 6px 9px; + overflow: hidden; + border-radius: 999px; + background: transparent; + color: var(--lmri-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lmri-name.active { + border-color: color-mix(in srgb, var(--lmri-accent) 70%, white 10%); + background: color-mix(in srgb, var(--lmri-accent) 24%, transparent); + color: var(--lmri-text); +} + +.lmri-content { + padding: 12px; +} + +.lmri-state, +.lmri-empty { + display: flex; + min-height: 230px; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 10px; + color: var(--lmri-muted); + text-align: center; +} + +.lmri-empty-icon { + color: var(--lmri-accent); + font-size: 28px; +} + +.lmri-empty p { + max-width: 280px; + margin: 0; +} + +.lmri-card, +.lmri-notice { + overflow: hidden; + border: 1px solid var(--lmri-border); + border-radius: 12px; + background: var(--lmri-panel); + box-shadow: 0 8px 28px rgb(0 0 0 / 18%); +} + +.lmri-preview { + position: relative; + width: 100%; + aspect-ratio: 4 / 3; + overflow: hidden; + background: #101012; +} + +.lmri-preview img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.lmri-card-body, +.lmri-notice { + padding: 13px; +} + +.lmri-card-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.lmri-card h2 { + font-size: 16px; + line-height: 1.25; +} + +.lmri-file-name { + margin: 4px 0 0; + overflow-wrap: anywhere; + color: var(--lmri-muted); + font-size: 11px; +} + +.lmri-flags { + display: flex; + align-items: center; + gap: 5px; + color: #facc15; + font-size: 15px; +} + +.lmri-update { + padding: 2px 6px; + border-radius: 999px; + background: #166534; + color: #dcfce7; + font-size: 9px; + text-transform: uppercase; +} + +.lmri-metadata { + margin-top: 12px; + padding: 8px 10px; + border: 1px solid var(--lmri-border); + border-radius: 9px; + background: color-mix(in srgb, var(--lmri-bg) 58%, transparent); +} + +.lmri-usage-tips { + margin-top: 0; +} + +.lmri-meta-row { + display: grid; + grid-template-columns: minmax(72px, 0.8fr) minmax(0, 1.5fr); + gap: 8px; + padding: 3px 0; +} + +.lmri-meta-label { + color: var(--lmri-muted); +} + +.lmri-meta-value { + overflow-wrap: anywhere; + text-align: right; +} + +.lmri-section-title, +.lmri-copy h3 { + margin: 13px 0 6px; + color: var(--lmri-muted); + font-size: 10px; + font-weight: 650; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.lmri-pills { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.lmri-pill { + max-width: 100%; + padding: 4px 7px; + overflow: hidden; + border: 1px solid var(--lmri-border); + border-radius: 6px; + background: color-mix(in srgb, var(--lmri-bg) 56%, transparent); + color: var(--lmri-muted); + font-size: 10px; + text-overflow: ellipsis; +} + +.lmri-triggers .lmri-pill { + border-color: color-mix(in srgb, var(--lmri-accent) 52%, var(--lmri-border)); + color: var(--lmri-text); +} + +.lmri-copy p { + margin: 0; + color: var(--lmri-text); + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.lmri-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + margin-top: 10px; +} + +.lmri-primary-actions { + grid-template-columns: 1fr; + margin-top: 14px; +} + +.lmri-button { + min-width: 0; + padding: 7px 8px; + border-radius: 7px; + background: color-mix(in srgb, var(--lmri-bg) 70%, transparent); + text-align: center; + text-decoration: none; +} + +.lmri-button:hover, +.lmri-icon-button:hover:not(:disabled), +.lmri-candidate:hover { + border-color: color-mix(in srgb, var(--lmri-accent) 75%, var(--lmri-border)); + background: color-mix(in srgb, var(--lmri-accent) 18%, var(--lmri-bg)); +} + +.lmri-manager { + border-color: color-mix(in srgb, var(--lmri-accent) 68%, var(--lmri-border)); + background: color-mix(in srgb, var(--lmri-accent) 24%, var(--lmri-bg)); +} + +.lmri-civitai-red { + border-color: #7f1d1d; + color: #fecaca; +} + +.lmri-notice h3 { + font-size: 14px; +} + +.lmri-notice p { + margin: 7px 0 12px; + color: var(--lmri-muted); +} + +.lmri-error { + border-color: #7f1d1d; +} + +.lmri-candidates { + display: flex; + flex-direction: column; + gap: 6px; +} + +.lmri-candidate { + display: flex; + width: 100%; + align-items: flex-start; + flex-direction: column; + padding: 8px 9px; + border-radius: 8px; + background: color-mix(in srgb, var(--lmri-bg) 65%, transparent); + text-align: left; +} + +.lmri-candidate span { + color: var(--lmri-muted); + font-size: 10px; + overflow-wrap: anywhere; +} + +@media (max-width: 360px) { + .lmri-actions { + grid-template-columns: 1fr; + } +} diff --git a/web/comfyui/lora_manager_sidebar.js b/web/comfyui/lora_manager_sidebar.js new file mode 100644 index 0000000..9df0998 --- /dev/null +++ b/web/comfyui/lora_manager_sidebar.js @@ -0,0 +1,697 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +import { + buildExternalLinks, + cleanLoraName, + extractLoraNames, + getSelectedGraphNodes, + loraSearchTerm, + matchModelItems, + normalizeLoraIdentifier, + normalizeUsageTips, +} from "./lora_manager_sidebar_utils.js"; + +const TAB_ID = "lm-remote-lora-info"; +const COMMAND_ID = "LMRemote.OpenLoraInfo"; +const AUTO_OPEN_SETTING = "LMRemote.LoraInfo.AutoOpen"; +const STYLE_ID = "lm-remote-lora-info-style"; +const NODE_SELECTION_HOOK = Symbol.for("lmRemote.loraInfo.nodeSelectionHook"); +const CANVAS_SELECTION_HOOK = Symbol.for("lmRemote.loraInfo.canvasSelectionHook"); + +let sidebarRoot = null; +let selectedNode = null; +let selectedNames = []; +let activeName = ""; +let selectionSignature = ""; +let lookupState = { status: "idle" }; +let lookupGeneration = 0; +let lookupController = null; +let monitorTimer = null; + +function createElement(tag, className, text) { + const element = document.createElement(tag); + if (className) element.className = className; + if (text != null) element.textContent = String(text); + return element; +} + +function ensureStyles() { + if (document.getElementById(STYLE_ID)) return; + const link = document.createElement("link"); + link.id = STYLE_ID; + link.rel = "stylesheet"; + link.href = new URL("./lora_manager_sidebar.css", import.meta.url).href; + document.head.appendChild(link); +} + +function selectedNodeLabel() { + if (!selectedNode) return ""; + return ( + selectedNode.title || + selectedNode.comfyClass || + selectedNode.type || + `Node ${selectedNode.id ?? ""}` + ); +} + +function makeExternalLink(label, url, extraClass = "") { + const link = createElement( + "a", + `lmri-button lmri-link ${extraClass}`.trim(), + label + ); + link.href = url; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + return link; +} + +function managerSearchUrl(name) { + return `/loras?search=${encodeURIComponent(loraSearchTerm(name) || name)}`; +} + +function safePreviewUrl(value) { + if (!value) return ""; + try { + const parsed = new URL(String(value), window.location.origin); + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + return parsed.href; + } + } catch { + return ""; + } + return ""; +} + +function toDisplayList(value) { + if (Array.isArray(value)) { + return value.map((item) => String(item).trim()).filter(Boolean); + } + if (value && typeof value === "object") { + return Object.entries(value) + .filter(([, enabled]) => Boolean(enabled)) + .map(([name]) => name); + } + return []; +} + +function appendPills(container, values, className = "") { + const unique = Array.from(new Set(values.filter(Boolean))); + if (!unique.length) return; + const pills = createElement("div", `lmri-pills ${className}`.trim()); + unique.forEach((value) => { + pills.appendChild(createElement("span", "lmri-pill", value)); + }); + container.appendChild(pills); +} + +function appendMetaRow(container, label, value) { + if (value == null || value === "") return; + const row = createElement("div", "lmri-meta-row"); + row.append( + createElement("span", "lmri-meta-label", label), + createElement("span", "lmri-meta-value", value) + ); + container.appendChild(row); +} + +function formatFileSize(value) { + const bytes = Number(value); + if (!Number.isFinite(bytes) || bytes <= 0) return ""; + const units = ["B", "KB", "MB", "GB"]; + let size = bytes; + let unit = 0; + while (size >= 1024 && unit < units.length - 1) { + size /= 1024; + unit += 1; + } + return `${size.toFixed(unit > 1 ? 1 : 0)} ${units[unit]}`; +} + +function appendSearchActions(container, query, model = null) { + const links = buildExternalLinks(query, model); + const actions = createElement("div", "lmri-actions"); + actions.append( + makeExternalLink("Civitai", links.civitai, "lmri-civitai"), + makeExternalLink("Civitai Red", links.civitaiRed, "lmri-civitai-red"), + makeExternalLink("CivArchive", links.civArchive, "lmri-archive") + ); + container.appendChild(actions); +} + +function renderEmpty(content) { + const empty = createElement("div", "lmri-empty"); + empty.append( + createElement("i", "pi pi-info-circle lmri-empty-icon"), + createElement("h3", "", "Select a LoRA loader"), + createElement( + "p", + "", + "Select any node with a LoRA name, LoRA stack, or value." + ) + ); + content.appendChild(empty); +} + +function renderLoading(content) { + const loading = createElement("div", "lmri-state"); + loading.append( + createElement("i", "pi pi-spin pi-spinner"), + createElement("span", "", `Looking up ${activeName}…`) + ); + content.appendChild(loading); +} + +function renderMissing(content) { + const panel = createElement("section", "lmri-notice"); + panel.append( + createElement("h3", "", "No LoRA Manager card found"), + createElement( + "p", + "", + `“${activeName}” is selected, but it is not indexed by the remote LoRA Manager.` + ) + ); + panel.appendChild( + makeExternalLink( + "Search LoRA Manager", + managerSearchUrl(activeName), + "lmri-manager" + ) + ); + appendSearchActions(panel, activeName); + content.appendChild(panel); +} + +function renderError(content) { + const panel = createElement("section", "lmri-notice lmri-error"); + panel.append( + createElement("h3", "", "LoRA Manager unavailable"), + createElement( + "p", + "", + lookupState.message || "The remote Manager did not answer this lookup." + ) + ); + const retry = createElement("button", "lmri-button", "Try again"); + retry.type = "button"; + retry.addEventListener("click", () => lookupActiveName()); + panel.appendChild(retry); + appendSearchActions(panel, activeName); + content.appendChild(panel); +} + +function useResolvedCandidate(model) { + lookupGeneration += 1; + lookupController?.abort(); + lookupState = { status: "found", model }; + renderSidebar(); +} + +function renderAmbiguous(content) { + const panel = createElement("section", "lmri-notice"); + panel.append( + createElement("h3", "", "Choose the matching LoRA"), + createElement( + "p", + "", + "More than one Manager card has this filename. Pick the folder used by the node." + ) + ); + + const candidates = createElement("div", "lmri-candidates"); + (lookupState.candidates || []).forEach((model) => { + const button = createElement("button", "lmri-candidate"); + button.type = "button"; + const title = model.model_name || model.file_name || "Unnamed LoRA"; + const path = [model.folder, model.file_name].filter(Boolean).join("/"); + button.append( + createElement("strong", "", title), + createElement("span", "", path || model.file_path || "") + ); + button.addEventListener("click", () => useResolvedCandidate(model)); + candidates.appendChild(button); + }); + panel.appendChild(candidates); + appendSearchActions(panel, activeName); + content.appendChild(panel); +} + +function renderModelCard(content, model) { + const card = createElement("article", "lmri-card"); + const previewUrl = safePreviewUrl(model.preview_url); + if (previewUrl) { + const preview = createElement("div", "lmri-preview"); + const image = document.createElement("img"); + image.src = previewUrl; + image.alt = `Preview for ${model.model_name || activeName}`; + image.loading = "lazy"; + image.addEventListener("error", () => preview.remove()); + preview.appendChild(image); + card.appendChild(preview); + } + + const body = createElement("div", "lmri-card-body"); + const heading = createElement("div", "lmri-card-heading"); + const titleGroup = createElement("div", ""); + titleGroup.append( + createElement("h2", "", model.model_name || model.file_name || activeName), + createElement( + "p", + "lmri-file-name", + [model.folder, model.file_name].filter(Boolean).join("/") || + model.file_path || + activeName + ) + ); + heading.appendChild(titleGroup); + + const flags = createElement("div", "lmri-flags"); + if (model.favorite) flags.appendChild(createElement("span", "", "★")); + if (model.update_available) { + flags.appendChild(createElement("span", "lmri-update", "Update")); + } + if (flags.childNodes.length) heading.appendChild(flags); + body.appendChild(heading); + + const metadata = createElement("div", "lmri-metadata"); + appendMetaRow(metadata, "Base model", model.base_model); + appendMetaRow(metadata, "Type", model.sub_type); + appendMetaRow(metadata, "Version", model.civitai?.name); + appendMetaRow(metadata, "Size", formatFileSize(model.file_size)); + appendMetaRow( + metadata, + "Used", + Number.isFinite(Number(model.usage_count)) + ? `${Number(model.usage_count)} times` + : "" + ); + if (model.sha256) { + const hash = String(model.sha256); + appendMetaRow(metadata, "SHA256", hash.length > 16 ? `${hash.slice(0, 16)}…` : hash); + metadata.lastElementChild?.querySelector(".lmri-meta-value")?.setAttribute( + "title", + hash + ); + } + body.appendChild(metadata); + + const trainedWords = toDisplayList(model.civitai?.trainedWords); + if (trainedWords.length) { + body.appendChild(createElement("h3", "lmri-section-title", "Trigger words")); + appendPills(body, trainedWords, "lmri-triggers"); + } + + const tags = [ + ...toDisplayList(model.tags), + ...toDisplayList(model.auto_tags), + ]; + if (tags.length) { + body.appendChild(createElement("h3", "lmri-section-title", "Tags")); + appendPills(body, tags); + } + + const usageTips = normalizeUsageTips(model.usage_tips); + if (usageTips.length) { + const section = createElement("section", "lmri-copy"); + section.appendChild(createElement("h3", "", "Usage tips")); + const values = createElement("div", "lmri-metadata lmri-usage-tips"); + usageTips.forEach((tip) => appendMetaRow(values, tip.label, tip.value)); + section.appendChild(values); + body.appendChild(section); + } + if (model.notes) { + const section = createElement("section", "lmri-copy"); + section.append( + createElement("h3", "", "Manager notes"), + createElement("p", "", model.notes) + ); + body.appendChild(section); + } + + const actions = createElement("div", "lmri-actions lmri-primary-actions"); + actions.appendChild( + makeExternalLink( + "Open Manager card", + managerSearchUrl(model.file_name || activeName), + "lmri-manager" + ) + ); + body.appendChild(actions); + appendSearchActions(body, activeName, model); + card.appendChild(body); + content.appendChild(card); +} + +function renderSidebar() { + if (!sidebarRoot) return; + sidebarRoot.replaceChildren(); + + const header = createElement("header", "lmri-header"); + const heading = createElement("div", ""); + heading.append( + createElement("h1", "", "LoRA Info"), + createElement("p", "", selectedNodeLabel() || "Selected node") + ); + const refresh = createElement("button", "lmri-icon-button"); + refresh.type = "button"; + refresh.title = "Refresh LoRA Manager card"; + refresh.setAttribute("aria-label", refresh.title); + refresh.appendChild(createElement("i", "pi pi-refresh")); + refresh.disabled = !activeName || lookupState.status === "loading"; + refresh.addEventListener("click", () => lookupActiveName()); + header.append(heading, refresh); + sidebarRoot.appendChild(header); + + if (selectedNames.length > 1) { + const selector = createElement("div", "lmri-name-selector"); + selectedNames.forEach((name) => { + const button = createElement( + "button", + normalizeLoraIdentifier(name) === normalizeLoraIdentifier(activeName) + ? "lmri-name active" + : "lmri-name", + loraSearchTerm(name) || name + ); + button.type = "button"; + button.title = name; + button.addEventListener("click", () => { + if (normalizeLoraIdentifier(name) === normalizeLoraIdentifier(activeName)) { + return; + } + activeName = name; + lookupActiveName(); + }); + selector.appendChild(button); + }); + sidebarRoot.appendChild(selector); + } + + const content = createElement("main", "lmri-content"); + sidebarRoot.appendChild(content); + if (!activeName) { + renderEmpty(content); + } else if (lookupState.status === "loading") { + renderLoading(content); + } else if (lookupState.status === "found") { + renderModelCard(content, lookupState.model); + } else if (lookupState.status === "ambiguous") { + renderAmbiguous(content); + } else if (lookupState.status === "missing") { + renderMissing(content); + } else if (lookupState.status === "error") { + renderError(content); + } else { + renderLoading(content); + } +} + +async function responseError(response) { + try { + const payload = await response.json(); + return payload.error || `Request failed with HTTP ${response.status}`; + } catch { + return `Request failed with HTTP ${response.status}`; + } +} + +async function fallbackListLookup(name, signal) { + const term = loraSearchTerm(name); + const params = new URLSearchParams({ + page: "1", + page_size: "100", + search: term, + fuzzy_search: "true", + }); + const response = await api.fetchApi(`/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); + return { + success: true, + query: name, + ...result, + }; +} + +async function resolveManagerCard(name, signal) { + const response = await api.fetchApi( + `/lm/loras/resolve?name=${encodeURIComponent(name)}`, + { signal } + ); + if (response.status === 404) { + return fallbackListLookup(name, signal); + } + if (!response.ok) throw new Error(await responseError(response)); + const result = await response.json(); + if (result?.success && !result.found && !result.ambiguous) { + try { + return await fallbackListLookup(name, signal); + } catch (error) { + if (error?.name === "AbortError") throw error; + } + } + return result; +} + +async function lookupActiveName() { + const name = cleanLoraName(activeName); + if (!name) return; + + const generation = ++lookupGeneration; + lookupController?.abort(); + lookupController = new AbortController(); + lookupState = { status: "loading" }; + renderSidebar(); + + try { + const result = await resolveManagerCard(name, lookupController.signal); + if (generation !== lookupGeneration) return; + + if (!result?.success) { + throw new Error(result?.error || "The Manager lookup failed."); + } + if (result.found && result.model) { + lookupState = { status: "found", model: result.model }; + } else if (result.ambiguous && result.candidates?.length) { + lookupState = { + status: "ambiguous", + candidates: result.candidates, + }; + } else { + lookupState = { status: "missing" }; + } + } catch (error) { + if (error?.name === "AbortError" || generation !== lookupGeneration) return; + lookupState = { + status: "error", + message: error instanceof Error ? error.message : String(error), + }; + } + renderSidebar(); +} + +function autoOpenEnabled() { + return app.extensionManager?.setting?.get?.(AUTO_OPEN_SETTING) !== false; +} + +function unwrapValue(value) { + return value && typeof value === "object" && "value" in value + ? value.value + : value; +} + +function activeSidebarId(manager, sidebar) { + return unwrapValue( + sidebar?.activeSidebarTabId ?? manager?.activeSidebarTabId + ); +} + +function openSidebarTab() { + const manager = app.extensionManager; + if (!manager) return false; + const sidebar = manager.sidebarTab || manager; + if (activeSidebarId(manager, sidebar) === TAB_ID) return true; + + if (typeof manager.setActiveSidebarTab === "function") { + manager.setActiveSidebarTab(TAB_ID); + if (activeSidebarId(manager, sidebar) === TAB_ID) return true; + } + + if (sidebar && "activeSidebarTabId" in sidebar) { + try { + const current = sidebar.activeSidebarTabId; + if (current && typeof current === "object" && "value" in current) { + current.value = TAB_ID; + } else { + sidebar.activeSidebarTabId = TAB_ID; + } + } catch { + // Some frontend versions expose a readonly store property. + } + if (activeSidebarId(manager, sidebar) === TAB_ID) return true; + } + + if (typeof sidebar?.toggleSidebarTab === "function") { + sidebar.toggleSidebarTab(TAB_ID); + return true; + } + if (typeof manager.toggleSidebarTab === "function") { + manager.toggleSidebarTab(TAB_ID); + return true; + } + if (typeof manager.command?.execute === "function") { + manager.command.execute(`Workspace.ToggleSidebarTab.${TAB_ID}`); + return true; + } + return false; +} + +function updateSelection({ autoOpen = false, force = false } = {}) { + const nodes = getSelectedGraphNodes(app.canvas); + const node = nodes.length === 1 ? nodes[0] : null; + const names = node ? extractLoraNames(node) : []; + const signature = `${node?.id ?? ""}|${names + .map(normalizeLoraIdentifier) + .join("|")}`; + + if (!force && signature === selectionSignature) { + if (autoOpen && names.length && autoOpenEnabled()) openSidebarTab(); + return; + } + + selectionSignature = signature; + selectedNode = node; + selectedNames = names; + const currentStillExists = names.some( + (name) => + normalizeLoraIdentifier(name) === normalizeLoraIdentifier(activeName) + ); + activeName = currentStillExists ? activeName : names[0] || ""; + + lookupGeneration += 1; + lookupController?.abort(); + lookupState = { status: activeName ? "loading" : "idle" }; + renderSidebar(); + + if (activeName) { + if (autoOpen && autoOpenEnabled()) openSidebarTab(); + lookupActiveName(); + } +} + +function chainCanvasSelection() { + const canvas = app.canvas; + if (!canvas || canvas[CANVAS_SELECTION_HOOK]) return; + canvas[CANVAS_SELECTION_HOOK] = true; + + const previous = canvas.onSelectionChange; + canvas.onSelectionChange = function (...args) { + const result = + typeof previous === "function" ? previous.apply(this, args) : undefined; + queueMicrotask(() => updateSelection({ autoOpen: true })); + return result; + }; +} + +function chainNodeSelection(nodeType) { + const prototype = nodeType?.prototype; + if (!prototype || prototype[NODE_SELECTION_HOOK]) return; + prototype[NODE_SELECTION_HOOK] = true; + + const previous = prototype.onSelected; + prototype.onSelected = function (...args) { + const result = + typeof previous === "function" ? previous.apply(this, args) : undefined; + queueMicrotask(() => updateSelection({ autoOpen: true })); + return result; + }; +} + +function registerSidebarTab() { + const manager = app.extensionManager; + const sidebar = manager?.sidebarTab; + const tabs = + sidebar?.sidebarTabs?.value ?? + sidebar?.sidebarTabs ?? + manager?.getSidebarTabs?.() ?? + []; + if (Array.isArray(tabs) && tabs.some((tab) => tab.id === TAB_ID)) return; + + const specification = { + id: TAB_ID, + icon: "pi pi-id-card", + title: "LoRA Info", + tooltip: "LoRA Manager card and Civitai links for the selected loader", + type: "custom", + render(container) { + ensureStyles(); + container.style.height = "100%"; + container.style.minHeight = "0"; + sidebarRoot = createElement("div", "lmri-root"); + container.replaceChildren(sidebarRoot); + renderSidebar(); + updateSelection({ force: true }); + }, + destroy() { + lookupGeneration += 1; + lookupController?.abort(); + sidebarRoot?.remove(); + sidebarRoot = null; + lookupState = { status: "idle" }; + }, + }; + + if (typeof manager?.registerSidebarTab === "function") { + manager.registerSidebarTab(specification); + } else if (typeof sidebar?.registerSidebarTab === "function") { + sidebar.registerSidebarTab(specification); + } else { + console.error( + "[LM-Remote] This ComfyUI frontend does not support custom sidebar tabs." + ); + } +} + +app.registerExtension({ + name: "LoraManager.RemoteLoraInfoSidebar", + settings: [ + { + id: AUTO_OPEN_SETTING, + name: "Open LoRA Info when selecting a LoRA loader", + type: "boolean", + defaultValue: true, + category: ["LM Remote", "LoRA Info", "Auto-open"], + }, + ], + commands: [ + { + id: COMMAND_ID, + label: "Open LoRA Info", + icon: "pi pi-id-card", + function: () => { + updateSelection({ force: true }); + openSidebarTab(); + }, + }, + ], + getSelectionToolboxCommands(selectedItem) { + return extractLoraNames(selectedItem).length ? [COMMAND_ID] : []; + }, + beforeRegisterNodeDef(nodeType) { + chainNodeSelection(nodeType); + }, + setup() { + ensureStyles(); + registerSidebarTab(); + chainCanvasSelection(); + updateSelection(); + if (monitorTimer == null) { + monitorTimer = window.setInterval(() => { + chainCanvasSelection(); + updateSelection(); + }, 500); + } + }, +}); diff --git a/web/comfyui/lora_manager_sidebar_utils.js b/web/comfyui/lora_manager_sidebar_utils.js new file mode 100644 index 0000000..0696558 --- /dev/null +++ b/web/comfyui/lora_manager_sidebar_utils.js @@ -0,0 +1,392 @@ +const WEIGHT_EXTENSION = /\.(?:safetensors|ckpt|pt|pth|bin)$/i; +const LORA_SYNTAX = /]+)(?::[^>]*)?>/gi; +const DISABLED_VALUES = new Set([ + "", + "none", + "null", + "disabled", + "select a lora", + "select lora", +]); + +export function cleanLoraName(value) { + if (typeof value !== "string") return ""; + + const trimmed = value.trim().replace(/^["']|["']$/g, ""); + const exactSyntax = /^]+)(?::[^>]*)?>$/i.exec(trimmed); + const name = (exactSyntax?.[1] || trimmed) + .replace(/\\/g, "/") + .replace(/\/{2,}/g, "/") + .replace(/^\.\//, "") + .trim(); + + if (DISABLED_VALUES.has(name.toLowerCase())) return ""; + return name; +} + +export function normalizeLoraIdentifier(value) { + return cleanLoraName(value) + .replace(WEIGHT_EXTENSION, "") + .replace(/^\/+|\/+$/g, "") + .toLowerCase(); +} + +export function loraSearchTerm(value) { + const clean = cleanLoraName(value); + const basename = clean.replace(/\\/g, "/").split("/").pop() || clean; + return basename.replace(WEIGHT_EXTENSION, "").trim(); +} + +function formatUsageTipLabel(value) { + return String(value) + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .replace(/^./, (letter) => letter.toUpperCase()); +} + +function formatUsageTipValue(value) { + if (value == null) return ""; + if (typeof value === "object") { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); +} + +export function normalizeUsageTips(value) { + let parsed = value; + if (typeof value === "string") { + const text = value.trim(); + if (!text || text === "{}" || text === "[]" || text === "null") return []; + try { + parsed = JSON.parse(text); + } catch { + return [{ label: "Note", value: text }]; + } + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return []; + return Object.entries(parsed) + .map(([key, entry]) => ({ + label: formatUsageTipLabel(key), + value: formatUsageTipValue(entry), + })) + .filter((entry) => entry.value !== ""); +} + +export function extractLoraSyntax(value) { + if (typeof value !== "string") return []; + const names = []; + LORA_SYNTAX.lastIndex = 0; + for (const match of value.matchAll(LORA_SYNTAX)) { + const name = cleanLoraName(match[1]); + if (name) names.push(name); + } + return names; +} + +function isEnabledEntry(entry) { + if (!entry || typeof entry !== "object") return true; + return entry.active !== false && entry.enabled !== false && entry.on !== false; +} + +function collectStructuredNames(value, output, allowObjectKeys = false) { + if (typeof value === "string") { + const syntaxNames = extractLoraSyntax(value); + if (syntaxNames.length) { + output.push(...syntaxNames); + } else { + const name = cleanLoraName(value); + if (name) output.push(name); + } + return; + } + + if (Array.isArray(value)) { + value.forEach((entry) => collectStructuredNames(entry, output, true)); + return; + } + + if (!value || typeof value !== "object" || !isEnabledEntry(value)) return; + + const namedValue = + value.name ?? + value.lora_name ?? + value.loraName ?? + value.lora ?? + value.path ?? + value.file; + if (typeof namedValue === "string") { + collectStructuredNames(namedValue, output); + return; + } + + const nested = value.loras ?? value.items ?? value.values; + if (Array.isArray(nested) || (nested && typeof nested === "object")) { + collectStructuredNames(nested, output, true); + } + + if (!allowObjectKeys) return; + for (const [key, entry] of Object.entries(value)) { + if (WEIGHT_EXTENSION.test(key) && entry !== false && entry !== 0) { + collectStructuredNames(key, output); + } + if (!entry || typeof entry !== "object" || !isEnabledEntry(entry)) continue; + const entryName = + entry.name ?? + entry.lora_name ?? + entry.loraName ?? + entry.lora ?? + entry.path ?? + entry.file; + if (typeof entryName === "string") { + collectStructuredNames(entryName, output); + } else if (WEIGHT_EXTENSION.test(key)) { + collectStructuredNames(key, output); + } + } +} + +function normalizeWidgetName(name) { + return String(name || "") + .trim() + .toLowerCase() + .replace(/[\s-]+/g, "_"); +} + +function loraSlotIndex(name) { + const normalized = normalizeWidgetName(name); + const patterns = [ + /^lora_?(\d+)(?:_(?:name|path|file|text))?$/, + /^lora_(?:name|path|file)(?:_text)?_?(\d+)$/, + ]; + for (const pattern of patterns) { + const match = pattern.exec(normalized); + if (match) return String(Number(match[1])); + } + return null; +} + +function isLoraSelectorName(name) { + const normalized = normalizeWidgetName(name); + return ( + /^(?:lora|loras|lora_name|lora_path|lora_file)$/.test(normalized) || + loraSlotIndex(normalized) !== null || + /^lora.*_(?:name|path|file)$/.test(normalized) + ); +} + +function isFalseLike(value) { + return ( + value === false || + value === 0 || + ["0", "false", "off", "disabled", "no"].includes( + String(value || "").trim().toLowerCase() + ) + ); +} + +function isLoraSlotEnabled(widgetName, widgetsByName) { + const slot = loraSlotIndex(widgetName); + if (slot === null) return true; + const companionNames = [ + `enabled_${slot}`, + `enable_${slot}`, + `lora_enabled_${slot}`, + `lora_${slot}_enabled`, + ]; + for (const name of companionNames) { + if (widgetsByName.has(name)) { + return !isFalseLike(widgetsByName.get(name)?.value); + } + } + return true; +} + +function shouldReadLoraSlot(widgetName, widgetsByName) { + const slot = loraSlotIndex(widgetName); + if (slot === null) return true; + + const count = Number(widgetsByName.get("lora_count")?.value); + if (Number.isFinite(count) && Number(slot) > count) return false; + + const normalized = normalizeWidgetName(widgetName); + const inputMode = String(widgetsByName.get("input_mode")?.value || "") + .trim() + .toLowerCase(); + const isTextSelector = normalized === `lora_name_text_${slot}`; + const isDropdownSelector = normalized === `lora_name_${slot}`; + + if (inputMode === "text" && isDropdownSelector) { + return !widgetsByName.has(`lora_name_text_${slot}`); + } + if (inputMode && inputMode !== "text" && isTextSelector) { + return !widgetsByName.has(`lora_name_${slot}`); + } + return true; +} + +export function extractLoraNames(node) { + if (!node || typeof node !== "object") return []; + + const output = []; + const descriptor = [ + node.comfyClass, + node.type, + node.title, + node.constructor?.comfyClass, + ] + .filter(Boolean) + .join(" "); + const isLoraNode = /lora/i.test(descriptor); + + const hasManagerWidget = node.lorasWidget?.value != null; + if (hasManagerWidget) { + collectStructuredNames(node.lorasWidget.value, output, true); + } + + const widgets = node.widgets || []; + const widgetsByName = new Map( + widgets.map((widget) => [normalizeWidgetName(widget?.name), widget]) + ); + + for (const widget of hasManagerWidget ? [] : widgets) { + const widgetName = String(widget?.name || ""); + const value = widget?.value; + const slotEnabled = + shouldReadLoraSlot(widgetName, widgetsByName) && + isLoraSlotEnabled(widgetName, widgetsByName); + const syntaxNames = slotEnabled ? extractLoraSyntax(value) : []; + if (syntaxNames.length) output.push(...syntaxNames); + + if (isLoraSelectorName(widgetName)) { + if (slotEnabled) collectStructuredNames(value, output, true); + } else if ( + isLoraNode && + /^(?:text|lora_syntax|lora_code)$/i.test(widgetName) + ) { + output.push(...syntaxNames); + } else if ( + isLoraNode && + typeof value === "string" && + WEIGHT_EXTENSION.test(cleanLoraName(value)) + ) { + collectStructuredNames(value, output); + } + } + + const seen = new Set(); + return output.filter((value) => { + const key = normalizeLoraIdentifier(value); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export function getSelectedGraphNodes(canvas) { + if (!canvas) return []; + + const selectedItems = canvas.selectedItems; + if (selectedItems && typeof selectedItems.values === "function") { + return Array.from(selectedItems.values()).filter( + (item) => item && (item.widgets || item.comfyClass || item.type) + ); + } + + return Object.values(canvas.selected_nodes || {}).filter(Boolean); +} + +function aliasesForModel(model) { + const fileName = cleanLoraName(model?.file_name || ""); + const modelName = cleanLoraName(model?.model_name || ""); + const folder = String(model?.folder || "") + .replace(/\\/g, "/") + .replace(/^\/+|\/+$/g, ""); + const relativePath = folder && fileName ? `${folder}/${fileName}` : fileName; + const filePath = cleanLoraName(model?.file_path || ""); + + return { + fileName: normalizeLoraIdentifier(fileName), + modelName: normalizeLoraIdentifier(modelName), + relativePath: normalizeLoraIdentifier(relativePath), + filePath: normalizeLoraIdentifier(filePath), + }; +} + +function matchScore(query, model) { + const normalized = normalizeLoraIdentifier(query); + if (!normalized) return 0; + + const basename = normalized.split("/").pop(); + const hasPath = normalized.includes("/"); + const aliases = aliasesForModel(model); + + if (hasPath) { + if (aliases.relativePath === normalized) return 100; + if ( + aliases.filePath === normalized || + aliases.filePath.endsWith(`/${normalized}`) + ) { + return 95; + } + return 0; + } + + if (aliases.fileName.split("/").pop() === basename) return 90; + if (aliases.modelName === normalized) return 85; + return 0; +} + +export function matchModelItems(query, items) { + let bestScore = 0; + let candidates = []; + + for (const item of Array.isArray(items) ? items : []) { + const score = matchScore(query, item); + if (!score) continue; + if (score > bestScore) { + bestScore = score; + candidates = [item]; + } else if (score === bestScore) { + candidates.push(item); + } + } + + return { + found: candidates.length === 1, + ambiguous: candidates.length > 1, + model: candidates.length === 1 ? candidates[0] : null, + candidates, + }; +} + +function exactCivitaiUrl(host, model) { + const modelId = model?.civitai?.modelId; + const versionId = model?.civitai?.id; + if (!modelId) return null; + const version = versionId + ? `?modelVersionId=${encodeURIComponent(String(versionId))}` + : ""; + return `https://${host}/models/${encodeURIComponent(String(modelId))}${version}`; +} + +export function buildExternalLinks(query, model = null) { + const term = + loraSearchTerm(model?.model_name || query) || loraSearchTerm(query); + const encodedTerm = encodeURIComponent(term); + const archiveTerm = String(model?.sha256 || term); + + return { + civitai: + exactCivitaiUrl("civitai.com", model) || + `https://civitai.com/search/models?query=${encodedTerm}`, + civitaiRed: + exactCivitaiUrl("civitai.red", model) || + `https://civitai.red/search/models?query=${encodedTerm}`, + civArchive: `https://civarchive.com/search?q=${encodeURIComponent(archiveTerm)}`, + }; +}