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:
@@ -170,6 +170,10 @@ The system runs in two modes:
|
||||
- Route registrars organize endpoints by domain: `ModelRouteRegistrar`, `RecipeRouteRegistrar`, etc.
|
||||
- Request handlers in `py/routes/handlers/` implement route logic
|
||||
- All routes use aiohttp, return `web.json_response` or `web.Response`
|
||||
- Endpoints consumed by the companion browser extension (lm-civitai-extension)
|
||||
MUST also accept `GET` with query-string params: the extension is GET-only by
|
||||
convention (see its AGENTS.md), even for state-changing operations such as
|
||||
`GET /api/lm/recipe/{recipe_id}/reimport`
|
||||
|
||||
### Recipe System
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# CivitAI image imports can end up with 0 LoRAs
|
||||
|
||||
## Symptom
|
||||
|
||||
Importing a CivitAI image URL can produce a recipe with **zero LoRA
|
||||
entries**, even though the image page lists LoRAs in its resource panel.
|
||||
|
||||
Reported example: `https://civitai.red/images/140818889` was imported as a
|
||||
local recipe with 0 LoRAs, while the page shows 3 LoRAs. Some images (e.g.
|
||||
NSFW / higher browsing level) additionally require a login to view, so their
|
||||
data is not publicly reachable at all.
|
||||
|
||||
## Root cause
|
||||
|
||||
URL imports use only two data sources:
|
||||
|
||||
1. **CivitAI REST image API** — `GET /api/v1/images?imageId=<id>&nsfw=X&withMeta=true` → `meta`
|
||||
2. **Embedded image metadata** — EXIF/XMP read from the downloaded bytes
|
||||
|
||||
For the same image both sources can be empty, and the one source that does
|
||||
contain the data is never queried. Verified for image 140818889:
|
||||
|
||||
| Source | What it returned |
|
||||
|---|---|
|
||||
| REST image API | `meta` holds only a prompt; `modelVersionIds: []`; no `resources`/`hashes`; `baseModel: null` |
|
||||
| Downloaded image | PNG with **no EXIF/XMP** (the CDN URL ends in `.jpeg`, the body is PNG) |
|
||||
| Image page HTML | `__NEXT_DATA__` embeds the trpc `image.getGenerationData` result → full `resources` list: 3 LoRAs, each with `modelId`, `modelVersionId`, `modelName`, `modelType`, `versionName`, `baseModel` |
|
||||
|
||||
Key points:
|
||||
|
||||
- The page's resource panel is fed by an **internal, non-public trpc
|
||||
endpoint**, not by the public REST image API.
|
||||
- That internal endpoint is **login-gated** for some content — the
|
||||
"requires login" symptom.
|
||||
- Even with the version IDs in hand, `/model-versions/{id}` for these
|
||||
(Krea) versions returns **no `sha256`**, so an exact local-file hash match
|
||||
is impossible; only model/version identity is recoverable.
|
||||
|
||||
## Conclusion / status
|
||||
|
||||
0-LoRA imports are a data-source gap: public REST meta and image EXIF are
|
||||
both empty, while the only complete source (page generation data) is
|
||||
internal, sometimes login-gated, and not used by the importer.
|
||||
|
||||
Such imports **cannot be reliably auto-repaired/completed** by the backend
|
||||
alone. The old "Repair Metadata" feature only re-fetched the same incomplete
|
||||
REST meta and could not fix them; it was deprecated and has been removed.
|
||||
|
||||
**Fixed via the companion browser extension.** When the extension is
|
||||
installed with a valid license, it scrapes the image page's internal trpc
|
||||
generation data with the user's session and calls the payload-capable
|
||||
re-import endpoint (`POST /api/lm/recipe/{recipe_id}/reimport` with
|
||||
`image_url`/`name`/`resources`/`gen_params`/`base_model`/`tags` query
|
||||
params), which rebuilds the recipe from the caller-supplied metadata. The
|
||||
web UI delegates re-import of CivitAI-image-sourced recipes to the extension
|
||||
automatically (probe + `lm:reimport*` DOM events); without the extension,
|
||||
re-import silently falls back to the native path, which remains limited by
|
||||
the data-source gap documented above.
|
||||
@@ -2229,6 +2229,7 @@
|
||||
"rematchSkipped": "Keine Zuordnung für die {total} ausgewählten Rezepte erforderlich",
|
||||
"rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}",
|
||||
"reimporting": "Rezept wird aus Quelle neu importiert...",
|
||||
"reimportingViaExtension": "Rezept {current}/{total} wird über die Browser-Erweiterung neu importiert...",
|
||||
"reimportSuccess": "Rezept erfolgreich neu importiert",
|
||||
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",
|
||||
"reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen",
|
||||
|
||||
@@ -2229,6 +2229,7 @@
|
||||
"rematchSkipped": "No rematch needed for any of the {total} selected recipes",
|
||||
"rematchFailed": "Failed to rematch selected recipes: {message}",
|
||||
"reimporting": "Re-importing recipe from source...",
|
||||
"reimportingViaExtension": "Re-importing recipe {current}/{total} via browser extension...",
|
||||
"reimportSuccess": "Recipe re-imported successfully",
|
||||
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
|
||||
"reimportBulkFailed": "Failed to re-import some recipes",
|
||||
|
||||
@@ -2229,6 +2229,7 @@
|
||||
"rematchSkipped": "Ninguna de las {total} recetas seleccionadas necesita reasociación",
|
||||
"rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}",
|
||||
"reimporting": "Reimportando receta desde origen...",
|
||||
"reimportingViaExtension": "Reimportando receta {current}/{total} mediante la extensión del navegador...",
|
||||
"reimportSuccess": "Receta reimportada exitosamente",
|
||||
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",
|
||||
"reimportBulkFailed": "Error al reimportar algunas recetas",
|
||||
|
||||
@@ -2229,6 +2229,7 @@
|
||||
"rematchSkipped": "Aucune des {total} Recipes sélectionnées ne nécessite de réassociation",
|
||||
"rematchFailed": "Échec de la réassociation des Recipes sélectionnées : {message}",
|
||||
"reimporting": "Ré-import de la Recipe depuis la source...",
|
||||
"reimportingViaExtension": "Ré-import de la Recipe {current}/{total} via l’extension du navigateur...",
|
||||
"reimportSuccess": "Recette ré-importée avec succès",
|
||||
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",
|
||||
"reimportBulkFailed": "Échec du ré-import de certaines Recipes",
|
||||
|
||||
@@ -2229,6 +2229,7 @@
|
||||
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
|
||||
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
|
||||
"reimporting": "מייבא מתכון מחדש מהמקור...",
|
||||
"reimportingViaExtension": "מייבא מתכון מחדש {current}/{total} דרך תוסף הדפדפן...",
|
||||
"reimportSuccess": "המתכון יובא מחדש בהצלחה",
|
||||
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
|
||||
"reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל",
|
||||
|
||||
@@ -2229,6 +2229,7 @@
|
||||
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
|
||||
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
|
||||
"reimporting": "ソースからレシピを再インポート中...",
|
||||
"reimportingViaExtension": "ブラウザ拡張機能経由でレシピを再インポート中 ({current}/{total})...",
|
||||
"reimportSuccess": "レシピの再インポートが完了しました",
|
||||
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
|
||||
"reimportBulkFailed": "一部のレシピの再インポートに失敗しました",
|
||||
|
||||
@@ -2229,6 +2229,7 @@
|
||||
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
|
||||
"rematchFailed": "선택한 레시피 재매칭 실패: {message}",
|
||||
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
|
||||
"reimportingViaExtension": "브라우저 확장 프로그램을 통해 레시피를 다시 가져오는 중 ({current}/{total})...",
|
||||
"reimportSuccess": "레시피를 다시 가져왔습니다",
|
||||
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
|
||||
"reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다",
|
||||
|
||||
@@ -2229,6 +2229,7 @@
|
||||
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
|
||||
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
|
||||
"reimporting": "Переимпорт рецепта из источника...",
|
||||
"reimportingViaExtension": "Переимпорт рецепта {current}/{total} через расширение браузера...",
|
||||
"reimportSuccess": "Рецепт успешно переимпортирован",
|
||||
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
|
||||
"reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты",
|
||||
|
||||
@@ -2229,6 +2229,7 @@
|
||||
"rematchSkipped": "{total} 个所选配方均无需重新匹配",
|
||||
"rematchFailed": "重新匹配所选配方失败:{message}",
|
||||
"reimporting": "正在从源重新导入配方...",
|
||||
"reimportingViaExtension": "正在通过浏览器扩展重新导入配方 {current}/{total}...",
|
||||
"reimportSuccess": "配方已从源重新导入成功",
|
||||
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
|
||||
"reimportBulkFailed": "重新导入某些配方失败",
|
||||
|
||||
@@ -2229,6 +2229,7 @@
|
||||
"rematchSkipped": "{total} 個所選配方均無需重新匹配",
|
||||
"rematchFailed": "重新匹配所選配方失敗:{message}",
|
||||
"reimporting": "正在從來源重新匯入配方...",
|
||||
"reimportingViaExtension": "正在透過瀏覽器擴充功能重新匯入配方 {current}/{total}...",
|
||||
"reimportSuccess": "配方已從來源重新匯入成功",
|
||||
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
|
||||
"reimportBulkFailed": "重新匯入某些配方失敗",
|
||||
|
||||
@@ -1020,12 +1020,55 @@ class RecipeManagementHandler:
|
||||
persisted_source_path=persisted_source_path,
|
||||
)
|
||||
|
||||
async with self._import_semaphore:
|
||||
import_response = await self._do_import_from_url(
|
||||
source_path,
|
||||
recipe_scanner,
|
||||
target_dir=old_folder,
|
||||
)
|
||||
# Optional caller-supplied metadata payload (companion browser
|
||||
# extension re-import). Only honored for CivitAI image page
|
||||
# sources; everything else uses the native URL import below.
|
||||
params = request.rel_url.query
|
||||
payload_image_url = params.get("image_url")
|
||||
payload_name = params.get("name")
|
||||
payload_resources = params.get("resources")
|
||||
has_import_payload = bool(
|
||||
payload_image_url and payload_name and payload_resources
|
||||
)
|
||||
|
||||
import_response: web.Response | None = None
|
||||
if has_import_payload and image_id:
|
||||
try:
|
||||
async with self._import_semaphore:
|
||||
import_response = await self._import_remote_recipe_impl(
|
||||
image_url=payload_image_url,
|
||||
name=payload_name,
|
||||
resources_raw=payload_resources,
|
||||
gen_params_raw=params.get("gen_params"),
|
||||
tags_raw=params.get("tags"),
|
||||
base_model=params.get("base_model", "") or "",
|
||||
source_path=source_path,
|
||||
target_dir=old_folder,
|
||||
)
|
||||
except RecipeValidationError as exc:
|
||||
# Malformed resources/gen_params JSON: treat as "no
|
||||
# payload" and use the legacy URL re-import.
|
||||
self._logger.warning(
|
||||
"Ignoring malformed re-import payload for recipe %s "
|
||||
"(%s); falling back to source URL re-import",
|
||||
recipe_id,
|
||||
exc,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.warning(
|
||||
"Payload-based re-import failed for recipe %s: %s; "
|
||||
"falling back to source URL re-import",
|
||||
recipe_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
if import_response is None:
|
||||
async with self._import_semaphore:
|
||||
import_response = await self._do_import_from_url(
|
||||
source_path,
|
||||
recipe_scanner,
|
||||
target_dir=old_folder,
|
||||
)
|
||||
|
||||
await self._persistence_service.delete_recipe(
|
||||
recipe_scanner=recipe_scanner, recipe_id=recipe_id
|
||||
@@ -1052,14 +1095,19 @@ class RecipeManagementHandler:
|
||||
exc,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"old_recipe_id": recipe_id,
|
||||
"recipe_id": new_recipe_id,
|
||||
"source_path": source_path,
|
||||
}
|
||||
response_body: Dict[str, Any] = {
|
||||
"success": True,
|
||||
"old_recipe_id": recipe_id,
|
||||
"recipe_id": new_recipe_id,
|
||||
"source_path": source_path,
|
||||
}
|
||||
loras_count = await self._count_recipe_loras(
|
||||
recipe_scanner, new_recipe_id
|
||||
)
|
||||
if loras_count is not None:
|
||||
response_body["loras_count"] = loras_count
|
||||
|
||||
return web.json_response(response_body)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||
except RecipeValidationError as exc:
|
||||
@@ -1092,31 +1140,14 @@ class RecipeManagementHandler:
|
||||
if not resources_raw:
|
||||
raise RecipeValidationError("Missing required field: resources")
|
||||
|
||||
checkpoint_entry, lora_entries = self._parse_resources_payload(
|
||||
resources_raw
|
||||
)
|
||||
gen_params_request = self._parse_gen_params(params.get("gen_params"))
|
||||
|
||||
self._logger.info(
|
||||
"Remote recipe import received: url=%s, lora_count=%d",
|
||||
image_url,
|
||||
len(lora_entries),
|
||||
)
|
||||
self._logger.debug(
|
||||
" gen_params_keys=%s, checkpoint_keys=%s",
|
||||
sorted(gen_params_request.keys()) if gen_params_request else [],
|
||||
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
|
||||
)
|
||||
|
||||
# Throttle concurrent imports to avoid starving ComfyUI's event loop
|
||||
async with self._import_semaphore:
|
||||
return await self._do_import_remote_recipe(
|
||||
return await self._import_remote_recipe_impl(
|
||||
image_url=image_url,
|
||||
name=name,
|
||||
lora_entries=lora_entries,
|
||||
checkpoint_entry=checkpoint_entry,
|
||||
gen_params_request=gen_params_request,
|
||||
tags=self._parse_tags(params.get("tags")),
|
||||
resources_raw=resources_raw,
|
||||
gen_params_raw=params.get("gen_params"),
|
||||
tags_raw=params.get("tags"),
|
||||
base_model=params.get("base_model", "") or "",
|
||||
source_path=params.get("source_path") or image_url,
|
||||
)
|
||||
@@ -1130,6 +1161,52 @@ class RecipeManagementHandler:
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def _import_remote_recipe_impl(
|
||||
self,
|
||||
*,
|
||||
image_url: str,
|
||||
name: str,
|
||||
resources_raw: str,
|
||||
gen_params_raw: Optional[str],
|
||||
tags_raw: Optional[str],
|
||||
base_model: str,
|
||||
source_path: str,
|
||||
target_dir: str | None = None,
|
||||
) -> web.Response:
|
||||
"""Payload-based remote import engine shared by import-remote and the
|
||||
extension-driven re-import path.
|
||||
|
||||
Parses the caller-supplied payloads and delegates to
|
||||
:meth:`_do_import_remote_recipe`. Raises ``RecipeValidationError`` on
|
||||
malformed payloads so callers can decide how to handle them (the
|
||||
re-import path falls back to the legacy URL import).
|
||||
"""
|
||||
checkpoint_entry, lora_entries = self._parse_resources_payload(resources_raw)
|
||||
gen_params_request = self._parse_gen_params(gen_params_raw)
|
||||
|
||||
self._logger.info(
|
||||
"Remote recipe import received: url=%s, lora_count=%d",
|
||||
image_url,
|
||||
len(lora_entries),
|
||||
)
|
||||
self._logger.debug(
|
||||
" gen_params_keys=%s, checkpoint_keys=%s",
|
||||
sorted(gen_params_request.keys()) if gen_params_request else [],
|
||||
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
|
||||
)
|
||||
|
||||
return await self._do_import_remote_recipe(
|
||||
image_url=image_url,
|
||||
name=name,
|
||||
lora_entries=lora_entries,
|
||||
checkpoint_entry=checkpoint_entry,
|
||||
gen_params_request=gen_params_request,
|
||||
tags=self._parse_tags(tags_raw),
|
||||
base_model=base_model,
|
||||
source_path=source_path,
|
||||
target_dir=target_dir,
|
||||
)
|
||||
|
||||
async def _do_import_remote_recipe(
|
||||
self,
|
||||
*,
|
||||
@@ -1141,6 +1218,7 @@ class RecipeManagementHandler:
|
||||
tags: list[Any],
|
||||
base_model: str,
|
||||
source_path: str,
|
||||
target_dir: str | None = None,
|
||||
) -> web.Response:
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
@@ -1304,6 +1382,7 @@ class RecipeManagementHandler:
|
||||
tags=tags,
|
||||
metadata=metadata,
|
||||
extension=extension,
|
||||
target_dir=target_dir,
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
|
||||
@@ -1768,6 +1847,25 @@ class RecipeManagementHandler:
|
||||
return []
|
||||
return [tag.strip() for tag in tag_text.split(",") if tag.strip()]
|
||||
|
||||
async def _count_recipe_loras(
|
||||
self, recipe_scanner: Any, recipe_id: Optional[str]
|
||||
) -> Optional[int]:
|
||||
"""Best-effort LoRA count for a freshly saved recipe (for the
|
||||
re-import response). Returns None when the recipe cannot be read."""
|
||||
if not recipe_id:
|
||||
return None
|
||||
try:
|
||||
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
|
||||
except Exception as exc:
|
||||
self._logger.debug(
|
||||
"Could not read new recipe %s for loras_count: %s",
|
||||
recipe_id,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
loras = (recipe or {}).get("loras")
|
||||
return len(loras) if isinstance(loras, list) else None
|
||||
|
||||
def _parse_gen_params(self, payload: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
@@ -110,6 +110,11 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe"
|
||||
),
|
||||
# The companion browser extension only ever issues GET requests, so the
|
||||
# payload-based re-import variant must also be reachable via GET.
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipe/{recipe_id}/send-workflow", "send_recipe_workflow"
|
||||
),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user