From 2a6d7923b257ca68e7c32a2623ef04df17e87c2e Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 11 Jun 2026 17:00:35 -0400 Subject: [PATCH 1/4] feat(api): add append mode to recall reference images POST /api/v1/recall/{queue_id}?append=true now asks the frontend to add the recalled reference images (ip_adapters and model-free reference_images) to its existing list instead of replacing it. The flag rides inside the event's parameters dict so the generated client schema needs no regeneration, and is injected after the persistence loop so it is never stored as a recall parameter. Mutually exclusive with strict. The frontend dispatches refImagesRecalled with replace:false in append mode, and skips the dispatch entirely when nothing resolved so a failed append can never clear the user's current reference images. Co-Authored-By: Claude Fable 5 --- invokeai/app/api/routers/recall_parameters.py | 26 ++++++++ .../src/services/events/setEventListeners.tsx | 18 ++++- tests/app/routers/test_recall_parameters.py | 66 +++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/invokeai/app/api/routers/recall_parameters.py b/invokeai/app/api/routers/recall_parameters.py index 31120d59a02..d79045d02a5 100644 --- a/invokeai/app/api/routers/recall_parameters.py +++ b/invokeai/app/api/routers/recall_parameters.py @@ -406,6 +406,14 @@ async def update_recall_parameters( default=False, description="When true, parameters not included in the request are reset to their defaults (cleared).", ), + append: bool = Query( + default=False, + description=( + "When true, recalled reference images (ip_adapters and reference_images) are " + "appended to the frontend's existing reference-image list instead of replacing it. " + "Mutually exclusive with strict." + ), + ), ) -> dict[str, Any]: """ Update recallable parameters that can be recalled on the frontend. @@ -421,6 +429,10 @@ async def update_recall_parameters( to their defaults (cleared on the frontend). Defaults to false, which preserves the existing behaviour of only updating the parameters that are explicitly provided. + append: When true, recalled reference images (``ip_adapters`` and + ``reference_images``) are appended to whatever reference images the + frontend already has, instead of replacing the whole list. Mutually + exclusive with ``strict`` (which clears omitted parameters). Returns: A dictionary containing the updated parameters and status @@ -437,6 +449,12 @@ async def update_recall_parameters( """ logger = ApiDependencies.invoker.services.logger + if strict and append: + raise HTTPException( + status_code=400, + detail="The 'strict' and 'append' query parameters are mutually exclusive", + ) + # Validate image access before processing — prevents information leakage # (dimensions) and derived-image minting via ControlNet preprocessors. _assert_recall_image_access(parameters, current_user) @@ -522,6 +540,14 @@ async def update_recall_parameters( provided_params["reference_images"] = resolved_refs logger.info(f"Resolved {len(resolved_refs)} reference image(s)") + # Append mode rides along inside the event's parameters dict rather + # than as a new event field so the generated client schema (which + # types parameters as a free-form object) doesn't need regenerating. + # Added after the persistence loop above, so the flag itself is never + # stored as a recall parameter. + if append: + provided_params["append"] = True + # Emit event to notify frontend of parameter updates try: logger.info( diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx index 4d5b5901321..8dbc348e5ea 100644 --- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx +++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx @@ -731,6 +731,10 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis const hasIpAdapters = data.parameters.ip_adapters !== undefined; const hasRefImages = data.parameters.reference_images !== undefined; + // Append mode (POST /api/v1/recall/{queue_id}?append=true): add the + // recalled reference images to the existing list instead of replacing + // it. The backend passes the flag inside the parameters dict. + const append = data.parameters.append === true; if (hasIpAdapters || hasRefImages) { const allRefImagePromises: Promise[] = []; @@ -874,9 +878,21 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis } // Single dispatch after all IP adapter + reference image promises settle. - // Always replace:true so stale entries from a previous recall are cleared. + // replace:true (the default) clears stale entries from a previous + // recall; append mode instead pushes onto the existing list and + // deliberately dispatches nothing when no valid states resolved, so + // a failed append can never wipe the user's current reference images. Promise.all(allRefImagePromises).then((results) => { const validStates = results.filter((state): state is RefImageState => state !== null); + if (append) { + if (validStates.length > 0) { + dispatch(refImagesRecalled({ entities: validStates, replace: false })); + log.info( + `Appended ${validStates.length} reference image(s) (IP adapters + model-free) to existing list` + ); + } + return; + } dispatch(refImagesRecalled({ entities: validStates, replace: true })); if (validStates.length > 0) { log.info( diff --git a/tests/app/routers/test_recall_parameters.py b/tests/app/routers/test_recall_parameters.py index 9dddf497ec6..d5967c76166 100644 --- a/tests/app/routers/test_recall_parameters.py +++ b/tests/app/routers/test_recall_parameters.py @@ -696,6 +696,72 @@ def test_non_strict_omits_unset_fields( assert "seed" not in params +class TestAppendMode: + """Tests for the ``append`` query parameter. + + ``append=true`` asks the frontend to add the recalled reference images to + its existing list instead of replacing it. The flag travels inside the + event's ``parameters`` dict (so the generated client schema needs no + change) and must never be persisted as a recall parameter. + """ + + def test_append_flag_rides_in_parameters( + self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient + ) -> None: + monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({"cat.png": (1024, 768)})) + + response = client.post( + "/api/v1/recall/default?append=true", + json={"reference_images": [{"image_name": "cat.png"}]}, + ) + assert response.status_code == 200 + params = response.json()["parameters"] + assert params["append"] is True + assert params["reference_images"][0]["image"]["image_name"] == "cat.png" + + def test_append_flag_absent_by_default( + self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient + ) -> None: + monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({"cat.png": (1024, 768)})) + + response = client.post( + "/api/v1/recall/default", + json={"reference_images": [{"image_name": "cat.png"}]}, + ) + assert response.status_code == 200 + assert "append" not in response.json()["parameters"] + + def test_append_flag_not_persisted( + self, monkeypatch: Any, patched_dependencies: MockApiDependencies, mock_invoker: Invoker, client: TestClient + ) -> None: + """The flag is injected after the persistence loop — only real recall + parameters may be written to client state.""" + monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({"cat.png": (1024, 768)})) + persisted_keys: list[str] = [] + monkeypatch.setattr( + mock_invoker.services.client_state_persistence, + "set_by_key", + lambda user_id, key, value: persisted_keys.append(key) or value, + ) + + response = client.post( + "/api/v1/recall/default?append=true", + json={"reference_images": [{"image_name": "cat.png"}]}, + ) + assert response.status_code == 200 + assert persisted_keys == ["recall_reference_images"] + + def test_append_and_strict_are_mutually_exclusive( + self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient + ) -> None: + response = client.post( + "/api/v1/recall/default?strict=true&append=true", + json={"reference_images": [{"image_name": "cat.png"}]}, + ) + assert response.status_code == 400 + assert "mutually exclusive" in response.json()["detail"] + + @pytest.fixture def mock_api_deps(): """Patch ApiDependencies.invoker with a mock that simulates subfolder-aware image service.""" From 438ff7bd6e16de7e3ab137f4987c8ba54e34adce Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sat, 13 Jun 2026 09:20:05 -0400 Subject: [PATCH 2/4] fix(sockets): emit recall event once to owner+admin room union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RecallParametersUpdatedEvent was emitted in two separate socket.io calls — one to the owner's user room, one to the admin room. A socket that belongs to both (the "system" user in single-user mode is also an admin, so it joins user:system AND admin) received the event twice. That double delivery was invisible for the scalar/replace recall fields, which are idempotent, but the append-mode reference-image recall pushes rather than replaces — so each append showed up as two copies of the same reference image in the InvokeAI canvas. Emit once to the room union [user_room, "admin"] instead. python-socketio deduplicates recipients across a room list, so a socket in both rooms is delivered to exactly once, while genuinely distinct owner/admin sockets still each receive it. Co-Authored-By: Claude Opus 4.8 (1M context) --- invokeai/app/api/sockets.py | 17 +++++++-- .../routers/test_multiuser_authorization.py | 38 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/invokeai/app/api/sockets.py b/invokeai/app/api/sockets.py index 5783b804c0b..7e93c332d64 100644 --- a/invokeai/app/api/sockets.py +++ b/invokeai/app/api/sockets.py @@ -313,11 +313,22 @@ async def _handle_queue_event(self, event: FastAPIEvent[QueueEventBase]): logger.debug(f"Emitted private queue item event {event_name} to user room {user_room} and admin room") - # RecallParametersUpdatedEvent is private - only emit to owner + admins + # RecallParametersUpdatedEvent is private - only emit to owner + admins. + # + # Emit to the union of the owner room and the admin room in a SINGLE + # call. python-socketio deduplicates recipients across a room list, + # so a socket that belongs to BOTH rooms — e.g. the "system" user in + # single-user mode, which is also an admin — receives the event + # exactly once. Two separate emits would deliver it twice: harmless + # for the idempotent scalar recall fields (the frontend just re-sets + # them), but the append-mode reference-image recall *pushes* rather + # than replaces, so a double delivery adds the same reference image + # twice. elif isinstance(event_data, RecallParametersUpdatedEvent): user_room = f"user:{event_data.user_id}" - await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room=user_room) - await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room="admin") + await self._sio.emit( + event=event_name, data=event_data.model_dump(mode="json"), room=[user_room, "admin"] + ) logger.debug(f"Emitted private recall_parameters_updated event to user room {user_room} and admin room") # BatchEnqueuedEvent carries the enqueuing user's batch_id, origin, and diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py index 93a0c710643..367697a5ef1 100644 --- a/tests/app/routers/test_multiuser_authorization.py +++ b/tests/app/routers/test_multiuser_authorization.py @@ -1821,6 +1821,44 @@ def test_queue_cleared_still_broadcast(self, socketio: Any) -> None: rooms_emitted_to = [call.kwargs.get("room") for call in mock_emit.call_args_list] assert "default" in rooms_emitted_to + def test_recall_parameters_emitted_once_to_owner_and_admin_rooms(self, socketio: Any) -> None: + """RecallParametersUpdatedEvent must be delivered to the owner + admin rooms + in a SINGLE emit call (room list), not two separate emits. + + A socket that is in both rooms — e.g. the system user in single-user mode, + who is also an admin — would otherwise receive the event twice. That is + harmless for the idempotent scalar recall fields but doubles every entry + for the append-mode reference-image recall, which pushes rather than + replaces. python-socketio deduplicates recipients across a room list, so + a single emit to [user_room, "admin"] delivers exactly once per socket. + """ + import asyncio + from unittest.mock import AsyncMock + + from invokeai.app.services.events.events_common import RecallParametersUpdatedEvent + + event = RecallParametersUpdatedEvent.build( + queue_id="default", + user_id="owner-recall", + parameters={"reference_images": [{"image": {"image_name": "cat.png"}}], "append": True}, + ) + + mock_emit = AsyncMock() + socketio._sio.emit = mock_emit + + asyncio.run(socketio._handle_queue_event(("recall_parameters_updated", event))) + + # Exactly one emit, targeting the union of the owner and admin rooms. + assert mock_emit.call_count == 1, ( + "recall event must be emitted once to a room list, not once per room — " + "two emits double-deliver to a socket in both rooms" + ) + room = mock_emit.call_args.kwargs.get("room") + assert isinstance(room, list) + assert set(room) == {"user:owner-recall", "admin"} + # And never to the shared queue room, which would leak to other users. + assert "default" not in room + class TestCustomNodesAuthorization: """Tests that custom_nodes endpoints enforce AdminUserOrDefault. From 7490ca7dd53c0084cd856181b1bc72063981c589 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sat, 13 Jun 2026 10:04:01 -0400 Subject: [PATCH 3/4] chore(api): regenerate openapi.json + schema.ts for recall append param Rebuilds the committed OpenAPI schema and generated TypeScript types so the update_recall_parameters operation advertises the new append query parameter. Generated via 'make frontend-openapi' / 'frontend-typegen' equivalent; the only change is the added append param + its docstring. Co-Authored-By: Claude Opus 4.8 (1M context) --- invokeai/frontend/web/openapi.json | 14 +++++++++++++- invokeai/frontend/web/src/services/api/schema.ts | 6 ++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index 2c9526c59a9..bd535874973 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -9575,7 +9575,7 @@ "post": { "tags": ["recall"], "summary": "Update Recall Parameters", - "description": "Update recallable parameters that can be recalled on the frontend.\n\nThis endpoint allows updating parameters such as prompt, model, steps, and other\ngeneration settings. These parameters are stored in client state and can be\naccessed by the frontend to populate UI elements.\n\nArgs:\n queue_id: The queue ID to associate these parameters with\n parameters: The RecallParameter object containing the parameters to update\n strict: When true, parameters not included in the request body are reset\n to their defaults (cleared on the frontend). Defaults to false,\n which preserves the existing behaviour of only updating the\n parameters that are explicitly provided.\n\nReturns:\n A dictionary containing the updated parameters and status\n\nExample:\n POST /api/v1/recall/{queue_id}?strict=true\n {\n \"positive_prompt\": \"a beautiful landscape\",\n \"model\": \"sd-1.5\",\n \"steps\": 20\n }\n # In strict mode, all other parameters (reference_images, loras, etc.)\n # are cleared. In non-strict mode (default) they would be left as-is.", + "description": "Update recallable parameters that can be recalled on the frontend.\n\nThis endpoint allows updating parameters such as prompt, model, steps, and other\ngeneration settings. These parameters are stored in client state and can be\naccessed by the frontend to populate UI elements.\n\nArgs:\n queue_id: The queue ID to associate these parameters with\n parameters: The RecallParameter object containing the parameters to update\n strict: When true, parameters not included in the request body are reset\n to their defaults (cleared on the frontend). Defaults to false,\n which preserves the existing behaviour of only updating the\n parameters that are explicitly provided.\n append: When true, recalled reference images (``ip_adapters`` and\n ``reference_images``) are appended to whatever reference images the\n frontend already has, instead of replacing the whole list. Mutually\n exclusive with ``strict`` (which clears omitted parameters).\n\nReturns:\n A dictionary containing the updated parameters and status\n\nExample:\n POST /api/v1/recall/{queue_id}?strict=true\n {\n \"positive_prompt\": \"a beautiful landscape\",\n \"model\": \"sd-1.5\",\n \"steps\": 20\n }\n # In strict mode, all other parameters (reference_images, loras, etc.)\n # are cleared. In non-strict mode (default) they would be left as-is.", "operationId": "update_recall_parameters", "security": [ { @@ -9605,6 +9605,18 @@ "title": "Strict" }, "description": "When true, parameters not included in the request are reset to their defaults (cleared)." + }, + { + "name": "append", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "When true, recalled reference images (ip_adapters and reference_images) are appended to the frontend's existing reference-image list instead of replacing it. Mutually exclusive with strict.", + "default": false, + "title": "Append" + }, + "description": "When true, recalled reference images (ip_adapters and reference_images) are appended to the frontend's existing reference-image list instead of replacing it. Mutually exclusive with strict." } ], "requestBody": { diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 5726458dc3a..5b23f359e85 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -2676,6 +2676,10 @@ export type paths = { * to their defaults (cleared on the frontend). Defaults to false, * which preserves the existing behaviour of only updating the * parameters that are explicitly provided. + * append: When true, recalled reference images (``ip_adapters`` and + * ``reference_images``) are appended to whatever reference images the + * frontend already has, instead of replacing the whole list. Mutually + * exclusive with ``strict`` (which clears omitted parameters). * * Returns: * A dictionary containing the updated parameters and status @@ -38300,6 +38304,8 @@ export interface operations { query?: { /** @description When true, parameters not included in the request are reset to their defaults (cleared). */ strict?: boolean; + /** @description When true, recalled reference images (ip_adapters and reference_images) are appended to the frontend's existing reference-image list instead of replacing it. Mutually exclusive with strict. */ + append?: boolean; }; header?: never; path: { From 14d6c148f4fcb5667461a1c9dd18c6e3992af884 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sat, 27 Jun 2026 20:00:16 -0400 Subject: [PATCH 4/4] docs: document append query parameter for recall API Documents the new append=true query parameter on POST /api/v1/recall/{queue_id}: - new Query parameters subsection covering strict and append - mutual exclusivity (strict+append -> 400) with error body - append-mode cURL example - updated WebSocket Events + frontend log sample for the merged reference-image list Co-Authored-By: Claude Opus 4.8 (1M context) --- .../docs/development/Guides/recall-api.mdx | 61 +++++++++++++++++-- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/docs/src/content/docs/development/Guides/recall-api.mdx b/docs/src/content/docs/development/Guides/recall-api.mdx index f366da79b33..f376e2af4bd 100644 --- a/docs/src/content/docs/development/Guides/recall-api.mdx +++ b/docs/src/content/docs/development/Guides/recall-api.mdx @@ -71,6 +71,31 @@ Content-Type: application/json All parameters are optional — only send the fields you want to update. +#### Query parameters + +The POST endpoint accepts two optional boolean query parameters that control +how reference images are merged into the frontend state: + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `strict` | `false` | When `true`, parameters **not** included in the request body are reset to their defaults (cleared on the frontend). When `false`, only the parameters you send are updated and everything else is left as-is. | +| `append` | `false` | When `true`, recalled reference images (`ip_adapters` and `reference_images`) are **appended** to the frontend's existing reference-image list instead of replacing it. When `false` (or omitted), the recalled reference images **replace** the existing list. | + +`strict` and `append` are mutually exclusive — `strict` clears omitted +parameters while `append` preserves and extends the existing list, so the two +cannot be combined. Sending `?strict=true&append=true` returns +**400 Bad Request**: + +```json +{ + "detail": "The 'strict' and 'append' query parameters are mutually exclusive" +} +``` + +`append` only affects the reference-image collections (`ip_adapters` and +`reference_images`). All other parameters (prompts, model, LoRAs, control +layers, etc.) are updated the same way regardless of the flag. + ### GET — Retrieve Recall Parameters ```http @@ -294,6 +319,26 @@ curl -X POST http://localhost:9090/api/v1/recall/default \ }' ``` +### Appending reference images (append mode) + +By default, recalled reference images **replace** whatever the frontend +already has. Pass `?append=true` to **add** the recalled `ip_adapters` and +`reference_images` to the existing list instead: + +```bash +# Add a reference image without clearing the ones already on the frontend +curl -X POST 'http://localhost:9090/api/v1/recall/default?append=true' \ + -H "Content-Type: application/json" \ + -d '{ + "reference_images": [ + {"image_name": "extra_reference.png"} + ] + }' +``` + +Combining `append=true` with `strict=true` is invalid and returns +**400 Bad Request** (see [Query parameters](#query-parameters)). + ### Model-free reference images (FLUX.2 Klein / FLUX Kontext / Qwen Image Edit) ```bash @@ -426,13 +471,18 @@ room. Connected frontend clients automatically: 1. Apply standard parameters (prompts, steps, dimensions, etc.). 2. Load and add LoRAs to the LoRA list. 3. Apply control-layer configurations. -4. Apply IP Adapter / FLUX Redux configurations with their images. -5. Append model-free reference images, using the config flavor that - matches the currently-selected main model. +4. Merge the recalled reference images — IP Adapter / FLUX Redux entries and + model-free reference images both feed the same reference-image list, using + the config flavor that matches the currently-selected main model. By + default this **replaces** the existing list; with `append=true` it is + **added** to whatever is already there (see + [Query parameters](#query-parameters)). ## Error Handling -- **400 Bad Request** — invalid parameters or parameter values. +- **400 Bad Request** — invalid parameters or parameter values, or the + mutually exclusive `strict=true&append=true` combination (see + [Query parameters](#query-parameters)). - **500 Internal Server Error** — server-side storage or retrieval failure. Errors include detailed messages. Missing images and unresolved model @@ -460,8 +510,7 @@ messages under the `events` namespace. ``` INFO: Applied 5 recall parameters to store -INFO: Applied 1 IP adapter(s), replacing existing list -INFO: Applied 1 model-free reference image(s) +INFO: Applied 2 reference image(s) (IP adapters + model-free), replacing existing list DEBUG: Built IP adapter ref image state: ip-adapter-xyz... (weight: 0.7) DEBUG: IP adapter image: outputs/images/depth_map.png (1024x768) ```