mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
feat(recipes): delegate CivitAI-image re-import to companion browser extension
Recipes imported from CivitAI image URLs can contain 0 LoRAs: the backend
only sees the REST image API + EXIF, while the complete generation data
lives in the image page's internal trpc payload (see
docs/recipe-civitai-image-no-metadata.md). When the companion
lm-civitai-extension is installed with a valid license, re-import (single
and bulk) of CivitAI-image-sourced recipes is now delegated to the
extension via DOM CustomEvents; the extension scrapes the image page with
the user's session and calls back into the reimport endpoint with the
full metadata payload. Without the extension (or with an invalid license)
the native path runs unchanged.
- POST /api/lm/recipe/{id}/reimport accepts optional payload params
(image_url/name/resources/gen_params/base_model/tags); the payload path
reuses the import-remote engine with reimport semantics (user-edit
carryover, delete-after-save), and malformed/failed payloads fall back
to the legacy URL import. Response gains loras_count.
- The endpoint also accepts GET: the extension is GET-only by convention
(documented in AGENTS.md).
- New static/js/utils/extensionReimportBridge.js (probeExtension /
delegateReimport / getCivitaiImageInfo) wired into RecipeContextMenu
and BulkManager with silent native fallback.
- i18n: toast.recipes.reimportingViaExtension added and translated in
all 9 locales.
This commit is contained in:
@@ -6,6 +6,7 @@ import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js
|
||||
import { updateRecipeMetadata } from '../../api/recipeApi.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { moveManager } from '../../managers/MoveManager.js';
|
||||
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js';
|
||||
|
||||
export class RecipeContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
@@ -355,6 +356,24 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recipes imported from a CivitAI image page can carry incomplete
|
||||
// metadata (0 LoRAs); the companion browser extension can re-import
|
||||
// them with the full page data. Fall back to the native path whenever
|
||||
// the extension is absent, unlicensed, or the delegation fails.
|
||||
const recipeItem = state.virtualScroller?.items?.find(item => item?.id === recipeId);
|
||||
const civitaiImage = getCivitaiImageInfo(recipeItem?.source_path);
|
||||
if (civitaiImage) {
|
||||
try {
|
||||
const probe = await probeExtension();
|
||||
if (probe?.supported && probe?.licenseValid) {
|
||||
await this.reimportViaExtension(recipeId, civitaiImage, recipeItem?.title || '');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Extension re-import unavailable, using native path:', error);
|
||||
}
|
||||
}
|
||||
|
||||
state.loadingManager.showSimpleLoading('Re-importing recipe from source...');
|
||||
|
||||
try {
|
||||
@@ -377,6 +396,34 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
showToast('recipes.contextMenu.reimport.failed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Re-import a single CivitAI-image recipe through the companion browser
|
||||
// extension. Throws on delegation failure so the caller can fall back to
|
||||
// the native path.
|
||||
async reimportViaExtension(recipeId, civitaiImage, title) {
|
||||
state.loadingManager.showSimpleLoading('Re-importing recipe via browser extension...');
|
||||
|
||||
try {
|
||||
const { failed } = await delegateReimport([{
|
||||
recipeId,
|
||||
imageId: civitaiImage.imageId,
|
||||
imageUrl: civitaiImage.imageUrl,
|
||||
title,
|
||||
}]);
|
||||
|
||||
state.loadingManager.hide();
|
||||
if (failed > 0) {
|
||||
showToast('recipes.contextMenu.reimport.failed', { message: 'Extension re-import failed' }, 'error');
|
||||
} else {
|
||||
showToast('toast.recipes.reimportSuccess', {}, 'success');
|
||||
}
|
||||
const { resetAndReload } = await import('../../api/recipeApi.js');
|
||||
resetAndReload(false, { preserveScroll: false });
|
||||
} catch (error) {
|
||||
state.loadingManager.hide();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mix in shared methods from ModelContextMenuMixin
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createBaseModelPicker, inferBaseModelsFromFilepaths } from '../componen
|
||||
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
||||
import { eventManager } from '../utils/EventManager.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../utils/extensionReimportBridge.js';
|
||||
import { getNsfwLevelSelector } from '../components/shared/NsfwLevelSelector.js';
|
||||
|
||||
export class BulkManager {
|
||||
@@ -857,17 +858,74 @@ export class BulkManager {
|
||||
`Re-importing recipe 1/${total}...`
|
||||
);
|
||||
|
||||
// Partition the selection: recipes sourced from a CivitAI image page
|
||||
// can be delegated to the companion browser extension (which scrapes
|
||||
// the full page metadata); everything else uses the native endpoint.
|
||||
const delegatable = [];
|
||||
const nativeFilePaths = [];
|
||||
for (const filePath of filePaths) {
|
||||
const recipeItem = recipeMap.get(filePath);
|
||||
const civitaiImage = getCivitaiImageInfo(recipeItem?.source_path);
|
||||
if (civitaiImage && recipeItem?.id) {
|
||||
delegatable.push({
|
||||
filePath,
|
||||
recipeId: recipeItem.id,
|
||||
imageId: civitaiImage.imageId,
|
||||
imageUrl: civitaiImage.imageUrl,
|
||||
title: recipeItem.title || '',
|
||||
});
|
||||
} else {
|
||||
nativeFilePaths.push(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Probe once; on any probe/delegate failure the delegatable recipes
|
||||
// fall back to the native sequential loop below.
|
||||
if (delegatable.length > 0) {
|
||||
try {
|
||||
const probe = await probeExtension();
|
||||
if (probe?.supported && probe?.licenseValid) {
|
||||
const batchResult = await delegateReimport(
|
||||
delegatable.map(({ recipeId, imageId, imageUrl, title }) => ({
|
||||
recipeId, imageId, imageUrl, title,
|
||||
})),
|
||||
{
|
||||
onProgress: (progress) => {
|
||||
progressUI.updateProgress(
|
||||
Math.floor(((progress.current || 0) / total) * 100),
|
||||
progress.title || '',
|
||||
translate('toast.recipes.reimportingViaExtension', {
|
||||
current: progress.current || 0,
|
||||
total,
|
||||
})
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
completed += batchResult.completed;
|
||||
failed += batchResult.failed;
|
||||
} else {
|
||||
nativeFilePaths.push(...delegatable.map(entry => entry.filePath));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[reimportSelectedRecipes] extension delegation failed, using native path:', error);
|
||||
nativeFilePaths.push(...delegatable.map(entry => entry.filePath));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
for (let i = 0; i < filePaths.length; i++) {
|
||||
const filePath = filePaths[i];
|
||||
const processedBeforeNative = completed + failed;
|
||||
for (let i = 0; i < nativeFilePaths.length; i++) {
|
||||
const filePath = nativeFilePaths[i];
|
||||
const recipeItem = recipeMap.get(filePath);
|
||||
const recipeId = recipeItem?.id;
|
||||
const recipeName = recipeItem?.title || recipeId || 'Unknown';
|
||||
const processed = processedBeforeNative + i;
|
||||
|
||||
progressUI.updateProgress(
|
||||
Math.floor((i / total) * 100),
|
||||
Math.floor((processed / total) * 100),
|
||||
recipeName,
|
||||
`Re-importing recipe ${Math.min(i + 1, total)}/${total}...`
|
||||
`Re-importing recipe ${Math.min(processed + 1, total)}/${total}...`
|
||||
);
|
||||
|
||||
if (!recipeId) {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Bridge to the companion LoRA Manager browser extension.
|
||||
*
|
||||
* The extension can re-import recipes sourced from CivitAI image pages with
|
||||
* the complete page metadata (internal trpc data scraped with the user's
|
||||
* session), fixing recipes that the native import (REST API + EXIF only)
|
||||
* saved with 0 LoRAs.
|
||||
*
|
||||
* Protocol: DOM CustomEvents on `document`; `detail` is ALWAYS a JSON
|
||||
* string on both sides.
|
||||
*
|
||||
* LM page -> extension: `lm:reimportProbe`, detail `{}`.
|
||||
* extension -> LM page: `lm:reimportProbeResult`,
|
||||
* detail `{supported, licenseValid, extensionVersion?, reason?}`.
|
||||
* LM page -> extension: `lm:reimportViaExtension`,
|
||||
* detail `{requestId, recipes: [{recipeId, imageId, imageUrl, title}]}`.
|
||||
* extension -> LM page: `lm:reimportProgress`,
|
||||
* detail `{requestId, current, total, recipeId, title, status, message?}`.
|
||||
* extension -> LM page: `lm:reimportBatchDone`,
|
||||
* detail `{requestId, completed, failed}`.
|
||||
*/
|
||||
|
||||
const PROBE_EVENT = 'lm:reimportProbe';
|
||||
const PROBE_RESULT_EVENT = 'lm:reimportProbeResult';
|
||||
const REIMPORT_EVENT = 'lm:reimportViaExtension';
|
||||
const PROGRESS_EVENT = 'lm:reimportProgress';
|
||||
const BATCH_DONE_EVENT = 'lm:reimportBatchDone';
|
||||
|
||||
const DEFAULT_PROBE_TIMEOUT_MS = 500;
|
||||
// Generous batch timeout; any progress event resets it (heartbeat).
|
||||
const DEFAULT_REIMPORT_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
|
||||
// Mirrors py/utils/civitai_utils.py (_SUPPORTED_CIVITAI_PAGE_HOSTS).
|
||||
const SUPPORTED_CIVITAI_PAGE_HOSTS = new Set([
|
||||
'civitai.com',
|
||||
'civitai.red',
|
||||
'civitai.green',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Parse the JSON-string `detail` of a protocol event.
|
||||
* @param {CustomEvent} event
|
||||
* @returns {object|null} Parsed detail object, or null when absent/invalid.
|
||||
*/
|
||||
function parseDetail(event) {
|
||||
try {
|
||||
const detail = JSON.parse(event?.detail ?? 'null');
|
||||
return detail && typeof detail === 'object' ? detail : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a protocol event with a JSON-stringified detail.
|
||||
* @param {string} type - Event name.
|
||||
* @param {object} payload - Detail payload (JSON-stringified).
|
||||
*/
|
||||
function dispatchProtocolEvent(type, payload) {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent(type, { detail: JSON.stringify(payload ?? {}) })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a correlation id for a re-import batch.
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateRequestId() {
|
||||
if (globalThis.crypto?.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return `lm-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe whether the companion extension is installed and usable.
|
||||
*
|
||||
* @param {{timeoutMs?: number}} [options]
|
||||
* @returns {Promise<{supported: boolean, licenseValid: boolean, extensionVersion?: string, reason?: string}|null>}
|
||||
* Resolves with the probe result, or null when the extension is absent or
|
||||
* too old to answer (timeout).
|
||||
*/
|
||||
export function probeExtension({ timeoutMs = DEFAULT_PROBE_TIMEOUT_MS } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => finish(null), timeoutMs);
|
||||
|
||||
const finish = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener(PROBE_RESULT_EVENT, onResult);
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const onResult = (event) => {
|
||||
const detail = parseDetail(event);
|
||||
if (!detail) return;
|
||||
finish({
|
||||
supported: Boolean(detail.supported),
|
||||
licenseValid: Boolean(detail.licenseValid),
|
||||
extensionVersion: detail.extensionVersion,
|
||||
reason: detail.reason,
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener(PROBE_RESULT_EVENT, onResult);
|
||||
dispatchProtocolEvent(PROBE_EVENT, {});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegate a batch of recipe re-imports to the companion extension.
|
||||
*
|
||||
* @param {Array<{recipeId: string, imageId: number, imageUrl: string, title: string}>} recipes
|
||||
* @param {{onProgress?: (progress: object) => void, timeoutMs?: number}} [options]
|
||||
* @returns {Promise<{completed: number, failed: number}>} Resolves on
|
||||
* `lm:reimportBatchDone`; rejects on timeout. Listeners are cleaned up in
|
||||
* all outcomes.
|
||||
*/
|
||||
export function delegateReimport(recipes, { onProgress, timeoutMs = DEFAULT_REIMPORT_TIMEOUT_MS } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!Array.isArray(recipes) || recipes.length === 0) {
|
||||
reject(new Error('delegateReimport requires a non-empty recipe list'));
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = generateRequestId();
|
||||
let settled = false;
|
||||
let timer = null;
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener(PROGRESS_EVENT, onProgressEvent);
|
||||
document.removeEventListener(BATCH_DONE_EVENT, onBatchDone);
|
||||
};
|
||||
const succeed = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(value);
|
||||
};
|
||||
const fail = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const armTimer = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(
|
||||
() => fail(new Error('Extension re-import timed out')),
|
||||
timeoutMs
|
||||
);
|
||||
};
|
||||
|
||||
const onProgressEvent = (event) => {
|
||||
const detail = parseDetail(event);
|
||||
if (!detail || detail.requestId !== requestId) return;
|
||||
// Heartbeat: any progress for this batch resets the timeout.
|
||||
armTimer();
|
||||
if (typeof onProgress === 'function') {
|
||||
try {
|
||||
onProgress(detail);
|
||||
} catch (error) {
|
||||
console.error('[extensionReimportBridge] onProgress callback failed:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
const onBatchDone = (event) => {
|
||||
const detail = parseDetail(event);
|
||||
if (!detail || detail.requestId !== requestId) return;
|
||||
succeed({
|
||||
completed: Number.isInteger(detail.completed) ? detail.completed : 0,
|
||||
failed: Number.isInteger(detail.failed) ? detail.failed : 0,
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener(PROGRESS_EVENT, onProgressEvent);
|
||||
document.addEventListener(BATCH_DONE_EVENT, onBatchDone);
|
||||
armTimer();
|
||||
dispatchProtocolEvent(REIMPORT_EVENT, { requestId, recipes });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract CivitAI image page info from a recipe source_path.
|
||||
* Mirrors py/utils/civitai_utils.py `extract_civitai_image_id`.
|
||||
*
|
||||
* @param {string|null} sourcePath - Recipe source_path.
|
||||
* @returns {{imageId: number, imageUrl: string}|null} Null when the path is
|
||||
* not a `/images/<id>` URL on civitai.com/.red/.green.
|
||||
*/
|
||||
export function getCivitaiImageInfo(sourcePath) {
|
||||
if (!sourcePath || typeof sourcePath !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(sourcePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
if (!SUPPORTED_CIVITAI_PAGE_HOSTS.has(parsed.hostname.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathMatch = parsed.pathname.match(/\/images\/(\d+)/);
|
||||
if (!pathMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { imageId: Number(pathMatch[1]), imageUrl: sourcePath };
|
||||
}
|
||||
Reference in New Issue
Block a user