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:
Will Miao
2026-09-06 20:26:14 +08:00
parent e2d85a0a21
commit a17399d667
20 changed files with 1079 additions and 38 deletions
@@ -0,0 +1,181 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const showSimpleLoadingMock = vi.fn();
const hideLoadingMock = vi.fn();
const resetAndReloadMock = vi.fn();
const probeExtensionMock = vi.fn();
const delegateReimportMock = vi.fn();
const stateStub = {
virtualScroller: { items: [] },
loadingManager: {
showSimpleLoading: showSimpleLoadingMock,
hide: hideLoadingMock,
},
};
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
copyToClipboard: vi.fn(),
sendLoraToWorkflow: vi.fn(),
}));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
setSessionItem: vi.fn(),
removeSessionItem: vi.fn(),
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
updateRecipeMetadata: vi.fn(),
resetAndReload: resetAndReloadMock,
}));
vi.mock('../../../static/js/state/index.js', () => ({
state: stateStub,
}));
vi.mock('../../../static/js/managers/MoveManager.js', () => ({
moveManager: { showMoveModal: vi.fn() },
}));
vi.mock('../../../static/js/components/ContextMenu/ModelContextMenuMixin.js', () => ({
ModelContextMenuMixin: {
handleCommonMenuActions: vi.fn(() => false),
initNSFWSelector: vi.fn(),
},
}));
// Keep the real getCivitaiImageInfo (gating logic under test); mock only the
// extension communication.
vi.mock('../../../static/js/utils/extensionReimportBridge.js', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
probeExtension: probeExtensionMock,
delegateReimport: delegateReimportMock,
};
});
describe('RecipeContextMenu.reimportRecipe extension delegation', () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = `
<div id="recipeContextMenu" class="context-menu" style="display: none;">
<div class="context-menu-item" data-action="reimport"></div>
</div>
`;
stateStub.virtualScroller.items = [
{
id: 'recipe-1',
file_path: '/recipes/recipe-1.webp',
title: 'Civitai Recipe',
source_path: 'https://civitai.com/images/12345',
},
{
id: 'recipe-2',
file_path: '/recipes/recipe-2.webp',
title: 'Local Recipe',
source_path: '/data/imports/local.png',
},
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, recipe_id: 'new-id', loras_count: 2 }),
});
});
afterEach(() => {
delete global.fetch;
});
async function createMenu() {
const { RecipeContextMenu } = await import(
'../../../static/js/components/ContextMenu/RecipeContextMenu.js'
);
return new RecipeContextMenu();
}
it('delegates to the extension for a CivitAI image source when licensed', async () => {
probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: true });
delegateReimportMock.mockResolvedValue({ completed: 1, failed: 0 });
const menu = await createMenu();
await menu.reimportRecipe('recipe-1');
expect(delegateReimportMock).toHaveBeenCalledWith([{
recipeId: 'recipe-1',
imageId: 12345,
imageUrl: 'https://civitai.com/images/12345',
title: 'Civitai Recipe',
}]);
expect(global.fetch).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.reimportSuccess', {}, 'success');
expect(resetAndReloadMock).toHaveBeenCalledWith(false, { preserveScroll: false });
});
it('shows the failure toast when the extension reports failures', async () => {
probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: true });
delegateReimportMock.mockResolvedValue({ completed: 0, failed: 1 });
const menu = await createMenu();
await menu.reimportRecipe('recipe-1');
expect(showToastMock).toHaveBeenCalledWith(
'recipes.contextMenu.reimport.failed',
{ message: 'Extension re-import failed' },
'error'
);
expect(global.fetch).not.toHaveBeenCalled();
expect(resetAndReloadMock).toHaveBeenCalledWith(false, { preserveScroll: false });
});
it('uses the native path for non-CivitAI sources without probing', async () => {
const menu = await createMenu();
await menu.reimportRecipe('recipe-2');
expect(probeExtensionMock).not.toHaveBeenCalled();
expect(delegateReimportMock).not.toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-2/reimport', {
method: 'POST',
});
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.reimportSuccess', {}, 'success');
});
it('uses the native path when the extension is absent (probe timeout)', async () => {
probeExtensionMock.mockResolvedValue(null);
const menu = await createMenu();
await menu.reimportRecipe('recipe-1');
expect(delegateReimportMock).not.toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/reimport', {
method: 'POST',
});
});
it('uses the native path when the license is invalid', async () => {
probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: false });
const menu = await createMenu();
await menu.reimportRecipe('recipe-1');
expect(delegateReimportMock).not.toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/reimport', {
method: 'POST',
});
});
it('falls back to the native path when delegation fails', async () => {
probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: true });
delegateReimportMock.mockRejectedValue(new Error('Extension re-import timed out'));
const menu = await createMenu();
await menu.reimportRecipe('recipe-1');
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/reimport', {
method: 'POST',
});
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.reimportSuccess', {}, 'success');
});
});
@@ -0,0 +1,216 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import {
probeExtension,
delegateReimport,
getCivitaiImageInfo,
} from '../../../static/js/utils/extensionReimportBridge.js';
const dispatchedEvents = [];
function dispatchProtocolEvent(type, payload) {
document.dispatchEvent(
new CustomEvent(type, { detail: JSON.stringify(payload) })
);
}
// Installs a fake extension that answers probes with the given result.
function installProbeResponder(result) {
const listener = () => dispatchProtocolEvent('lm:reimportProbeResult', result);
document.addEventListener('lm:reimportProbe', listener);
return () => document.removeEventListener('lm:reimportProbe', listener);
}
afterEach(() => {
dispatchedEvents.length = 0;
});
describe('probeExtension', () => {
it('resolves null when no extension answers within the timeout', async () => {
const result = await probeExtension({ timeoutMs: 20 });
expect(result).toBeNull();
});
it('resolves the probe result when the extension answers', async () => {
const uninstall = installProbeResponder({
supported: true,
licenseValid: true,
extensionVersion: '1.2.3',
});
try {
const result = await probeExtension({ timeoutMs: 1000 });
expect(result).toEqual({
supported: true,
licenseValid: true,
extensionVersion: '1.2.3',
reason: undefined,
});
} finally {
uninstall();
}
});
it('reports unsupported/unlicensed answers verbatim', async () => {
const uninstall = installProbeResponder({
supported: false,
licenseValid: false,
reason: 'license expired',
});
try {
const result = await probeExtension({ timeoutMs: 1000 });
expect(result.supported).toBe(false);
expect(result.licenseValid).toBe(false);
expect(result.reason).toBe('license expired');
} finally {
uninstall();
}
});
it('ignores malformed probe results and times out', async () => {
const listener = () => {
document.dispatchEvent(
new CustomEvent('lm:reimportProbeResult', { detail: '{broken json' })
);
};
document.addEventListener('lm:reimportProbe', listener);
try {
const result = await probeExtension({ timeoutMs: 20 });
expect(result).toBeNull();
} finally {
document.removeEventListener('lm:reimportProbe', listener);
}
});
});
describe('delegateReimport', () => {
const recipes = [
{ recipeId: 'r1', imageId: 123, imageUrl: 'https://civitai.com/images/123', title: 'One' },
{ recipeId: 'r2', imageId: 456, imageUrl: 'https://civitai.com/images/456', title: 'Two' },
];
it('rejects immediately for an empty recipe list', async () => {
await expect(delegateReimport([])).rejects.toThrow('non-empty');
});
it('rejects on timeout when the extension never answers', async () => {
await expect(
delegateReimport(recipes, { timeoutMs: 20 })
).rejects.toThrow('timed out');
});
it('dispatches the batch with a requestId and resolves on batchDone', async () => {
const progressEvents = [];
let seenRequest = null;
const listener = (event) => {
seenRequest = JSON.parse(event.detail);
const { requestId } = seenRequest;
// Progress for a DIFFERENT batch must be ignored.
dispatchProtocolEvent('lm:reimportProgress', {
requestId: 'other-batch',
current: 99,
total: 99,
recipeId: 'nope',
title: 'nope',
status: 'success',
});
dispatchProtocolEvent('lm:reimportProgress', {
requestId,
current: 1,
total: 2,
recipeId: 'r1',
title: 'One',
status: 'success',
});
dispatchProtocolEvent('lm:reimportProgress', {
requestId,
current: 2,
total: 2,
recipeId: 'r2',
title: 'Two',
status: 'failed',
message: 'boom',
});
dispatchProtocolEvent('lm:reimportBatchDone', {
requestId,
completed: 1,
failed: 1,
});
};
document.addEventListener('lm:reimportViaExtension', listener);
try {
const result = await delegateReimport(recipes, {
onProgress: (progress) => progressEvents.push(progress),
timeoutMs: 1000,
});
expect(seenRequest.recipes).toEqual(recipes);
expect(typeof seenRequest.requestId).toBe('string');
expect(seenRequest.requestId.length).toBeGreaterThan(0);
expect(result).toEqual({ completed: 1, failed: 1 });
// Only this batch's progress events reach the callback.
expect(progressEvents.map((p) => p.recipeId)).toEqual(['r1', 'r2']);
expect(progressEvents[1].status).toBe('failed');
} finally {
document.removeEventListener('lm:reimportViaExtension', listener);
}
});
it('resets the timeout on every progress heartbeat', async () => {
vi.useFakeTimers();
let requestId = null;
const listener = (event) => {
requestId = JSON.parse(event.detail).requestId;
};
document.addEventListener('lm:reimportViaExtension', listener);
try {
const promise = delegateReimport(recipes, { timeoutMs: 1000 });
// At t=900ms a progress event arrives, pushing the deadline to t=1900ms.
await vi.advanceTimersByTimeAsync(900);
dispatchProtocolEvent('lm:reimportProgress', {
requestId,
current: 1,
total: 2,
recipeId: 'r1',
title: 'One',
status: 'started',
});
// t=1800ms: past the original deadline, still alive thanks to heartbeat.
await vi.advanceTimersByTimeAsync(900);
dispatchProtocolEvent('lm:reimportBatchDone', {
requestId,
completed: 2,
failed: 0,
});
await expect(promise).resolves.toEqual({ completed: 2, failed: 0 });
} finally {
document.removeEventListener('lm:reimportViaExtension', listener);
vi.useRealTimers();
}
});
});
describe('getCivitaiImageInfo', () => {
it.each([
'https://civitai.com/images/12345',
'https://civitai.red/images/12345',
'https://civitai.green/images/12345',
'https://civitai.com/images/12345?foo=bar',
])('extracts the image id from %s', (url) => {
expect(getCivitaiImageInfo(url)).toEqual({ imageId: 12345, imageUrl: url });
});
it.each([
null,
'',
'not a url',
'ftp://civitai.com/images/12345',
'https://civitai.com/models/12345',
'https://example.com/images/12345',
'https://image.civitai.com/x/y/original=true/pic.png',
])('returns null for %s', (url) => {
expect(getCivitaiImageInfo(url)).toBeNull();
});
});