From 06b971cbe5c4af8835069090fff92d537cb37577 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 03:50:31 +0000 Subject: [PATCH 1/7] Initial plan From ae281e98e29d212b11ca6c9f25f53983a41a6e78 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 04:11:05 +0000 Subject: [PATCH 2/7] feat: add round-robin session queue scheduling across users - Add SESSION_QUEUE_MODE type and session_queue_mode config field - Modify dequeue() to support round-robin ordering when multiuser mode is active, serving each user in turn based on last-served timestamp - Add tests for FIFO and round-robin dequeue behavior Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --- .../app/services/config/config_default.py | 3 + .../session_queue/session_queue_sqlite.py | 46 +++- .../test_session_queue_dequeue.py | 214 ++++++++++++++++++ 3 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 tests/app/services/session_queue/test_session_queue_dequeue.py diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py index 2cc2aaf273c..7f5039474e8 100644 --- a/invokeai/app/services/config/config_default.py +++ b/invokeai/app/services/config/config_default.py @@ -29,6 +29,7 @@ ATTENTION_SLICE_SIZE = Literal["auto", "balanced", "max", 1, 2, 3, 4, 5, 6, 7, 8] LOG_FORMAT = Literal["plain", "color", "syslog", "legacy"] LOG_LEVEL = Literal["debug", "info", "warning", "error", "critical"] +SESSION_QUEUE_MODE = Literal["FIFO", "round_robin"] CONFIG_SCHEMA_VERSION = "4.0.2" @@ -102,6 +103,7 @@ class InvokeAIAppConfig(BaseSettings): pil_compress_level: The compress_level setting of PIL.Image.save(), used for PNG encoding. All settings are lossless. 0 = no compression, 1 = fastest with slightly larger filesize, 9 = slowest with smallest filesize. 1 is typically the best setting. max_queue_size: Maximum number of items in the session queue. clear_queue_on_startup: Empties session queue on startup. + session_queue_mode: Session queue mode. Use 'FIFO' for traditional first-in-first-out, or 'round_robin' to serve each user's jobs in turn. In single-user mode, FIFO is always used regardless of this setting. allow_nodes: List of nodes to allow. Omit to allow all. deny_nodes: List of nodes to deny. Omit to deny none. node_cache_size: How many cached nodes to keep in memory. @@ -191,6 +193,7 @@ class InvokeAIAppConfig(BaseSettings): pil_compress_level: int = Field(default=1, description="The compress_level setting of PIL.Image.save(), used for PNG encoding. All settings are lossless. 0 = no compression, 1 = fastest with slightly larger filesize, 9 = slowest with smallest filesize. 1 is typically the best setting.") max_queue_size: int = Field(default=10000, gt=0, description="Maximum number of items in the session queue.") clear_queue_on_startup: bool = Field(default=False, description="Empties session queue on startup.") + session_queue_mode: SESSION_QUEUE_MODE = Field(default="round_robin", description="Session queue mode. Use 'FIFO' for traditional first-in-first-out, or 'round_robin' to serve each user's jobs in turn. In single-user mode, FIFO is always used regardless of this setting.") # NODES allow_nodes: Optional[list[str]] = Field(default=None, description="List of nodes to allow. Omit to allow all.") diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py index 4f46136fd79..fe7cc138bd1 100644 --- a/invokeai/app/services/session_queue/session_queue_sqlite.py +++ b/invokeai/app/services/session_queue/session_queue_sqlite.py @@ -155,9 +155,45 @@ async def enqueue_batch( return enqueue_result def dequeue(self) -> Optional[SessionQueueItem]: - with self._db.transaction() as cursor: - cursor.execute( - """--sql + config = self.__invoker.services.configuration + use_round_robin = config.multiuser and config.session_queue_mode == "round_robin" + + if use_round_robin: + query = """--sql + WITH user_last_served AS ( + -- Track when each user last had an item started, to determine whose turn it is. + SELECT user_id, MAX(started_at) AS last_served_at + FROM session_queue + WHERE started_at IS NOT NULL + GROUP BY user_id + ), + user_next_item AS ( + -- For each user, select their single best pending item (highest priority, then oldest). + SELECT + user_id, + item_id, + ROW_NUMBER() OVER ( + PARTITION BY user_id + ORDER BY priority DESC, item_id ASC + ) AS rn + FROM session_queue + WHERE status = 'pending' + ) + SELECT + sq.*, + u.display_name AS user_display_name, + u.email AS user_email + FROM session_queue sq + LEFT JOIN users u ON sq.user_id = u.user_id + JOIN user_next_item uni ON sq.item_id = uni.item_id AND uni.rn = 1 + LEFT JOIN user_last_served uls ON sq.user_id = uls.user_id + ORDER BY + COALESCE(uls.last_served_at, '1970-01-01') ASC, + sq.item_id ASC + LIMIT 1 + """ + else: + query = """--sql SELECT sq.*, u.display_name as user_display_name, @@ -170,7 +206,9 @@ def dequeue(self) -> Optional[SessionQueueItem]: sq.item_id ASC LIMIT 1 """ - ) + + with self._db.transaction() as cursor: + cursor.execute(query) result = cast(Union[sqlite3.Row, None], cursor.fetchone()) if result is None: return None diff --git a/tests/app/services/session_queue/test_session_queue_dequeue.py b/tests/app/services/session_queue/test_session_queue_dequeue.py new file mode 100644 index 00000000000..0f82f2babaa --- /dev/null +++ b/tests/app/services/session_queue/test_session_queue_dequeue.py @@ -0,0 +1,214 @@ +"""Tests for session queue dequeue() ordering: FIFO and round-robin modes.""" + +import json +import uuid +from typing import Optional + +import pytest +from pydantic_core import to_jsonable_python + +from invokeai.app.services.config.config_default import InvokeAIAppConfig +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue +from invokeai.app.services.shared.graph import Graph, GraphExecutionState + +_EMPTY_SESSION_JSON = json.dumps(to_jsonable_python(GraphExecutionState(graph=Graph()).model_dump())) + + +@pytest.fixture +def session_queue_fifo(mock_invoker: Invoker) -> SqliteSessionQueue: + """Queue backed by a single-user (FIFO) invoker.""" + # Default config has multiuser=False, so FIFO is always used. + db = mock_invoker.services.board_records._db + queue = SqliteSessionQueue(db=db) + queue.start(mock_invoker) + return queue + + +@pytest.fixture +def session_queue_round_robin(mock_invoker: Invoker) -> SqliteSessionQueue: + """Queue backed by a multiuser invoker with round_robin mode.""" + mock_invoker.services.configuration = InvokeAIAppConfig( + use_memory_db=True, + node_cache_size=0, + multiuser=True, + session_queue_mode="round_robin", + ) + db = mock_invoker.services.board_records._db + queue = SqliteSessionQueue(db=db) + queue.start(mock_invoker) + return queue + + +def _insert_queue_item( + session_queue: SqliteSessionQueue, + queue_id: str, + user_id: str, + priority: int = 0, +) -> int: + """Directly insert a minimal queue item and return its item_id.""" + session_id = str(uuid.uuid4()) + batch_id = str(uuid.uuid4()) + with session_queue._db.transaction() as cursor: + cursor.execute( + """--sql + INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (queue_id, _EMPTY_SESSION_JSON, session_id, batch_id, None, priority, None, None, None, None, user_id), + ) + return cursor.lastrowid # type: ignore[return-value] + + +def _dequeue_user_ids(session_queue: SqliteSessionQueue, count: int) -> list[Optional[str]]: + """Dequeue `count` items and return the list of user_ids in dequeue order.""" + result = [] + for _ in range(count): + item = session_queue.dequeue() + result.append(item.user_id if item is not None else None) + return result + + +# --------------------------------------------------------------------------- +# FIFO tests +# --------------------------------------------------------------------------- + + +def test_fifo_single_user_order(session_queue_fifo: SqliteSessionQueue) -> None: + """FIFO: items from a single user are dequeued in insertion order.""" + queue_id = "default" + _insert_queue_item(session_queue_fifo, queue_id, "user_a") + _insert_queue_item(session_queue_fifo, queue_id, "user_a") + _insert_queue_item(session_queue_fifo, queue_id, "user_a") + + user_ids = _dequeue_user_ids(session_queue_fifo, 3) + assert user_ids == ["user_a", "user_a", "user_a"] + + +def test_fifo_multi_user_preserves_insertion_order(session_queue_fifo: SqliteSessionQueue) -> None: + """FIFO: jobs from multiple users are dequeued in strict insertion order, not interleaved.""" + queue_id = "default" + # Insert A1, A2, B1, C1, C2, A3 – FIFO should preserve this exact order. + _insert_queue_item(session_queue_fifo, queue_id, "user_a") + _insert_queue_item(session_queue_fifo, queue_id, "user_a") + _insert_queue_item(session_queue_fifo, queue_id, "user_b") + _insert_queue_item(session_queue_fifo, queue_id, "user_c") + _insert_queue_item(session_queue_fifo, queue_id, "user_c") + _insert_queue_item(session_queue_fifo, queue_id, "user_a") + + user_ids = _dequeue_user_ids(session_queue_fifo, 6) + assert user_ids == ["user_a", "user_a", "user_b", "user_c", "user_c", "user_a"] + + +def test_fifo_priority_respected(session_queue_fifo: SqliteSessionQueue) -> None: + """FIFO: higher-priority items are dequeued before lower-priority ones.""" + queue_id = "default" + _insert_queue_item(session_queue_fifo, queue_id, "user_a", priority=0) + _insert_queue_item(session_queue_fifo, queue_id, "user_a", priority=10) + + user_ids = _dequeue_user_ids(session_queue_fifo, 2) + # Both are user_a; second inserted item has higher priority and should come first. + assert user_ids == ["user_a", "user_a"] + + +def test_fifo_returns_none_when_empty(session_queue_fifo: SqliteSessionQueue) -> None: + """FIFO: dequeue returns None when the queue is empty.""" + assert session_queue_fifo.dequeue() is None + + +# --------------------------------------------------------------------------- +# Round-robin tests +# --------------------------------------------------------------------------- + + +def test_round_robin_interleaves_users(session_queue_round_robin: SqliteSessionQueue) -> None: + """Round-robin: jobs from multiple users are interleaved one per user per round. + + Queue insertion order (matching the issue example): + A job 1, A job 2, B job 1, C job 1, C job 2, A job 3 + + Expected dequeue order: + A job 1, B job 1, C job 1, A job 2, C job 2, A job 3 + """ + queue_id = "default" + _insert_queue_item(session_queue_round_robin, queue_id, "user_a") + _insert_queue_item(session_queue_round_robin, queue_id, "user_a") + _insert_queue_item(session_queue_round_robin, queue_id, "user_b") + _insert_queue_item(session_queue_round_robin, queue_id, "user_c") + _insert_queue_item(session_queue_round_robin, queue_id, "user_c") + _insert_queue_item(session_queue_round_robin, queue_id, "user_a") + + user_ids = _dequeue_user_ids(session_queue_round_robin, 6) + assert user_ids == ["user_a", "user_b", "user_c", "user_a", "user_c", "user_a"] + + +def test_round_robin_single_user_behaves_like_fifo(session_queue_round_robin: SqliteSessionQueue) -> None: + """Round-robin with only one user produces the same order as FIFO.""" + queue_id = "default" + _insert_queue_item(session_queue_round_robin, queue_id, "user_a") + _insert_queue_item(session_queue_round_robin, queue_id, "user_a") + _insert_queue_item(session_queue_round_robin, queue_id, "user_a") + + user_ids = _dequeue_user_ids(session_queue_round_robin, 3) + assert user_ids == ["user_a", "user_a", "user_a"] + + +def test_round_robin_handles_user_joining_mid_queue(session_queue_round_robin: SqliteSessionQueue) -> None: + """Round-robin: a user who joins later is correctly interleaved.""" + queue_id = "default" + _insert_queue_item(session_queue_round_robin, queue_id, "user_a") + _insert_queue_item(session_queue_round_robin, queue_id, "user_a") + _insert_queue_item(session_queue_round_robin, queue_id, "user_b") + + user_ids = _dequeue_user_ids(session_queue_round_robin, 3) + # Round 1: A (oldest rank-1 item), B (rank-1 item) + # Round 2: A (rank-2 item) + assert user_ids == ["user_a", "user_b", "user_a"] + + +def test_round_robin_returns_none_when_empty(session_queue_round_robin: SqliteSessionQueue) -> None: + """Round-robin: dequeue returns None when the queue is empty.""" + assert session_queue_round_robin.dequeue() is None + + +def test_round_robin_priority_within_user_respected(session_queue_round_robin: SqliteSessionQueue) -> None: + """Round-robin: within a single user's items, higher priority is dequeued first.""" + queue_id = "default" + # Insert low-priority item first, then high-priority for same user. + _insert_queue_item(session_queue_round_robin, queue_id, "user_a", priority=0) + _insert_queue_item(session_queue_round_robin, queue_id, "user_a", priority=10) + _insert_queue_item(session_queue_round_robin, queue_id, "user_b", priority=0) + + # Round 1: user_a's best item (priority 10), user_b's only item. + # Round 2: user_a's remaining item (priority 0). + items = [] + for _ in range(3): + item = session_queue_round_robin.dequeue() + assert item is not None + items.append((item.user_id, item.priority)) + + assert items[0] == ("user_a", 10) + assert items[1] == ("user_b", 0) + assert items[2] == ("user_a", 0) + + +def test_round_robin_ignored_in_single_user_mode(mock_invoker: Invoker) -> None: + """When multiuser=False, round_robin config is ignored and FIFO is used.""" + mock_invoker.services.configuration = InvokeAIAppConfig( + use_memory_db=True, + node_cache_size=0, + multiuser=False, + session_queue_mode="round_robin", + ) + db = mock_invoker.services.board_records._db + queue = SqliteSessionQueue(db=db) + queue.start(mock_invoker) + + queue_id = "default" + _insert_queue_item(queue, queue_id, "user_a") + _insert_queue_item(queue, queue_id, "user_a") + _insert_queue_item(queue, queue_id, "user_b") + + # FIFO order: user_a, user_a, user_b + user_ids = _dequeue_user_ids(queue, 3) + assert user_ids == ["user_a", "user_a", "user_b"] From 28c63b15f38f4d90f5c69da5a561553ac939ad60 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sat, 25 Apr 2026 14:33:44 -0400 Subject: [PATCH 3/7] fix(multiuser): restore X/Y queue badge and cross-user queue list Three regressions from the multiuser isolation work in 33ec16de were preventing non-admin users from seeing the broader queue: 1. The "X/Y" pending badge collapsed to a single number because the backend stopped returning per-user counts and the frontend dropped the X/Y formatting. Restored user_pending/user_in_progress on SessionQueueStatus and the X/Y formatter; get_queue_status now takes an explicit is_admin flag for current-item visibility. 2. The queue list only showed the caller's own jobs because get_queue_item_ids filtered by user. Per-item field redaction already happens in list_all_queue_items / get_queue_items_by_item_ids, so the id list itself can be returned unfiltered. 3. After enqueue or status change in another user's batch, A's queue list, badge totals, and item statuses stayed stale until reload because QueueItemStatusChangedEvent and BatchEnqueuedEvent went only to user:{owner} + admin rooms. Now the full event still goes to those rooms, and a sanitized companion (user_id="redacted", identifiers and error fields stripped) is broadcast to the queue room with the owner and admin sids in skip_sid so they don't receive a clobbering duplicate. The frontend handler short-circuits the redacted variant to tag invalidation only, skipping per-session side effects. Co-Authored-By: Claude Opus 4.7 (1M context) --- invokeai/app/api/routers/session_queue.py | 20 +-- invokeai/app/api/sockets.py | 103 ++++++++++++--- .../app/services/config/config_default.py | 2 +- .../session_queue/session_queue_base.py | 15 ++- .../session_queue/session_queue_common.py | 6 + .../session_queue/session_queue_sqlite.py | 47 ++++--- .../queue/components/QueueCountBadge.tsx | 28 +++- .../frontend/web/src/services/api/schema.ts | 26 +++- .../src/services/events/setEventListeners.tsx | 18 +++ .../routers/test_multiuser_authorization.py | 120 +++++++++++++++--- 10 files changed, 317 insertions(+), 68 deletions(-) diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py index 41a5a411c7a..d62cac5095f 100644 --- a/invokeai/app/api/routers/session_queue.py +++ b/invokeai/app/api/routers/session_queue.py @@ -141,12 +141,11 @@ async def get_queue_item_ids( queue_id: str = Path(description="The queue id to perform this operation on"), order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"), ) -> ItemIdsResult: - """Gets all queue item ids that match the given parameters. Non-admin users only see their own items.""" + """Gets all queue item ids that match the given parameters. The IDs themselves are not sensitive; + per-item field redaction is performed when the items are fetched via list_all_queue_items or + get_queue_items_by_item_ids.""" try: - user_id = None if current_user.is_admin else current_user.user_id - return ApiDependencies.invoker.services.session_queue.get_queue_item_ids( - queue_id=queue_id, order_dir=order_dir, user_id=user_id - ) + return ApiDependencies.invoker.services.session_queue.get_queue_item_ids(queue_id=queue_id, order_dir=order_dir) except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while listing all queue item ids: {e}") @@ -436,10 +435,15 @@ async def get_queue_status( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> SessionQueueAndProcessorStatus: - """Gets the status of the session queue. Non-admin users see only their own counts and cannot see current item details unless they own it.""" + """Gets the status of the session queue. Returns global counts plus the calling user's own + pending/in_progress counts (so the UI can show an X/Y badge). Non-admin users cannot see the + current item's identifiers unless they own it.""" try: - user_id = None if current_user.is_admin else current_user.user_id - queue = ApiDependencies.invoker.services.session_queue.get_queue_status(queue_id, user_id=user_id) + queue = ApiDependencies.invoker.services.session_queue.get_queue_status( + queue_id, + user_id=current_user.user_id, + is_admin=current_user.is_admin, + ) processor = ApiDependencies.invoker.services.session_processor.get_status() return SessionQueueAndProcessorStatus(queue=queue, processor=processor) except Exception as e: diff --git a/invokeai/app/api/sockets.py b/invokeai/app/api/sockets.py index 5783b804c0b..b02b5bbb067 100644 --- a/invokeai/app/api/sockets.py +++ b/invokeai/app/api/sockets.py @@ -260,20 +260,37 @@ async def _handle_sub_bulk_download(self, sid: str, data: Any) -> None: async def _handle_unsub_bulk_download(self, sid: str, data: Any) -> None: await self._sio.leave_room(sid, BulkDownloadSubscriptionEvent(**data).bulk_download_id) + def _owner_and_admin_sids(self, owner_user_id: str) -> list[str]: + """Sids belonging to the event's owner or to any admin. + + Used as `skip_sid` when broadcasting a sanitized companion event to the queue room, + so the owner and admins (who already received the full event) don't get a second + copy that would clobber their cache with redacted values. + """ + return [ + sid + for sid, info in self._socket_users.items() + if info.get("user_id") == owner_user_id or info.get("is_admin") + ] + async def _handle_queue_event(self, event: FastAPIEvent[QueueEventBase]): """Handle queue events with user isolation. - All queue item events (invocation events AND QueueItemStatusChangedEvent) are - private to the owning user and admins. They carry unsanitized user_id, batch_id, - session_id, origin, destination and error metadata, and must never be broadcast - to the whole queue room — otherwise any other authenticated subscriber could - observe cross-user queue activity. + Queue events split into two routing paths: - RecallParametersUpdatedEvent is also private to the owner + admins. + 1. The owner and admins receive the full unsanitized event in their `user:{id}` / + `admin` rooms. The full payload may include batch_id, session_id, origin, + destination, error metadata, etc. - BatchEnqueuedEvent carries the enqueuing user's batch_id/origin/counts and - is also routed privately. QueueClearedEvent is the only queue event that - is still broadcast to the whole queue room. + 2. For events that other authenticated users need to know about so their queue list + and badge counts stay in sync (QueueItemStatusChangedEvent and BatchEnqueuedEvent), + a sanitized companion event is also emitted to the full queue room with the + owner's and admins' sids in `skip_sid`. The companion uses `user_id="redacted"` + as a sentinel so the frontend handler knows to do tag invalidation only and skip + per-session side effects. + + InvocationEventBase events stay private (owner + admins only). RecallParametersUpdatedEvent + is also private. QueueClearedEvent has no user identity and is broadcast to the queue room. IMPORTANT: Check InvocationEventBase BEFORE QueueItemEventBase since InvocationEventBase inherits from QueueItemEventBase. The order of isinstance checks matters! @@ -302,10 +319,51 @@ async def _handle_queue_event(self, event: FastAPIEvent[QueueEventBase]): logger.debug(f"Emitted private invocation event {event_name} to user room {user_room} and admin room") - # Other queue item events (QueueItemStatusChangedEvent) carry unsanitized - # user_id, batch_id, session_id, origin, destination and error metadata. - # They are private to the owning user + admins — never broadcast to the - # full queue room. + # QueueItemStatusChangedEvent: full to owner+admin, sanitized to everyone else in + # the queue room so their queue list, badge, and item caches refresh. + elif isinstance(event_data, QueueItemStatusChangedEvent): + 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") + + sanitized = event_data.model_copy( + update={ + "user_id": "redacted", + "batch_id": "redacted", + "session_id": "redacted", + "origin": None, + "destination": None, + "error_type": None, + "error_message": None, + "error_traceback": None, + } + ) + # Strip identifying fields out of the embedded batch_status / queue_status too. + sanitized.batch_status = sanitized.batch_status.model_copy( + update={"batch_id": "redacted", "origin": None, "destination": None} + ) + sanitized.queue_status = sanitized.queue_status.model_copy( + update={ + "item_id": None, + "session_id": None, + "batch_id": None, + "user_pending": None, + "user_in_progress": None, + } + ) + await self._sio.emit( + event=event_name, + data=sanitized.model_dump(mode="json"), + room=event_data.queue_id, + skip_sid=self._owner_and_admin_sids(event_data.user_id), + ) + + logger.debug( + f"Emitted queue_item_status_changed: full to {user_room}+admin, sanitized to queue {event_data.queue_id}" + ) + + # Other queue item events (currently none beyond QueueItemStatusChangedEvent that + # carry user_id) stay private to owner + admins. elif isinstance(event_data, QueueItemEventBase) and hasattr(event_data, "user_id"): 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) @@ -320,14 +378,25 @@ async def _handle_queue_event(self, event: FastAPIEvent[QueueEventBase]): await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), 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 - # enqueued counts. Route it privately to the owner + admins so other - # users do not observe cross-user batch activity. + # BatchEnqueuedEvent: full to owner+admin, sanitized to everyone else in the queue + # room so their badge total and queue list pick up the new items. elif isinstance(event_data, BatchEnqueuedEvent): 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") - logger.debug(f"Emitted private batch_enqueued event to user room {user_room} and admin room") + + sanitized = event_data.model_copy( + update={"user_id": "redacted", "batch_id": "redacted", "origin": None} + ) + await self._sio.emit( + event=event_name, + data=sanitized.model_dump(mode="json"), + room=event_data.queue_id, + skip_sid=self._owner_and_admin_sids(event_data.user_id), + ) + logger.debug( + f"Emitted batch_enqueued: full to {user_room}+admin, sanitized to queue {event_data.queue_id}" + ) else: # For remaining queue events (e.g. QueueClearedEvent) that do not diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py index 240371e981b..c99461b3fab 100644 --- a/invokeai/app/services/config/config_default.py +++ b/invokeai/app/services/config/config_default.py @@ -109,7 +109,7 @@ class InvokeAIAppConfig(BaseSettings): force_tiled_decode: Whether to enable tiled VAE decode (reduces memory consumption with some performance penalty). pil_compress_level: The compress_level setting of PIL.Image.save(), used for PNG encoding. All settings are lossless. 0 = no compression, 1 = fastest with slightly larger filesize, 9 = slowest with smallest filesize. 1 is typically the best setting. max_queue_size: Maximum number of items in the session queue. - session_queue_mode: Session queue mode. Use 'FIFO' for traditional first-in-first-out, or 'round_robin' to serve each user's jobs in turn. In single-user mode, FIFO is always used regardless of this setting. + session_queue_mode: Session queue mode. Use 'FIFO' for traditional first-in-first-out, or 'round_robin' to serve each user's jobs in turn. In single-user mode, FIFO is always used regardless of this setting.
Valid values: `FIFO`, `round_robin` clear_queue_on_startup: Empties session queue on startup. If true, disables `max_queue_history`. max_queue_history: Keep the last N completed, failed, and canceled queue items. Older items are deleted on startup. Set to 0 to prune all terminal items. Ignored if `clear_queue_on_startup` is true. allow_nodes: List of nodes to allow. Omit to allow all. diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py index 14b93d97fc7..04bd81e3174 100644 --- a/invokeai/app/services/session_queue/session_queue_base.py +++ b/invokeai/app/services/session_queue/session_queue_base.py @@ -73,8 +73,19 @@ def is_full(self, queue_id: str) -> IsFullResult: pass @abstractmethod - def get_queue_status(self, queue_id: str, user_id: Optional[str] = None) -> SessionQueueStatus: - """Gets the status of the queue. If user_id is provided, also includes user-specific counts.""" + def get_queue_status( + self, + queue_id: str, + user_id: Optional[str] = None, + is_admin: bool = False, + ) -> SessionQueueStatus: + """Gets the status of the queue. + + Always returns global pending/in_progress/etc. counts. When user_id is provided, also + populates user_pending and user_in_progress with that user's own counts (so the UI can + render an X/Y badge). When is_admin is False, the current item's identifiers are hidden + unless the calling user owns the in-progress item. + """ pass @abstractmethod diff --git a/invokeai/app/services/session_queue/session_queue_common.py b/invokeai/app/services/session_queue/session_queue_common.py index d87221fbbae..7472ea07f63 100644 --- a/invokeai/app/services/session_queue/session_queue_common.py +++ b/invokeai/app/services/session_queue/session_queue_common.py @@ -309,6 +309,12 @@ class SessionQueueStatus(BaseModel): failed: int = Field(..., description="Number of queue items with status 'error'") canceled: int = Field(..., description="Number of queue items with status 'canceled'") total: int = Field(..., description="Total number of queue items") + user_pending: Optional[int] = Field( + default=None, description="Number of pending queue items for the calling user (multiuser only)" + ) + user_in_progress: Optional[int] = Field( + default=None, description="Number of in-progress queue items for the calling user (multiuser only)" + ) class SessionQueueCountsByDestination(BaseModel): diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py index 2e7c9256947..326baed1b31 100644 --- a/invokeai/app/services/session_queue/session_queue_sqlite.py +++ b/invokeai/app/services/session_queue/session_queue_sqlite.py @@ -884,9 +884,25 @@ def get_queue_item_ids( return ItemIdsResult(item_ids=item_ids, total_count=len(item_ids)) - def get_queue_status(self, queue_id: str, user_id: Optional[str] = None) -> SessionQueueStatus: + def get_queue_status( + self, + queue_id: str, + user_id: Optional[str] = None, + is_admin: bool = False, + ) -> SessionQueueStatus: with self._db.transaction() as cursor: - # When user_id is provided (non-admin), only count that user's items + cursor.execute( + """--sql + SELECT status, count(*) + FROM session_queue + WHERE queue_id = ? + GROUP BY status + """, + (queue_id,), + ) + counts_result = cast(list[sqlite3.Row], cursor.fetchall()) + + user_counts_result: list[sqlite3.Row] = [] if user_id is not None: cursor.execute( """--sql @@ -897,24 +913,23 @@ def get_queue_status(self, queue_id: str, user_id: Optional[str] = None) -> Sess """, (queue_id, user_id), ) - else: - cursor.execute( - """--sql - SELECT status, count(*) - FROM session_queue - WHERE queue_id = ? - GROUP BY status - """, - (queue_id,), - ) - counts_result = cast(list[sqlite3.Row], cursor.fetchall()) + user_counts_result = cast(list[sqlite3.Row], cursor.fetchall()) current_item = self.get_current(queue_id=queue_id) total = sum(row[1] or 0 for row in counts_result) counts: dict[str, int] = {row[0]: row[1] for row in counts_result} - # For non-admin users, hide current item details if they don't own it - show_current_item = current_item is not None and (user_id is None or current_item.user_id == user_id) + user_pending: Optional[int] = None + user_in_progress: Optional[int] = None + if user_id is not None: + user_counts: dict[str, int] = {row[0]: row[1] for row in user_counts_result} + user_pending = user_counts.get("pending", 0) + user_in_progress = user_counts.get("in_progress", 0) + + # Non-admins cannot see the current item's identifiers unless they own it. + show_current_item = current_item is not None and ( + is_admin or user_id is None or current_item.user_id == user_id + ) return SessionQueueStatus( queue_id=queue_id, @@ -927,6 +942,8 @@ def get_queue_status(self, queue_id: str, user_id: Optional[str] = None) -> Sess failed=counts.get("failed", 0), canceled=counts.get("canceled", 0), total=total, + user_pending=user_pending, + user_in_progress=user_in_progress, ) def get_batch_status(self, queue_id: str, batch_id: str, user_id: Optional[str] = None) -> BatchStatus: diff --git a/invokeai/frontend/web/src/features/queue/components/QueueCountBadge.tsx b/invokeai/frontend/web/src/features/queue/components/QueueCountBadge.tsx index e8636466066..1ba2ffd572d 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueCountBadge.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueCountBadge.tsx @@ -1,4 +1,6 @@ import { Badge, Portal } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectIsAuthenticated } from 'features/auth/store/authSlice'; import type { RefObject } from 'react'; import { memo, useEffect, useMemo, useState } from 'react'; import { useGetQueueStatusQuery } from 'services/api/endpoints/queue'; @@ -10,14 +12,24 @@ type Props = { type SessionQueueStatus = components['schemas']['SessionQueueStatus']; +const hasUserCounts = (queueData: SessionQueueStatus): boolean => { + return ( + queueData.user_pending !== undefined && + queueData.user_pending !== null && + queueData.user_in_progress !== undefined && + queueData.user_in_progress !== null + ); +}; + /** - * Calculates the appropriate badge text based on queue status. + * Calculates the appropriate badge text based on queue status and authentication state. * Returns null if badge should be hidden. * - * In multiuser mode, the backend already scopes counts to the current user for non-admins, - * so pending + in_progress reflects the user's own queue items. + * In multiuser mode, the badge is "X/Y" where X is the calling user's pending+in_progress count + * and Y is the total across all users. In single-user mode (or when user counts are unavailable) + * the badge shows the total only. */ -const getBadgeText = (queueData: SessionQueueStatus | undefined): string | null => { +const getBadgeText = (queueData: SessionQueueStatus | undefined, isAuthenticated: boolean): string | null => { if (!queueData) { return null; } @@ -28,18 +40,24 @@ const getBadgeText = (queueData: SessionQueueStatus | undefined): string | null return null; } + if (isAuthenticated && hasUserCounts(queueData)) { + const userPending = queueData.user_pending! + queueData.user_in_progress!; + return `${userPending}/${totalPending}`; + } + return totalPending.toString(); }; export const QueueCountBadge = memo(({ targetRef }: Props) => { const [badgePos, setBadgePos] = useState<{ x: string; y: string } | null>(null); + const isAuthenticated = useAppSelector(selectIsAuthenticated); const { queueData } = useGetQueueStatusQuery(undefined, { selectFromResult: (res) => ({ queueData: res.data?.queue, }), }); - const badgeText = useMemo(() => getBadgeText(queueData), [queueData]); + const badgeText = useMemo(() => getBadgeText(queueData, isAuthenticated), [queueData, isAuthenticated]); useEffect(() => { if (!targetRef.current) { diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 4b8e4da95a5..f12ec2e538e 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1795,7 +1795,9 @@ export type paths = { }; /** * Get Queue Item Ids - * @description Gets all queue item ids that match the given parameters. Non-admin users only see their own items. + * @description Gets all queue item ids that match the given parameters. The IDs themselves are not sensitive; + * per-item field redaction is performed when the items are fetched via list_all_queue_items or + * get_queue_items_by_item_ids. */ get: operations["get_queue_item_ids"]; put?: never; @@ -2055,7 +2057,9 @@ export type paths = { }; /** * Get Queue Status - * @description Gets the status of the session queue. Non-admin users see only their own counts and cannot see current item details unless they own it. + * @description Gets the status of the session queue. Returns global counts plus the calling user's own + * pending/in_progress counts (so the UI can show an X/Y badge). Non-admin users cannot see the + * current item's identifiers unless they own it. */ get: operations["get_queue_status"]; put?: never; @@ -15641,6 +15645,7 @@ export type components = { * force_tiled_decode: Whether to enable tiled VAE decode (reduces memory consumption with some performance penalty). * pil_compress_level: The compress_level setting of PIL.Image.save(), used for PNG encoding. All settings are lossless. 0 = no compression, 1 = fastest with slightly larger filesize, 9 = slowest with smallest filesize. 1 is typically the best setting. * max_queue_size: Maximum number of items in the session queue. + * session_queue_mode: Session queue mode. Use 'FIFO' for traditional first-in-first-out, or 'round_robin' to serve each user's jobs in turn. In single-user mode, FIFO is always used regardless of this setting.
Valid values: `FIFO`, `round_robin` * clear_queue_on_startup: Empties session queue on startup. If true, disables `max_queue_history`. * max_queue_history: Keep the last N completed, failed, and canceled queue items. Older items are deleted on startup. Set to 0 to prune all terminal items. Ignored if `clear_queue_on_startup` is true. * allow_nodes: List of nodes to allow. Omit to allow all. @@ -15972,6 +15977,13 @@ export type components = { * @default 10000 */ max_queue_size?: number; + /** + * Session Queue Mode + * @description Session queue mode. Use 'FIFO' for traditional first-in-first-out, or 'round_robin' to serve each user's jobs in turn. In single-user mode, FIFO is always used regardless of this setting. + * @default round_robin + * @enum {string} + */ + session_queue_mode?: "FIFO" | "round_robin"; /** * Clear Queue On Startup * @description Empties session queue on startup. If true, disables `max_queue_history`. @@ -26807,6 +26819,16 @@ export type components = { * @description Total number of queue items */ total: number; + /** + * User Pending + * @description Number of pending queue items for the calling user (multiuser only) + */ + user_pending?: number | null; + /** + * User In Progress + * @description Number of in-progress queue items for the calling user (multiuser only) + */ + user_in_progress?: number | null; }; /** * SetupRequest diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx index 6771e9e7e00..d742ad09bf5 100644 --- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx +++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx @@ -388,6 +388,24 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis }); socket.on('queue_item_status_changed', (data) => { + // Sanitized companion event sent to non-owner queue subscribers in multiuser mode. The + // backend sets user_id="redacted" and clears identifiers/error fields. We must not run + // payload-driven cache mutations or per-session side effects (node state reset, progress + // clear, completion bookkeeping) — those belong to the owner. Just invalidate queue tags + // so the non-owner's queue list and badge counts refetch with sanitized data. + if (data.user_id === 'redacted') { + log.trace({ data }, `Sanitized queue_item_status_changed for item ${data.item_id}`); + const tags: ApiTagDescription[] = [ + 'SessionQueueStatus', + 'SessionQueueItemIdList', + { type: 'SessionQueueItem', id: data.item_id }, + { type: 'SessionQueueItem', id: LIST_TAG }, + { type: 'SessionQueueItem', id: LIST_ALL_TAG }, + ]; + dispatch(queueApi.util.invalidateTags(tags)); + return; + } + if (finishedQueueItemIds.has(data.item_id)) { log.trace({ data }, `Received event for already-finished queue item ${data.item_id}`); return; diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py index 85354c6a577..813b5170a09 100644 --- a/tests/app/routers/test_multiuser_authorization.py +++ b/tests/app/routers/test_multiuser_authorization.py @@ -1332,14 +1332,31 @@ def test_get_queue_status_hides_current_item_for_non_owner(self): assert status_obj.session_id is None assert status_obj.batch_id is None - def test_session_queue_status_no_user_fields(self): - """SessionQueueStatus should not have user_pending/user_in_progress fields anymore. - Non-admin users now get their own counts in the main pending/in_progress fields.""" + def test_session_queue_status_has_user_fields(self): + """SessionQueueStatus exposes user_pending/user_in_progress so the queue badge + can render an X/Y count (X = caller's jobs, Y = global total).""" from invokeai.app.services.session_queue.session_queue_common import SessionQueueStatus fields = set(SessionQueueStatus.model_fields.keys()) - assert "user_pending" not in fields - assert "user_in_progress" not in fields + assert "user_pending" in fields + assert "user_in_progress" in fields + + status_obj = SessionQueueStatus( + queue_id="default", + item_id=None, + session_id=None, + batch_id=None, + pending=5, + in_progress=1, + completed=0, + failed=0, + canceled=0, + total=6, + user_pending=2, + user_in_progress=1, + ) + assert status_obj.user_pending == 2 + assert status_obj.user_in_progress == 1 # =========================================================================== @@ -1707,8 +1724,11 @@ def test_batch_enqueued_event_carries_user_id(self) -> None: assert event.queue_id == "default" def test_queue_item_status_changed_routed_privately(self, socketio: Any) -> None: - """Verify that _handle_queue_event emits QueueItemStatusChangedEvent ONLY to - user:{user_id} and admin rooms, never to the queue_id room.""" + """_handle_queue_event must emit the FULL QueueItemStatusChangedEvent only to the + owner's user room and the admin room. A sanitized companion (user_id="redacted", + identifiers stripped) is also emitted to the queue_id room so other users' UIs can + refresh, with the owner's and admins' sids in skip_sid so they don't get a duplicate + that would clobber their cache.""" import asyncio from unittest.mock import AsyncMock @@ -1757,20 +1777,60 @@ def test_queue_item_status_changed_routed_privately(self, socketio: Any) -> None ), ) + # Track owner sid so we can verify skip_sid is honored + socketio._socket_users["sid-owner"] = {"user_id": "owner-xyz", "is_admin": False} + socketio._socket_users["sid-admin"] = {"user_id": "admin-1", "is_admin": True} + socketio._socket_users["sid-other"] = {"user_id": "other-user", "is_admin": False} + mock_emit = AsyncMock() socketio._sio.emit = mock_emit asyncio.run(socketio._handle_queue_event(("queue_item_status_changed", event))) - rooms_emitted_to = [call.kwargs.get("room") for call in mock_emit.call_args_list] - assert "user:owner-xyz" in rooms_emitted_to - assert "admin" in rooms_emitted_to - # CRITICAL: must NOT emit to the queue_id room — that would leak to other users - assert "default" not in rooms_emitted_to + # Collect (room, payload, skip_sid) for each emit call + emits = [ + (c.kwargs.get("room"), c.kwargs.get("data"), c.kwargs.get("skip_sid")) for c in mock_emit.call_args_list + ] + + # Full event must go to owner room and admin room with original sensitive fields + owner_emits = [(p, s) for r, p, s in emits if r == "user:owner-xyz"] + admin_emits = [(p, s) for r, p, s in emits if r == "admin"] + assert len(owner_emits) == 1 and len(admin_emits) == 1 + for payload, _ in owner_emits + admin_emits: + assert payload["user_id"] == "owner-xyz" + assert payload["batch_id"] == "batch-private" + assert payload["session_id"] == "sess-private" + assert payload["destination"] == "canvas" + + # A sanitized companion event must go to the queue_id room with sensitive fields cleared + queue_emits = [(p, s) for r, p, s in emits if r == "default"] + assert len(queue_emits) == 1, "expected exactly one sanitized emit to queue room" + sanitized_payload, skip_sid = queue_emits[0] + assert sanitized_payload["user_id"] == "redacted" + assert sanitized_payload["batch_id"] == "redacted" + assert sanitized_payload["session_id"] == "redacted" + assert sanitized_payload["origin"] is None + assert sanitized_payload["destination"] is None + assert sanitized_payload["error_type"] is None + assert sanitized_payload["batch_status"]["batch_id"] == "redacted" + assert sanitized_payload["batch_status"]["destination"] is None + assert sanitized_payload["queue_status"]["item_id"] is None + assert sanitized_payload["queue_status"]["batch_id"] is None + assert sanitized_payload["queue_status"]["user_pending"] is None + # Owner and admin sids must be skipped so they don't receive the duplicate + assert "sid-owner" in skip_sid + assert "sid-admin" in skip_sid + # Third-party user must NOT be skipped — they need the sanitized event + assert "sid-other" not in skip_sid + # Status (non-sensitive) is preserved so the non-owner UI knows what changed + assert sanitized_payload["status"] == "in_progress" + assert sanitized_payload["item_id"] == 1 def test_batch_enqueued_routed_privately(self, socketio: Any) -> None: - """Verify that _handle_queue_event emits BatchEnqueuedEvent ONLY to - user:{user_id} and admin rooms, never to the queue_id room.""" + """_handle_queue_event must emit the FULL BatchEnqueuedEvent only to the owner's + user room and the admin room. A sanitized companion (user_id="redacted", batch_id + and origin stripped) is also emitted to the queue_id room so other users' badge + totals refresh, with owner/admin sids in skip_sid.""" import asyncio from unittest.mock import AsyncMock @@ -1791,15 +1851,39 @@ def test_batch_enqueued_routed_privately(self, socketio: Any) -> None: ) event = BatchEnqueuedEvent.build(enqueue_result, user_id="owner-zzz") + socketio._socket_users["sid-owner"] = {"user_id": "owner-zzz", "is_admin": False} + socketio._socket_users["sid-admin"] = {"user_id": "admin-1", "is_admin": True} + socketio._socket_users["sid-other"] = {"user_id": "other-user", "is_admin": False} + mock_emit = AsyncMock() socketio._sio.emit = mock_emit asyncio.run(socketio._handle_queue_event(("batch_enqueued", event))) - rooms_emitted_to = [call.kwargs.get("room") for call in mock_emit.call_args_list] - assert "user:owner-zzz" in rooms_emitted_to - assert "admin" in rooms_emitted_to - assert "default" not in rooms_emitted_to + emits = [ + (c.kwargs.get("room"), c.kwargs.get("data"), c.kwargs.get("skip_sid")) for c in mock_emit.call_args_list + ] + + # Full event to owner + admin contains the real batch_id and origin + owner_emits = [(p, s) for r, p, s in emits if r == "user:owner-zzz"] + admin_emits = [(p, s) for r, p, s in emits if r == "admin"] + assert len(owner_emits) == 1 and len(admin_emits) == 1 + for payload, _ in owner_emits + admin_emits: + assert payload["user_id"] == "owner-zzz" + assert payload["batch_id"] == "batch-pvt" + assert payload["origin"] == "workflows" + + # Sanitized event to queue room: user/batch/origin redacted, owner+admin skipped + queue_emits = [(p, s) for r, p, s in emits if r == "default"] + assert len(queue_emits) == 1 + sanitized_payload, skip_sid = queue_emits[0] + assert sanitized_payload["user_id"] == "redacted" + assert sanitized_payload["batch_id"] == "redacted" + assert sanitized_payload["origin"] is None + assert sanitized_payload["enqueued"] == 5 # count is non-sensitive + assert "sid-owner" in skip_sid + assert "sid-admin" in skip_sid + assert "sid-other" not in skip_sid def test_queue_cleared_still_broadcast(self, socketio: Any) -> None: """QueueClearedEvent does not carry user identity and should still be broadcast From 8179b9de63ed14301e4072baaf13b7aa0e227526 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sat, 25 Apr 2026 14:47:34 -0400 Subject: [PATCH 4/7] docs: regenerate settings.json for session_queue_mode Run via `pnpm run generate-docs-data`. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/src/generated/settings.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/src/generated/settings.json b/docs/src/generated/settings.json index 32140da667a..f0cc5c8961e 100644 --- a/docs/src/generated/settings.json +++ b/docs/src/generated/settings.json @@ -574,6 +574,20 @@ "type": "", "validation": {} }, + { + "category": "GENERATION", + "default": "round_robin", + "description": "Session queue mode. Use 'FIFO' for traditional first-in-first-out, or 'round_robin' to serve each user's jobs in turn. In single-user mode, FIFO is always used regardless of this setting.", + "env_var": "INVOKEAI_SESSION_QUEUE_MODE", + "literal_values": [ + "FIFO", + "round_robin" + ], + "name": "session_queue_mode", + "required": false, + "type": "typing.Literal['FIFO', 'round_robin']", + "validation": {} + }, { "category": "GENERATION", "default": false, From aa865f6e0877b307ba5bc06fe740b8f516dfe011 Mon Sep 17 00:00:00 2001 From: Jonathan <34005131+JPPhoto@users.noreply.github.com> Date: Fri, 8 May 2026 18:50:49 -0500 Subject: [PATCH 5/7] feat: add image subfolder strategy setting UI (#9133) * feat: add image subfolder strategy setting UI * fix: address image subfolder strategy review --- .../docs/configuration/invokeai-yaml.mdx | 19 +++ invokeai/app/api/routers/app_info.py | 47 ++++++-- invokeai/frontend/web/public/locales/en.json | 7 ++ ...ttingsImageSubfolderStrategySelect.test.ts | 43 +++++++ .../SettingsImageSubfolderStrategySelect.tsx | 100 ++++++++++++++++ .../SettingsModal/SettingsModal.tsx | 2 + .../frontend/web/src/services/api/schema.ts | 6 + tests/app/routers/test_app_info.py | 109 ++++++++++++++++++ 8 files changed, 322 insertions(+), 11 deletions(-) create mode 100644 invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsImageSubfolderStrategySelect.test.ts create mode 100644 invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsImageSubfolderStrategySelect.tsx diff --git a/docs/src/content/docs/configuration/invokeai-yaml.mdx b/docs/src/content/docs/configuration/invokeai-yaml.mdx index 43a68ee9f74..987c8eb98a2 100644 --- a/docs/src/content/docs/configuration/invokeai-yaml.mdx +++ b/docs/src/content/docs/configuration/invokeai-yaml.mdx @@ -114,6 +114,25 @@ Most common algorithms are supported, like `md5`, `sha256`, and `sha512`. These These options set the paths of various directories and files used by InvokeAI. Any user-defined paths should be absolute paths. +#### Image Subfolder Strategy + +By default, generated images are stored in a single flat directory under `outputs/images/`. The `image_subfolder_strategy` setting lets you organize newly-created images into subfolders automatically. You can edit this setting in `invokeai.yaml` or, as an admin user, in the Settings panel. + +```yaml +image_subfolder_strategy: flat # default value +``` + +Available strategies: + +| Strategy | Example Path | Description | +| -------- | -------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `flat` | `outputs/images/abc123.png` | Store images directly in the images directory. | +| `date` | `outputs/images/2026/03/17/abc123.png` | Organize images by creation date. | +| `type` | `outputs/images/general/abc123.png` | Organize images by image category. | +| `hash` | `outputs/images/ab/abc123.png` | Use the first two characters of the image UUID for filesystem performance with large collections. | + +Changing this setting only affects newly-created images. Existing images remain in their current locations. + #### Logging Several different log handler destinations are available, and multiple destinations are supported by providing a list: diff --git a/invokeai/app/api/routers/app_info.py b/invokeai/app/api/routers/app_info.py index 68c0055e1cd..f2cd65aa1c6 100644 --- a/invokeai/app/api/routers/app_info.py +++ b/invokeai/app/api/routers/app_info.py @@ -3,17 +3,19 @@ from importlib.metadata import distributions from pathlib import Path as FilePath from threading import Lock +from typing import Any import torch import yaml from fastapi import Body, HTTPException, Path from fastapi.routing import APIRouter -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from invokeai.app.api.auth_dependencies import AdminUserOrDefault from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.services.config.config_default import ( EXTERNAL_PROVIDER_CONFIG_FIELDS, + IMAGE_SUBFOLDER_STRATEGY, DefaultInvokeAIAppConfig, InvokeAIAppConfig, get_config, @@ -105,15 +107,37 @@ class ExternalProviderConfigModel(BaseModel): _EXTERNAL_PROVIDER_CONFIG_LOCK = Lock() +def _remove_nullable_default_from_schema(schema: dict[str, Any]) -> None: + schema.pop("default", None) + any_of = schema.pop("anyOf", None) + if isinstance(any_of, list): + non_null_schemas = [ + subschema for subschema in any_of if isinstance(subschema, dict) and subschema.get("type") != "null" + ] + if len(non_null_schemas) == 1: + schema.update(non_null_schemas[0]) + + class UpdateAppGenerationSettingsRequest(BaseModel): """Writable generation-related app settings.""" + image_subfolder_strategy: IMAGE_SUBFOLDER_STRATEGY | None = Field( + default=None, + description="Strategy for organizing images into subfolders.", + json_schema_extra=_remove_nullable_default_from_schema, + ) max_queue_history: int | None = Field( default=None, ge=0, description="Keep the last N completed, failed, and canceled queue items on startup. Set to 0 to prune all terminal items.", ) + @model_validator(mode="after") + def validate_explicit_nulls(self) -> "UpdateAppGenerationSettingsRequest": + if "image_subfolder_strategy" in self.model_fields_set and self.image_subfolder_strategy is None: + raise ValueError("image_subfolder_strategy may not be null") + return self + @app_router.get( "/runtime_config", operation_id="get_runtime_config", status_code=200, response_model=InvokeAIAppConfigWithSetFields @@ -133,18 +157,19 @@ async def update_runtime_config( _: AdminUserOrDefault, changes: UpdateAppGenerationSettingsRequest = Body(description="Writable runtime configuration changes"), ) -> InvokeAIAppConfigWithSetFields: - config = get_config() - update_dict = changes.model_dump(exclude_unset=True) - config.update_config(update_dict) + with _EXTERNAL_PROVIDER_CONFIG_LOCK: + config = get_config() + update_dict = changes.model_dump(exclude_unset=True) + config.update_config(update_dict) - if config.config_file_path.exists(): - persisted_config = load_and_migrate_config(config.config_file_path) - else: - persisted_config = DefaultInvokeAIAppConfig() + if config.config_file_path.exists(): + persisted_config = load_and_migrate_config(config.config_file_path) + else: + persisted_config = DefaultInvokeAIAppConfig() - persisted_config.update_config(update_dict) - persisted_config.write_file(config.config_file_path) - return InvokeAIAppConfigWithSetFields(set_fields=config.model_fields_set, config=config) + persisted_config.update_config(update_dict) + persisted_config.write_file(config.config_file_path) + return InvokeAIAppConfigWithSetFields(set_fields=config.model_fields_set, config=config) @app_router.get( diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 05edf886890..e7631ff6236 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1800,6 +1800,13 @@ "enableNSFWChecker": "Enable NSFW Checker", "general": "General", "generation": "Generation", + "imageSubfolderStrategy": "Image Subfolder Strategy", + "imageSubfolderStrategyDate": "Date", + "imageSubfolderStrategyFlat": "Flat", + "imageSubfolderStrategyHash": "Hash", + "imageSubfolderStrategySaveFailed": "Failed to save Image Subfolder Strategy", + "imageSubfolderStrategyType": "Type", + "imageSubfolderStrategyUnknown": "Unknown ({{strategy}})", "maxQueueHistory": "Max Queue History", "maxQueueHistorySaveFailed": "Failed to save Max Queue History", "models": "Models", diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsImageSubfolderStrategySelect.test.ts b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsImageSubfolderStrategySelect.test.ts new file mode 100644 index 00000000000..ecfe12310e8 --- /dev/null +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsImageSubfolderStrategySelect.test.ts @@ -0,0 +1,43 @@ +import type { S } from 'services/api/types'; +import type { Equals } from 'tsafe'; +import { assert } from 'tsafe'; +import { describe, expect, it } from 'vitest'; + +import { + getImageSubfolderStrategyOption, + imageSubfolderStrategyOptions, + isImageSubfolderStrategy, +} from './SettingsImageSubfolderStrategySelect'; + +describe('image subfolder strategy settings options', () => { + it('covers all runtime config image subfolder strategies exposed by the API', () => { + type ImageSubfolderStrategy = NonNullable; + type OptionValue = (typeof imageSubfolderStrategyOptions)[number]['value']; + + assert, never>>(); + assert, never>>(); + }); + + it('includes all runtime config image subfolder strategies', () => { + expect(imageSubfolderStrategyOptions.map((option) => option.value)).toEqual(['flat', 'date', 'type', 'hash']); + }); + + it('validates image subfolder strategy values', () => { + expect(isImageSubfolderStrategy('date')).toBe(true); + expect(isImageSubfolderStrategy('unknown')).toBe(false); + }); + + it('gets the option for the active strategy', () => { + expect(getImageSubfolderStrategyOption('hash')).toEqual({ + label: 'settings.imageSubfolderStrategyHash', + value: 'hash', + }); + }); + + it('gets an explicit unknown option for an unrecognized active strategy', () => { + expect(getImageSubfolderStrategyOption('unknown')).toEqual({ + label: 'settings.imageSubfolderStrategyUnknown', + value: 'unknown', + }); + }); +}); diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsImageSubfolderStrategySelect.tsx b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsImageSubfolderStrategySelect.tsx new file mode 100644 index 00000000000..b156a50de27 --- /dev/null +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsImageSubfolderStrategySelect.tsx @@ -0,0 +1,100 @@ +import type { ComboboxOnChange } from '@invoke-ai/ui-library'; +import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectCurrentUser } from 'features/auth/store/authSlice'; +import { toast } from 'features/toast/toast'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useGetRuntimeConfigQuery, useUpdateRuntimeConfigMutation } from 'services/api/endpoints/appInfo'; +import type { S } from 'services/api/types'; + +type ImageSubfolderStrategy = NonNullable; + +type ImageSubfolderStrategyOption = { + label: string; + value: ImageSubfolderStrategy; +}; + +type ImageSubfolderStrategySelectOption = { + label: string; + value: string; +}; + +export const imageSubfolderStrategyOptions = [ + { label: 'settings.imageSubfolderStrategyFlat', value: 'flat' }, + { label: 'settings.imageSubfolderStrategyDate', value: 'date' }, + { label: 'settings.imageSubfolderStrategyType', value: 'type' }, + { label: 'settings.imageSubfolderStrategyHash', value: 'hash' }, +] satisfies ImageSubfolderStrategyOption[]; + +export const isImageSubfolderStrategy = (value: unknown): value is ImageSubfolderStrategy => + imageSubfolderStrategyOptions.some((option) => option.value === value); + +export const getImageSubfolderStrategyOption = (strategy: string): ImageSubfolderStrategySelectOption => + imageSubfolderStrategyOptions.find((option) => option.value === strategy) ?? { + label: 'settings.imageSubfolderStrategyUnknown', + value: strategy, + }; + +export const SettingsImageSubfolderStrategySelect = memo(() => { + const { t } = useTranslation(); + const currentUser = useAppSelector(selectCurrentUser); + const { data: runtimeConfig } = useGetRuntimeConfigQuery(); + const [updateRuntimeConfig, { isLoading }] = useUpdateRuntimeConfigMutation(); + const imageSubfolderStrategy: string = runtimeConfig?.config.image_subfolder_strategy ?? 'flat'; + const canEditRuntimeConfig = runtimeConfig ? !runtimeConfig.config.multiuser || currentUser?.is_admin : false; + + const options = useMemo(() => { + const localizedOptions: ImageSubfolderStrategySelectOption[] = imageSubfolderStrategyOptions.map((option) => ({ + ...option, + label: t(option.label), + })); + + if (!isImageSubfolderStrategy(imageSubfolderStrategy)) { + localizedOptions.push({ + label: t('settings.imageSubfolderStrategyUnknown', { strategy: imageSubfolderStrategy }), + value: imageSubfolderStrategy, + }); + } + + return localizedOptions; + }, [imageSubfolderStrategy, t]); + + const value = useMemo( + () => options.find((option) => option.value === imageSubfolderStrategy), + [imageSubfolderStrategy, options] + ); + + const onChange = useCallback( + async (selection) => { + if (!isImageSubfolderStrategy(selection?.value) || selection.value === imageSubfolderStrategy) { + return; + } + + try { + await updateRuntimeConfig({ image_subfolder_strategy: selection.value }).unwrap(); + } catch { + toast({ + id: 'SETTINGS_IMAGE_SUBFOLDER_STRATEGY_SAVE_FAILED', + title: t('settings.imageSubfolderStrategySaveFailed'), + status: 'error', + }); + } + }, + [imageSubfolderStrategy, t, updateRuntimeConfig] + ); + + return ( + + {t('settings.imageSubfolderStrategy')} + + + ); +}); + +SettingsImageSubfolderStrategySelect.displayName = 'SettingsImageSubfolderStrategySelect'; diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx index 1f28e786c15..64478953a37 100644 --- a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx @@ -28,6 +28,7 @@ import { useRefreshAfterResetModal } from 'features/system/components/SettingsMo import { SettingsDeveloperLogIsEnabled } from 'features/system/components/SettingsModal/SettingsDeveloperLogIsEnabled'; import { SettingsDeveloperLogLevel } from 'features/system/components/SettingsModal/SettingsDeveloperLogLevel'; import { SettingsDeveloperLogNamespaces } from 'features/system/components/SettingsModal/SettingsDeveloperLogNamespaces'; +import { SettingsImageSubfolderStrategySelect } from 'features/system/components/SettingsModal/SettingsImageSubfolderStrategySelect'; import { useClearIntermediates } from 'features/system/components/SettingsModal/useClearIntermediates'; import { StickyScrollable } from 'features/system/components/StickyScrollable'; import { @@ -319,6 +320,7 @@ const SettingsModal = (props: { children: ReactElement }) => { + diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 62070fcbbbe..63e365ce332 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -30603,6 +30603,12 @@ export type components = { * @description Writable generation-related app settings. */ UpdateAppGenerationSettingsRequest: { + /** + * Image Subfolder Strategy + * @description Strategy for organizing images into subfolders. + * @enum {string} + */ + image_subfolder_strategy?: "flat" | "date" | "type" | "hash"; /** * Max Queue History * @description Keep the last N completed, failed, and canceled queue items on startup. Set to 0 to prune all terminal items. diff --git a/tests/app/routers/test_app_info.py b/tests/app/routers/test_app_info.py index afc7115cfc7..2f6d0217044 100644 --- a/tests/app/routers/test_app_info.py +++ b/tests/app/routers/test_app_info.py @@ -7,9 +7,12 @@ from fastapi.testclient import TestClient from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api.routers import app_info from invokeai.app.api_app import app +from invokeai.app.services.auth.token_service import TokenData from invokeai.app.services.config.config_default import get_config, load_and_migrate_config, load_external_api_keys from invokeai.app.services.external_generation.external_generation_common import ExternalProviderStatus +from invokeai.app.services.image_files.image_subfolder_strategy import DateStrategy, create_subfolder_strategy from invokeai.app.services.invoker import Invoker from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType @@ -177,5 +180,111 @@ def test_set_external_provider_config_clears_provider_models_when_api_key_remove mock_install.delete.assert_called_once_with("openai_model") +def test_update_runtime_config_persists_image_subfolder_strategy( + monkeypatch: Any, mock_invoker: Invoker, client: TestClient +) -> None: + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) + + response = client.patch("/api/v1/app/runtime_config", json={"image_subfolder_strategy": "date"}) + + assert response.status_code == 200 + assert response.json()["config"]["image_subfolder_strategy"] == "date" + + config_path = get_config().config_file_path + file_config = load_and_migrate_config(config_path) + assert file_config.image_subfolder_strategy == "date" + assert "image_subfolder_strategy: date" in config_path.read_text() + assert get_config().image_subfolder_strategy == "date" + assert isinstance(create_subfolder_strategy(get_config().image_subfolder_strategy), DateStrategy) + + +def test_update_runtime_config_rejects_null_image_subfolder_strategy( + monkeypatch: Any, mock_invoker: Invoker, client: TestClient +) -> None: + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) + + response = client.patch("/api/v1/app/runtime_config", json={"image_subfolder_strategy": None}) + + assert response.status_code == 422 + + +def test_update_runtime_config_image_subfolder_strategy_schema() -> None: + app.openapi_schema = None + property_schema = app.openapi()["components"]["schemas"]["UpdateAppGenerationSettingsRequest"]["properties"][ + "image_subfolder_strategy" + ] + + assert property_schema == { + "description": "Strategy for organizing images into subfolders.", + "enum": ["flat", "date", "type", "hash"], + "title": "Image Subfolder Strategy", + "type": "string", + } + + +def test_update_runtime_config_reads_and_writes_yaml_under_config_lock( + monkeypatch: Any, mock_invoker: Invoker, client: TestClient +) -> None: + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) + + class TrackingLock: + is_locked = False + load_seen = False + write_seen = False + + def __enter__(self) -> None: + self.is_locked = True + + def __exit__(self, *_: Any) -> None: + self.is_locked = False + + tracking_lock = TrackingLock() + original_load_and_migrate_config = app_info.load_and_migrate_config + original_write_file = app_info.InvokeAIAppConfig.write_file + + def load_and_migrate_config_with_lock_assertion(config_path: Path) -> Any: + assert tracking_lock.is_locked + tracking_lock.load_seen = True + return original_load_and_migrate_config(config_path) + + def write_file_with_lock_assertion( + config: app_info.InvokeAIAppConfig, dest_path: Path, as_example: bool = False + ) -> None: + assert tracking_lock.is_locked + tracking_lock.write_seen = True + return original_write_file(config, dest_path, as_example) + + monkeypatch.setattr(app_info, "_EXTERNAL_PROVIDER_CONFIG_LOCK", tracking_lock) + monkeypatch.setattr(app_info, "load_and_migrate_config", load_and_migrate_config_with_lock_assertion) + monkeypatch.setattr(app_info.InvokeAIAppConfig, "write_file", write_file_with_lock_assertion) + + response = client.patch("/api/v1/app/runtime_config", json={"max_queue_history": 10}) + + assert response.status_code == 200 + assert tracking_lock.load_seen + assert tracking_lock.write_seen + + +def test_update_runtime_config_rejects_non_admin_users( + monkeypatch: Any, mock_invoker: Invoker, client: TestClient +) -> None: + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) + monkeypatch.setattr(mock_invoker.services.configuration, "multiuser", True) + monkeypatch.setattr( + "invokeai.app.api.auth_dependencies.verify_token", + lambda _: TokenData(user_id="user-1", email="user@example.com", is_admin=False), + ) + monkeypatch.setattr(mock_invoker.services.users, "get", Mock(return_value=Mock(is_active=True))) + + response = client.patch( + "/api/v1/app/runtime_config", + json={"image_subfolder_strategy": "date"}, + headers={"Authorization": "Bearer non-admin-token"}, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "Admin privileges required" + + def _get_provider_config(payload: list[dict[str, Any]], provider_id: str) -> dict[str, Any]: return next(item for item in payload if item["provider_id"] == provider_id) From 413ea2d6901aa548d03cffaa3c0bcf4ec25f132c Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sat, 9 May 2026 16:00:46 +0200 Subject: [PATCH 6/7] ui: translations update from weblate (#9088) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * translationBot(ui): update translation (Russian) Currently translated at 56.8% (1508 of 2652 strings) Co-authored-by: Dmitry Warkentin Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/ru/ Translation: InvokeAI/Web UI * translationBot(ui): update translation (Italian) Currently translated at 97.2% (2649 of 2724 strings) translationBot(ui): update translation (Italian) Currently translated at 97.2% (2639 of 2713 strings) translationBot(ui): update translation (Italian) Currently translated at 97.2% (2594 of 2666 strings) translationBot(ui): update translation (Italian) Currently translated at 97.1% (2577 of 2652 strings) Co-authored-by: Riccardo Giovanetti Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ Translation: InvokeAI/Web UI * translationBot(ui): update translation files Updated by "Remove blank strings" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/ Translation: InvokeAI/Web UI * translationBot(ui): update translation (English (United Kingdom)) Currently translated at 0.1% (3 of 2729 strings) translationBot(ui): update translation (Romanian) Currently translated at 11.8% (323 of 2724 strings) translationBot(ui): update translation (Romanian) Currently translated at 0.1% (1 of 2724 strings) Co-authored-by: Filip Mînăilă Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/en_GB/ Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/ro/ Translation: InvokeAI/Web UI --------- Co-authored-by: Dmitry Warkentin Co-authored-by: Riccardo Giovanetti Co-authored-by: Filip Mînăilă --- .../frontend/web/public/locales/en-GB.json | 8 +- invokeai/frontend/web/public/locales/it.json | 167 ++++++- invokeai/frontend/web/public/locales/ro.json | 458 +++++++++++++++++- invokeai/frontend/web/public/locales/ru.json | 111 ++++- 4 files changed, 724 insertions(+), 20 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en-GB.json b/invokeai/frontend/web/public/locales/en-GB.json index 0967ef424bc..c6bbc13e434 100644 --- a/invokeai/frontend/web/public/locales/en-GB.json +++ b/invokeai/frontend/web/public/locales/en-GB.json @@ -1 +1,7 @@ -{} +{ + "accessibility": { + "about": "About", + "createIssue": "Create Issue", + "submitSupportTicket": "Submit Support Ticket" + } +} diff --git a/invokeai/frontend/web/public/locales/it.json b/invokeai/frontend/web/public/locales/it.json index d823258dbfe..737644875c4 100644 --- a/invokeai/frontend/web/public/locales/it.json +++ b/invokeai/frontend/web/public/locales/it.json @@ -468,6 +468,14 @@ "selectLassoTool": { "title": "Strumento Lazo", "desc": "Seleziona lo strumento lazo." + }, + "mergeDown": { + "title": "Unisci livello verso il basso", + "desc": "Unisci il livello selezionato al livello immediatamente sottostante." + }, + "mergeVisible": { + "title": "Unisci tutti i livelli visibili", + "desc": "Unisci tutti i livelli visibili del tipo di livello selezionato." } }, "workflows": { @@ -788,7 +796,8 @@ "welcome": "Benvenuti in Gestione Modelli", "bundleDescription": "Ogni pacchetto include modelli essenziali per ogni famiglia di modelli e modelli base selezionati per iniziare.", "quickStart": "Pacchetti di avvio rapido", - "browseAll": "Oppure sfoglia tutti i modelli disponibili:" + "browseAll": "Oppure sfoglia tutti i modelli disponibili:", + "externalDescription": "Collega una chiave API Gemini o OpenAI per abilitare la generazione esterna. L'utilizzo potrebbe comportare costi da parte del fornitore." }, "launchpadTab": "Rampa di lancio", "installBundle": "Installa pacchetto", @@ -808,9 +817,9 @@ "reidentifySuccess": "Modello reidentificato con successo", "reidentifyUnknown": "Impossibile identificare il modello", "reidentifyError": "Errore durante la reidentificazione del modello", - "flux2KleinQwen3EncoderPlaceholder": "Dal modello principale", + "flux2KleinQwen3EncoderPlaceholder": "Dal modello diffusori", "flux2KleinQwen3Encoder": "Encoder Qwen3 (opzionale)", - "flux2KleinVaePlaceholder": "Dal modello principale", + "flux2KleinVaePlaceholder": "Dal modello diffusori", "flux2KleinVae": "VAE (opzionale)", "zImageQwen3SourcePlaceholder": "Obbligatorio se VAE/Encoder è vuoto", "zImageQwen3Source": "Modello sorgente Qwen3 e VAE", @@ -896,7 +905,49 @@ "qwenImageQuantization": "Quantizzazione dell'encoder", "qwenImageQuantizationNone": "Nessuna (bf16)", "modelPickerFallbackNoModelsInstalledNonAdmin": "Nessun modello installato. Chiedi al tuo amministratore di InvokeAI () di installare alcuni modelli.", - "noModelsInstalledAskAdmin": "Chiedi al tuo amministratore di installarne alcuni." + "noModelsInstalledAskAdmin": "Chiedi al tuo amministratore di installarne alcuni.", + "externalImageGenerator": "Generatore di immagini esterno", + "externalProviders": "Fornitori esterni", + "externalSetupTitle": "Configurazione dei fornitori esterni", + "externalSetupDescription": "Collega una chiave API per abilitare la generazione di immagini esterne. I modelli di avvio esterni vengono installati automaticamente quando viene configurato un provider.", + "externalInstallDefaults": "Modelli di avviamento ad installazione automatica", + "externalProvidersUnavailable": "In questa versione non sono supportati i provider esterni.", + "externalSetupFooter": "È necessaria una chiave API. I fornitori esterni utilizzano API remote; l'utilizzo potrebbe comportare costi a carico del fornitore.", + "externalProviderCardDescription": "Configura le credenziali {{providerId}} per la generazione di immagini esterne.", + "externalApiKey": "Chiave API", + "externalApiKeyPlaceholder": "Incolla la tua chiave API", + "externalApiKeyPlaceholderSet": "Chiave API configurata", + "externalApiKeyHelper": "Memorizzato nel file di configurazione di InvokeAI.", + "externalBaseUrl": "URL di base (facoltativo)", + "externalBaseUrlHelper": "Se necessario, sovrascrivi l'URL di base predefinito dell'API.", + "externalResetHelper": "Cancella la chiave API e l'URL di base.", + "sortByName": "Nome", + "sortBySize": "Dimensione", + "sortByDateAdded": "Data di aggiunta", + "sortByDateModified": "Data di modifica", + "sortByPath": "Percorso", + "sortByType": "Tipo", + "sortByFormat": "Formato", + "sortDefault": "Predefinito", + "externalProvider": "Fornitore esterno", + "externalCapabilities": "Capacità", + "externalDefaults": "Impostazioni predefinite", + "providerId": "ID Fornitore", + "providerModelId": "ID modello del fornitore", + "supportedModes": "Modalità supportate", + "supportsNegativePrompt": "Supporta il prompt negativo", + "supportsReferenceImages": "Supporta immagini di riferimento", + "supportsSeed": "Supporta il Seme", + "supportsGuidance": "Supporta la guida", + "maxImagesPerRequest": "Numero massimo di immagini per richiesta", + "maxReferenceImages": "Numero massimo di immagini di riferimento", + "maxImageWidth": "Larghezza massima immagine", + "flux2KleinVaeNoModelPlaceholder": "Nessun modello diffusori disponibile", + "flux2KleinQwen3EncoderNoModelPlaceholder": "Nessun modello diffusori disponibile", + "maxImageHeight": "Altezza massima dell'immagine", + "numImages": "Numero di immagini", + "textLLM": "LLM testuale", + "sourceUrl": "URL di origine" }, "parameters": { "images": "Immagini", @@ -992,7 +1043,9 @@ "noAnimaQwen3EncoderModelSelected": "Nessun modello di encoder Anima Qwen3 selezionato", "noAnimaT5EncoderModelSelected": "Nessun modello di encoder Anima T5 selezionato", "noQwenImageComponentSourceSelected": "I modelli GGUF Qwen Image richiedono una sorgente componente diffusori per VAE/encoder", - "boardNotWritable": "Non hai i permessi di scrittura per la bacheca \"{{boardName}}\". Seleziona una bacheca di tua proprietà oppure passa a Non categorizzata." + "boardNotWritable": "Non hai i permessi di scrittura per la bacheca \"{{boardName}}\". Seleziona una bacheca di tua proprietà oppure passa a Non categorizzata.", + "noFlux2KleinVaeModelSelected": "Nessun VAE selezionato. I modelli FLUX.2 Klein senza diffusori richiedono un VAE autonomo", + "noFlux2KleinQwen3EncoderModelSelected": "Nessun encoder Qwen3 selezionato. I modelli Klein FLUX.2 senza diffusori richiedono un encoder Qwen3 autonomo" }, "useCpuNoise": "Usa la CPU per generare rumore", "iterations": "Iterazioni", @@ -1031,8 +1084,10 @@ "showOptionsPanel": "Mostra pannello laterale (O o T)", "seedVarianceRandomizePercent": "Percentuale di variazione", "seedVarianceStrength": "Intensità della varianza", - "seedVarianceEnabled": "Potenziamento della varianza del seme", - "colorCompensation": "Compensazione Colore" + "seedVarianceEnabled": "Migliora varianza seme", + "colorCompensation": "Compensazione Colore", + "disabledNotSupported": "Non supportato dal modello", + "imageSize": "Dimensioni immagine" }, "settings": { "models": "Modelli", @@ -1075,7 +1130,12 @@ "modelDescriptionsDisabledDesc": "Le descrizioni dei modelli nei menu a discesa sono state disattivate. Abilitale nelle Impostazioni.", "preferAttentionStyleNumeric": "Preferisci lo stile di attenzione numerico", "maxQueueHistory": "Cronologia massima della coda", - "maxQueueHistorySaveFailed": "Impossibile salvare la cronologia della coda massima" + "maxQueueHistorySaveFailed": "Impossibile salvare la cronologia della coda massima", + "middleClickOpenInNewTab": "Utilizza il clic centrale del mouse per aprire le immagini in una nuova scheda", + "externalProviders": "Fornitori esterni", + "externalProviderConfigured": "Configurato", + "externalProviderNotConfigured": "Chiave API necessaria", + "externalProviderNotConfiguredHint": "Aggiungi la tua chiave API in Gestione Modello o nella configurazione del server per abilitare questo provider." }, "toast": { "uploadFailed": "Caricamento fallito", @@ -2164,7 +2224,15 @@ "recallParameter": "Richiama {{label}}", "seedVarianceRandomizePercent": "Casualità della varianza del seme %", "seedVarianceEnabled": "Varianza seme abilitata", - "seedVarianceStrength": "Intensità della varianza del seme" + "seedVarianceStrength": "Intensità della varianza del seme", + "geminiTemperature": "Gemini Temperatura", + "geminiThinkingLevel": "Gemini Livello di ragionamento", + "openaiQuality": "OpenAI Qualità", + "openaiInputFidelity": "OpenAI Fedeltà Input", + "imageSize": "Dimensioni immagine", + "openaiBackground": "OpenAI Sfondo", + "seedreamWatermark": "Filigrana Seedream", + "seedreamOptimizePrompt": "Seedream Ottimizza Prompt" }, "hrf": { "metadata": { @@ -2353,7 +2421,21 @@ "promptHistory": "Cronologia dei prompt", "clearHistory": "Cancella cronologia", "usePrompt": "Utilizza il prompt", - "searchPrompts": "Ricerca..." + "searchPrompts": "Ricerca...", + "imageToPrompt": "Immagine a prompt", + "selectVisionModel": "Seleziona il modello di visione...", + "changeImage": "Cambia immagine", + "uploadImage": "Carica immagine", + "generatePrompt": "Genera prompt", + "expandPromptWithLLM": "Espandi il prompt con LLM", + "expandPrompt": "Espandi il prompt", + "selectTextLLM": "Seleziona LLM testuale...", + "expand": "Espandi", + "noTextLLMInstalledTitle": "Nessun modello LLM testuale installato", + "noTextLLMInstalledDescription": "L'espansione del prompt richiede un modello linguistico causale (LLM) di tipo testuale. Consigliamo Qwen2.5-1.5B-Instruct (~3 GB): è piccolo, veloce e disponibile come modello di partenza.", + "noVisionModelInstalledTitle": "Nessun modello di visione installato", + "noVisionModelInstalledDescription": "La funzione di conversione immagine-a-prompt richiede un modello di linguaggio visivo (ad esempio LLaVA Onevision). Il pacchetto iniziale da 0,5 byte (~1 GB) è quello predefinito più leggero.", + "openModelManager": "Apri Gestione Modelli" }, "controlLayers": { "addLayer": "Aggiungi Livello", @@ -2469,7 +2551,8 @@ "brush": "Pennello", "eraser": "Cancellino", "gradient": "Gradiente", - "text": "Testo" + "text": "Testo", + "lasso": "Lazo" }, "filter": { "apply": "Applica", @@ -2967,6 +3050,29 @@ "freehand": "A mano libera", "polygon": "Poligono", "polygonHint": "Fai clic per aggiungere punti, fai clic sul primo punto per chiudere." + }, + "transparencyLocked": "Trasparenza bloccata", + "transparencyUnlocked": "Trasparenza sbloccata", + "snapshot": { + "snapshots": "Salva o carica l'istantanea della tela", + "saveSnapshot": "Salva istantanea", + "restoreSnapshot": "Ripristina istantanea", + "snapshotNamePlaceholder": "Nome dell'istantanea", + "save": "Salva", + "delete": "Elimina", + "snapshotSaved": "Istantanea \"{{name}}\" salvata", + "snapshotRestored": "Istantanea \"{{name}}\" ripristinata", + "snapshotDeleted": "Istantanea \"{{name}}\" eliminata", + "snapshotSaveFailed": "Impossibile salvare l'istantanea", + "snapshotRestoreFailed": "Impossibile ripristinare l'istantanea", + "snapshotDeleteFailed": "Impossibile eliminare l'istantanea", + "snapshotMissingImages_one": "{{count}} immagine a cui fa riferimento questa istantanea non esiste più e verrà visualizzata come segnaposto", + "snapshotMissingImages_many": "{{count}} immagini a cui fa riferimento questa istantanea non esistono più e verranno visualizzate come segnaposto", + "snapshotMissingImages_other": "{{count}} immagini a cui fa riferimento questa istantanea non esistono più e verranno visualizzate come segnaposto", + "snapshotIncompatible": "Questa istantanea è stata creata con una versione diversa e non è più compatibile", + "overwriteSnapshotTitle": "Sovrascrivere l'istantanea?", + "overwriteSnapshotMessage": "Esiste già un'istantanea denominata \"{{name}}\". Si desidera sovrascriverla?", + "overwrite": "Sovrascrivi" } }, "ui": { @@ -2980,7 +3086,8 @@ "upscaling": "Amplia", "upscalingTab": "$t(ui.tabs.upscaling) $t(common.tab)", "gallery": "Galleria", - "generate": "Genera" + "generate": "Genera", + "customNodes": "Nodi" }, "launchpad": { "workflowsTitle": "Approfondisci i flussi di lavoro.", @@ -3154,9 +3261,13 @@ "readReleaseNotes": "Leggi le note di rilascio", "watchRecentReleaseVideos": "Guarda i video su questa versione", "items": [ + "Strumenti di prompt LLM: utilizza modelli linguistici locali per espandere i prompt o generarli da immagini. Installa un modello LLM di testo (ad esempio Qwen2.5-1.5B-Instruct) per iniziare.", + "Supporto per FLUX.2 Klein: InvokeAI ora supporta i nuovi modelli FLUX.2 Klein (varianti 4B e 9B) nei formati GGUF, FP8 e Diffusers. Le funzionalità includono conversione da testo a immagine, da immagine a immagine, inpainting e outpainting. Consulta la sezione \"Modelli di base\" per iniziare.", + "Il supporto DyPE per i modelli FLUX migliora le immagini ad alta risoluzione (>1536 px fino a 4K). Vai alla sezione \"Opzioni avanzate\" per attivarlo.", + "Diversità di Z-Image Turbo: Attiva \"Migliora varianza seme\" in \"Opzioni avanzate\" per aumentare la diversità delle tue generazioni con ZiT.", "La modalità multiutente supporta più utenti isolati sullo stesso server.", "Supporto migliorato per i modelli Z-Image e FLUX.2.", - "Numerosi miglioramenti dell'interfaccia utente e nuove funzionalità Tela." + "Numerosi miglioramenti all'interfaccia utente e nuove funzionalità per la tela." ], "watchUiUpdatesOverview": "Guarda la panoramica degli aggiornamenti dell'interfaccia utente", "takeUserSurvey": "📣 Facci sapere cosa ne pensi di InvokeAI. Partecipa al nostro sondaggio sull'esperienza utente!" @@ -3303,5 +3414,35 @@ "mouseWheelZoom": "Rotellina del mouse: Zoom", "spaceDragPan": "Spazio + trascina: Panoramica", "dragCropBoxToAdjust": "Trascina il riquadro di ritaglio o le maniglie per regolare" + }, + "customNodes": { + "title": "Nodi personalizzati", + "gitUrl": "URL del repository Git", + "gitUrlLabel": "URL del repository", + "install": "Installa", + "installing": "Installazione in corso", + "installSuccess": "Pacchetto nodi installato", + "installTitle": "Installa pacchetto Nodi", + "installFailed": "Installazione non riuscita", + "installError": "Si è verificato un errore imprevisto durante l'installazione.", + "securityWarning": "I nodi personalizzati eseguono codice sul tuo sistema. Installa pacchetti di nodi solo da autori di cui ti fidi. I nodi dannosi potrebbero danneggiare il tuo sistema o compromettere i tuoi dati.", + "installDescription": "Clona il repository nella tua directory nodi. I file del flusso di lavoro (.json) vengono importati nella tua libreria. Le dipendenze Python (requirements.txt o pyproject.toml) NON vengono installate automaticamente: segui la documentazione del pacchetto node per installarle manualmente.", + "dependenciesRequiredTitle": "Installazione manuale delle dipendenze richiesta", + "dependenciesRequiredDescription": "'{{name}}' include un {{file}}. Segui la documentazione del pacchetto di nodi per installare le sue dipendenze Python prima di utilizzare i suoi nodi.", + "uninstall": "Disinstalla", + "reload": "Ricarica", + "reloading": "Ricaricamento in corso", + "noNodePacks": "Nessun pacchetto di nodi personalizzato installato.", + "scanFolder": "Scansiona la cartella", + "scanFolderDescription": "I pacchetti di nodi inseriti nella directory dei nodi vengono rilevati automaticamente all'avvio. Utilizzare il pulsante Ricarica per rilevare i pacchetti appena aggiunti senza riavviare il programma.", + "nodesDirectory": "Cartella nodi", + "installQueue": "Registro di installazione", + "queueEmpty": "Nessuna attività di installazione recente.", + "name": "Nome", + "message": "Messaggio", + "nodeCount_one": "{{count}} nodo", + "nodeCount_many": "{{count}} nodi", + "nodeCount_other": "{{count}} nodi", + "uninstalled": "Disinstallato" } } diff --git a/invokeai/frontend/web/public/locales/ro.json b/invokeai/frontend/web/public/locales/ro.json index 0967ef424bc..9fb4068a93f 100644 --- a/invokeai/frontend/web/public/locales/ro.json +++ b/invokeai/frontend/web/public/locales/ro.json @@ -1 +1,457 @@ -{} +{ + "accessibility": { + "about": "Despre", + "reset": "Resetează", + "menu": "Meniu", + "mode": "Mod" + }, + "common": { + "hotkeysLabel": "Scurtături", + "languagePickerLabel": "Limbă", + "githubLabel": "Github", + "discordLabel": "Discord", + "settingsLabel": "Setări", + "nodes": "Workflow-uri", + "upload": "Încarcă", + "load": "Încarcă", + "back": "Înapoi", + "statusDisconnected": "Deconectat", + "loading": "Se încarcă", + "cancel": "Anulează", + "accept": "Acceptă", + "linear": "Linear", + "random": "Random", + "communityLabel": "Comunitate", + "advanced": "Avansat", + "controlNet": "ControlNet", + "auto": "Auto", + "on": "Pornit", + "checkpoint": "Checkpoint", + "data": "Date", + "details": "Detalii", + "inpaint": "inpaint", + "outpaint": "outpaint", + "outputs": "Outputs", + "safetensors": "Safetensors", + "simple": "Simplu", + "template": "Șablon", + "ai": "ai", + "error": "Eroare", + "file": "Fișier", + "folder": "Folder", + "format": "format", + "input": "Input", + "installed": "Instalat", + "unknown": "Necunoscut", + "delete": "Șterge", + "direction": "Direcție", + "save": "Salvează", + "updated": "Actualizat", + "created": "Creat", + "or": "sau", + "red": "Roșu", + "green": "Verde", + "blue": "Albastru", + "alpha": "Alpha", + "copy": "Copiază", + "add": "Adaugă", + "beta": "Beta", + "selected": "Selectat", + "editor": "Editor", + "tab": "Filă", + "enabled": "Activat", + "disabled": "Dezactivat", + "apply": "Aplică", + "view": "Vizualizează", + "edit": "Editează", + "off": "Oprit", + "reset": "Resetează", + "none": "Niciunul", + "new": "Nou" + }, + "modelManager": { + "model": "Model", + "manual": "Manual", + "name": "Nume", + "description": "Descriere", + "config": "Configurare", + "width": "Lățime", + "height": "Înălțime", + "search": "Caută", + "load": "Încarcă", + "active": "activ", + "selected": "Selectat", + "delete": "Șterge", + "convert": "Convertește", + "alpha": "Alpha", + "none": "niciunul", + "vae": "VAE", + "variant": "Variantă", + "settings": "Setări", + "advanced": "Avansat", + "cancel": "Anulează", + "edit": "Editează", + "path": "Path", + "prune": "Taie", + "source": "Sursă", + "metadata": "Metadata", + "huggingFace": "HuggingFace", + "huggingFacePlaceholder": "autor/nume-model", + "install": "Instalează", + "loraModels": "LoRAs", + "main": "Main" + }, + "parameters": { + "general": "General", + "images": "Imagini", + "steps": "Pași", + "width": "Lățime", + "height": "Înălțime", + "seed": "Seed", + "type": "Tip", + "strength": "Putere", + "upscaling": "Upscaling", + "scale": "Scale", + "symmetry": "Simetrie", + "info": "Informații", + "scheduler": "Planificator", + "coherenceMode": "Mod", + "patchmatchDownScaleSize": "Downscale", + "cancel": { + "cancel": "Anulează" + }, + "invoke": { + "invoke": "Invocă" + }, + "iterations": "Iterații", + "aspect": "Aspect" + }, + "settings": { + "models": "Modele", + "developer": "Developer", + "general": "General", + "generation": "Generare", + "beta": "Beta" + }, + "boards": { + "cancel": "Anulează", + "loading": "Se încarcă...", + "move": "Mută", + "uncategorized": "Necategorizat", + "archived": "Arhivat", + "boards": "Boards" + }, + "gallery": { + "copy": "Copiază", + "download": "Descarcă", + "loading": "Se încarcă", + "drop": "Lasă", + "image": "imagine", + "starImage": "Adaugă la favorite", + "unstarImage": "Elimină de la favorite", + "slider": "Slider", + "sideBySide": "Side-by-Side", + "hover": "Hover", + "go": "Du-te", + "gallery": "Galerie" + }, + "metadata": { + "height": "Înălțime", + "metadata": "Metadata", + "model": "Model", + "scheduler": "Planificator", + "seed": "Seed", + "steps": "Pași", + "width": "Lățime", + "workflow": "Workflow", + "vae": "VAE", + "cfgRescaleMultiplier": "$t(parameters.cfgRescaleMultiplier)" + }, + "models": { + "loading": "se încarcă", + "lora": "LoRA", + "concepts": "Concepte" + }, + "nodes": { + "notes": "Note", + "workflow": "Workflow", + "workflowAuthor": "Autor", + "workflowContact": "Contact", + "workflowName": "Nume", + "workflowNotes": "Note", + "workflowTags": "Etichete", + "workflowVersion": "Versiune", + "executionStateError": "Eroare", + "executionStateCompleted": "Completat", + "version": "Versiune", + "boolean": "Booleani", + "collection": "Colecție", + "edge": "Muchie", + "enum": "Enum", + "float": "Float", + "integer": "Integer", + "node": "Nod", + "scheduler": "Planificator", + "string": "String", + "ipAdapter": "IP-Adapter", + "edit": "Editează", + "graph": "Graf" + }, + "sdxl": { + "loading": "Se încarcă...", + "refiner": "Refiner", + "scheduler": "Planificator", + "steps": "Pași" + }, + "queue": { + "queue": "Coadă", + "resume": "Reia", + "pause": "Întrerupe", + "cancel": "Anulează", + "prune": "Taie", + "clear": "Golește", + "current": "Curent", + "next": "Următorul", + "status": "Status", + "total": "Total", + "pending": "În așteptare", + "completed": "Completat", + "failed": "Eșuat", + "canceled": "Anulat", + "batch": "Lot", + "item": "Item", + "session": "Sesiune", + "front": "față", + "back": "spate", + "time": "Timp", + "origin": "Origine", + "destination": "Destinație", + "upscaling": "Upscaling", + "canvas": "Canvas", + "generation": "Generare", + "workflows": "Workflows", + "other": "Altele", + "gallery": "Galerie" + }, + "popovers": { + "compositingCoherenceMode": { + "heading": "Mod" + }, + "controlNetWeight": { + "heading": "Weight" + }, + "lora": { + "heading": "LoRA" + }, + "paramModel": { + "heading": "Model" + }, + "paramScheduler": { + "heading": "Planificator" + }, + "paramSeed": { + "heading": "Seed" + }, + "paramSteps": { + "heading": "Pași" + }, + "paramVAE": { + "heading": "VAE" + }, + "paramIterations": { + "heading": "Iterații" + }, + "controlNet": { + "heading": "ControlNet" + }, + "controlNetProcessor": { + "heading": "Procesator" + }, + "loraWeight": { + "heading": "Weight" + }, + "paramAspect": { + "heading": "Aspect" + }, + "paramHeight": { + "heading": "Înălțime" + }, + "paramWidth": { + "heading": "Lățime" + }, + "patchmatchDownScaleSize": { + "heading": "Downscale" + }, + "refinerScheduler": { + "heading": "Planificator" + }, + "refinerSteps": { + "heading": "Pași" + }, + "ipAdapterMethod": { + "heading": "Mod" + }, + "scale": { + "heading": "Scale" + }, + "creativity": { + "heading": "Creativitate" + }, + "structure": { + "heading": "Structură" + } + }, + "invocationCache": { + "clear": "Golește", + "enable": "Activează", + "disable": "Dezactivează" + }, + "workflows": { + "workflows": "Workflows", + "ascending": "În ordine crescătoare", + "created": "Creat", + "descending": "În ordine descrescătoare", + "opened": "Deschis", + "updated": "Actualizat", + "name": "Nume" + }, + "accordions": { + "generation": { + "title": "Generare" + }, + "image": { + "title": "Imagine" + }, + "advanced": { + "title": "Avansat" + }, + "control": { + "title": "Control" + }, + "compositing": { + "title": "Se compune", + "infillTab": "Infill" + } + }, + "toast": { + "parameters": "Parametri" + }, + "controlLayers": { + "rectangle": "Dreptunghi", + "opacity": "Opacitate", + "duplicate": "Duplică", + "width": "Lățime", + "transparency": "Transparență", + "locked": "Blocat", + "unlocked": "Deblocat", + "fill": { + "solid": "Solid", + "grid": "Grid", + "crosshatch": "Crosshatch", + "vertical": "Vertical", + "horizontal": "Orizontal", + "diagonal": "Diagonal" + }, + "tool": { + "brush": "Pensulă", + "eraser": "Radieră", + "rectangle": "Dreptunghi", + "bbox": "Bbox", + "move": "Mută", + "view": "Vizualizează" + }, + "filter": { + "filter": "Filtrează", + "filters": "Filtre", + "apply": "Aplică", + "cancel": "Anulează", + "reset": "Resetare", + "process": "Procesează", + "spandrel_filter": { + "model": "Model" + }, + "depth_anything_depth_estimation": { + "model_size_small": "Mică", + "model_size_base": "Bază", + "model_size_large": "Mare" + }, + "hed_edge_detection": { + "scribble": "Scribble" + }, + "lineart_edge_detection": { + "coarse": "Coarse" + }, + "pidi_edge_detection": { + "scribble": "Scribble" + } + }, + "transform": { + "transform": "Transformă", + "reset": "Resetează", + "apply": "Aplică", + "cancel": "Anulează" + }, + "settings": { + "snapToGrid": { + "off": "Oprit", + "on": "Pornit" + } + }, + "HUD": { + "bbox": "Bbox" + }, + "canvas": "Canvas", + "regional": "Regional", + "global": "Global", + "prompt": "Prompt", + "weight": "Weight" + }, + "ui": { + "tabs": { + "canvas": "Canvas", + "workflows": "Workflows", + "models": "Modele", + "queue": "Coadă", + "upscaling": "Upscaling", + "gallery": "Galerie" + } + }, + "upscaling": { + "creativity": "Creativitate", + "structure": "Structură", + "scale": "Scale", + "upscale": "Upscale" + }, + "stylePresets": { + "active": "Activ", + "name": "Nume", + "preview": "Previzualizare", + "private": "Privat", + "shared": "Partajat", + "type": "Tip", + "nameColumn": "'nume'", + "negativePromptColumn": "'negative_prompt'" + }, + "system": { + "logLevel": { + "trace": "Trace", + "debug": "Debug", + "info": "Informații", + "warn": "Avertizează", + "error": "Eroare", + "fatal": "Fatal" + }, + "logNamespaces": { + "gallery": "Galerie", + "models": "Modele", + "config": "Configurare", + "canvas": "Canvas", + "generation": "Generare", + "workflows": "Workflows", + "system": "Sistem", + "events": "Evenimente", + "queue": "Coadă", + "metadata": "Metadata" + } + } +} diff --git a/invokeai/frontend/web/public/locales/ru.json b/invokeai/frontend/web/public/locales/ru.json index fd9c0875cec..279b1b0eabe 100644 --- a/invokeai/frontend/web/public/locales/ru.json +++ b/invokeai/frontend/web/public/locales/ru.json @@ -27,7 +27,7 @@ "modelManager": "Менеджер моделей", "controlNet": "ControlNet", "advanced": "Расширенные", - "t2iAdapter": "T2I Adapter", + "t2iAdapter": "T2I адаптер", "checkpoint": "Checkpoint", "format": "Формат", "unknown": "Неизвестно", @@ -39,7 +39,7 @@ "created": "Создано", "error": "Ошибка", "simple": "Простой", - "ipAdapter": "IP Adapter", + "ipAdapter": "IP адаптер", "installed": "Установлено", "ai": "ИИ", "auto": "Авто", @@ -98,7 +98,8 @@ "model_withCount_many": "{{count}} Моделей", "options_withCount_one": "{{count}} Опция", "options_withCount_few": "{{count}} Опции", - "options_withCount_many": "{{count}} Опций" + "options_withCount_many": "{{count}} Опций", + "crop": "Обрезать" }, "gallery": { "galleryImageSize": "Размер изображений", @@ -962,7 +963,7 @@ "pause": "Пауза", "resume": "Возобновить", "restartFailed": "Ошибка перезапуска", - "restartFile": "Перезапустить загрузку", + "restartFile": "Перезапустить файл", "restartRequired": "Требуется перезапуск", "resumeRefused": "Сервер отклонил попытку возобновления. Требуется перезапуск.", "uncategorizedImages": "Без категории", @@ -970,7 +971,11 @@ "deletedImagesCannotBeRestored": "Удалённые изображения нельзя восстановить.", "hideBoards": "Скрыть коллекции", "locateInGalery": "Показать в галерее", - "viewBoards": "Просмотреть коллекции" + "viewBoards": "Просмотреть коллекции", + "setBoardVisibility": "Установить видимость коллекции", + "setVisibilityPrivate": "Сделать приватной", + "setVisibilityPublic": "Сделать публичной", + "updateBoardVisibilityError": "Ошибка изменения видимости коллекции" }, "dynamicPrompts": { "seedBehaviour": { @@ -2010,5 +2015,101 @@ "newUserExperience": { "toGetStarted": "Чтобы начать работу, введите в поле запрос и нажмите Invoke, чтобы сгенерировать первое изображение. Выберите шаблон запроса, чтобы улучшить результаты. Вы можете сохранить изображения непосредственно в Галерею или отредактировать их на Холсте.", "gettingStartedSeries": "Хотите получить больше рекомендаций? Ознакомьтесь с нашей серией Getting Started Series для получения советов по раскрытию всего потенциала Invoke Studio." + }, + "auth": { + "login": { + "title": "Войти в InvokeAI", + "password": "Пароль", + "passwordPlaceholder": "Пароль", + "email": "Почта", + "emailPlaceholder": "Почта", + "rememberMe": "Запомнить на 7 дней", + "signIn": "Войти", + "signingIn": "Вход...", + "loginFailed": "Ошибка логина. Проверьте введенные данные.", + "sessionExpired": "Срок действия учетных данных истек. Войдите в систему заново, чтобы продолжить." + }, + "setup": { + "title": "Добро пожаловать в InvokeAI", + "subtitle": "Чтобы начать, настройте главную учетную запись", + "email": "Почта", + "emailPlaceholder": "admin@example.com", + "emailHelper": "Это будет вашим логином для входа", + "displayName": "Отображаемое имя", + "displayNamePlaceholder": "Администратор", + "displayNameHelper": "Ваше имя, как оно будет отображаться в приложении", + "password": "Пароль", + "passwordPlaceholder": "Пароль", + "passwordHelper": "Должно быть не менее 8 символов, включая заглавные и строчные буквы, а также цифры", + "passwordTooShort": "Пароль должен содержать хотя бы 8 символов", + "passwordMissingRequirements": "Пароль должен содержать заглавные и строчные буквы, а также цифры", + "confirmPassword": "Подтвердите пароль", + "confirmPasswordPlaceholder": "Подтвердите пароль", + "passwordsDoNotMatch": "Пароли не сходятся", + "createAccount": "Создать главный аккаунт", + "creatingAccount": "Настройка...", + "setupFailed": "Ошибка настройки. Пожалуйста, попробуйте ещё раз.", + "passwordHelperRelaxed": "Введите любой пароль (отобразится сложность)" + }, + "userMenu": "Меню", + "admin": "Администратор", + "logout": "Выйти", + "adminOnlyFeature": "Эта функция доступна только администраторам.", + "profile": { + "menuItem": "Мой профиль", + "title": "Мой профиль", + "email": "Почта", + "emailReadOnly": "Почта не может быть изменена", + "displayName": "Отображаемое имя", + "displayNamePlaceholder": "Ваше имя", + "changePassword": "Изменить пароль", + "currentPassword": "Текущий пароль", + "currentPasswordPlaceholder": "Текущий пароль", + "newPassword": "Новый пароль", + "newPasswordPlaceholder": "Новый пароль", + "confirmPassword": "Подтвердите новый пароль", + "confirmPasswordPlaceholder": "Подтвердите новый пароль", + "passwordsDoNotMatch": "Пароли не сходятся", + "saveSuccess": "Профиль успешно обновлен", + "saveFailed": "Ошибка сохранения профиля. Пожалуйста, попробуйте снова." + }, + "userManagement": { + "menuItem": "Управление пользователями", + "title": "Управление пользователями", + "email": "Почта", + "emailPlaceholder": "user@example.com", + "displayName": "Отображаемое имя", + "displayNamePlaceholder": "Отображаемое имя", + "password": "Пароль", + "passwordPlaceholder": "Пароль", + "newPassword": "Новый пароль", + "newPasswordPlaceholder": "Оставьте пустым, чтобы не менять пароль", + "role": "Роль", + "status": "Статус", + "actions": "Действия", + "isAdmin": "Администратор", + "user": "Пользователь", + "you": "Вы", + "createUser": "Создать пользователя", + "editUser": "Изменить пользователя", + "deleteUser": "Удалить пользователя", + "deleteConfirm": "Вы точно хотите удалить \"{{name}}\"? Это необратимое действие.", + "generatePassword": "Сгенерировать сильный пароль", + "showPassword": "Показать пароль", + "hidePassword": "Скрыть пароль", + "activate": "Включить", + "deactivate": "Отключить", + "saveFailed": "Не получилось сохранить пользователя. Пожалуйста, попробуйте ещё раз.", + "deleteFailed": "Не получилось удалить пользователя. Пожалуйста, попробуйте ещё раз.", + "loadFailed": "Не получилось загрузить пользователей.", + "back": "Назад", + "cannotDeleteSelf": "Вы не можете удалить свой аккаунт", + "cannotDeactivateSelf": "Вы не можете отключить свой аккаунт" + }, + "passwordStrength": { + "weak": "Слабый пароль", + "moderate": "Средний пароль", + "strong": "Сложный пароль" + } } } From aaa379b1c2a9e185bd78edb120bb05eefb91de7e Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sat, 9 May 2026 11:18:59 -0400 Subject: [PATCH 7/7] fix(session_queue): restore user_pending/user_in_progress computation lost in merge The merge of main into this branch combined two conflicting refactors of get_queue_status: the branch added per-user user_pending/user_in_progress fields while main introduced acting_user_id for redaction. The merge kept the new structure plus the references in the return statement, but lost the lines that compute those variables, leaving user_counts_result populated but unused and raising NameError on every dequeue. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/services/session_queue/session_queue_sqlite.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py index 094272a213c..c1bb71e0b74 100644 --- a/invokeai/app/services/session_queue/session_queue_sqlite.py +++ b/invokeai/app/services/session_queue/session_queue_sqlite.py @@ -926,6 +926,13 @@ def get_queue_status( total = sum(row[1] or 0 for row in counts_result) counts: dict[str, int] = {row[0]: row[1] for row in counts_result} + user_pending: Optional[int] = None + user_in_progress: Optional[int] = None + if user_id is not None: + user_counts: dict[str, int] = {row[0]: row[1] for row in user_counts_result} + user_pending = user_counts.get("pending", 0) + user_in_progress = user_counts.get("in_progress", 0) + # Redaction is decided from the same current_item snapshot used to embed identifiers, # so a concurrent transition (e.g. B finishing while A's status changes) cannot leave # stale identifiers in the result. user_id (count filter) and acting_user_id