diff --git a/photomap/backend/config.py b/photomap/backend/config.py index afbcbb14..efb370f5 100644 --- a/photomap/backend/config.py +++ b/photomap/backend/config.py @@ -81,6 +81,18 @@ class Config(BaseModel): locationiq_api_key: str | None = Field( default=None, description="LocationIQ API key for map services" ) + invokeai_url: str | None = Field( + default=None, + description="Base URL of a running InvokeAI backend, e.g. http://localhost:9090", + ) + invokeai_username: str | None = Field( + default=None, + description="Username for authenticating against InvokeAI (future use)", + ) + invokeai_password: str | None = Field( + default=None, + description="Password for authenticating against InvokeAI (future use)", + ) @field_validator("config_version") @classmethod @@ -108,6 +120,9 @@ def to_dict(self) -> dict[str, Any]: "config_version": self.config_version, "albums": {key: album.to_dict() for key, album in self.albums.items()}, "locationiq_api_key": self.locationiq_api_key, + "invokeai_url": self.invokeai_url, + "invokeai_username": self.invokeai_username, + "invokeai_password": self.invokeai_password, } @@ -163,6 +178,40 @@ def set_locationiq_api_key(self, api_key: str | None) -> None: # Clear cache after saving to ensure fresh reads self._config = None + def get_invokeai_settings(self) -> dict[str, str | None]: + """Return the configured InvokeAI connection settings.""" + config = self.load_config() + return { + "url": config.invokeai_url, + "username": config.invokeai_username, + "password": config.invokeai_password, + } + + def set_invokeai_settings( + self, + url: str | None = None, + username: str | None = None, + password: str | None = None, + ) -> None: + """Update the InvokeAI connection settings. + + Empty strings are normalized to None so that the fields can be cleared. + """ + config = self.load_config() + + def _clean(value: str | None) -> str | None: + if value is None: + return None + value = value.strip() + return value or None + + config.invokeai_url = _clean(url) + config.invokeai_username = _clean(username) + config.invokeai_password = _clean(password) + self._config = config + self.save_config() + self._config = None + def load_config(self) -> Config: """Load configuration from YAML file.""" if self._config is None: @@ -186,6 +235,9 @@ def load_config(self) -> Config: config_version=config_data.get("config_version", "1.0.0"), albums=albums, locationiq_api_key=config_data.get("locationiq_api_key"), + invokeai_url=config_data.get("invokeai_url"), + invokeai_username=config_data.get("invokeai_username"), + invokeai_password=config_data.get("invokeai_password"), ) except Exception as e: diff --git a/photomap/backend/metadata_formatting.py b/photomap/backend/metadata_formatting.py index ee1e10f5..dd8959b3 100644 --- a/photomap/backend/metadata_formatting.py +++ b/photomap/backend/metadata_formatting.py @@ -44,7 +44,11 @@ def format_metadata( or "generation_mode" in metadata or "canvas_v2_metadata" in metadata ): - return format_invoke_metadata(result, metadata) + config_manager = get_config_manager() + invokeai_url = config_manager.get_invokeai_settings().get("url") + return format_invoke_metadata( + result, metadata, show_recall_buttons=bool(invokeai_url) + ) else: config_manager = get_config_manager() api_key = config_manager.get_locationiq_api_key() diff --git a/photomap/backend/metadata_modules/invoke/invoke_metadata_view.py b/photomap/backend/metadata_modules/invoke/invoke_metadata_view.py index 8bb2d8d2..e08991df 100644 --- a/photomap/backend/metadata_modules/invoke/invoke_metadata_view.py +++ b/photomap/backend/metadata_modules/invoke/invoke_metadata_view.py @@ -253,6 +253,109 @@ def _canvas_control_layers( ) return result + # ---- recall payload -------------------------------------------------- + + def to_recall_payload(self, include_seed: bool = True) -> dict: + """Build a payload suitable for InvokeAI's ``/api/v1/recall/{queue_id}``. + + The returned dict follows the schema documented in the InvokeAI + ``RECALL_PARAMETERS`` docs. Fields that the current metadata does not + carry are simply omitted, so that the receiving InvokeAI backend only + overwrites values it has been given. + + ``include_seed`` toggles whether the random seed is included. The UI + uses ``False`` for the "remix" action so that the receiving InvokeAI + backend re-randomizes the generation. + """ + m = self.metadata + payload: dict = {} + + positive = self.positive_prompt + if positive: + payload["positive_prompt"] = positive + negative = self.negative_prompt + if negative: + payload["negative_prompt"] = negative + + model_name = self.model_name + if model_name: + payload["model"] = model_name + + if include_seed and self.seed is not None: + payload["seed"] = int(self.seed) + + # Numeric / scalar fields — only v3 and v5 carry these. + if not isinstance(m, GenerationMetadata2): + for attr, key in ( + ("steps", "steps"), + ("cfg_scale", "cfg_scale"), + ("cfg_rescale_multiplier", "cfg_rescale_multiplier"), + ("guidance", "guidance"), + ("width", "width"), + ("height", "height"), + ("scheduler", "scheduler"), + ("clip_skip", "clip_skip"), + ("seamless_x", "seamless_x"), + ("seamless_y", "seamless_y"), + ): + value = getattr(m, attr, None) + if value is not None: + payload[key] = value + + # v5 carries refiner fields too. + if isinstance(m, GenerationMetadata5): + for attr, key in ( + ("refiner_cfg_scale", "refiner_cfg_scale"), + ("refiner_steps", "refiner_steps"), + ("refiner_start", "refiner_denoise_start"), + ("refiner_positive_aesthetic_score", "refiner_positive_aesthetic_score"), + ("refiner_negative_aesthetic_score", "refiner_negative_aesthetic_score"), + ("strength", "denoise_strength"), + ): + value = getattr(m, attr, None) + if value is not None: + payload[key] = value + + loras = [ + {"model_name": lora.model_name, "weight": float(lora.weight)} + for lora in self.loras + if lora.model_name + ] + if loras: + payload["loras"] = loras + + control_layers: list[dict] = [] + for layer in self.control_layers: + if not layer.model_name: + continue + entry: dict = {"model_name": layer.model_name} + if layer.image_name: + entry["image_name"] = layer.image_name + if isinstance(layer.weight, int | float): + entry["weight"] = float(layer.weight) + control_layers.append(entry) + if control_layers: + payload["control_layers"] = control_layers + + ip_adapters: list[dict] = [] + reference_images: list[dict] = [] + for ref in self.reference_images: + if ref.model_name: + entry: dict = {"model_name": ref.model_name} + if ref.image_name: + entry["image_name"] = ref.image_name + if isinstance(ref.weight, int | float): + entry["weight"] = float(ref.weight) + ip_adapters.append(entry) + elif ref.image_name: + reference_images.append({"image_name": ref.image_name}) + if ip_adapters: + payload["ip_adapters"] = ip_adapters + if reference_images: + payload["reference_images"] = reference_images + + return payload + # ---- raster images --------------------------------------------------- @property diff --git a/photomap/backend/metadata_modules/invoke_formatter.py b/photomap/backend/metadata_modules/invoke_formatter.py index cc313d83..f83017cb 100644 --- a/photomap/backend/metadata_modules/invoke_formatter.py +++ b/photomap/backend/metadata_modules/invoke_formatter.py @@ -37,8 +37,54 @@ "" ) +# Asterisk icon for the recall button — matches InvokeAI's own recall iconography. +_RECALL_SVG = ( + '' + '' + "" +) + +# Two circling arrows — a "refresh / remix" icon matching the reference +# screenshot. +_REMIX_SVG = ( + '" +) + -def format_invoke_metadata(slide_data: SlideSummary, metadata: dict) -> SlideSummary: +def _recall_buttons_html() -> str: + """Render the recall / remix button group shown at the bottom of the drawer.""" + return ( + '
' + '" + '" + "
" + ) + + +def format_invoke_metadata( + slide_data: SlideSummary, + metadata: dict, + show_recall_buttons: bool = False, +) -> SlideSummary: """Render InvokeAI metadata into an HTML table on ``slide_data.description``. Also populates ``slide_data.reference_images`` with the image names of any @@ -106,7 +152,12 @@ def format_invoke_metadata(slide_data: SlideSummary, metadata: dict) -> SlideSum if control_layers and (ctrl_html := _tuple_table(control_layers)): rows.append(f"Control Layers{ctrl_html}") - slide_data.description = "" + "".join(rows) + "
" + slide_data.description = ( + "" + + "".join(rows) + + "
" + + (_recall_buttons_html() if show_recall_buttons else "") + ) slide_data.reference_images = [ ri.image_name for ri in reference_images if ri.image_name ] + [ diff --git a/photomap/backend/photomap_server.py b/photomap/backend/photomap_server.py index 81067e1d..ea463a5e 100644 --- a/photomap/backend/photomap_server.py +++ b/photomap/backend/photomap_server.py @@ -23,6 +23,7 @@ from photomap.backend.routers.curation import router as curation_router from photomap.backend.routers.filetree import filetree_router from photomap.backend.routers.index import index_router +from photomap.backend.routers.invoke import invoke_router from photomap.backend.routers.search import search_router from photomap.backend.routers.umap import umap_router from photomap.backend.routers.upgrade import upgrade_router @@ -42,6 +43,7 @@ album_router, filetree_router, upgrade_router, + invoke_router, ]: app.include_router(router) diff --git a/photomap/backend/routers/invoke.py b/photomap/backend/routers/invoke.py new file mode 100644 index 00000000..3d11bd2a --- /dev/null +++ b/photomap/backend/routers/invoke.py @@ -0,0 +1,264 @@ +"""Router for integration with a running InvokeAI backend. + +Provides: + +* ``GET /invokeai/config`` / ``POST /invokeai/config`` — view and update the + InvokeAI connection settings stored in the PhotoMap config file. +* ``POST /invokeai/recall`` — take an (album_key, image index, include_seed) + tuple, load the image metadata, build a recall payload from it, and proxy + it to the configured InvokeAI backend's ``/api/v1/recall/{queue_id}`` + endpoint. + +When the configured InvokeAI backend runs in multi-user mode, the +``username`` / ``password`` fields are used to obtain a JWT bearer token +via ``/api/v1/auth/login``. The token is cached in-process and +automatically refreshed on 401. +""" + +from __future__ import annotations + +import logging +import time + +import httpx +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field, ValidationError + +from ..config import get_config_manager +from ..metadata_modules.invoke.invoke_metadata_view import InvokeMetadataView +from ..metadata_modules.invokemetadata import GenerationMetadataAdapter +from .album import get_embeddings_for_album + +logger = logging.getLogger(__name__) + +invoke_router = APIRouter(prefix="/invokeai", tags=["InvokeAI"]) + +# 5 seconds is plenty for a local loopback call; anything slower almost +# certainly means the backend is unreachable rather than genuinely busy. +_HTTP_TIMEOUT = 5.0 + +config_manager = get_config_manager() + +# ── InvokeAI JWT token cache ────────────────────────────────────────── +_cached_token: str | None = None +_token_expires_at: float = 0.0 +_token_base_url: str | None = None +_token_username: str | None = None + + +async def _get_auth_headers(base_url: str, username: str | None, password: str | None) -> dict[str, str]: + """Return an ``Authorization`` header dict, or empty dict for anonymous access. + + If a valid cached token exists it is reused. If no token is cached the + caller should first try the request without credentials — the InvokeAI + backend running in single-user mode will accept it. When the caller + receives a 401 it should call ``_invalidate_token_cache`` and call this + function again; the second call will perform the login. + """ + global _cached_token, _token_expires_at, _token_base_url, _token_username # noqa: PLW0603 + + # Reuse cached token if still valid for this backend+user + if ( + _cached_token + and time.monotonic() < _token_expires_at + and _token_base_url == base_url + and _token_username == username + ): + return {"Authorization": f"Bearer {_cached_token}"} + + # No credentials configured — anonymous access + if not username or not password: + return {} + + # No cached token yet — try to obtain one + login_url = f"{base_url.rstrip('/')}/api/v1/auth/login" + try: + async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client: + resp = await client.post(login_url, json={"email": username, "password": password}) + except httpx.RequestError as exc: + logger.warning("InvokeAI auth request failed: %s", exc) + raise HTTPException( + status_code=502, + detail=f"Could not reach InvokeAI backend for authentication: {exc}", + ) from exc + + if resp.status_code != 200: + detail = resp.json().get("detail", resp.text[:200]) if resp.headers.get("content-type", "").startswith("application/json") else resp.text[:200] + raise HTTPException( + status_code=502, + detail=f"InvokeAI authentication failed ({resp.status_code}): {detail}", + ) + + data = resp.json() + _cached_token = data["token"] + _token_expires_at = time.monotonic() + data.get("expires_in", 86400) - 60 # refresh 60s early + _token_base_url = base_url + _token_username = username + return {"Authorization": f"Bearer {_cached_token}"} + + +def _invalidate_token_cache() -> None: + """Clear the cached token so the next request re-authenticates.""" + global _cached_token, _token_expires_at # noqa: PLW0603 + _cached_token = None + _token_expires_at = 0.0 + + +class InvokeAISettings(BaseModel): + """Mirrors the three config fields we expose via the settings panel.""" + + url: str | None = None + username: str | None = None + password: str | None = None + + +class RecallRequest(BaseModel): + """Payload posted by the drawer's recall / remix buttons.""" + + album_key: str = Field(..., description="Album containing the image") + index: int = Field(..., ge=0, description="Image index within the album") + include_seed: bool = Field( + True, + description="If False, omit the seed from the recall payload (remix mode)", + ) + queue_id: str = Field("default", description="InvokeAI queue id to target") + + +@invoke_router.get("/config") +async def get_invokeai_config() -> dict: + """Return the persisted InvokeAI connection settings. + + The password is never returned in the clear — we only indicate whether + one is stored. + """ + settings = config_manager.get_invokeai_settings() + return { + "url": settings["url"] or "", + "username": settings["username"] or "", + "has_password": bool(settings["password"]), + } + + +@invoke_router.post("/config") +async def set_invokeai_config(settings: InvokeAISettings) -> dict: + """Persist the InvokeAI connection settings. + + A password of ``None`` leaves the existing stored password untouched so + the settings panel can re-submit without clobbering what was saved. + """ + _invalidate_token_cache() + existing = config_manager.get_invokeai_settings() + password = settings.password if settings.password is not None else existing["password"] + try: + config_manager.set_invokeai_settings( + url=settings.url, + username=settings.username, + password=password, + ) + except Exception as exc: + logger.exception("Failed to persist InvokeAI settings") + raise HTTPException( + status_code=500, detail=f"Failed to save settings: {exc}" + ) from exc + return {"success": True} + + +def _load_raw_metadata(album_key: str, index: int) -> dict: + embeddings = get_embeddings_for_album(album_key) + if not embeddings: + raise HTTPException(status_code=404, detail="Album not found") + indexes = embeddings.indexes + metadata = indexes["sorted_metadata"] + if index < 0 or index >= len(metadata): + raise HTTPException(status_code=404, detail="Index out of range") + entry = metadata[index] + return entry if isinstance(entry, dict) else {} + + +def _build_recall_payload(raw_metadata: dict, include_seed: bool) -> dict: + if not raw_metadata: + raise HTTPException( + status_code=400, detail="No InvokeAI metadata available for this image" + ) + try: + parsed = GenerationMetadataAdapter().parse(raw_metadata) + except ValidationError as exc: + logger.warning("Failed to parse invoke metadata for recall: %s", exc) + raise HTTPException( + status_code=400, detail="Image does not contain recognizable InvokeAI metadata" + ) from exc + view = InvokeMetadataView(parsed) + return view.to_recall_payload(include_seed=include_seed) + + +@invoke_router.post("/recall") +async def recall_parameters(request: RecallRequest) -> dict: + """Forward a parsed recall payload to the configured InvokeAI backend. + + When the backend runs in multi-user mode and credentials are configured, + a JWT bearer token is obtained (and cached) automatically. A 401 from + the recall endpoint triggers a single re-authentication attempt. + """ + settings = config_manager.get_invokeai_settings() + base_url = settings["url"] + if not base_url: + raise HTTPException( + status_code=400, + detail=( + "InvokeAI backend URL is not configured. Set it in the " + "PhotoMap settings panel." + ), + ) + + raw_metadata = _load_raw_metadata(request.album_key, request.index) + payload = _build_recall_payload(raw_metadata, include_seed=request.include_seed) + if not payload: + raise HTTPException( + status_code=400, + detail="No recallable parameters were found in this image's metadata", + ) + + url = f"{base_url.rstrip('/')}/api/v1/recall/{request.queue_id}" + username = settings["username"] + password = settings["password"] + + # Try without credentials first (single-user mode). If the backend + # requires authentication (401), obtain a token and retry. + auth_headers = await _get_auth_headers(base_url, username, password) + + try: + async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client: + response = await client.post(url, json=payload, params={"strict": "true"}, headers=auth_headers) + + if response.status_code == 401 and username and password: + _invalidate_token_cache() + auth_headers = await _get_auth_headers(base_url, username, password) + response = await client.post(url, json=payload, params={"strict": "true"}, headers=auth_headers) + except httpx.RequestError as exc: + logger.warning("InvokeAI recall request failed: %s", exc) + raise HTTPException( + status_code=502, + detail=f"Could not reach InvokeAI backend at {base_url}: {exc}", + ) from exc + + if response.status_code >= 400: + logger.warning( + "InvokeAI recall returned %s: %s", response.status_code, response.text + ) + raise HTTPException( + status_code=502, + detail=( + f"InvokeAI backend returned {response.status_code}: {response.text[:200]}" + ), + ) + + try: + remote = response.json() + except ValueError: + remote = {"raw": response.text} + + return { + "success": True, + "sent": payload, + "response": remote, + } diff --git a/photomap/frontend/static/css/metadata-drawer.css b/photomap/frontend/static/css/metadata-drawer.css index d7f41fc7..014408ce 100644 --- a/photomap/frontend/static/css/metadata-drawer.css +++ b/photomap/frontend/static/css/metadata-drawer.css @@ -43,7 +43,7 @@ align-items: flex-start; justify-content: center; margin-bottom: 0; - padding: 1em 2em; + padding: 1em 2em 0; } .filenameRow { @@ -122,7 +122,7 @@ color: #faea0e; text-decoration: none; font-weight: bold; - font-size: 0.7em; + font-size: 1em; } .cluster-info-badge { @@ -144,7 +144,7 @@ border-collapse: collapse; border: 1px solid #bbb; width: 100%; - margin: 0.5em 0; + margin: 0.5em 0 0; } .invoke-metadata th, @@ -155,6 +155,88 @@ padding: 6px 10px; } +/* ===== Recall / Remix buttons (pinned outside scrollable area) ===== */ +#recallButtonsContainer:empty { + display: none; +} + +#recallButtonsContainer { + padding: 0 2em; +} + +#metadataLinkContainer { + padding: 0.4em 2em 0.8em; +} + +.invoke-recall-controls { + display: flex; + gap: 12px; + margin: 2px 0 4px 0; + align-items: center; +} + +.invoke-recall-btn { + display: inline-flex; + align-items: center; + gap: 6px; + background: #2a2a2a; + color: #faea0e; + border: 1px solid #555; + border-radius: 4px; + padding: 6px 12px; + cursor: pointer; + font-size: 14px; + line-height: 1; + height: 32px; + box-sizing: border-box; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.invoke-recall-btn:hover:not(:disabled) { + background: #3a3a3a; + border-color: #faea0e; +} + +.invoke-recall-btn:disabled { + opacity: 0.6; + cursor: wait; +} + +.invoke-recall-btn svg { + flex-shrink: 0; +} + +.invoke-recall-status { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 14px; + height: 14px; + font-size: 14px; + font-weight: bold; + line-height: 1; + overflow: hidden; +} + +.invoke-recall-status:empty { + display: none; +} + +.invoke-recall-status.success { + color: #4caf50; +} + +.invoke-recall-status.error { + color: #e53935; +} + +.invoke-recall-error { + color: #e53935; + font-size: 12px; + margin-top: 4px; + word-break: break-word; +} + /* ===== BANNER DRAWER CONTAINER ===== */ .banner-drawer-container { position: fixed; diff --git a/photomap/frontend/static/css/settings.css b/photomap/frontend/static/css/settings.css index 245493b9..d2ca5044 100644 --- a/photomap/frontend/static/css/settings.css +++ b/photomap/frontend/static/css/settings.css @@ -152,6 +152,13 @@ } +#invokeaiUrlInput, +#invokeaiUsernameInput, +#invokeaiPasswordInput { + width: 100%; + box-sizing: border-box; +} + #albumSelect { padding: 0.5em; border-radius: 4px; @@ -200,3 +207,50 @@ margin-bottom: 0.5em; } +/* ===== ACCORDION SECTIONS ===== */ +.settings-accordion { + margin-top: 1em; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + overflow: hidden; +} + +.accordion-header { + width: 100%; + display: flex; + align-items: center; + gap: 0.5em; + padding: 0.6em 0.8em; + background: rgba(255, 255, 255, 0.05); + border: none; + color: #faea0e; + font-size: 1em; + font-weight: 600; + cursor: pointer; + text-align: left; + transition: background 0.15s ease; +} + +.accordion-header:hover { + background: rgba(255, 255, 255, 0.1); +} + +.accordion-chevron { + display: inline-block; + font-size: 0.7em; + transition: transform 0.2s ease; +} + +.accordion-header[aria-expanded="true"] .accordion-chevron { + transform: rotate(90deg); +} + +.accordion-body { + display: none; + padding: 0 0.8em 0.8em; +} + +.accordion-body.open { + display: block; +} + diff --git a/photomap/frontend/static/javascript/grid-view.js b/photomap/frontend/static/javascript/grid-view.js index b0a93475..240133c5 100644 --- a/photomap/frontend/static/javascript/grid-view.js +++ b/photomap/frontend/static/javascript/grid-view.js @@ -678,8 +678,31 @@ class GridViewManager { const processedDescription = replaceReferenceImagesWithLinks(rawDescription, referenceImages, state.album); document.getElementById("descriptionText").innerHTML = processedDescription; + + // Inject filepath as first row of the metadata table + const filepath = data["filepath"] || ""; + if (filepath) { + const table = document.querySelector("#descriptionText .invoke-metadata"); + if (table) { + const row = document.createElement("tr"); + row.innerHTML = `Path${filepath}`; + table.tBodies[0] + ? table.tBodies[0].insertBefore(row, table.tBodies[0].firstChild) + : table.insertBefore(row, table.firstChild); + } + } + + // Move recall/remix buttons out of the scrollable description area + const recallContainer = document.getElementById("recallButtonsContainer"); + if (recallContainer) { + recallContainer.innerHTML = ""; + const recallControls = document.querySelector("#descriptionText .invoke-recall-controls"); + if (recallControls) { + recallContainer.appendChild(recallControls); + } + } + document.getElementById("filenameText").textContent = data["filename"] || ""; - document.getElementById("filepathText").textContent = data["filepath"] || ""; document.getElementById("metadataLink").href = data["metadata_url"] || "#"; // Update cluster information display diff --git a/photomap/frontend/static/javascript/invoke-recall.js b/photomap/frontend/static/javascript/invoke-recall.js new file mode 100644 index 00000000..c98d0e51 --- /dev/null +++ b/photomap/frontend/static/javascript/invoke-recall.js @@ -0,0 +1,158 @@ +// invoke-recall.js +// Wires up the Recall / Remix buttons emitted by the InvokeAI metadata +// formatter at the bottom of the metadata drawer. Pressing a button sends a +// request to the PhotoMap backend which in turn proxies a recall payload to +// the configured InvokeAI backend. + +import { state } from "./state.js"; + +const STATUS_RESET_MS = 2000; + +// Pull the sorted-album index out of the drawer's metadata_url, which is of +// the form ``get_metadata/{album_key}/{index}``. We deliberately parse the +// URL stored on the slide's dataset (via the drawer) rather than trusting +// state.album — metadata_url is what the server will honor. +export function parseMetadataUrl(metadataUrl) { + if (!metadataUrl) { + return null; + } + // Handles both relative and absolute forms. + const cleaned = metadataUrl.replace(/^.*get_metadata\//, ""); + const parts = cleaned.split("/").filter(Boolean); + if (parts.length < 2) { + return null; + } + const index = parseInt(parts[parts.length - 1], 10); + if (!Number.isFinite(index)) { + return null; + } + const albumKey = decodeURIComponent(parts.slice(0, -1).join("/")); + return { albumKey, index }; +} + +function showStatus(button, kind) { + const statusEl = button.querySelector(".invoke-recall-status"); + if (!statusEl) { + return; + } + statusEl.classList.remove("success", "error"); + statusEl.innerHTML = ""; + if (kind === "success") { + statusEl.classList.add("success"); + statusEl.textContent = "✓"; + } else if (kind === "error") { + statusEl.classList.add("error"); + statusEl.innerHTML = + '' + + '' + + ""; + } + if (kind) { + setTimeout(() => { + statusEl.classList.remove("success", "error"); + statusEl.innerHTML = ""; + }, STATUS_RESET_MS); + } +} + +function showErrorMessage(button, message) { + const controls = button.closest(".invoke-recall-controls"); + if (!controls) { + return; + } + // Remove any existing error banner + const existing = controls.parentElement.querySelector(".invoke-recall-error"); + if (existing) { + existing.remove(); + } + if (!message) { + return; + } + const banner = document.createElement("div"); + banner.className = "invoke-recall-error"; + banner.textContent = message; + controls.insertAdjacentElement("afterend", banner); + setTimeout(() => banner.remove(), STATUS_RESET_MS * 3); +} + +export async function sendRecall({ albumKey, index, includeSeed }) { + const response = await fetch("invokeai/recall", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + album_key: albumKey, + index, + include_seed: includeSeed, + }), + }); + if (!response.ok) { + let message = `HTTP ${response.status}`; + try { + const body = await response.json(); + if (body && body.detail) { + message = body.detail; + } + } catch { + // ignore JSON parse errors — fall back to the generic message + } + const err = new Error(message); + err.status = response.status; + throw err; + } + return response.json(); +} + +function getMetadataUrlFromDrawer() { + // Prefer the actual anchor element so we stay in sync with what the drawer + // is currently displaying. + const metadataLink = document.getElementById("metadataLink"); + if (metadataLink && metadataLink.getAttribute("href")) { + return metadataLink.getAttribute("href"); + } + return null; +} + +async function handleRecallClick(button) { + if (button.disabled) { + return; + } + const mode = button.dataset.recallMode; + const metadataUrl = getMetadataUrlFromDrawer(); + const parsed = parseMetadataUrl(metadataUrl); + if (!parsed) { + showStatus(button, "error"); + console.warn("Could not determine album/index for recall from metadataUrl", metadataUrl); + return; + } + // Fall back to the live album if we couldn't recover one from the URL. + const albumKey = parsed.albumKey || state.album; + + button.disabled = true; + try { + await sendRecall({ + albumKey, + index: parsed.index, + includeSeed: mode !== "remix", + }); + showStatus(button, "success"); + } catch (err) { + console.error("InvokeAI recall failed:", err); + showStatus(button, "error"); + showErrorMessage(button, err && err.message ? err.message : "Recall failed"); + } finally { + button.disabled = false; + } +} + +// Event delegation — the drawer's HTML is rebuilt every slide, so we can't +// attach listeners directly to the buttons. Listening on the document once +// is both simpler and reliable across re-renders. +document.addEventListener("click", (e) => { + const button = e.target.closest(".invoke-recall-btn"); + if (!button) { + return; + } + e.preventDefault(); + e.stopPropagation(); + handleRecallClick(button); +}); diff --git a/photomap/frontend/static/javascript/metadata-drawer.js b/photomap/frontend/static/javascript/metadata-drawer.js index 84891ca6..0cc7f8bc 100644 --- a/photomap/frontend/static/javascript/metadata-drawer.js +++ b/photomap/frontend/static/javascript/metadata-drawer.js @@ -89,8 +89,31 @@ export function updateMetadataOverlay(slide) { const processedDescription = replaceReferenceImagesWithLinks(rawDescription, referenceImages, state.album); document.getElementById("descriptionText").innerHTML = processedDescription; + + // Inject filepath as first row of the metadata table + const filepath = slide.dataset.filepath || ""; + if (filepath) { + const table = document.querySelector("#descriptionText .invoke-metadata"); + if (table) { + const row = document.createElement("tr"); + row.innerHTML = `Path${filepath}`; + table.tBodies[0] + ? table.tBodies[0].insertBefore(row, table.tBodies[0].firstChild) + : table.insertBefore(row, table.firstChild); + } + } + + // Move recall/remix buttons out of the scrollable description area + const recallContainer = document.getElementById("recallButtonsContainer"); + if (recallContainer) { + recallContainer.innerHTML = ""; + const recallControls = document.querySelector("#descriptionText .invoke-recall-controls"); + if (recallControls) { + recallContainer.appendChild(recallControls); + } + } + document.getElementById("filenameText").textContent = slide.dataset.filename || ""; - document.getElementById("filepathText").textContent = slide.dataset.filepath || ""; document.getElementById("metadataLink").href = slide.dataset.metadata_url || "#"; // Update cluster information display diff --git a/photomap/frontend/static/javascript/settings.js b/photomap/frontend/static/javascript/settings.js index 59c2afc2..463f9f2a 100644 --- a/photomap/frontend/static/javascript/settings.js +++ b/photomap/frontend/static/javascript/settings.js @@ -27,6 +27,9 @@ function cacheElements() { slowerBtn: document.getElementById("slowerBtn"), fasterBtn: document.getElementById("fasterBtn"), locationiqApiKeyInput: document.getElementById("locationiqApiKeyInput"), + invokeaiUrlInput: document.getElementById("invokeaiUrlInput"), + invokeaiUsernameInput: document.getElementById("invokeaiUsernameInput"), + invokeaiPasswordInput: document.getElementById("invokeaiPasswordInput"), showControlPanelTextCheckbox: document.getElementById("showControlPanelTextCheckbox"), confirmDeleteCheckbox: document.getElementById("confirmDeleteCheckbox"), gridThumbSizeFactor: document.getElementById("gridThumbSizeFactor"), @@ -175,6 +178,7 @@ async function populateModalFields() { } await loadLocationIQApiKey(); + await loadInvokeAISettings(); } // Event listener setup @@ -287,6 +291,76 @@ async function saveLocationIQApiKey(apiKey) { } } +async function loadInvokeAISettings() { + if (!elements.invokeaiUrlInput) { + return; + } + try { + const response = await fetch("invokeai/config"); + if (!response.ok) { + return; + } + const data = await response.json(); + elements.invokeaiUrlInput.value = data.url || ""; + if (elements.invokeaiUsernameInput) { + elements.invokeaiUsernameInput.value = data.username || ""; + } + if (elements.invokeaiPasswordInput) { + // Never echo passwords; indicate if one is stored. + elements.invokeaiPasswordInput.value = ""; + elements.invokeaiPasswordInput.placeholder = data.has_password + ? "(password saved — leave blank to keep)" + : "(for future multi-user support)"; + } + } catch (error) { + console.error("Failed to load InvokeAI settings:", error); + } +} + +async function saveInvokeAISettings() { + if (!elements.invokeaiUrlInput) { + return; + } + const body = { + url: elements.invokeaiUrlInput.value, + username: elements.invokeaiUsernameInput ? elements.invokeaiUsernameInput.value : "", + }; + // Only include password when the user actually typed one — otherwise the + // backend keeps the stored value. + if (elements.invokeaiPasswordInput && elements.invokeaiPasswordInput.value) { + body.password = elements.invokeaiPasswordInput.value; + } + try { + await fetch("invokeai/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } catch (error) { + console.error("Failed to save InvokeAI settings:", error); + } +} + +function setupInvokeAISettingsControls() { + if (!elements.invokeaiUrlInput) { + return; + } + const debounced = (fn) => { + let timeout = null; + return () => { + clearTimeout(timeout); + timeout = setTimeout(fn, 600); + }; + }; + const debouncedSave = debounced(saveInvokeAISettings); + [elements.invokeaiUrlInput, elements.invokeaiUsernameInput, elements.invokeaiPasswordInput] + .filter(Boolean) + .forEach((input) => { + input.addEventListener("input", debouncedSave); + input.addEventListener("blur", saveInvokeAISettings); + }); +} + function setupLocationIQApiKeyControl() { if (!elements.locationiqApiKeyInput) { return; @@ -421,6 +495,29 @@ function setupResetDefaultsControls() { } } +// Accordion section toggle +function setupAccordions() { + document.querySelectorAll(".settings-accordion .accordion-header").forEach((header) => { + const section = header.closest(".settings-accordion").dataset.section; + const body = header.nextElementSibling; + const storageKey = `settings-accordion-${section}`; + + // Restore persisted open/closed state + const wasOpen = localStorage.getItem(storageKey) === "true"; + if (wasOpen) { + header.setAttribute("aria-expanded", "true"); + body.classList.add("open"); + } + + header.addEventListener("click", () => { + const expanded = header.getAttribute("aria-expanded") === "true"; + header.setAttribute("aria-expanded", String(!expanded)); + body.classList.toggle("open"); + localStorage.setItem(storageKey, String(!expanded)); + }); + }); +} + // MAIN INITIALIZATION FUNCTION async function initializeSettings() { cacheElements(); @@ -429,11 +526,13 @@ async function initializeSettings() { await loadAvailableAlbums(); // Setup all controls + setupAccordions(); setupDelayControls(); setupModeControls(); setupModalControls(); setupAlbumSelector(); setupLocationIQApiKeyControl(); + setupInvokeAISettingsControls(); setupConfirmDeleteControl(); setupGridThumbSizeFactorControl(); setupSearchSettingsControls(); diff --git a/photomap/frontend/static/main.js b/photomap/frontend/static/main.js index ddcca976..eef5c7d3 100644 --- a/photomap/frontend/static/main.js +++ b/photomap/frontend/static/main.js @@ -6,6 +6,7 @@ import './javascript/bookmarks.js'; import './javascript/cluster-utils.js'; import './javascript/events.js'; import './javascript/metadata-drawer.js'; +import './javascript/invoke-recall.js'; import './javascript/page-visibility.js'; import './javascript/search-ui.js'; import './javascript/search.js'; diff --git a/photomap/frontend/templates/modules/metadata-drawer.html b/photomap/frontend/templates/modules/metadata-drawer.html index 2cf668c7..dfbeff7e 100644 --- a/photomap/frontend/templates/modules/metadata-drawer.html +++ b/photomap/frontend/templates/modules/metadata-drawer.html @@ -22,23 +22,25 @@ - +
-
- +
+ +
+ + diff --git a/photomap/frontend/templates/modules/settings.html b/photomap/frontend/templates/modules/settings.html index 9a2e3ea2..7c2690ca 100644 --- a/photomap/frontend/templates/modules/settings.html +++ b/photomap/frontend/templates/modules/settings.html @@ -17,173 +17,248 @@ -
- -
-
Image Change Interval
- -
- - 10 - -
+ + {% if not album_locked or multiple_locked_albums %} +
+ +
+ + {% if not album_locked %} + + {% endif %}
+
+ {% endif %} - -
-
Image Slideshow Mode:
+ +
+ +
+
+ +
+
Image Change Interval
+
+ + 10 + +
+
-
- - + +
+
Image Slideshow Mode:
+
+ + +
+
- -
- -
- - -
-
+ +
+ +
+
+ +
+ + +
+
-
- -
- - -
-
+
+ + +
- -
- -
- - + {% if not album_locked %} +
+ + +
+ {% endif %}
- -
- - -
+ +
+ +
+
+ +
+ + +
+
- {% if not album_locked %} -
- - +
+ +
+ + +
+
+
- {% endif %} - + {% if not album_locked %} -
-
- - - - Optional: Enables location names and maps in EXIF data. - Get a free key - -
-
- {% endif %} +
+ +
+
+
+ + + + Optional: Enables location names and maps in EXIF data. + Get a free key + +
+
- - {% if not album_locked or multiple_locked_albums %} -
- -
- - {% if not album_locked %} - - {% endif %} +
+
+ + +
+
+ + +
+
+ + + + Optional: URL of a running InvokeAI server to enable the + Recall / Remix buttons in the metadata drawer. + Add a username and password if the InvokeAI server requires + authentication. + +
+
{% endif %} +
diff --git a/tests/backend/test_invoke_metadata.py b/tests/backend/test_invoke_metadata.py index 78aff1ba..387454c5 100644 --- a/tests/backend/test_invoke_metadata.py +++ b/tests/backend/test_invoke_metadata.py @@ -817,3 +817,80 @@ def test_control_layer_row_skipped_when_fully_empty(self): assert tuple_table.count("") == 1 assert "canny" in tuple_table assert "e.png" in tuple_table + + +# --------------------------------------------------------------------------- +# InvokeMetadataView.to_recall_payload — serialization for /api/v1/recall +# --------------------------------------------------------------------------- + + +class TestRecallPayload: + def test_v3_includes_core_fields_loras_controls_ip_adapters(self, v3_metadata): + payload = _view(v3_metadata).to_recall_payload(include_seed=True) + assert payload["positive_prompt"] == "a cat riding a skateboard" + assert payload["negative_prompt"] == "blurry, low quality" + assert payload["model"] == "dreamshaper" + assert payload["seed"] == 42 + assert payload["loras"] == [ + {"model_name": "detail_lora", "weight": 0.8}, + {"model_name": "style_lora", "weight": 0.5}, + ] + assert payload["ip_adapters"] == [ + {"model_name": "ip_adapter_sd15", "image_name": "ref.png", "weight": 0.5} + ] + assert payload["control_layers"] == [ + { + "model_name": "canny_sd15", + "image_name": "control.png", + "weight": 0.7, + } + ] + + def test_remix_omits_seed(self, v3_metadata): + payload = _view(v3_metadata).to_recall_payload(include_seed=False) + assert "seed" not in payload + # Everything else still present + assert payload["positive_prompt"] == "a cat riding a skateboard" + assert payload["loras"] + + def test_v5_ref_images_path(self, v5_ref_images_metadata): + payload = _view(v5_ref_images_metadata).to_recall_payload(include_seed=True) + assert payload["positive_prompt"] == "a dog in a hat" + assert payload["model"] == "flux-schnell" + assert payload["seed"] == 100 + # disabled ref images and disabled control layers are filtered out + assert payload["ip_adapters"] == [ + {"model_name": "ipa-flux", "image_name": "ref1.png", "weight": 0.7} + ] + assert payload["control_layers"] == [ + {"model_name": "canny_flux", "image_name": "edges.png", "weight": 0.9} + ] + + def test_empty_metadata_returns_empty_payload(self): + # Build a view over a near-empty v5 record so that to_recall_payload + # still exercises the "omit None" branches. + metadata = {"metadata_version": 5} + view = _view(metadata) + payload = view.to_recall_payload(include_seed=True) + assert payload == {} + + +# --------------------------------------------------------------------------- +# format_invoke_metadata — recall button rendering +# --------------------------------------------------------------------------- + + +class TestFormatInvokeRecallButtons: + def test_buttons_hidden_by_default(self, v3_metadata): + html = format_invoke_metadata(_slide(), v3_metadata).description + assert "invoke-recall-controls" not in html + + def test_buttons_shown_when_enabled(self, v3_metadata): + html = format_invoke_metadata( + _slide(), v3_metadata, show_recall_buttons=True + ).description + assert 'class="invoke-recall-controls"' in html + assert 'data-recall-mode="recall"' in html + assert 'data-recall-mode="remix"' in html + # Asterisk SVG is present for the recall button + assert html.count('class="invoke-recall-btn"') == 2 diff --git a/tests/backend/test_invoke_router.py b/tests/backend/test_invoke_router.py new file mode 100644 index 00000000..9c089f5b --- /dev/null +++ b/tests/backend/test_invoke_router.py @@ -0,0 +1,243 @@ +"""Tests for the InvokeAI recall proxy router. + +Covers: + +* GET/POST ``/invokeai/config`` round-tripping via the config manager. +* ``POST /invokeai/recall`` returning a clean error when the backend URL is + not configured. +* ``POST /invokeai/recall`` happy path where we stub ``httpx.AsyncClient`` and + verify the payload shape that would reach the upstream InvokeAI backend. +""" + +from __future__ import annotations + +import httpx +import pytest + +from photomap.backend.config import get_config_manager + + +@pytest.fixture +def clear_invokeai_config(): + """Ensure each test starts and ends without stale InvokeAI config.""" + manager = get_config_manager() + manager.set_invokeai_settings(url=None, username=None, password=None) + yield + manager.set_invokeai_settings(url=None, username=None, password=None) + + +def test_get_config_empty(client, clear_invokeai_config): + response = client.get("/invokeai/config") + assert response.status_code == 200 + body = response.json() + assert body == {"url": "", "username": "", "has_password": False} + + +def test_set_and_get_config(client, clear_invokeai_config): + response = client.post( + "/invokeai/config", + json={ + "url": "http://localhost:9090", + "username": "alice", + "password": "secret", + }, + ) + assert response.status_code == 200 + assert response.json() == {"success": True} + + response = client.get("/invokeai/config") + body = response.json() + assert body["url"] == "http://localhost:9090" + assert body["username"] == "alice" + # Password is never echoed back — only a boolean flag. + assert body["has_password"] is True + assert "password" not in body + + +def test_set_config_preserves_password_on_null(client, clear_invokeai_config): + # First store a password. + client.post( + "/invokeai/config", + json={"url": "http://localhost:9090", "password": "original"}, + ) + # Now update other fields without sending a password field at all. + client.post("/invokeai/config", json={"url": "http://other:9090"}) + + manager = get_config_manager() + manager.reload_config() + settings = manager.get_invokeai_settings() + assert settings["url"] == "http://other:9090" + assert settings["password"] == "original" + + +def test_recall_requires_configured_url(client, clear_invokeai_config): + response = client.post( + "/invokeai/recall", + json={"album_key": "whatever", "index": 0, "include_seed": True}, + ) + assert response.status_code == 400 + assert "not configured" in response.json()["detail"].lower() + + +def test_recall_proxies_payload_to_invokeai_backend( + client, clear_invokeai_config, monkeypatch +): + """Stub out the upstream call and verify the exact payload we forward.""" + # Configure the backend URL. + client.post("/invokeai/config", json={"url": "http://localhost:9090"}) + + # Stub out embeddings lookup so the router receives a canned metadata dict. + raw_metadata = { + "metadata_version": 3, + "app_version": "3.5.0", + "positive_prompt": "a landscape", + "negative_prompt": "blurry", + "model": {"model_name": "dreamshaper", "base_model": "sd-1"}, + "seed": 321, + "steps": 25, + "cfg_scale": 7.5, + "width": 512, + "height": 512, + } + + from photomap.backend.routers import invoke as invoke_module + + monkeypatch.setattr( + invoke_module, "_load_raw_metadata", lambda album_key, index: raw_metadata + ) + + captured = {} + + class _StubResponse: + status_code = 200 + text = "{}" + + def json(self): + return {"status": "success", "updated_count": 7} + + class _StubClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def post(self, url, json, **kwargs): + captured["url"] = url + captured["json"] = json + captured["params"] = kwargs.get("params") + captured["headers"] = kwargs.get("headers") + return _StubResponse() + + monkeypatch.setattr(invoke_module.httpx, "AsyncClient", _StubClient) + + response = client.post( + "/invokeai/recall", + json={"album_key": "any", "index": 0, "include_seed": True}, + ) + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert body["sent"]["positive_prompt"] == "a landscape" + assert body["sent"]["seed"] == 321 + assert body["sent"]["steps"] == 25 + assert body["response"]["status"] == "success" + + # The request was routed to the configured backend + the documented path. + assert captured["url"] == "http://localhost:9090/api/v1/recall/default" + assert captured["json"]["seed"] == 321 + assert captured["json"]["model"] == "dreamshaper" + + +def test_recall_remix_omits_seed(client, clear_invokeai_config, monkeypatch): + client.post("/invokeai/config", json={"url": "http://localhost:9090"}) + + raw_metadata = { + "metadata_version": 3, + "app_version": "3.5.0", + "positive_prompt": "x", + "model": {"model_name": "m", "base_model": "sd-1"}, + "seed": 12345, + } + + from photomap.backend.routers import invoke as invoke_module + + monkeypatch.setattr( + invoke_module, "_load_raw_metadata", lambda album_key, index: raw_metadata + ) + + captured = {} + + class _StubResponse: + status_code = 200 + text = "{}" + + def json(self): + return {"status": "success"} + + class _StubClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def post(self, url, json, **kwargs): + captured["json"] = json + return _StubResponse() + + monkeypatch.setattr(invoke_module.httpx, "AsyncClient", _StubClient) + + response = client.post( + "/invokeai/recall", + json={"album_key": "any", "index": 0, "include_seed": False}, + ) + assert response.status_code == 200 + assert "seed" not in captured["json"] + + +def test_recall_upstream_unreachable_returns_502( + client, clear_invokeai_config, monkeypatch +): + client.post("/invokeai/config", json={"url": "http://localhost:9999"}) + + raw_metadata = { + "metadata_version": 3, + "app_version": "3.5.0", + "positive_prompt": "x", + "model": {"model_name": "m", "base_model": "sd-1"}, + } + + from photomap.backend.routers import invoke as invoke_module + + monkeypatch.setattr( + invoke_module, "_load_raw_metadata", lambda album_key, index: raw_metadata + ) + + class _StubClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def post(self, url, json, **kwargs): + raise httpx.ConnectError("connection refused") + + monkeypatch.setattr(invoke_module.httpx, "AsyncClient", _StubClient) + + response = client.post( + "/invokeai/recall", + json={"album_key": "any", "index": 0, "include_seed": True}, + ) + assert response.status_code == 502 + assert "Could not reach InvokeAI backend" in response.json()["detail"] diff --git a/tests/frontend/invoke-recall.test.js b/tests/frontend/invoke-recall.test.js new file mode 100644 index 00000000..a421e629 --- /dev/null +++ b/tests/frontend/invoke-recall.test.js @@ -0,0 +1,184 @@ +// Unit tests for invoke-recall.js — the Recall / Remix buttons wired into the +// InvokeAI metadata drawer. + +import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals"; + +// Mock the chain of modules pulled in by state.js so the module can load in +// jsdom without hitting real DOM bootstraps. +jest.unstable_mockModule("../../photomap/frontend/static/javascript/album-manager.js", () => ({ + albumManager: { fetchAvailableAlbums: jest.fn(() => Promise.resolve([])) }, + checkAlbumIndex: jest.fn(), +})); +jest.unstable_mockModule("../../photomap/frontend/static/javascript/index.js", () => ({ + getIndexMetadata: jest.fn(() => Promise.resolve({ filename_count: 0 })), +})); +jest.unstable_mockModule("../../photomap/frontend/static/javascript/utils.js", () => ({ + showSpinner: jest.fn(), + hideSpinner: jest.fn(), +})); + +const { state } = await import("../../photomap/frontend/static/javascript/state.js"); +const { parseMetadataUrl, sendRecall } = await import("../../photomap/frontend/static/javascript/invoke-recall.js"); + +describe("invoke-recall.js", () => { + describe("parseMetadataUrl", () => { + it("parses a relative metadata_url into album key and index", () => { + expect(parseMetadataUrl("get_metadata/my-album/42")).toEqual({ + albumKey: "my-album", + index: 42, + }); + }); + + it("handles absolute URLs with prefixes", () => { + expect(parseMetadataUrl("http://localhost:8050/get_metadata/vacation/7")).toEqual({ + albumKey: "vacation", + index: 7, + }); + }); + + it("decodes URL-encoded album keys", () => { + expect(parseMetadataUrl("get_metadata/my%20album/3")).toEqual({ + albumKey: "my album", + index: 3, + }); + }); + + it("returns null for malformed metadata URLs", () => { + expect(parseMetadataUrl("")).toBeNull(); + expect(parseMetadataUrl(null)).toBeNull(); + expect(parseMetadataUrl("get_metadata/onlyalbum")).toBeNull(); + expect(parseMetadataUrl("get_metadata/album/notanumber")).toBeNull(); + }); + }); + + describe("sendRecall", () => { + afterEach(() => { + // Make sure other tests can't see leftover fetch stubs. + delete global.fetch; + }); + + it("POSTs album_key / index / include_seed to /invokeai/recall", async () => { + const fetchMock = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true }), + }) + ); + global.fetch = fetchMock; + + const result = await sendRecall({ + albumKey: "vacation", + index: 3, + includeSeed: false, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe("invokeai/recall"); + expect(opts.method).toBe("POST"); + expect(JSON.parse(opts.body)).toEqual({ + album_key: "vacation", + index: 3, + include_seed: false, + }); + expect(result).toEqual({ success: true }); + }); + + it("throws with the server-provided detail on non-2xx responses", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 502, + json: () => Promise.resolve({ detail: "boom" }), + }) + ); + await expect(sendRecall({ albumKey: "a", index: 0, includeSeed: true })).rejects.toThrow("boom"); + }); + }); + + describe("button click handling", () => { + beforeEach(() => { + state.album = "fallback-album"; + document.body.innerHTML = ` + +
+ + +
+ `; + }); + + afterEach(() => { + delete global.fetch; + document.body.innerHTML = ""; + }); + + async function flushPromises() { + // Two ticks: one for the fetch Promise, one for the .then() chain. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + } + + it("shows a success checkmark on successful recall and forwards album/index", async () => { + const fetchMock = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true }), + }) + ); + global.fetch = fetchMock; + + const recallButton = document.querySelector('[data-recall-mode="recall"]'); + recallButton.click(); + await flushPromises(); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.album_key).toBe("my-album"); + expect(body.index).toBe(5); + expect(body.include_seed).toBe(true); + + const status = recallButton.querySelector(".invoke-recall-status"); + expect(status.classList.contains("success")).toBe(true); + expect(status.textContent).toBe("✓"); + }); + + it("passes include_seed=false for the remix button", async () => { + const fetchMock = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true }), + }) + ); + global.fetch = fetchMock; + + document.querySelector('[data-recall-mode="remix"]').click(); + await flushPromises(); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.include_seed).toBe(false); + }); + + it("shows a red X on failure", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 502, + json: () => Promise.resolve({ detail: "backend down" }), + }) + ); + + const btn = document.querySelector('[data-recall-mode="recall"]'); + btn.click(); + await flushPromises(); + + const status = btn.querySelector(".invoke-recall-status"); + expect(status.classList.contains("error")).toBe(true); + expect(status.querySelector("svg")).not.toBeNull(); + }); + }); +});