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) ``` 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/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/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index a1c10ed8ed3..7033408b197 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -9593,7 +9593,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": [ { @@ -9623,6 +9623,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 822b3fd8ead..7864579706a 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -2684,6 +2684,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 @@ -38452,6 +38456,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: { diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx index a6043c47f45..e6010ce4ca1 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_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py index 104fd37159a..8cca29dee69 100644 --- a/tests/app/routers/test_multiuser_authorization.py +++ b/tests/app/routers/test_multiuser_authorization.py @@ -1844,6 +1844,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. 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."""