Compare commits

..
2 Commits
Author SHA1 Message Date
Ethanfel 7886ae85d5 feat: add in-app LM Remote configuration 2026-08-24 22:19:30 +02:00
Ethanfel 5365d9a06b feat: add selected-loader LoRA info sidebar 2026-08-24 20:32:25 +02:00
7 changed files with 35 additions and 1524 deletions
+1 -3
View File
@@ -102,10 +102,8 @@ All nodes appear under the **Lora Manager** category in the ComfyUI node menu, w
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 image or video preview, file details, base model, trigger words, tags, usage tips, and direct model links.
- Cached community creations and Civitai examples appear with video controls, their shared prompt and negative prompt, generation settings, attribution, navigation, and one-click prompt copying.
- 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.
- LoRA Info closes automatically after the selected LoRA loader is cleared, but stays open while switching directly between loaders.
- 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.
+5 -67
View File
@@ -36,8 +36,6 @@ _TEST_CONNECTION_ROUTE = "/api/lm-remote/test-connection"
_PROXY_HOP_HEADER = "X-LM-Remote-Proxy"
_MAX_CONFIG_BODY = 64 * 1024
_MAX_TEST_RESPONSE = 64 * 1024
_MEDIA_STREAM_CHUNK_SIZE = 64 * 1024
_MEDIA_IDLE_TIMEOUT = 300
# ---------------------------------------------------------------------------
# URL prefixes that should be forwarded to the remote LoRA Manager
@@ -618,22 +616,6 @@ def _is_ws_route(path: str) -> bool:
return path in _WS_ROUTES
def _is_streaming_media_route(path: str) -> bool:
"""Return whether a proxied response must be relayed incrementally."""
return path.startswith("/api/lm/previews") or path.startswith(
"/example_images_static/"
)
def _media_request_timeout(snapshot: ConfigSnapshot) -> aiohttp.ClientTimeout:
"""Allow long playback while still bounding connect and idle stalls."""
return aiohttp.ClientTimeout(
total=None,
sock_connect=min(snapshot.timeout, 30),
sock_read=max(snapshot.timeout, _MEDIA_IDLE_TIMEOUT),
)
async def _proxy_ws(
request: web.Request, snapshot: ConfigSnapshot
) -> web.WebSocketResponse:
@@ -725,9 +707,7 @@ async def _proxy_ws(
return local_ws
async def _proxy_http(
request: web.Request, snapshot: ConfigSnapshot
) -> web.StreamResponse:
async def _proxy_http(request: web.Request, snapshot: ConfigSnapshot) -> web.Response:
"""Forward an HTTP request to the remote LoRA Manager and return its response."""
remote_url = f"{snapshot.remote_url}{request.path}"
if request.query_string:
@@ -755,48 +735,14 @@ async def _proxy_http(
headers[k] = v
headers[_PROXY_HOP_HEADER] = "1"
streaming_media = _is_streaming_media_route(request.path)
downstream: web.StreamResponse | None = None
try:
async with _proxy_session_lease(snapshot) as session:
request_options = {
"method": request.method,
"url": remote_url,
"headers": headers,
"data": body,
}
if streaming_media:
request_options.update(
{
"timeout": _media_request_timeout(snapshot),
"auto_decompress": False,
}
)
async with session.request(
**request_options,
method=request.method,
url=remote_url,
headers=headers,
data=body,
) as resp:
if streaming_media:
resp_headers = {}
for k, v in resp.headers.items():
if k.lower() not in (
"transfer-encoding",
"connection",
"set-cookie",
):
resp_headers[k] = v
downstream = web.StreamResponse(
status=resp.status,
headers=resp_headers,
)
await downstream.prepare(request)
if request.method != "HEAD":
async for chunk in resp.content.iter_chunked(
_MEDIA_STREAM_CHUNK_SIZE
):
await downstream.write(chunk)
await downstream.write_eof()
return downstream
resp_body = await resp.read()
resp_headers = {}
for k, v in resp.headers.items():
@@ -813,14 +759,6 @@ async def _proxy_http(
headers=resp_headers,
)
except Exception as exc:
if downstream is not None and downstream.prepared:
logger.debug(
"[LM-Remote] Media stream ended for %s %s: %s",
request.method,
request.path,
exc,
)
return downstream
logger.error(
"[LM-Remote] Proxy error for %s %s: %s", request.method, request.path, exc
)
@@ -3,77 +3,13 @@ import assert from "node:assert/strict";
import {
buildExternalLinks,
closeActiveSidebarTab,
extractLoraNames,
getActiveSidebarTabId,
getSelectedGraphNodes,
isVideoMedia,
matchModelItems,
mergeCivitaiMetadata,
normalizeLoraIdentifier,
normalizeMediaSettings,
normalizeSharedMedia,
normalizeUsageTips,
} from "../../web/comfyui/lora_manager_sidebar_utils.js";
test("closes only the active LoRA sidebar across ComfyUI API shapes", () => {
const managed = {
activeSidebarTabId: "lm-remote-lora-info",
setActiveSidebarTab(id) {
this.activeSidebarTabId = id;
},
};
assert.equal(
closeActiveSidebarTab(managed, "lm-remote-lora-info"),
true
);
assert.equal(getActiveSidebarTabId(managed), null);
const activeRef = { value: "lm-remote-lora-info" };
const refManager = { sidebarTab: { activeSidebarTabId: activeRef } };
assert.equal(
closeActiveSidebarTab(refManager, "lm-remote-lora-info"),
true
);
assert.equal(activeRef.value, null);
let otherTabToggleCount = 0;
const otherTabManager = {
sidebarTab: {
activeSidebarTabId: "node-library",
toggleSidebarTab() {
otherTabToggleCount += 1;
},
},
};
assert.equal(
closeActiveSidebarTab(otherTabManager, "lm-remote-lora-info"),
false
);
assert.equal(otherTabToggleCount, 0);
});
test("falls back to a guarded toggle for readonly sidebar state", () => {
let activeId = "lm-remote-lora-info";
let toggleCount = 0;
const sidebarTab = {
toggleSidebarTab(tabId) {
toggleCount += 1;
activeId = activeId === tabId ? null : tabId;
},
};
Object.defineProperty(sidebarTab, "activeSidebarTabId", {
get: () => activeId,
});
assert.equal(
closeActiveSidebarTab({ sidebarTab }, "lm-remote-lora-info"),
true
);
assert.equal(activeId, null);
assert.equal(toggleCount, 1);
});
test("normalizes loader paths and weight extensions", () => {
assert.equal(
normalizeLoraIdentifier("Styles\\Portrait.safetensors"),
@@ -95,168 +31,6 @@ test("formats Manager usage presets and hides empty JSON", () => {
]);
});
test("detects direct and encoded video preview URLs", () => {
assert.equal(
isVideoMedia(
"/api/lm/previews?path=%2Fmodels%2Floras%2Fexample.MP4"
),
true
);
assert.equal(isVideoMedia("https://example.com/demo.WEBM?download=1"), true);
assert.equal(isVideoMedia("https://example.com/media/42", "video"), true);
assert.equal(isVideoMedia("https://example.com/still.png"), false);
assert.doesNotThrow(() => isVideoMedia("/preview?path=%E0%A4%A"));
});
test("merges full Civitai metadata without losing resolve fields", () => {
assert.deepEqual(
mergeCivitaiMetadata(
{ id: 7, modelId: 9, trainedWords: ["portrait"] },
{ description: "Full details", images: [{ id: 1 }] }
),
{
id: 7,
modelId: 9,
trainedWords: ["portrait"],
description: "Full details",
images: [{ id: 1 }],
}
);
});
test("normalizes community and Civitai media with associated prompts", () => {
const media = normalizeSharedMedia(
[
{
civitai_image_id: 10,
preview_url:
"/api/lm/previews?path=%2Fexamples%2Fcommunity-video.mp4",
media_type: "video",
prompt: "community prompt",
negative_prompt: "community negative",
username: "artist",
steps: 20,
cfg_scale: 4.5,
width: 1280,
height: 720,
},
],
{
images: [
{
id: 10,
url: "https://example.com/duplicate.mp4",
type: "video",
nsfwLevel: 16,
meta: { prompt: "duplicate prompt" },
},
{
id: 11,
url: "https://example.com/still.webp",
meta: {
meta: {
prompt: "example prompt",
negativePrompt: "example negative",
sampler: "Euler",
seed: 12,
Size: "640x480",
Model: "Example checkpoint",
clipSkip: 2,
},
},
},
],
customImages: [
{ id: 12, url: "javascript:alert(1)", meta: { prompt: "unsafe" } },
{
id: 14,
url: "https://user:password@example.com/private.webp",
meta: { prompt: "credential leak" },
},
{
id: 13,
url: "https://example.com/failed.webp",
downloadFailed: true,
},
{
id: "local-one",
url: "",
meta: { prompt: "local custom prompt" },
},
],
},
[
{
name: "custom_local-one.webm",
path: "/example_images_static/abc/custom_local-one.webm",
is_video: true,
},
]
);
assert.equal(media.length, 3);
assert.deepEqual(
{
source: media[0].source,
mediaType: media[0].mediaType,
prompt: media[0].prompt,
negativePrompt: media[0].negativePrompt,
username: media[0].username,
steps: media[0].steps,
cfgScale: media[0].cfgScale,
},
{
source: "Community creation",
mediaType: "video",
prompt: "community prompt",
negativePrompt: "community negative",
username: "artist",
steps: 20,
cfgScale: 4.5,
}
);
assert.equal(media[0].url.includes("/api/lm/previews"), true);
assert.equal(media[0].nsfwLevel, 16);
assert.equal(media[1].prompt, "example prompt");
assert.equal(media[1].negativePrompt, "example negative");
assert.equal(media[1].sampler, "Euler");
assert.equal(media[1].seed, 12);
assert.equal(media[1].width, 640);
assert.equal(media[1].height, 480);
assert.equal(media[1].modelName, "Example checkpoint");
assert.equal(media[1].clipSkip, 2);
assert.equal(media[2].url, "/example_images_static/abc/custom_local-one.webm");
assert.equal(media[2].mediaType, "video");
assert.equal(media[2].prompt, "local custom prompt");
});
test("optimizes Civitai media and normalizes mature-content settings", () => {
const media = normalizeSharedMedia([], {
images: [
{
id: 1,
url: "https://image.civitai.com/example/original=true/video.mp4",
type: "video",
},
],
});
assert.equal(media.length, 1);
assert.match(media[0].url, /transcode=true,width=450,optimized=true/);
assert.deepEqual(
normalizeMediaSettings({
blur_mature_content: false,
mature_blur_level: "XXX",
show_only_sfw: true,
}),
{
blurMatureContent: false,
matureBlurLevel: 16,
showOnlySfw: true,
}
);
});
test("extracts stock and numbered LoRA loader widgets", () => {
const node = {
comfyClass: "Power Lora Loader",
-98
View File
@@ -4,7 +4,6 @@ import importlib.util
import json
import sys
import types
from contextlib import asynccontextmanager
from pathlib import Path
import pytest
@@ -509,103 +508,6 @@ class FakeSession:
self.closed = True
@pytest.mark.asyncio
async def test_media_proxy_streams_range_response_without_buffering(
isolated_proxy, modules, monkeypatch
):
proxy, config = isolated_proxy
captured = {}
class UpstreamContent:
async def iter_chunked(self, size):
assert size == proxy._MEDIA_STREAM_CHUNK_SIZE
for chunk in (b"abc", b"def"):
yield chunk
class UpstreamResponse:
status = 206
headers = {
"Content-Type": "video/mp4",
"Content-Length": "6",
"Content-Range": "bytes 0-5/20",
"Accept-Ranges": "bytes",
"Set-Cookie": "remote=secret",
}
content = UpstreamContent()
read_called = False
async def read(self):
self.read_called = True
raise AssertionError("streaming media must not call read()")
upstream = UpstreamResponse()
class RequestContext:
async def __aenter__(self):
return upstream
async def __aexit__(self, *_args):
return None
class StreamingSession:
def request(self, **kwargs):
captured["request"] = kwargs
return RequestContext()
@asynccontextmanager
async def fake_lease(snapshot):
captured["generation"] = snapshot.generation
yield StreamingSession()
class DownstreamResponse:
def __init__(self, *, status, headers):
self.status = status
self.headers = headers
self.prepared = False
self.chunks = []
self.eof = False
async def prepare(self, request):
self.prepared = True
self.request = request
async def write(self, chunk):
self.chunks.append(chunk)
async def write_eof(self):
self.eof = True
monkeypatch.setattr(proxy, "_proxy_session_lease", fake_lease)
monkeypatch.setattr(proxy.web, "StreamResponse", DownstreamResponse)
snapshot = modules.config.ConfigSnapshot(
config.generation,
"http://manager.local:8188",
30,
(),
)
request = DummyRequest(
path="/api/lm/previews",
headers={"Range": "bytes=0-"},
)
response = await proxy._proxy_http(request, snapshot)
assert response.status == 206
assert response.chunks == [b"abc", b"def"]
assert response.eof is True
assert upstream.read_called is False
assert response.headers["Content-Range"] == "bytes 0-5/20"
assert response.headers["Accept-Ranges"] == "bytes"
assert response.headers["Content-Length"] == "6"
assert "Set-Cookie" not in response.headers
assert captured["request"]["headers"]["Range"] == "bytes=0-"
assert captured["request"]["auto_decompress"] is False
timeout = captured["request"]["timeout"]
assert timeout.total is None
assert timeout.sock_connect == 30
assert timeout.sock_read == proxy._MEDIA_IDLE_TIMEOUT
@pytest.mark.asyncio
async def test_proxy_session_does_not_retain_remote_cookies(
isolated_proxy, monkeypatch
+1 -211
View File
@@ -163,38 +163,13 @@
background: #101012;
}
.lmri-preview img,
.lmri-preview video {
.lmri-preview img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.lmri-preview video {
object-fit: contain;
}
.lmri-media-blurred {
filter: blur(22px) brightness(0.55);
transform: scale(1.08);
}
.lmri-media-reveal {
position: absolute;
z-index: 1;
top: 50%;
left: 50%;
padding: 7px 10px;
border: 1px solid rgb(255 255 255 / 45%);
border-radius: 7px;
background: rgb(10 10 12 / 82%);
color: white;
font: inherit;
cursor: pointer;
transform: translate(-50%, -50%);
}
.lmri-card-body,
.lmri-notice {
padding: 13px;
@@ -304,191 +279,6 @@
white-space: pre-wrap;
}
.lmri-shared {
margin-top: 14px;
overflow: hidden;
border: 1px solid var(--lmri-border);
border-radius: 10px;
background: color-mix(in srgb, var(--lmri-bg) 58%, transparent);
}
.lmri-shared-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 9px 10px;
}
.lmri-shared-header h3,
.lmri-shared-prompt h4 {
margin: 0;
color: var(--lmri-text);
font-size: 11px;
font-weight: 650;
}
.lmri-shared-source {
display: block;
margin-top: 1px;
color: var(--lmri-muted);
font-size: 9px;
}
.lmri-shared-navigation {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 5px;
}
.lmri-shared-nav,
.lmri-copy-button {
border: 1px solid var(--lmri-border);
border-radius: 6px;
background: var(--lmri-panel);
color: var(--lmri-text);
font: inherit;
cursor: pointer;
}
.lmri-shared-nav {
display: grid;
width: 24px;
height: 24px;
place-items: center;
padding: 0;
font-size: 17px;
line-height: 1;
}
.lmri-shared-position {
min-width: 29px;
color: var(--lmri-muted);
font-size: 9px;
text-align: center;
}
.lmri-shared-dots {
display: flex;
gap: 5px;
padding: 8px 9px 0;
overflow-x: auto;
}
.lmri-shared-dot {
flex: 0 0 8px;
width: 8px;
height: 8px;
padding: 0;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--lmri-muted) 52%, transparent);
cursor: pointer;
}
.lmri-shared-dot.active {
background: var(--lmri-accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--lmri-accent) 28%, transparent);
}
.lmri-shared-dot:focus-visible {
outline: 2px solid color-mix(in srgb, var(--lmri-accent) 75%, white 15%);
outline-offset: 2px;
}
.lmri-shared-viewer {
position: relative;
width: 100%;
min-height: 120px;
max-height: 360px;
aspect-ratio: 4 / 3;
overflow: hidden;
border-block: 1px solid var(--lmri-border);
background: #101012;
}
.lmri-shared-viewer img,
.lmri-shared-viewer video {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.lmri-shared-params {
padding: 8px 9px 0;
}
.lmri-shared-prompt {
padding: 9px 10px 10px;
}
.lmri-prompt-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.lmri-copy-button {
min-width: 48px;
padding: 3px 7px;
color: var(--lmri-muted);
font-size: 9px;
}
.lmri-copy-button:disabled {
cursor: wait;
opacity: 0.65;
}
.lmri-shared-prompt-text,
.lmri-negative-prompt p {
margin: 0;
overflow-wrap: anywhere;
color: var(--lmri-text);
font-size: 11px;
line-height: 1.45;
white-space: pre-wrap;
}
.lmri-shared-prompt-text {
max-height: 9em;
overflow-y: auto;
}
.lmri-negative-prompt {
padding: 0 10px 10px;
color: var(--lmri-muted);
font-size: 10px;
}
.lmri-negative-prompt summary {
cursor: pointer;
}
.lmri-negative-prompt p {
margin-top: 6px;
color: var(--lmri-muted);
}
.lmri-enrichment-state {
display: flex;
align-items: center;
gap: 7px;
margin-top: 12px;
color: var(--lmri-muted);
font-size: 10px;
}
.lmri-shared-nav:hover,
.lmri-copy-button:hover,
.lmri-media-reveal:hover {
border-color: color-mix(in srgb, var(--lmri-accent) 75%, var(--lmri-border));
}
.lmri-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
+27 -537
View File
@@ -4,17 +4,11 @@ import { api } from "../../scripts/api.js";
import {
buildExternalLinks,
cleanLoraName,
closeActiveSidebarTab,
extractLoraNames,
getActiveSidebarTabId,
getSelectedGraphNodes,
isVideoMedia,
loraSearchTerm,
matchModelItems,
mergeCivitaiMetadata,
normalizeLoraIdentifier,
normalizeMediaSettings,
normalizeSharedMedia,
normalizeUsageTips,
} from "./lora_manager_sidebar_utils.js";
import { openRemoteConfigDialog } from "./remote_config_dialog.js";
@@ -27,9 +21,6 @@ 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");
const MAX_SHARED_MEDIA = 40;
const MATURE_MEDIA_LEVEL = 4;
const SIDEBAR_CLOSE_DELAY_MS = 80;
let sidebarRoot = null;
let selectedNode = null;
@@ -40,7 +31,6 @@ let lookupState = { status: "idle" };
let lookupGeneration = 0;
let lookupController = null;
let monitorTimer = null;
let sidebarCloseTimer = null;
function createElement(tag, className, text) {
const element = document.createElement(tag);
@@ -108,11 +98,7 @@ function safePreviewUrl(value) {
if (!value) return "";
try {
const parsed = new URL(String(value), window.location.origin);
if (
!parsed.username &&
!parsed.password &&
(parsed.protocol === "http:" || parsed.protocol === "https:")
) {
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
return parsed.href;
}
} catch {
@@ -121,124 +107,6 @@ function safePreviewUrl(value) {
return "";
}
function createMediaElement(url, mediaType, alt) {
const safeUrl = safePreviewUrl(url);
if (!safeUrl) return null;
if (mediaType === "video") {
const video = document.createElement("video");
video.src = safeUrl;
video.controls = true;
video.muted = true;
video.loop = true;
video.playsInline = true;
video.preload = "metadata";
video.referrerPolicy = "no-referrer";
video.setAttribute("playsinline", "");
video.setAttribute("aria-label", alt);
return video;
}
const image = document.createElement("img");
image.src = safeUrl;
image.alt = alt;
image.loading = "lazy";
image.referrerPolicy = "no-referrer";
return image;
}
function addMatureMediaGate(
container,
media,
level,
{ gateUnknown = false, settings = null } = {}
) {
const mediaSettings = settings || normalizeMediaSettings();
if (!mediaSettings.blurMatureContent) return;
const numericLevel = Number(level);
const hasKnownLevel =
level != null && Number.isFinite(numericLevel) && numericLevel > 0;
if (
(hasKnownLevel && numericLevel < mediaSettings.matureBlurLevel) ||
(!hasKnownLevel && !gateUnknown)
) {
return;
}
media.classList.add("lmri-media-blurred");
const previousTabIndex = media.getAttribute("tabindex");
const hadControls = media instanceof HTMLVideoElement && media.controls;
if (media instanceof HTMLVideoElement) {
media.pause();
media.controls = false;
}
media.tabIndex = -1;
media.setAttribute("aria-hidden", "true");
const reveal = createElement(
"button",
"lmri-media-reveal",
hasKnownLevel ? "Show mature preview" : "Show unrated preview"
);
reveal.type = "button";
reveal.addEventListener("click", () => {
media.classList.remove("lmri-media-blurred");
media.removeAttribute("aria-hidden");
if (previousTabIndex == null) media.removeAttribute("tabindex");
else media.setAttribute("tabindex", previousTabIndex);
if (media instanceof HTMLVideoElement && hadControls) media.controls = true;
reveal.remove();
});
container.appendChild(reveal);
}
async function copyText(value) {
const text = String(value || "");
if (!text) return false;
try {
await navigator.clipboard?.writeText(text);
if (navigator.clipboard?.writeText) return true;
} catch {
// Fall through to the legacy copy path for non-secure ComfyUI origins.
}
const textarea = document.createElement("textarea");
const previousFocus = document.activeElement;
textarea.value = text;
textarea.readOnly = true;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, text.length);
try {
return document.execCommand?.("copy") === true;
} catch {
return false;
} finally {
textarea.remove();
previousFocus?.focus?.();
}
}
function createCopyButton(text) {
const button = createElement("button", "lmri-copy-button", "Copy");
button.type = "button";
button.title = "Copy shared prompt";
button.setAttribute("aria-live", "polite");
button.addEventListener("click", async () => {
button.disabled = true;
const copied = await copyText(text);
button.textContent = copied ? "Copied" : "Copy failed";
button.disabled = false;
button.focus();
window.setTimeout(() => {
if (button.isConnected) button.textContent = "Copy";
}, 1800);
});
return button;
}
function toDisplayList(value) {
if (Array.isArray(value)) {
return value.map((item) => String(item).trim()).filter(Boolean);
@@ -362,36 +230,11 @@ function renderError(content) {
content.appendChild(panel);
}
async function useResolvedCandidate(model) {
const generation = ++lookupGeneration;
function useResolvedCandidate(model) {
lookupGeneration += 1;
lookupController?.abort();
lookupController = new AbortController();
lookupState = {
status: "found",
model,
enrichment: "loading",
mediaIndex: 0,
};
lookupState = { status: "found", model };
renderSidebar();
try {
const enriched = await safelyEnrichManagerCard(
model,
lookupController.signal
);
if (generation !== lookupGeneration) return;
lookupState = {
status: "found",
model: enriched,
enrichment: "ready",
mediaIndex: 0,
};
refreshEnrichmentSlot();
} catch (error) {
if (error?.name !== "AbortError" && generation === lookupGeneration) {
lookupState = { ...lookupState, enrichment: "ready" };
renderSidebar();
}
}
}
function renderAmbiguous(content) {
@@ -415,9 +258,7 @@ function renderAmbiguous(content) {
createElement("strong", "", title),
createElement("span", "", path || model.file_path || "")
);
button.addEventListener("click", () => {
void useResolvedCandidate(model);
});
button.addEventListener("click", () => useResolvedCandidate(model));
candidates.appendChild(button);
});
panel.appendChild(candidates);
@@ -425,214 +266,19 @@ function renderAmbiguous(content) {
content.appendChild(panel);
}
function sharedParameterLabels(media) {
const values = [];
if (media.width && media.height) values.push(`${media.width}×${media.height}`);
if (media.steps != null && media.steps !== "") {
values.push(`${media.steps} steps`);
}
if (media.sampler) values.push(String(media.sampler));
if (media.cfgScale != null && media.cfgScale !== "") {
values.push(`CFG ${media.cfgScale}`);
}
if (media.seed != null && media.seed !== "") {
values.push(`Seed ${media.seed}`);
}
if (media.denoise != null && media.denoise !== "") {
values.push(`Denoise ${media.denoise}`);
}
if (media.clipSkip != null && media.clipSkip !== "") {
values.push(`Clip skip ${media.clipSkip}`);
}
if (media.modelName) values.push(`Model ${media.modelName}`);
if (media.baseModel) values.push(`Base ${media.baseModel}`);
if (media.reactionCount > 0) values.push(`${media.reactionCount}`);
return values;
}
function createSharedMediaSection(model) {
const items = Array.isArray(model.sharedMedia) ? model.sharedMedia : [];
if (!items.length) return null;
const requestedIndex = Number(lookupState.mediaIndex);
const index = Number.isInteger(requestedIndex)
? Math.min(Math.max(requestedIndex, 0), items.length - 1)
: 0;
const media = items[index];
const section = createElement("section", "lmri-shared");
const header = createElement("div", "lmri-shared-header");
const heading = createElement("div", "");
heading.append(
createElement("h3", "", "Shared examples"),
createElement(
"span",
"lmri-shared-source",
[media.source, media.username ? `by ${media.username}` : ""]
.filter(Boolean)
.join(" · ")
)
);
header.appendChild(heading);
if (items.length > 1) {
const navigation = createElement("div", "lmri-shared-navigation");
const previous = createElement("button", "lmri-shared-nav", "");
previous.type = "button";
previous.title = "Previous shared example";
previous.setAttribute("aria-label", previous.title);
const position = createElement(
"span",
"lmri-shared-position",
`${index + 1}/${items.length}`
);
position.setAttribute("role", "status");
position.setAttribute("aria-live", "polite");
const next = createElement("button", "lmri-shared-nav", "");
next.type = "button";
next.title = "Next shared example";
next.setAttribute("aria-label", next.title);
previous.dataset.direction = "previous";
next.dataset.direction = "next";
const replaceAt = (nextIndex, focusSelector) => {
lookupState = { ...lookupState, mediaIndex: nextIndex };
const replacement = createSharedMediaSection(model);
if (!replacement) return;
section.replaceWith(replacement);
replacement.querySelector(focusSelector)?.focus();
};
previous.addEventListener("click", () => {
replaceAt(
(index - 1 + items.length) % items.length,
'[data-direction="previous"]'
);
});
next.addEventListener("click", () => {
replaceAt((index + 1) % items.length, '[data-direction="next"]');
});
navigation.append(previous, position, next);
header.appendChild(navigation);
}
section.appendChild(header);
const viewer = createElement("div", "lmri-shared-viewer");
if (media.width && media.height) {
const aspect = media.width / media.height;
if (aspect >= 0.4 && aspect <= 2.5) {
viewer.style.aspectRatio = `${media.width} / ${media.height}`;
}
}
const mediaElement = createMediaElement(
media.url,
media.mediaType,
`${media.source} for ${model.model_name || activeName}`
);
if (mediaElement) {
mediaElement.addEventListener("error", () => viewer.remove());
viewer.appendChild(mediaElement);
addMatureMediaGate(viewer, mediaElement, media.nsfwLevel, {
gateUnknown: media.source === "Community creation",
settings: model.mediaSettings,
});
section.appendChild(viewer);
}
if (items.length > 2) {
const dots = createElement("div", "lmri-shared-dots");
items.forEach((item, itemIndex) => {
const dot = createElement(
"button",
itemIndex === index ? "lmri-shared-dot active" : "lmri-shared-dot"
);
dot.type = "button";
dot.title = `${item.source} ${itemIndex + 1}`;
dot.setAttribute("aria-label", `Show shared example ${itemIndex + 1}`);
dot.dataset.mediaIndex = String(itemIndex);
if (itemIndex === index) dot.setAttribute("aria-current", "true");
dot.addEventListener("click", () => {
lookupState = { ...lookupState, mediaIndex: itemIndex };
const replacement = createSharedMediaSection(model);
if (!replacement) return;
section.replaceWith(replacement);
replacement
.querySelector(`[data-media-index="${itemIndex}"]`)
?.focus();
});
dots.appendChild(dot);
});
section.appendChild(dots);
}
const parameters = sharedParameterLabels(media);
if (parameters.length) appendPills(section, parameters, "lmri-shared-params");
if (media.prompt) {
const prompt = createElement("section", "lmri-shared-prompt");
const promptHeader = createElement("div", "lmri-prompt-header");
promptHeader.append(
createElement("h4", "", "Shared prompt"),
createCopyButton(media.prompt)
);
prompt.append(
promptHeader,
createElement("p", "lmri-shared-prompt-text", media.prompt)
);
section.appendChild(prompt);
}
if (media.negativePrompt) {
const negative = createElement("details", "lmri-negative-prompt");
negative.append(
createElement("summary", "", "Negative prompt"),
createElement("p", "", media.negativePrompt)
);
section.appendChild(negative);
}
return section;
}
function populateEnrichmentSlot(container, model) {
container.replaceChildren();
const sharedMedia = createSharedMediaSection(model);
if (sharedMedia) {
container.appendChild(sharedMedia);
return;
}
if (lookupState.enrichment === "loading") {
const loading = createElement("div", "lmri-enrichment-state");
loading.setAttribute("role", "status");
loading.setAttribute("aria-live", "polite");
loading.append(
createElement("i", "pi pi-spin pi-spinner"),
createElement("span", "", "Loading shared media…")
);
container.appendChild(loading);
}
}
function refreshEnrichmentSlot() {
const slot = sidebarRoot?.querySelector(".lmri-enrichment-slot");
if (slot && lookupState.status === "found") {
populateEnrichmentSlot(slot, lookupState.model);
}
}
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 media = createMediaElement(
previewUrl,
isVideoMedia(model.preview_url) ? "video" : "image",
`Preview for ${model.model_name || activeName}`
);
if (media) {
media.addEventListener("error", () => preview.remove());
preview.appendChild(media);
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");
@@ -712,10 +358,6 @@ function renderModelCard(content, model) {
body.appendChild(section);
}
const enrichment = createElement("div", "lmri-enrichment-slot");
populateEnrichmentSlot(enrichment, model);
body.appendChild(enrichment);
const actions = createElement("div", "lmri-actions lmri-primary-actions");
actions.appendChild(
makeExternalLink(
@@ -851,129 +493,6 @@ async function resolveManagerCard(name, signal) {
return result;
}
async function fetchCivitaiMetadata(model, signal) {
const filePath = String(model?.file_path || "").trim();
if (!filePath) return null;
try {
const params = new URLSearchParams({ file_path: filePath });
const response = await api.fetchApi(`/api/lm/loras/metadata?${params}`, {
signal,
});
if (!response.ok) return null;
const payload = await response.json();
return payload?.success && payload.metadata ? payload.metadata : null;
} catch (error) {
if (error?.name === "AbortError") throw error;
return null;
}
}
function communityImagesForHash(payload, hash) {
const groups = payload?.images;
if (!groups || typeof groups !== "object" || Array.isArray(groups)) return [];
const normalizedHash = String(hash || "").toLowerCase();
for (const [key, images] of Object.entries(groups)) {
if (key.toLowerCase() === normalizedHash && Array.isArray(images)) {
return images;
}
}
return [];
}
async function fetchCommunityImages(model, signal) {
const hash = String(model?.sha256 || "").trim();
if (!hash) return [];
try {
const response = await api.fetchApi(
"/api/lm/community-images/by-hashes",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hashes: [hash] }),
signal,
}
);
if (!response.ok) return [];
const payload = await response.json();
return payload?.success ? communityImagesForHash(payload, hash) : [];
} catch (error) {
if (error?.name === "AbortError") throw error;
return [];
}
}
async function fetchExampleFiles(model, signal) {
const hash = String(model?.sha256 || "").trim();
if (!hash) return [];
try {
const params = new URLSearchParams({ model_hash: hash });
const response = await api.fetchApi(
`/api/lm/example-image-files?${params}`,
{ signal }
);
if (!response.ok) return [];
const payload = await response.json();
return payload?.success && Array.isArray(payload.files) ? payload.files : [];
} catch (error) {
if (error?.name === "AbortError") throw error;
return [];
}
}
async function fetchManagerSettings(signal) {
try {
const response = await api.fetchApi("/api/lm/settings", { signal });
if (!response.ok) return null;
const payload = await response.json();
return payload?.success ? payload.settings : null;
} catch (error) {
if (error?.name === "AbortError") throw error;
return null;
}
}
async function enrichManagerCard(model, signal) {
const [details, communityImages, exampleFiles, rawSettings] = await Promise.all([
fetchCivitaiMetadata(model, signal),
fetchCommunityImages(model, signal),
fetchExampleFiles(model, signal),
fetchManagerSettings(signal),
]);
const civitai = mergeCivitaiMetadata(model?.civitai, details);
const mediaSettings = normalizeMediaSettings(rawSettings);
let sharedMedia = normalizeSharedMedia(
communityImages,
civitai,
exampleFiles
);
if (mediaSettings.showOnlySfw) {
sharedMedia = sharedMedia.filter(
(media) =>
typeof media.nsfwLevel === "number" &&
media.nsfwLevel < MATURE_MEDIA_LEVEL
);
}
return {
...model,
civitai,
mediaSettings,
sharedMedia: sharedMedia.slice(0, MAX_SHARED_MEDIA),
};
}
async function safelyEnrichManagerCard(model, signal) {
try {
return await enrichManagerCard(model, signal);
} catch (error) {
if (error?.name === "AbortError") throw error;
return {
...model,
mediaSettings: normalizeMediaSettings(),
sharedMedia: [],
};
}
}
async function lookupActiveName() {
const name = cleanLoraName(activeName);
if (!name) return;
@@ -992,26 +511,7 @@ async function lookupActiveName() {
throw new Error(result?.error || "The Manager lookup failed.");
}
if (result.found && result.model) {
lookupState = {
status: "found",
model: result.model,
enrichment: "loading",
mediaIndex: 0,
};
renderSidebar();
const enriched = await safelyEnrichManagerCard(
result.model,
lookupController.signal
);
if (generation !== lookupGeneration) return;
lookupState = {
status: "found",
model: enriched,
enrichment: "ready",
mediaIndex: 0,
};
refreshEnrichmentSlot();
return;
lookupState = { status: "found", model: result.model };
} else if (result.ambiguous && result.candidates?.length) {
lookupState = {
status: "ambiguous",
@@ -1034,15 +534,27 @@ 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 (getActiveSidebarTabId(manager) === TAB_ID) return true;
if (activeSidebarId(manager, sidebar) === TAB_ID) return true;
if (typeof manager.setActiveSidebarTab === "function") {
manager.setActiveSidebarTab(TAB_ID);
if (getActiveSidebarTabId(manager) === TAB_ID) return true;
if (activeSidebarId(manager, sidebar) === TAB_ID) return true;
}
if (sidebar && "activeSidebarTabId" in sidebar) {
@@ -1056,7 +568,7 @@ function openSidebarTab() {
} catch {
// Some frontend versions expose a readonly store property.
}
if (getActiveSidebarTabId(manager) === TAB_ID) return true;
if (activeSidebarId(manager, sidebar) === TAB_ID) return true;
}
if (typeof sidebar?.toggleSidebarTab === "function") {
@@ -1074,29 +586,10 @@ function openSidebarTab() {
return false;
}
function cancelSidebarClose() {
if (sidebarCloseTimer == null) return;
window.clearTimeout(sidebarCloseTimer);
sidebarCloseTimer = null;
}
function scheduleSidebarClose() {
cancelSidebarClose();
sidebarCloseTimer = window.setTimeout(() => {
sidebarCloseTimer = null;
const nodes = getSelectedGraphNodes(app.canvas);
const node = nodes.length === 1 ? nodes[0] : null;
if (node && extractLoraNames(node).length) return;
closeActiveSidebarTab(app.extensionManager, TAB_ID);
}, SIDEBAR_CLOSE_DELAY_MS);
}
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 shouldCloseSidebar = selectedNames.length > 0 && names.length === 0;
if (names.length) cancelSidebarClose();
const signature = `${node?.id ?? ""}|${names
.map(normalizeLoraIdentifier)
.join("|")}`;
@@ -1123,8 +616,6 @@ function updateSelection({ autoOpen = false, force = false } = {}) {
if (activeName) {
if (autoOpen && autoOpenEnabled()) openSidebarTab();
lookupActiveName();
} else if (shouldCloseSidebar) {
scheduleSidebarClose();
}
}
@@ -1182,7 +673,6 @@ function registerSidebarTab() {
updateSelection({ force: true });
},
destroy() {
cancelSidebarClose();
lookupGeneration += 1;
lookupController?.abort();
sidebarRoot?.remove();
-381
View File
@@ -1,14 +1,5 @@
const WEIGHT_EXTENSION = /\.(?:safetensors|ckpt|pt|pth|bin)$/i;
const LORA_SYNTAX = /<lora:([^:>]+)(?::[^>]*)?>/gi;
const VIDEO_EXTENSION = /\.(?:mp4|webm|mov|m4v)$/i;
const NSFW_LEVELS = {
pg: 1,
pg13: 2,
r: 4,
x: 8,
xxx: 16,
blocked: 32,
};
const DISABLED_VALUES = new Set([
"",
"none",
@@ -86,378 +77,6 @@ export function normalizeUsageTips(value) {
.filter((entry) => entry.value !== "");
}
function decodeMediaPath(value) {
let decoded = String(value || "");
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const next = decodeURIComponent(decoded);
if (next === decoded) break;
decoded = next;
} catch {
break;
}
}
return decoded;
}
function mediaPath(value) {
const text = String(value || "").trim();
if (!text) return "";
try {
const parsed = new URL(text, "http://localhost/");
return decodeMediaPath(parsed.searchParams.get("path") || parsed.pathname)
.split(/[?#]/, 1)[0]
.toLowerCase();
} catch {
return decodeMediaPath(text).split(/[?#]/, 1)[0].toLowerCase();
}
}
export function isVideoMedia(value, declaredType = "") {
const type = String(declaredType || "").trim().toLowerCase();
if (type === "video" || type.startsWith("video/")) return true;
return VIDEO_EXTENSION.test(mediaPath(value));
}
function safeMediaReference(value) {
if (typeof value !== "string" || !value.trim()) return "";
try {
const parsed = new URL(value.trim(), "http://localhost/");
return !parsed.username &&
!parsed.password &&
(parsed.protocol === "http:" || parsed.protocol === "https:")
? value.trim()
: "";
} catch {
return "";
}
}
function stringValue(value) {
return typeof value === "string" ? value.trim() : "";
}
function firstString(...values) {
for (const value of values) {
const text = stringValue(value);
if (text) return text;
}
return "";
}
function objectValue(value) {
let parsed = value;
if (typeof value === "string") {
try {
parsed = JSON.parse(value);
} catch {
return {};
}
}
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: {};
}
function generationMetadata(item) {
const direct = objectValue(item?.meta);
return { ...direct, ...objectValue(direct.meta) };
}
function normalizeNsfwLevel(value) {
if (typeof value === "number" && Number.isFinite(value)) {
return value > 0 ? value : null;
}
if (typeof value !== "string" || !value.trim()) return null;
const normalized = value.trim().toLowerCase().replace(/[\s_-]+/g, "");
if (normalized === "unknown") return null;
if (normalized in NSFW_LEVELS) return NSFW_LEVELS[normalized];
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
}
function optimizeMediaUrl(value, mediaType) {
const safe = safeMediaReference(value);
if (!safe) return "";
try {
const parsed = new URL(safe, "http://localhost/");
if (
parsed.hostname !== "civitai.com" &&
parsed.hostname.endsWith(".civitai.com") &&
parsed.pathname.includes("/original=true")
) {
const replacement =
mediaType === "video"
? "/transcode=true,width=450,optimized=true"
: "/width=450,optimized=true";
parsed.pathname = parsed.pathname.replace("/original=true", replacement);
return parsed.href;
}
} catch {
return safe;
}
return safe;
}
function mediaIdentity(item, url) {
const id = item?.civitai_image_id ?? item?.id;
return id != null && String(id).trim()
? `id:${String(id).trim()}`
: `url:${url}`;
}
function localExampleFile(item, index, exampleFiles) {
if (!Array.isArray(exampleFiles) || !exampleFiles.length) return null;
if (typeof item?.id === "string" && item.id) {
const prefix = `custom_${item.id}`;
return exampleFiles.find((file) =>
String(file?.name || "").startsWith(prefix)
);
}
return exampleFiles.find((file) => {
const match = /(?:^|\/)image_(\d+)\./i.exec(String(file?.name || ""));
return match && Number(match[1]) === index;
});
}
function normalizeMediaItem(item, source, localFile = null) {
if (!item || typeof item !== "object" || item.downloadFailed) return null;
const meta = generationMetadata(item);
const rawUrl = firstString(
localFile?.path,
item.preview_url,
item.image_url,
item.url
);
const mediaType = isVideoMedia(
rawUrl,
localFile?.is_video ? "video" : item.media_type || item.type
)
? "video"
: "image";
const url = optimizeMediaUrl(rawUrl, mediaType);
if (!url) return null;
const prompt = firstString(item.prompt, meta.prompt);
const negativePrompt = firstString(
item.negative_prompt,
item.negativePrompt,
meta.negative_prompt,
meta.negativePrompt
);
const sizeLabel = firstString(item.size, meta.Size, meta.size);
const sizeMatch = /^(\d+)\s*[x×]\s*(\d+)$/i.exec(sizeLabel);
const width = Number(item.width || sizeMatch?.[1]);
const height = Number(item.height || sizeMatch?.[2]);
const reactionCount = [
item.like_count,
item.heart_count,
item.laugh_count,
item.comment_count,
].reduce((sum, value) => {
const count = Number(value);
return sum + (Number.isFinite(count) && count > 0 ? count : 0);
}, 0);
return {
id: mediaIdentity(item, url),
source,
url,
thumbnailUrl: safeMediaReference(item.thumbnail_url),
mediaType,
prompt,
negativePrompt,
username: firstString(item.username, item.creator?.username),
width: Number.isFinite(width) && width > 0 ? width : null,
height: Number.isFinite(height) && height > 0 ? height : null,
steps: item.steps ?? meta.steps ?? meta.Steps ?? null,
sampler: item.sampler ?? meta.sampler ?? meta.Sampler ?? "",
cfgScale:
item.cfg_scale ?? meta.cfg_scale ?? meta.cfgScale ?? meta.CFG ?? null,
seed: item.seed ?? meta.seed ?? meta.Seed ?? null,
denoise: item.denoise ?? meta.denoise ?? null,
clipSkip: meta.clip_skip ?? meta.clipSkip ?? null,
modelName: firstString(item.model_name, meta.Model, meta.model),
baseModel: firstString(
item.base_model,
item.baseModel,
meta.base_model,
meta.baseModel
),
nsfwLevel: normalizeNsfwLevel(
item.nsfwLevel ?? item.nsfw_level ?? item.nsfw
),
reactionCount,
civitaiImageId: item.civitai_image_id ?? item.id ?? null,
};
}
function missingMediaValue(value) {
return value == null || value === "";
}
function mergeMediaItem(primary, supplement) {
const merged = { ...primary };
const fillFields = [
"thumbnailUrl",
"prompt",
"negativePrompt",
"username",
"width",
"height",
"steps",
"sampler",
"cfgScale",
"seed",
"denoise",
"clipSkip",
"modelName",
"baseModel",
"civitaiImageId",
];
for (const field of fillFields) {
if (missingMediaValue(merged[field]) && !missingMediaValue(supplement[field])) {
merged[field] = supplement[field];
}
}
if (!String(merged.url).startsWith("/") && String(supplement.url).startsWith("/")) {
merged.url = supplement.url;
}
if (supplement.mediaType === "video") merged.mediaType = "video";
const levels = [merged.nsfwLevel, supplement.nsfwLevel].filter(
(value) => typeof value === "number" && Number.isFinite(value)
);
merged.nsfwLevel = levels.length ? Math.max(...levels) : null;
merged.reactionCount = Math.max(
Number(merged.reactionCount) || 0,
Number(supplement.reactionCount) || 0
);
return merged;
}
export function mergeCivitaiMetadata(summary, details) {
const merged = { ...objectValue(summary) };
for (const [key, value] of Object.entries(objectValue(details))) {
if (value !== undefined) merged[key] = value;
}
return merged;
}
export function normalizeSharedMedia(
communityImages,
civitaiMetadata,
exampleFiles = []
) {
const civitai = objectValue(civitaiMetadata);
const regularImages = Array.isArray(civitai.images) ? civitai.images : [];
const customImages = Array.isArray(civitai.customImages)
? civitai.customImages
: [];
const candidates = [
...(Array.isArray(communityImages)
? communityImages.map((item) => [item, "Community creation", null])
: []),
...regularImages.map((item, index) => [
item,
"Civitai example",
localExampleFile(item, index, exampleFiles),
]),
...customImages.map((item, index) => [
item,
"Custom example",
localExampleFile(item, regularImages.length + index, exampleFiles),
]),
];
const indexes = new Map();
const media = [];
for (const [item, source, localFile] of candidates) {
const normalized = normalizeMediaItem(item, source, localFile);
if (!normalized) continue;
const existingIndex = indexes.get(normalized.id);
if (existingIndex != null) {
media[existingIndex] = mergeMediaItem(media[existingIndex], normalized);
continue;
}
indexes.set(normalized.id, media.length);
media.push(normalized);
}
return media;
}
export function normalizeMediaSettings(value) {
const settings = objectValue(value);
return {
blurMatureContent: settings.blur_mature_content !== false,
matureBlurLevel:
normalizeNsfwLevel(settings.mature_blur_level) ?? NSFW_LEVELS.r,
showOnlySfw: settings.show_only_sfw === true,
};
}
function unwrapSidebarValue(value) {
return value && typeof value === "object" && "value" in value
? value.value
: value;
}
export function getActiveSidebarTabId(manager) {
if (!manager) return null;
const sidebar = manager.sidebarTab || manager;
return unwrapSidebarValue(
sidebar?.activeSidebarTabId ?? manager.activeSidebarTabId
);
}
export function closeActiveSidebarTab(manager, tabId) {
if (!manager || getActiveSidebarTabId(manager) !== tabId) return false;
const sidebar = manager.sidebarTab || manager;
if (typeof manager.setActiveSidebarTab === "function") {
try {
manager.setActiveSidebarTab(null);
if (getActiveSidebarTabId(manager) !== tabId) return true;
} catch {
// Older frontend wrappers may reject null.
}
}
if (
sidebar &&
(typeof sidebar === "object" || typeof sidebar === "function") &&
"activeSidebarTabId" in sidebar
) {
try {
const current = sidebar.activeSidebarTabId;
if (current && typeof current === "object" && "value" in current) {
current.value = null;
} else {
sidebar.activeSidebarTabId = null;
}
if (getActiveSidebarTabId(manager) !== tabId) return true;
} catch {
// Some frontend versions expose a readonly store property.
}
}
const toggleTargets = sidebar === manager ? [sidebar] : [sidebar, manager];
for (const target of toggleTargets) {
if (typeof target?.toggleSidebarTab !== "function") continue;
try {
target.toggleSidebarTab(tabId);
if (getActiveSidebarTabId(manager) !== tabId) return true;
} catch {
// Try the remaining compatibility paths.
}
}
if (typeof manager.command?.execute === "function") {
manager.command.execute(`Workspace.ToggleSidebarTab.${tabId}`);
return true;
}
return false;
}
export function extractLoraSyntax(value) {
if (typeof value !== "string") return [];
const names = [];