Files
MajoorWaldi-ComfyUI-Majoor-…/ui/features/panel/panelSimilarSearch.ts
T

555 lines
20 KiB
TypeScript

import { EVENTS } from "../../app/events.js";
import { get, getAssetMetadata, vectorFindSimilar } from "../../api/client.js";
import { buildListURL, buildNodeContextMembersURL } from "../../api/endpoints.js";
import { comfyToast } from "../../app/toast.js";
import { t } from "../../app/i18n.js";
const STACK_GROUP_RENDER_LIMIT = 500;
function isNodeContextRouteMissing(res: any) {
return (
Number(res?.status || 0) === 404 &&
String(res?.error || "")
.toLowerCase()
.includes("non-json")
);
}
function limitStackGroupMembers(list: any[], title = "") {
const items = Array.isArray(list) ? list : [];
if (items.length <= STACK_GROUP_RENDER_LIMIT) {
return { list: items, title, truncated: false, total: items.length };
}
return {
list: items.slice(0, STACK_GROUP_RENDER_LIMIT),
title: `${String(title || "").trim() || "Generation group"} - showing first ${STACK_GROUP_RENDER_LIMIT}/${items.length}`,
truncated: true,
total: items.length,
};
}
function buildGroupSourceId(detail: Record<string, any> = {}) {
const stackId = String(detail?.stackId || detail?.stack_id || "").trim();
if (stackId) return `stack:${stackId}`;
const assetId = String(detail?.asset?.id || "").trim();
if (detail?.isDupGroup) return assetId ? `duplicates:${assetId}` : "duplicates";
return assetId ? `group:${assetId}` : "group";
}
function readAssetField(asset: any, key: string): string {
return String(asset?.[key] ?? asset?.file_info?.[key] ?? "").trim();
}
/**
* Binds the "Find Similar" button and the stack-group open event.
*
* Both interactions build a virtual asset list from vector search results /
* a stack-group member set, write it to panel state, and switch the panel to
* the "similar" view scope.
*
* Listeners are removed automatically when `panelLifecycleAC` is aborted.
*/
export function bindSimilarSearch({
similarBtn,
similarPopover,
similarFindBtn,
similarDuplicatesBtn,
similarSameNodeBtn,
similarSameWorkflowBtn,
gridContainer,
state,
panelLifecycleAC,
isAiEnabled,
similarDisabledTitle,
readActiveAssetId,
readSelectedAssetIds,
readPanelValue,
writePanelValue,
scopeController,
closePopovers,
closePeerPopovers,
popovers,
workflowIdInput,
reloadGrid,
getDuplicatesAlert,
}: Record<string, any>): void {
const selectedAssetId = () =>
String(readActiveAssetId() || readSelectedAssetIds()[0] || "").trim();
const selectedNumericAssetId = () => {
const selectedId = Number(selectedAssetId());
return Number.isFinite(selectedId) && selectedId > 0 ? selectedId : 0;
};
const getActiveAssetFromGrid = () => {
const activeId = selectedAssetId();
if (!activeId) return null;
try {
const getAssets = gridContainer?._mjrGetAssets;
const list = typeof getAssets === "function" ? getAssets() : [];
return Array.isArray(list)
? list.find((asset) => String(asset?.id || "") === activeId) || null
: null;
} catch (err) {
console.debug?.(err);
return null;
}
};
const getActiveAsset = async () => {
const fromGrid = getActiveAssetFromGrid();
if (fromGrid) return fromGrid;
const id = selectedNumericAssetId();
if (!id) return null;
return fetchActiveAssetMetadata(id);
};
const fetchActiveAssetMetadata = async (id = selectedNumericAssetId()) => {
if (!id) return null;
try {
const res = await getAssetMetadata(id, { timeoutMs: 30_000 });
return res?.ok ? res.data || null : null;
} catch (err) {
console.debug?.(err);
return null;
}
};
const hydrateActiveAssetWhenMissing = async (asset: any, keys: string[]) => {
if (asset && keys.some((key) => readAssetField(asset, key))) return asset;
const fresh = await fetchActiveAssetMetadata();
return fresh || asset;
};
const readNodeContextFiles = (detail: Record<string, any> = {}) => {
const raw = Array.isArray(detail?.files) ? detail.files : [];
return raw
.map((file: any) => ({
filename: String(file?.filename || "").trim(),
subfolder: String(file?.subfolder || "").trim().replace(/\\/g, "/"),
type: String(file?.type || "").trim().toLowerCase(),
}))
.filter((file: any) => file.filename);
};
/**
* Fallback used when a node has no indexed source-node context (e.g. a
* Load Image node): resolve the file(s) referenced by the node against the
* asset index by filename and show exact matches in the "similar" scope.
*/
const openNodeFilesFallback = async (detail: Record<string, any>, files: any[]) => {
const matches: any[] = [];
const seen = new Set<string>();
for (const file of files.slice(0, 4)) {
const scope = file.type === "input" ? "input" : "output";
try {
const res = await get(
buildListURL({
q: file.filename,
scope,
limit: 50,
includeTotal: false,
}),
{ timeoutMs: 30_000 },
);
if (!res?.ok) continue;
const rows = Array.isArray(res?.data?.assets)
? res.data.assets
: Array.isArray(res?.data)
? res.data
: [];
const wantedName = file.filename.toLowerCase();
const wantedSuffix = (
file.subfolder ? `${file.subfolder}/${file.filename}` : file.filename
).toLowerCase();
for (const row of rows) {
const rowName = String(row?.filename || "").toLowerCase();
if (rowName !== wantedName) continue;
const rowPath = String(row?.filepath || row?.path || "")
.replace(/\\/g, "/")
.toLowerCase();
if (file.subfolder && rowPath && !rowPath.endsWith(wantedSuffix)) continue;
const key = String(row?.id || rowPath || rowName);
if (seen.has(key)) continue;
seen.add(key);
matches.push(row);
}
} catch (err) {
console.debug?.(err);
}
}
if (!matches.length) {
comfyToast(
t(
"nodeContext.fileNotIndexed",
"The node file is not indexed yet. Run a scan first.",
),
"info",
3200,
);
return;
}
const nodeLabel = String(
detail?.title ||
detail?.sourceNodeType ||
detail?.source_node_type ||
files[0]?.filename ||
"",
).trim();
writePanelValue("similarResults", matches);
writePanelValue(
"similarSourceAssetId",
`node-file:${files[0]?.filename || ""}`,
);
writePanelValue(
"similarTitle",
t("nodeContext.fileResultsTitle", "Node {node} file ({n} assets)", {
node: nodeLabel || files[0]?.filename || "",
n: matches.length,
}),
);
await scopeController?.setScope?.("similar");
};
const openNodeContext = async (detail: Record<string, any> = {}) => {
const sourceNodeId = String(detail?.sourceNodeId || detail?.source_node_id || "").trim();
const files = readNodeContextFiles(detail);
if (!sourceNodeId && !files.length) return;
try {
closePopovers?.();
} catch (err) {
console.debug?.(err);
}
if (!sourceNodeId) {
await openNodeFilesFallback(detail, files);
return;
}
try {
const res = await get(
buildNodeContextMembersURL(sourceNodeId, {
jobId: detail?.jobId || detail?.job_id || "",
latest: detail?.latest !== false,
limit: 500,
}),
{ timeoutMs: 30_000 },
);
if (!res?.ok) {
if (isNodeContextRouteMissing(res)) {
if (files.length) {
await openNodeFilesFallback(detail, files);
return;
}
const root = ((window as any).MajoorAssetsManager ||= {});
if (!root.nodeContextRouteMissingToastShown) {
root.nodeContextRouteMissingToastShown = true;
comfyToast(
t(
"nodeContext.routeMissing",
"Node context backend route is not loaded yet. Restart ComfyUI after updating Majoor Assets Manager.",
),
"warn",
7000,
);
}
return;
}
if (files.length) {
await openNodeFilesFallback(detail, files);
return;
}
comfyToast(
String(res?.error || t("nodeContext.loadFailed", "Failed to load node assets")),
"error",
3000,
);
return;
}
const list = Array.isArray(res?.data) ? res.data : [];
if (!list.length) {
if (files.length) {
await openNodeFilesFallback(detail, files);
return;
}
comfyToast(
t("nodeContext.noAssets", "No indexed assets found for this node yet."),
"info",
2600,
);
return;
}
const nodeLabel = String(
detail?.title || detail?.sourceNodeType || detail?.source_node_type || sourceNodeId,
).trim();
writePanelValue("similarResults", list);
writePanelValue("similarSourceAssetId", `node:${sourceNodeId}`);
writePanelValue(
"similarTitle",
t("nodeContext.resultsTitle", "Node {node} outputs ({n} assets)", {
node: nodeLabel || sourceNodeId,
n: list.length,
}),
);
await scopeController?.setScope?.("similar");
} catch (err) {
console.debug?.(err);
comfyToast(t("nodeContext.loadFailed", "Failed to load node assets"), "error", 3000);
}
};
const runFindSimilar = async () => {
if (!isAiEnabled()) {
comfyToast(similarDisabledTitle, "info", 2200);
return;
}
try {
closePopovers();
} catch (err) {
console.debug?.(err);
}
const selectedId = selectedNumericAssetId();
if (!selectedId) {
comfyToast(
t(
"search.selectAssetForSimilar",
"Select an asset first to find similar images/videos.",
),
"info",
2500,
);
return;
}
const prevTitle = similarBtn.title;
similarBtn.disabled = true;
similarBtn.title = t("search.findingSimilar", "Finding similar assets...");
try {
const res = await vectorFindSimilar(selectedId, {
topK: 100,
scope: state.scope || "output",
customRootId: state.customRootId || "",
});
if (!res?.ok) {
comfyToast(
String(
res?.error ||
t("search.findSimilarFailed", "Failed to find similar assets"),
),
"error",
3000,
);
return;
}
const list = Array.isArray(res?.data) ? res.data : [];
writePanelValue("similarResults", list);
writePanelValue("similarSourceAssetId", String(selectedId));
writePanelValue(
"similarTitle",
t("search.similarResults", "Similar to asset #{id} ({n} results)", {
id: selectedId,
n: list.length,
}),
);
await scopeController?.setScope?.("similar");
} catch (err) {
console.debug?.(err);
comfyToast(
t("search.findSimilarFailed", "Failed to find similar assets"),
"error",
3000,
);
} finally {
similarBtn.disabled = false;
similarBtn.title = prevTitle;
}
};
const runFindDuplicates = async () => {
try {
closePopovers?.();
} catch (err) {
console.debug?.(err);
}
const activeAsset = await getActiveAsset();
const activeId = String(activeAsset?.id || selectedAssetId() || "").trim();
const members = Array.isArray(activeAsset?._mjrDupMembers)
? activeAsset._mjrDupMembers
: [];
if (members.length >= 2) {
writePanelValue("similarResults", members);
writePanelValue("similarSourceAssetId", activeId ? `duplicates:${activeId}` : "duplicates");
writePanelValue(
"similarTitle",
t("search.duplicateResults", "Duplicates ({n} assets)", { n: members.length }),
);
await scopeController?.setScope?.("similar");
return;
}
const alert = getDuplicatesAlert?.() || {};
const group = alert?.firstGroup;
if (group && Array.isArray(group.assets) && group.assets.length >= 2) {
writePanelValue("similarResults", group.assets);
writePanelValue("similarSourceAssetId", "duplicates");
writePanelValue(
"similarTitle",
t("search.duplicateResults", "Duplicates ({n} assets)", { n: group.assets.length }),
);
await scopeController?.setScope?.("similar");
return;
}
comfyToast(
t(
"search.noKnownDuplicates",
"No duplicate group is available yet. Run duplicate analysis from the duplicate alert first.",
),
"info",
3200,
);
};
const runSameSaveNode = async () => {
const asset = await hydrateActiveAssetWhenMissing(await getActiveAsset(), [
"source_node_id",
"source_node_type",
]);
const sourceNodeId = readAssetField(asset, "source_node_id");
if (!sourceNodeId) {
comfyToast(
t("search.noSourceNode", "Selected asset has no persisted source node id."),
"info",
2600,
);
return;
}
await openNodeContext({
sourceNodeId,
sourceNodeType: readAssetField(asset, "source_node_type"),
jobId: readAssetField(asset, "job_id"),
title: readAssetField(asset, "source_node_type") || sourceNodeId,
});
};
const runSameWorkflow = async () => {
try {
closePopovers?.();
} catch (err) {
console.debug?.(err);
}
const asset = await hydrateActiveAssetWhenMissing(await getActiveAsset(), ["workflow_id"]);
const workflowId = readAssetField(asset, "workflow_id");
if (!workflowId) {
comfyToast(
t("search.noWorkflowId", "Selected asset has no persisted workflow id."),
"info",
2600,
);
return;
}
writePanelValue("workflowId", workflowId);
try {
if (workflowIdInput) workflowIdInput.value = workflowId;
} catch (err) {
console.debug?.(err);
}
try {
await scopeController?.setScope?.(state.scope || "output");
} catch (err) {
console.debug?.(err);
}
await reloadGrid?.();
};
// -- Similar menu button + actions --------------------------------------
similarBtn?.addEventListener(
"click",
(e: any) => {
e.stopPropagation();
try {
closePeerPopovers?.();
} catch (err) {
console.debug?.(err);
}
popovers?.toggle?.(similarPopover, similarBtn);
},
{ signal: panelLifecycleAC?.signal },
);
const bindAction = (button: any, handler: any) => {
button?.addEventListener(
"click",
(event: any) => {
event.stopPropagation();
void handler();
},
{ signal: panelLifecycleAC?.signal },
);
};
bindAction(similarFindBtn, runFindSimilar);
bindAction(similarDuplicatesBtn, runFindDuplicates);
bindAction(similarSameNodeBtn, runSameSaveNode);
bindAction(similarSameWorkflowBtn, runSameWorkflow);
// -- Stack-group open event ---------------------------------------------
gridContainer?.addEventListener(
EVENTS.OPEN_STACK_GROUP,
async (event: any) => {
try {
const detail = event?.detail || {};
const rawList = Array.isArray(detail?.members) ? detail.members : [];
const fallbackTitle = `Generation group (${rawList.length} assets)`;
const limited = limitStackGroupMembers(
rawList,
String(detail?.title || "").trim() || fallbackTitle,
);
if (limited.truncated) {
comfyToast(
`Large stack truncated to ${STACK_GROUP_RENDER_LIMIT}/${limited.total} assets to keep the grid responsive.`,
"warn",
5000,
);
}
writePanelValue("similarResults", limited.list);
writePanelValue("similarSourceAssetId", buildGroupSourceId(detail));
writePanelValue("similarTitle", limited.title || fallbackTitle);
await Promise.resolve();
await scopeController?.setScope?.("similar");
} catch (err) {
console.debug?.(err);
}
},
{ signal: panelLifecycleAC?.signal },
);
window.addEventListener(
EVENTS.OPEN_NODE_CONTEXT,
(event) => {
try {
const root = (window as any).MajoorAssetsManager;
if (root?.pendingNodeContext) root.pendingNodeContext = null;
} catch (err) {
console.debug?.(err);
}
void openNodeContext((event as any)?.detail || {});
},
{ signal: panelLifecycleAC?.signal },
);
try {
const pending = (window as any).MajoorAssetsManager?.pendingNodeContext || null;
if (pending) {
(window as any).MajoorAssetsManager.pendingNodeContext = null;
setTimeout(() => {
void openNodeContext(pending);
}, 0);
}
} catch (err) {
console.debug?.(err);
}
}