mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
Merge branch 'main' into lstein/recall-reference-images
This commit is contained in:
28
README.md
28
README.md
@@ -58,15 +58,39 @@ Invoke offers a fully featured workflow management solution, enabling users to c
|
||||
|
||||
Invoke features an organized gallery system for easily storing, accessing, and remixing your content in the Invoke workspace. Images can be dragged/dropped onto any Image-base UI element in the application, and rich metadata within the Image allows for easy recall of key prompts or settings used in your workflow.
|
||||
|
||||
### Model Support
|
||||
- SD 1.5
|
||||
- SD 2.0
|
||||
- SDXL
|
||||
- SD 3.5 Medium
|
||||
- SD 3.5 Large
|
||||
- CogView 4
|
||||
- Flux.1 Dev
|
||||
- Flux.1 Schnell
|
||||
- Flux.1 Kontext
|
||||
- Flux.1 Krea
|
||||
- Flux Redux
|
||||
- Flux Fill
|
||||
- Flux.2 Klein 4B
|
||||
- Flux.2 Klein 9B
|
||||
- Z-Image Turbo
|
||||
- Z-Image Base
|
||||
- Anima
|
||||
- Qwen Image
|
||||
- Qwen Image Edit
|
||||
- Nano Banana (API Only)
|
||||
- GPT Image (API Only)
|
||||
- Wan (API Only)
|
||||
|
||||
### Other features
|
||||
|
||||
- Support for both ckpt and diffusers models
|
||||
- SD1.5, SD2.0, SDXL, and FLUX support
|
||||
- Support for ckpt, diffusers, and some gguf models
|
||||
- Upscaling Tools
|
||||
- Embedding Manager & Support
|
||||
- Model Manager & Support
|
||||
- Workflow creation & management
|
||||
- Node-Based Architecture
|
||||
- Object Segmentation & Selection Models (SAM / SAM2)
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -140,13 +140,13 @@ As a regular user, you can:
|
||||
- ✅ View your own generation queue
|
||||
- ✅ Customize your UI preferences (theme, hotkeys, etc.)
|
||||
- ✅ View available models (read-only access to Model Manager)
|
||||
- ✅ Access shared boards (based on permissions granted to you) (FUTURE FEATURE)
|
||||
- ✅ Access workflows marked as public (FUTURE FEATURE)
|
||||
- ✅ View shared and public boards created by other users
|
||||
- ✅ View and use workflows marked as shared by other users
|
||||
|
||||
You cannot:
|
||||
|
||||
- ❌ Add, delete, or modify models
|
||||
- ❌ View or modify other users' boards, images, or workflows
|
||||
- ❌ View or modify other users' private boards, images, or workflows
|
||||
- ❌ Manage user accounts
|
||||
- ❌ Access system configuration
|
||||
- ❌ View or cancel other users' generation tasks
|
||||
@@ -173,7 +173,7 @@ Administrators have all regular user capabilities, plus:
|
||||
- ✅ Full model management (add, delete, configure models)
|
||||
- ✅ Create and manage user accounts
|
||||
- ✅ View and manage all users' generation queues
|
||||
- ✅ Create and manage shared boards (FUTURE FEATURE)
|
||||
- ✅ View and manage all users' boards, images, and workflows (including system-owned legacy content)
|
||||
- ✅ Access system configuration
|
||||
- ✅ Grant or revoke admin privileges
|
||||
|
||||
@@ -183,23 +183,30 @@ Administrators have all regular user capabilities, plus:
|
||||
|
||||
### Image Boards
|
||||
|
||||
In multi-user model, Image Boards work as before. Each user can create an unlimited number of boards and organize their images and assets as they see fit. Boards are private: you cannot see a board owned by a different user.
|
||||
In multi-user mode, each user can create an unlimited number of boards and organize their images and assets as they see fit. Boards have three visibility levels:
|
||||
|
||||
!!! tip "Shared Boards"
|
||||
InvokeAI 6.13 will add support for creating public boards that are accessible to all users.
|
||||
- **Private** (default): Only you (and administrators) can see and modify the board.
|
||||
- **Shared**: All users can view the board and its contents, but only you (and administrators) can modify it (rename, archive, delete, or add/remove images).
|
||||
- **Public**: All users can view the board. Only you (and administrators) can modify the board's structure (rename, archive, delete).
|
||||
|
||||
The Administrator can see all users Image Boards and their contents.
|
||||
To change a board's visibility, right-click on the board and select the desired visibility option.
|
||||
|
||||
### Going From Multi-User to Single-User mode
|
||||
Administrators can see and manage all users' image boards and their contents regardless of visibility settings.
|
||||
|
||||
### Going From Multi-User to Single-User Mode
|
||||
|
||||
If an InvokeAI instance was in multiuser mode and then restarted in single user mode (by setting `multiuser: false` in the configuration file), all users' boards will be consolidated in one place. Any images that were in "Uncategorized" will be merged together into a single Uncategorized board. If, at a later date, the server is restarted in multi-user mode, the boards and images will be separated and restored to their owners.
|
||||
|
||||
### Workflows
|
||||
|
||||
In the current released version (6.12) workflows are always shared among users. Any workflow that you create will be visible to other users and vice-versa, and there is no protection against one user modifying another user's workflow.
|
||||
Each user has their own private workflow library. Workflows you create are visible only to you by default.
|
||||
|
||||
!!! tip "Private and Shared Workflows"
|
||||
InvokeAI 6.13 will provide the ability to create private and shared workflows. A private workflow can only be viewed by the user who created it. At any time, however, the user can designate the workflow *shared*, in which case it can be opened on a read-only basis by all logged-in users.
|
||||
You can share a workflow with other users by marking it as **shared** (public). Shared workflows appear in all users' workflow libraries and can be opened by anyone, but only the owner (or an administrator) can modify or delete them.
|
||||
|
||||
To share a workflow, open it and use the sharing controls to toggle its public/shared status.
|
||||
|
||||
!!! warning "Preexisting workflows after enabling multi-user mode"
|
||||
When you enable multi-user mode for the first time on an existing InvokeAI installation, all workflows that were created before multi-user mode was activated will appear in the **shared workflows** section. These preexisting workflows are owned by the internal "system" account and are visible to all users. Administrators can edit or delete these shared legacy workflows. Regular users can view and use them but cannot modify them.
|
||||
|
||||
|
||||
### The Generation Queue
|
||||
@@ -330,11 +337,11 @@ These settings are stored per-user and won't affect other users.
|
||||
|
||||
### Can other users see my images?
|
||||
|
||||
No, unless you add them to a shared board (FUTURE FEATURE). All your personal boards and images are private.
|
||||
Not unless you change your board's visibility to "shared" or "public". All personal boards and images are private by default.
|
||||
|
||||
### Can I share my workflows with others?
|
||||
|
||||
Not directly. Ask your administrator to mark workflows as public if you want to share them.
|
||||
Yes. You can mark any workflow as shared (public), which makes it visible to all users. Other users can view and use shared workflows, but only you or an administrator can modify or delete them.
|
||||
|
||||
### How long do sessions last?
|
||||
|
||||
|
||||
@@ -117,7 +117,13 @@ async def create_workflow(
|
||||
workflow: WorkflowWithoutID = Body(description="The workflow to create", embed=True),
|
||||
) -> WorkflowRecordDTO:
|
||||
"""Creates a workflow"""
|
||||
return ApiDependencies.invoker.services.workflow_records.create(workflow=workflow, user_id=current_user.user_id)
|
||||
# In single-user mode, workflows are owned by 'system' and shared by default so all legacy/single-user
|
||||
# workflows remain visible. In multiuser mode, workflows are private to the creator by default.
|
||||
config = ApiDependencies.invoker.services.configuration
|
||||
is_public = not config.multiuser
|
||||
return ApiDependencies.invoker.services.workflow_records.create(
|
||||
workflow=workflow, user_id=current_user.user_id, is_public=is_public
|
||||
)
|
||||
|
||||
|
||||
@workflows_router.get(
|
||||
@@ -144,10 +150,10 @@ async def list_workflows(
|
||||
"""Gets a page of workflows"""
|
||||
config = ApiDependencies.invoker.services.configuration
|
||||
|
||||
# In multiuser mode, scope user-category workflows to the current user unless fetching shared workflows
|
||||
# In multiuser mode, scope user-category workflows to the current user unless fetching shared workflows.
|
||||
# Admins skip the user_id filter so they can see and manage all workflows including system-owned ones.
|
||||
user_id_filter: Optional[str] = None
|
||||
if config.multiuser:
|
||||
# Only filter 'user' category results by user_id when not explicitly listing public workflows
|
||||
if config.multiuser and not current_user.is_admin:
|
||||
has_user_category = not categories or WorkflowCategory.User in categories
|
||||
if has_user_category and is_public is not True:
|
||||
user_id_filter = current_user.user_id
|
||||
@@ -320,7 +326,7 @@ async def get_all_tags(
|
||||
"""Gets all unique tags from workflows"""
|
||||
config = ApiDependencies.invoker.services.configuration
|
||||
user_id_filter: Optional[str] = None
|
||||
if config.multiuser:
|
||||
if config.multiuser and not current_user.is_admin:
|
||||
has_user_category = not categories or WorkflowCategory.User in categories
|
||||
if has_user_category and is_public is not True:
|
||||
user_id_filter = current_user.user_id
|
||||
@@ -341,7 +347,7 @@ async def get_counts_by_tag(
|
||||
"""Counts workflows by tag"""
|
||||
config = ApiDependencies.invoker.services.configuration
|
||||
user_id_filter: Optional[str] = None
|
||||
if config.multiuser:
|
||||
if config.multiuser and not current_user.is_admin:
|
||||
has_user_category = not categories or WorkflowCategory.User in categories
|
||||
if has_user_category and is_public is not True:
|
||||
user_id_filter = current_user.user_id
|
||||
@@ -361,7 +367,7 @@ async def counts_by_category(
|
||||
"""Counts workflows by category"""
|
||||
config = ApiDependencies.invoker.services.configuration
|
||||
user_id_filter: Optional[str] = None
|
||||
if config.multiuser:
|
||||
if config.multiuser and not current_user.is_admin:
|
||||
has_user_category = WorkflowCategory.User in categories
|
||||
if has_user_category and is_public is not True:
|
||||
user_id_filter = current_user.user_id
|
||||
|
||||
@@ -29,6 +29,9 @@ class Migration28Callback:
|
||||
if "is_public" not in columns:
|
||||
cursor.execute("ALTER TABLE workflow_library ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflow_library_is_public ON workflow_library(is_public);")
|
||||
cursor.execute(
|
||||
"UPDATE workflow_library SET is_public = TRUE WHERE user_id = 'system';"
|
||||
) # one-time fix for legacy workflows
|
||||
|
||||
|
||||
def build_migration_28() -> Migration:
|
||||
|
||||
@@ -23,7 +23,12 @@ class WorkflowRecordsStorageBase(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create(self, workflow: WorkflowWithoutID, user_id: str = WORKFLOW_LIBRARY_DEFAULT_USER_ID) -> WorkflowRecordDTO:
|
||||
def create(
|
||||
self,
|
||||
workflow: WorkflowWithoutID,
|
||||
user_id: str = WORKFLOW_LIBRARY_DEFAULT_USER_ID,
|
||||
is_public: bool = False,
|
||||
) -> WorkflowRecordDTO:
|
||||
"""Creates a workflow."""
|
||||
pass
|
||||
|
||||
|
||||
@@ -48,7 +48,12 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
|
||||
raise WorkflowNotFoundError(f"Workflow with id {workflow_id} not found")
|
||||
return WorkflowRecordDTO.from_dict(dict(row))
|
||||
|
||||
def create(self, workflow: WorkflowWithoutID, user_id: str = WORKFLOW_LIBRARY_DEFAULT_USER_ID) -> WorkflowRecordDTO:
|
||||
def create(
|
||||
self,
|
||||
workflow: WorkflowWithoutID,
|
||||
user_id: str = WORKFLOW_LIBRARY_DEFAULT_USER_ID,
|
||||
is_public: bool = False,
|
||||
) -> WorkflowRecordDTO:
|
||||
if workflow.meta.category is WorkflowCategory.Default:
|
||||
raise ValueError("Default workflows cannot be created via this method")
|
||||
|
||||
@@ -59,11 +64,12 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
|
||||
INSERT OR IGNORE INTO workflow_library (
|
||||
workflow_id,
|
||||
workflow,
|
||||
user_id
|
||||
user_id,
|
||||
is_public
|
||||
)
|
||||
VALUES (?, ?, ?);
|
||||
VALUES (?, ?, ?, ?);
|
||||
""",
|
||||
(workflow_with_id.id, workflow_with_id.model_dump_json(), user_id),
|
||||
(workflow_with_id.id, workflow_with_id.model_dump_json(), user_id, is_public),
|
||||
)
|
||||
return self.get(workflow_with_id.id)
|
||||
|
||||
|
||||
@@ -132,7 +132,21 @@
|
||||
"prevPage": "Pagina precedente",
|
||||
"nextPage": "Pagina successiva",
|
||||
"resetToDefaults": "Ripristina impostazioni predefinite",
|
||||
"crop": "Ritaglia"
|
||||
"crop": "Ritaglia",
|
||||
"editName": "Modifica nome",
|
||||
"fitView": "Adatta la vista",
|
||||
"minimize": "Minimizza",
|
||||
"next": "Prossimo",
|
||||
"noMatchingItems": "Nessun articolo corrispondente",
|
||||
"notifications": "Notifiche",
|
||||
"previous": "Precedente",
|
||||
"removeFromCollection": "Rimuovi dalla raccolta",
|
||||
"resetView": "Ripristina la vista",
|
||||
"saveToAssets": "Salva nelle risorse",
|
||||
"settings": "Impostazioni",
|
||||
"toggleRgbHex": "Attiva/disattiva RGB/HEX",
|
||||
"unpin": "Sblocca",
|
||||
"openSlider": "Apri il cursore"
|
||||
},
|
||||
"gallery": {
|
||||
"galleryImageSize": "Dimensione dell'immagine",
|
||||
@@ -203,7 +217,12 @@
|
||||
"selectAnImageToCompare": "Seleziona un'immagine da confrontare",
|
||||
"openViewer": "Apri Visualizzatore",
|
||||
"closeViewer": "Chiudi Visualizzatore",
|
||||
"usePagedGalleryView": "Utilizza la visualizzazione Galleria a pagine"
|
||||
"usePagedGalleryView": "Utilizza la visualizzazione Galleria a pagine",
|
||||
"loadingGallery": "Caricamento galleria in corso...",
|
||||
"loadingMetadata": "Caricamento dei metadati in corso...",
|
||||
"noImagesFound": "Nessuna immagine trovata",
|
||||
"bulkDownloadReady": "Download pronto",
|
||||
"clickToDownload": "Clicca qui per scaricare"
|
||||
},
|
||||
"hotkeys": {
|
||||
"searchHotkeys": "Cerca tasti di scelta rapida",
|
||||
@@ -853,7 +872,23 @@
|
||||
"someModelsFailedToReidentify": "Non è stato possibile re-identificare {{count}} modello(i)",
|
||||
"modelsReidentifiedPartial": "Completato parzialmente",
|
||||
"someModelsReidentified": "{{succeeded}} re-identificato(i), {{failed}} fallito(i)",
|
||||
"modelsReidentifyError": "Errore nella re-identificazione dei modelli"
|
||||
"modelsReidentifyError": "Errore nella re-identificazione dei modelli",
|
||||
"deleteModelsConfirm": "Sei sicuro di voler eliminare {{count}} modello(i)? Questa azione non può essere annullata.",
|
||||
"deleteWarning": "I modelli presenti nella cartella dei modelli di Invoke verranno eliminati definitivamente dal disco.",
|
||||
"modelsDeleted": "{{count}} modello(i) eliminato(i) con successo",
|
||||
"modelsDeleteFailed": "Impossibile eliminare i modelli",
|
||||
"someModelsFailedToDelete": "Non è stato possibile eliminare {{count}} modello(i)",
|
||||
"modelsDeletedPartial": "Parzialmente completato",
|
||||
"someModelsDeleted": "{{deleted}} eliminato(i), {{failed}} fallito(i)",
|
||||
"modelsDeleteError": "Errore durante l'eliminazione dei modelli",
|
||||
"queueEmpty": "La coda di installazione è vuota.",
|
||||
"animaVaePlaceholder": "Seleziona VAE compatibile con Anima",
|
||||
"animaQwen3EncoderPlaceholder": "Seleziona l'encoder Qwen3 0.6B",
|
||||
"animaT5EncoderPlaceholder": "Seleziona l'encoder T5-XXL",
|
||||
"qwenImageComponentSourcePlaceholder": "Necessario per i modelli GGUF",
|
||||
"qwenImageComponentSource": "VAE/Sorgente Encoder (Diffusori)",
|
||||
"qwenImageQuantization": "Quantizzazione dell'encoder",
|
||||
"qwenImageQuantizationNone": "Nessuna (bf16)"
|
||||
},
|
||||
"parameters": {
|
||||
"images": "Immagini",
|
||||
@@ -944,7 +979,11 @@
|
||||
"fluxModelIncompatibleScaledBboxHeight": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), l'altezza ridimensionata del riquadro è {{height}}",
|
||||
"noZImageQwen3EncoderSourceSelected": "Nessuna sorgente Qwen3 Encoder: seleziona il modello Qwen3 Encoder o Qwen3 Source",
|
||||
"noZImageVaeSourceSelected": "Nessuna sorgente VAE: selezionare il modello di sorgente VAE (FLUX) o Qwen3",
|
||||
"noQwen3EncoderModelSelected": "Nessun modello di encoder Qwen3 selezionato per la generazione Klein di FLUX2"
|
||||
"noQwen3EncoderModelSelected": "Nessun modello di encoder Qwen3 selezionato per la generazione Klein di FLUX2",
|
||||
"noAnimaVaeModelSelected": "Nessun modello VAE Anima selezionato",
|
||||
"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"
|
||||
},
|
||||
"useCpuNoise": "Usa la CPU per generare rumore",
|
||||
"iterations": "Iterazioni",
|
||||
@@ -1345,7 +1384,9 @@
|
||||
"versionUnknown": " Versione sconosciuta",
|
||||
"generateValues": "Genera valori",
|
||||
"floatRangeGenerator": "Generatore di intervallo di numeri decimali",
|
||||
"integerRangeGenerator": "Generatore di intervallo di numeri interi"
|
||||
"integerRangeGenerator": "Generatore di intervallo di numeri interi",
|
||||
"noWorkflowToSave": "Nessun flusso di lavoro da salvare",
|
||||
"nodeData": "Dati del nodo"
|
||||
},
|
||||
"boards": {
|
||||
"autoAddBoard": "Aggiungi automaticamente bacheca",
|
||||
@@ -1498,7 +1539,9 @@
|
||||
"clearFailedAccessDenied": "Problema durante la cancellazione della coda: accesso negato",
|
||||
"user": "Utente",
|
||||
"cannotViewDetails": "Non hai l'autorizzazione per visualizzare i dettagli di questo elemento della coda",
|
||||
"fieldValuesHidden": "<Nascosto>"
|
||||
"fieldValuesHidden": "<Nascosto>",
|
||||
"queueActionsMenu": "Menu azioni in coda",
|
||||
"queueItem": "Elemento della coda"
|
||||
},
|
||||
"models": {
|
||||
"noMatchingModels": "Nessun modello corrispondente",
|
||||
@@ -1544,7 +1587,8 @@
|
||||
"promptsPreview": "Anteprima dei prompt",
|
||||
"showDynamicPrompts": "Mostra prompt dinamici",
|
||||
"loading": "Generazione prompt dinamici...",
|
||||
"promptsToGenerate": "Prompt da generare"
|
||||
"promptsToGenerate": "Prompt da generare",
|
||||
"problemGeneratingPrompts": "Problema nella generazione dei prompt"
|
||||
},
|
||||
"popovers": {
|
||||
"paramScheduler": {
|
||||
@@ -2278,7 +2322,11 @@
|
||||
"insert": "Inserisci",
|
||||
"noPromptHistory": "Nessuna cronologia di prompt registrata.",
|
||||
"noMatchingPrompts": "Nessun prompt corrispondente nella cronologia.",
|
||||
"toSwitchBetweenPrompts": "per passare da un prompt all'altro."
|
||||
"toSwitchBetweenPrompts": "per passare da un prompt all'altro.",
|
||||
"promptHistory": "Cronologia dei prompt",
|
||||
"clearHistory": "Cancella cronologia",
|
||||
"usePrompt": "Utilizza il prompt",
|
||||
"searchPrompts": "Ricerca..."
|
||||
},
|
||||
"controlLayers": {
|
||||
"addLayer": "Aggiungi Livello",
|
||||
@@ -2533,7 +2581,8 @@
|
||||
"horizontal": "Orizzontale",
|
||||
"diagonal": "Diagonale",
|
||||
"bgFillColor": "Colore di sfondo",
|
||||
"fgFillColor": "Colore di primo piano"
|
||||
"fgFillColor": "Colore di primo piano",
|
||||
"switchColors": "Commuta FG/BG (X)"
|
||||
},
|
||||
"locked": "Bloccato",
|
||||
"hidingType": "Nascondere {{type}}",
|
||||
@@ -2730,7 +2779,10 @@
|
||||
"autoSwitch": {
|
||||
"off": "Spento",
|
||||
"switchOnStart": "All'inizio",
|
||||
"switchOnFinish": "Alla fine"
|
||||
"switchOnFinish": "Alla fine",
|
||||
"doNotAutoSwitch": "Non commutare automaticamente",
|
||||
"switchOnStartDesc": "Attiva all'avvio",
|
||||
"switchOnFinishDesc": "Attiva al termine"
|
||||
},
|
||||
"invertMask": "Inverti maschera",
|
||||
"fitBboxToMasks": "Adatta il riquadro di delimitazione alle maschere",
|
||||
@@ -2836,7 +2888,11 @@
|
||||
"strikethrough": "Barrato",
|
||||
"alignLeft": "Allinea a sinistra",
|
||||
"alignCenter": "Allinea al centro",
|
||||
"alignRight": "Allinea a destra"
|
||||
"alignRight": "Allinea a destra",
|
||||
"lineHeight": "Spaziatura",
|
||||
"lineHeightDense": "Densa",
|
||||
"lineHeightNormal": "Normale",
|
||||
"lineHeightSpacious": "Spaziosa"
|
||||
},
|
||||
"workflowIntegration": {
|
||||
"title": "Eseguire il flusso di lavoro sula Tela",
|
||||
@@ -2858,7 +2914,13 @@
|
||||
"executionStarted": "L'esecuzione del flusso di lavoro è stata avviata",
|
||||
"executionStartedDescription": "Il risultato apparirà nell'area di lavoro una volta completata l'operazione.",
|
||||
"executionFailed": "Impossibile eseguire il flusso di lavoro"
|
||||
}
|
||||
},
|
||||
"disableReferenceImage": "Disabilita l'immagine di riferimento",
|
||||
"enableReferenceImage": "Abilita l'immagine di riferimento",
|
||||
"invertRegion": "Inverti la regione",
|
||||
"invalidReferenceImage": "Immagine di riferimento non valida:",
|
||||
"removeImageFromCollection": "Rimuovi l'immagine dalla raccolta",
|
||||
"selectRefImage": "Seleziona l'immagine di riferimento"
|
||||
},
|
||||
"ui": {
|
||||
"tabs": {
|
||||
@@ -3088,7 +3150,9 @@
|
||||
"title": "Sessioni in studio",
|
||||
"description": "Sessioni approfondite che esplorano le funzionalità avanzate di Invoke, i flussi di lavoro creativi e le discussioni della community."
|
||||
}
|
||||
}
|
||||
},
|
||||
"gettingStartedPlaylist": "Playlist per iniziare",
|
||||
"studioSessionsPlaylist": "Playlist delle sessioni in studio"
|
||||
},
|
||||
"modelCache": {
|
||||
"clear": "Cancella la cache del modello",
|
||||
@@ -3096,7 +3160,8 @@
|
||||
"clearFailed": "Problema durante la cancellazione della cache del modello"
|
||||
},
|
||||
"lora": {
|
||||
"weight": "Peso"
|
||||
"weight": "Peso",
|
||||
"removeLoRA": "Rimuovi LoRA"
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
@@ -3179,5 +3244,13 @@
|
||||
"moderate": "Password moderata",
|
||||
"strong": "Password forte"
|
||||
}
|
||||
},
|
||||
"cropper": {
|
||||
"cropImage": "Ritaglia l'immagine",
|
||||
"aspectRatio": "Rapporto d'aspetto",
|
||||
"free": "Libera",
|
||||
"mouseWheelZoom": "Rotellina del mouse: Zoom",
|
||||
"spaceDragPan": "Spazio + trascina: Panoramica",
|
||||
"dragCropBoxToAdjust": "Trascina il riquadro di ritaglio o le maniglie per regolare"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,17 +31,22 @@ import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiArrowCounterClockwiseBold, PiStarFill } from 'react-icons/pi';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useGetSetupStatusQuery } from 'services/api/endpoints/auth';
|
||||
import { useGetAllTagsQuery, useGetCountsByTagQuery } from 'services/api/endpoints/workflows';
|
||||
|
||||
export const WorkflowLibrarySideNav = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: setupStatus } = useGetSetupStatusQuery();
|
||||
const multiuserEnabled = setupStatus?.multiuser_enabled ?? false;
|
||||
|
||||
return (
|
||||
<Flex h="full" minH={0} overflow="hidden" flexDir="column" w={64} gap={0}>
|
||||
<Flex flexDir="column" w="full" pb={2} gap={2}>
|
||||
<WorkflowLibraryViewButton view="recent">{t('workflows.recentlyOpened')}</WorkflowLibraryViewButton>
|
||||
<YourWorkflowsButton />
|
||||
<WorkflowLibraryViewButton view="shared">{t('workflows.sharedWorkflows')}</WorkflowLibraryViewButton>
|
||||
{multiuserEnabled && (
|
||||
<WorkflowLibraryViewButton view="shared">{t('workflows.sharedWorkflows')}</WorkflowLibraryViewButton>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex h="full" minH={0} overflow="hidden" flexDir="column">
|
||||
<BrowseWorkflowsButton />
|
||||
|
||||
@@ -9,6 +9,7 @@ import InvokeLogo from 'public/assets/images/invoke-symbol-wht-lrg.svg';
|
||||
import { type ChangeEvent, memo, type MouseEvent, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiImage } from 'react-icons/pi';
|
||||
import { useGetSetupStatusQuery } from 'services/api/endpoints/auth';
|
||||
import { useUpdateWorkflowIsPublicMutation } from 'services/api/endpoints/workflows';
|
||||
import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
|
||||
|
||||
@@ -36,6 +37,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
|
||||
const dispatch = useAppDispatch();
|
||||
const workflowId = useAppSelector(selectWorkflowId);
|
||||
const currentUser = useAppSelector(selectCurrentUser);
|
||||
const { data: setupStatus } = useGetSetupStatusQuery();
|
||||
const loadWorkflowWithDialog = useLoadWorkflowWithDialog();
|
||||
|
||||
const isActive = useMemo(() => {
|
||||
@@ -47,8 +49,12 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
|
||||
}, [currentUser, workflow.user_id]);
|
||||
|
||||
const canEditOrDelete = useMemo(() => {
|
||||
// In single-user (legacy) mode, all workflows are editable — no concept of ownership.
|
||||
if (!setupStatus?.multiuser_enabled) {
|
||||
return true;
|
||||
}
|
||||
return isOwner || (currentUser?.is_admin ?? false);
|
||||
}, [isOwner, currentUser]);
|
||||
}, [setupStatus?.multiuser_enabled, isOwner, currentUser]);
|
||||
|
||||
const tags = useMemo(() => {
|
||||
if (!workflow.tags) {
|
||||
@@ -113,7 +119,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
|
||||
{t('workflows.opened')}
|
||||
</Badge>
|
||||
)}
|
||||
{workflow.is_public && workflow.category !== 'default' && (
|
||||
{setupStatus?.multiuser_enabled && workflow.is_public && workflow.category !== 'default' && (
|
||||
<Badge
|
||||
color="invokeGreen.400"
|
||||
borderColor="invokeGreen.700"
|
||||
@@ -160,7 +166,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
|
||||
</Text>
|
||||
)}
|
||||
<Spacer />
|
||||
{isOwner && <ShareWorkflowToggle workflow={workflow} />}
|
||||
{setupStatus?.multiuser_enabled && canEditOrDelete && <ShareWorkflowToggle workflow={workflow} />}
|
||||
{workflow.category === 'default' && <ViewWorkflow workflowId={workflow.workflow_id} />}
|
||||
{workflow.category !== 'default' && (
|
||||
<>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { t } from 'i18next';
|
||||
import { atom, computed } from 'nanostores';
|
||||
import type { ChangeEvent, RefObject } from 'react';
|
||||
import { memo, useCallback, useRef, useState } from 'react';
|
||||
import { useGetSetupStatusQuery } from 'services/api/endpoints/auth';
|
||||
import { useUpdateWorkflowIsPublicMutation } from 'services/api/endpoints/workflows';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
@@ -90,6 +91,8 @@ const Content = memo(({ workflow, cancelRef }: { workflow: WorkflowV3; cancelRef
|
||||
return '';
|
||||
});
|
||||
const [isPublic, setIsPublic] = useState(false);
|
||||
const { data: setupStatus } = useGetSetupStatusQuery();
|
||||
const multiuserEnabled = setupStatus?.multiuser_enabled ?? false;
|
||||
|
||||
const { createNewWorkflow } = useCreateLibraryWorkflow();
|
||||
const [updateIsPublic] = useUpdateWorkflowIsPublicMutation();
|
||||
@@ -143,10 +146,12 @@ const Content = memo(({ workflow, cancelRef }: { workflow: WorkflowV3; cancelRef
|
||||
<FormLabel mt="2">{t('workflows.workflowName')}</FormLabel>
|
||||
<Flex flexDir="column" width="full" gap="2">
|
||||
<Input ref={inputRef} value={name} onChange={onChange} placeholder={t('workflows.workflowName')} />
|
||||
<Flex alignItems="center" gap={2}>
|
||||
<Checkbox isChecked={isPublic} onChange={onChangeIsPublic} />
|
||||
<FormLabel mb={0}>{t('workflows.shareWorkflow')}</FormLabel>
|
||||
</Flex>
|
||||
{multiuserEnabled && (
|
||||
<Flex alignItems="center" gap={2}>
|
||||
<Checkbox isChecked={isPublic} onChange={onChangeIsPublic} />
|
||||
<FormLabel mb={0}>{t('workflows.shareWorkflow')}</FormLabel>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</FormControl>
|
||||
</AlertDialogBody>
|
||||
|
||||
@@ -332,3 +332,164 @@ def test_workflow_has_user_id_and_is_public_fields(client: TestClient, user1_tok
|
||||
assert "user_id" in data
|
||||
assert "is_public" in data
|
||||
assert data["is_public"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System-owned workflow visibility (regression tests for migration 30 fix)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _insert_system_workflow(mock_invoker: Invoker, name: str = "Legacy Workflow", is_public: bool = True) -> str:
|
||||
"""Insert a workflow owned by 'system' directly via the service layer, then set is_public."""
|
||||
from invokeai.app.services.workflow_records.workflow_records_common import WorkflowWithoutID
|
||||
|
||||
wf = WorkflowWithoutID(**{**WORKFLOW_BODY, "name": name})
|
||||
record = mock_invoker.services.workflow_records.create(workflow=wf, user_id="system")
|
||||
if is_public:
|
||||
mock_invoker.services.workflow_records.update_is_public(workflow_id=record.workflow_id, is_public=True)
|
||||
return record.workflow_id
|
||||
|
||||
|
||||
def test_system_public_workflow_visible_in_shared_listing(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
"""After migration 30, system-owned public workflows should appear in the shared workflows listing."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Legacy Workflow")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user&is_public=true",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
ids = [w["workflow_id"] for w in response.json()["items"]]
|
||||
assert wf_id in ids
|
||||
|
||||
|
||||
def test_system_public_workflow_not_in_your_workflows(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
"""System-owned public workflows should NOT appear in 'Your Workflows' listing."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Legacy Workflow")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
ids = [w["workflow_id"] for w in response.json()["items"]]
|
||||
assert wf_id not in ids
|
||||
|
||||
|
||||
def test_admin_can_list_system_workflows(client: TestClient, admin_token: str, mock_invoker: Invoker):
|
||||
"""Admins should see system-owned workflows in their listing."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Admin Visible Workflow")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
ids = [w["workflow_id"] for w in response.json()["items"]]
|
||||
assert wf_id in ids
|
||||
|
||||
|
||||
def test_admin_can_update_system_workflow(client: TestClient, admin_token: str, mock_invoker: Invoker):
|
||||
"""Admins should be able to update a system-owned workflow."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Editable Legacy")
|
||||
|
||||
# Get the full workflow to update it
|
||||
get_resp = client.get(
|
||||
f"/api/v1/workflows/i/{wf_id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert get_resp.status_code == 200
|
||||
workflow_data = get_resp.json()["workflow"]
|
||||
workflow_data["name"] = "Updated by Admin"
|
||||
|
||||
update_resp = client.patch(
|
||||
f"/api/v1/workflows/i/{wf_id}",
|
||||
json={"workflow": workflow_data},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert update_resp.status_code == 200
|
||||
assert update_resp.json()["workflow"]["name"] == "Updated by Admin"
|
||||
|
||||
|
||||
def test_admin_can_delete_system_workflow(client: TestClient, admin_token: str, mock_invoker: Invoker):
|
||||
"""Admins should be able to delete a system-owned workflow."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Deletable Legacy")
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1/workflows/i/{wf_id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_regular_user_cannot_update_system_workflow(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
"""Regular users should NOT be able to update a system-owned workflow."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Protected Legacy")
|
||||
|
||||
get_resp = client.get(
|
||||
f"/api/v1/workflows/i/{wf_id}",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert get_resp.status_code == 200
|
||||
workflow_data = get_resp.json()["workflow"]
|
||||
workflow_data["name"] = "Hijacked"
|
||||
|
||||
update_resp = client.patch(
|
||||
f"/api/v1/workflows/i/{wf_id}",
|
||||
json={"workflow": workflow_data},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert update_resp.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_regular_user_cannot_delete_system_workflow(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
"""Regular users should NOT be able to delete a system-owned workflow."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Undeletable Legacy")
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1/workflows/i/{wf_id}",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-user mode: default ownership + sharing on create
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def single_user_mode(monkeypatch: Any, mock_invoker: Invoker):
|
||||
"""Configure the app for single-user (legacy) mode."""
|
||||
mock_invoker.services.configuration.multiuser = False
|
||||
mock_workflow_thumbnails = MagicMock()
|
||||
mock_workflow_thumbnails.get_url.return_value = None
|
||||
mock_invoker.services.workflow_thumbnails = mock_workflow_thumbnails
|
||||
|
||||
mock_deps = MockApiDependencies(mock_invoker)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.workflows.ApiDependencies", mock_deps)
|
||||
yield
|
||||
|
||||
|
||||
def test_single_user_create_workflow_owned_by_system_and_public(single_user_mode: Any, client: TestClient):
|
||||
"""In single-user mode, newly created workflows should be owned by 'system' and shared (is_public=True)."""
|
||||
response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY})
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload["user_id"] == "system"
|
||||
assert payload["is_public"] is True
|
||||
|
||||
|
||||
def test_multiuser_create_workflow_owned_by_user_and_private(client: TestClient, user1_token: str):
|
||||
"""In multiuser mode, newly created workflows should be owned by the creator and private (is_public=False)."""
|
||||
response = client.post(
|
||||
"/api/v1/workflows/",
|
||||
json={"workflow": WORKFLOW_BODY},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload["user_id"] != "system"
|
||||
assert payload["is_public"] is False
|
||||
|
||||
Reference in New Issue
Block a user