feat: add selected-loader LoRA info sidebar
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
node_modules/
|
||||||
@@ -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 (Remote)** | Select LoRAs for WanVideo with block-level control. |
|
||||||
| **WanVideo Lora Select From Text (Remote)** | Select WanVideo LoRAs from text syntax. |
|
| **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 `<lora:name:strength>` 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
|
## How It Works
|
||||||
|
|
||||||
### Reverse Proxy
|
### 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)
|
- `/api/lm/*` -- all REST API endpoints (except send_sync routes below)
|
||||||
- `/extensions/ComfyUI-Lora-Manager/*` -- widget JS files and Vue widget bundle
|
- `/extensions/ComfyUI-Lora-Manager/*` -- widget JS files and Vue widget bundle
|
||||||
- `/loras_static/*`, `/locales/*`, `/example_images_static/*` -- static assets
|
- `/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
|
- `/ws/fetch-progress`, `/ws/download-progress`, `/ws/init-progress` -- WebSocket connections
|
||||||
|
|
||||||
**Handled locally** (events broadcast to local browser via `send_sync`):
|
**Handled locally** (events broadcast to local browser via `send_sync`):
|
||||||
@@ -135,8 +148,8 @@ After installation and configuration:
|
|||||||
1. Restart ComfyUI
|
1. Restart ComfyUI
|
||||||
2. Check logs for: `[LM-Remote] Proxy routes registered -> http://192.168.1.3:8188`
|
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)
|
3. Open ComfyUI -- the LoRA Manager web UI should load (proxied from remote)
|
||||||
4. Add a "Lora Loader (Remote, LoraManager)" node to a workflow
|
4. Add a stock or remote LoRA loader and click the node -- **LoRA Info** should open
|
||||||
5. Select a LoRA -- trigger words should populate from remote metadata
|
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
|
6. Run the workflow -- the LoRA loads from local shared storage
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "comfyui-lm-remote",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test tests/frontend/*.test.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ _PROXY_PAGE_ROUTES = {
|
|||||||
"/checkpoints",
|
"/checkpoints",
|
||||||
"/embeddings",
|
"/embeddings",
|
||||||
"/loras/recipes",
|
"/loras/recipes",
|
||||||
|
"/community",
|
||||||
"/statistics",
|
"/statistics",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: "<lora:one:1> <lora:two:0.7>" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
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: "<lora:one:1> <lora:three:0.7>" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <lora:name:strength> 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
const WEIGHT_EXTENSION = /\.(?:safetensors|ckpt|pt|pth|bin)$/i;
|
||||||
|
const LORA_SYNTAX = /<lora:([^:>]+)(?::[^>]*)?>/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 = /^<lora:([^:>]+)(?::[^>]*)?>$/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)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user