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 = ""
+ slide_data.description = (
+ ""
+ + (_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
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
- {% if not album_locked %}
-
-
-
+
+
+
+
+
+
+
+
- {% endif %}
-
+
{% if not album_locked %}
-
- {% endif %}
+
+
+
+
-
- {% if not album_locked or multiple_locked_albums %}
-
-
-
-
- {% if not album_locked %}
-
- {% endif %}
+
{% 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();
+ });
+ });
+});