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();
});
});
+144
View File
@@ -2341,3 +2341,147 @@ async def test_get_recipe_detail_includes_recipe_json_path(
assert response.status == 200
payload = await response.json()
assert "recipe_json_path" not in payload
async def test_reimport_with_extension_payload_uses_payload_path(
monkeypatch, tmp_path: Path
) -> None:
"""A re-import carrying the companion extension's metadata payload must
use the payload-based import engine (caller-supplied LoRAs) instead of
the legacy CivitAI image URL import, and report loras_count."""
provider_calls: list[str | int] = []
class Provider:
async def get_model_version_info(self, model_version_id):
provider_calls.append(model_version_id)
return {}, None
async def fake_get_default_metadata_provider():
return Provider()
monkeypatch.setattr(
"py.recipes.enrichment.get_default_metadata_provider",
fake_get_default_metadata_provider,
)
async with recipe_harness(monkeypatch, tmp_path) as harness:
old_file = harness.tmp_dir / "recipes" / "sub" / "rec-ext.webp"
harness.scanner.recipes["rec-ext"] = {
"id": "rec-ext",
"title": "Old title",
"file_path": str(old_file),
"tags": ["tag1"],
"source_path": "https://civitai.com/images/12345",
}
harness.civitai.image_info["12345"] = {
"id": 12345,
"url": "https://image.civitai.com/x/y/original=true/pic.png",
"type": "image",
}
harness.persistence.save_result = SimpleNamespace(
payload={"success": True, "recipe_id": "new-rec-ext"}, status=200
)
# The freshly saved recipe as the scanner would see it (for loras_count).
harness.scanner.recipes["new-rec-ext"] = {
"id": "new-rec-ext",
"loras": [{"file_name": "Painterly"}],
}
resources = [
{
"type": "lora",
"modelId": 20,
"modelVersionId": 44,
"modelName": "Painterly",
"modelVersionName": "v2",
"weight": 0.5,
},
]
# The extension only issues GET requests (per its API convention).
response = await harness.client.get(
"/api/lm/recipe/rec-ext/reimport",
params={
"image_url": "https://civitai.com/images/12345",
"name": "Extension Recipe",
"resources": json.dumps(resources),
"gen_params": json.dumps({"prompt": "from extension"}),
"base_model": "Flux",
},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert payload["old_recipe_id"] == "rec-ext"
assert payload["recipe_id"] == "new-rec-ext"
assert payload["loras_count"] == 1
save_call = harness.persistence.save_calls[-1]
# Caller-supplied payload data wins: name, LoRAs, gen params.
assert save_call["name"] == "Extension Recipe"
assert save_call["metadata"]["loras"][0]["file_name"] == "Painterly"
assert save_call["metadata"]["loras"][0]["weight"] == 0.5
assert save_call["metadata"]["gen_params"]["prompt"] == "from extension"
# Reimport semantics: original source_path and folder are preserved.
assert save_call["metadata"]["source_path"] == "https://civitai.com/images/12345"
assert save_call["target_dir"] == str(harness.tmp_dir / "recipes" / "sub")
# The old recipe is deleted and user edits carried over.
assert harness.persistence.delete_calls == ["rec-ext"]
assert harness.persistence.update_calls[-1]["recipe_id"] == "new-rec-ext"
assert harness.persistence.update_calls[-1]["updates"]["title"] == "Old title"
assert harness.persistence.update_calls[-1]["updates"]["tags"] == ["tag1"]
async def test_reimport_with_malformed_payload_falls_back_to_legacy(
monkeypatch, tmp_path: Path
) -> None:
"""Malformed resources JSON must be treated as "no payload": the legacy
source-URL import runs and the request still succeeds."""
async def fake_get_default_metadata_provider():
return SimpleNamespace(get_model_version_info=lambda id: ({}, None))
monkeypatch.setattr(
"py.recipes.enrichment.get_default_metadata_provider",
fake_get_default_metadata_provider,
)
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.scanner.recipes["rec-bad"] = {
"id": "rec-bad",
"title": "Broken payload",
"file_path": str(harness.tmp_dir / "recipes" / "rec-bad.webp"),
"tags": [],
"source_path": "https://civitai.com/images/12345",
}
harness.civitai.image_info["12345"] = {
"id": 12345,
"url": "https://image.civitai.com/x/y/original=true/pic.png",
"type": "image",
}
harness.persistence.save_result = SimpleNamespace(
payload={"success": True, "recipe_id": "legacy-new"}, status=200
)
harness.scanner.recipes["legacy-new"] = {"id": "legacy-new", "loras": []}
response = await harness.client.get(
"/api/lm/recipe/rec-bad/reimport",
params={
"image_url": "https://civitai.com/images/12345",
"name": "Ignored Name",
"resources": "{not valid json",
},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert payload["recipe_id"] == "legacy-new"
assert payload["loras_count"] == 0
save_call = harness.persistence.save_calls[-1]
# Legacy URL path: the payload name is ignored and the title is
# derived from the (empty) metadata, and no caller LoRAs are used.
assert save_call["name"] == "Civitai Image 12345"
assert save_call["metadata"]["loras"] == []
assert save_call["metadata"]["source_path"] == "https://civitai.com/images/12345"
assert harness.persistence.delete_calls == ["rec-bad"]