Compare commits

...

37 Commits

Author SHA1 Message Date
psychedelicious
5806a4bc73 chore: bump version to v5.1.1 2024-10-09 14:43:55 +11:00
psychedelicious
734631bfe4 feat(app): update example config file comment 2024-10-09 14:23:06 +11:00
psychedelicious
8d6996cdf0 fix(ui): sync pointer position on pointerdown
There's a Konva bug where `pointerenter` & `pointerleave` events aren't fired correctly on the stage.

In 87fdea4cc6 I made a change that surfaced this bug, breaking touch and Apple Pencil interactions, because the cursor position doesn't get updated.

Simple fix - ensure we update the cursor on `pointerdown` events, even though we shouldn't need to.

Will make a bug report upstream
2024-10-09 13:59:20 +11:00
psychedelicious
965d6be1f4 fix(ui): validate edges on paste
Closes #7058
2024-10-09 13:49:31 +11:00
psychedelicious
e31f253b90 fix(ui): canvas sliders
- Set an empty title to prevent browsers from showing "Please match the requested format." when hovering the number input
- Fix issue w/ `z-index` that prevented the popover button from being clicked while the input was focused
2024-10-09 13:45:36 +11:00
psychedelicious
5a94575603 chore(ui): lint 2024-10-09 13:43:22 +11:00
psychedelicious
1c3d06dc83 fix(ui): remove straggling onPointerUp handlers 2024-10-09 13:43:22 +11:00
psychedelicious
09b19e3640 fix(ui): formatting in translation source 2024-10-09 11:37:21 +11:00
Thomas Bolteau
1e0a4dfa3c translationBot(ui): update translation (French)
Currently translated at 55.6% (822 of 1477 strings)

Co-authored-by: Thomas Bolteau <thomas.bolteau50@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/fr/
Translation: InvokeAI/Web UI
2024-10-09 11:37:21 +11:00
Riccardo Giovanetti
5a1ab4aa9c translationBot(ui): update translation (Italian)
Currently translated at 98.7% (1461 of 1479 strings)

translationBot(ui): update translation (Italian)

Currently translated at 98.7% (1460 of 1479 strings)

translationBot(ui): update translation (Italian)

Currently translated at 98.5% (1458 of 1479 strings)

translationBot(ui): update translation (Italian)

Currently translated at 98.7% (1459 of 1477 strings)

translationBot(ui): update translation (Italian)

Currently translated at 98.7% (1453 of 1471 strings)

Co-authored-by: Riccardo Giovanetti <riccardo.giovanetti@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/
Translation: InvokeAI/Web UI
2024-10-09 11:37:21 +11:00
Anonymous
d5c872292f translationBot(ui): update translation (Russian)
Currently translated at 99.9% (1470 of 1471 strings)

translationBot(ui): update translation (Italian)

Currently translated at 98.7% (1452 of 1471 strings)

translationBot(ui): update translation (English)

Currently translated at 99.9% (1470 of 1471 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/en/
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/ru/
Translation: InvokeAI/Web UI
2024-10-09 11:37:21 +11:00
Mary Hipp Rogers
0d7edbce25 add missing translations (#7073)
Co-authored-by: Mary Hipp <maryhipp@Marys-MacBook-Air.local>
2024-10-08 20:07:00 -04:00
psychedelicious
e20d964b59 chore(ui): lint 2024-10-09 08:02:11 +11:00
psychedelicious
ee95321801 fix(ui): edge case where board edit button doesn't disappear 2024-10-09 08:02:11 +11:00
psychedelicious
179c6d206c tweak(ui): edit board title button layout 2024-10-09 08:02:11 +11:00
psychedelicious
ffecd83815 fix(ui): typo 2024-10-09 07:32:01 +11:00
psychedelicious
f1c538fafc fix(ui): workflow sort popover behaviour 2024-10-09 07:32:01 +11:00
Mary Hipp
ed88b096f3 (ui) update so that default list does not sort 2024-10-09 07:32:01 +11:00
Mary Hipp
a28cabdf97 restore sorting UI for workflow library 2024-10-09 07:32:01 +11:00
Mary Hipp
db25be3ba2 (ui): add opened/created/updated details to tooltip, default sort by opened (OSS) and created (non-OSS) 2024-10-09 07:32:01 +11:00
Mary Hipp Rogers
3b9d1e8218 misc(ui): image/asset tab tooltips, icon to rename board, getting started text (#7067)
* add tooltips for images/assets tabs

* add icon by board name that can be used to activate editable

* update getting started text

---------

Co-authored-by: Mary Hipp <maryhipp@Marys-MacBook-Air.local>
2024-10-08 15:46:08 -04:00
Mary Hipp
05d9ba8fa0 PR review feedback 2024-10-08 10:08:50 -04:00
Mary Hipp
3eee1ba113 remove prints 2024-10-08 10:08:50 -04:00
psychedelicious
7882e9beae feat(ui): WorkflowListItem simplify layout 2024-10-08 10:08:50 -04:00
Mary Hipp
7c9779b496 (ui) handle empty state 2024-10-08 10:08:50 -04:00
Mary Hipp
5832228fea lint and cleanup 2024-10-08 10:08:50 -04:00
Mary Hipp
1d32e70a75 (ui): clean up old workflow library 2024-10-08 10:08:50 -04:00
Mary Hipp
9092280583 (ui) new menu list of workflows 2024-10-08 10:08:50 -04:00
Mary Hipp
96dd1d5102 (api) update workflow list route to work with certain params optional so we can get all at once 2024-10-08 10:08:50 -04:00
Kent Keirsey
969f8b8e8d ruff update 2024-10-08 08:56:26 -04:00
David Burnett
ccb5f90556 Get Flux working on MPS when torch 2.5.0 test or nightlies are installed. 2024-10-08 08:56:26 -04:00
Alex Ameen
4770d9895d update flake (#7032)
Co-authored-by: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
2024-10-08 10:55:49 +11:00
Elias Rad
aeb2275bd8 Update LOCAL_DEVELOPMENT.md 2024-10-08 10:08:24 +11:00
Elias Rad
aff5524457 Update INVOCATIONS.md 2024-10-08 10:08:24 +11:00
Elias Rad
825c564089 Update tutorials.md 2024-10-08 10:08:24 +11:00
Elias Rad
9b97c57f00 Update development.md 2024-10-08 10:08:24 +11:00
skunkworxdark
4b3a201790 Add Enhance Detail to communityNodes.md
- Add Enhance Detail node
- Fix some broken github image links.
2024-10-08 09:56:15 +11:00
60 changed files with 1076 additions and 738 deletions

View File

@@ -144,7 +144,7 @@ As you might have noticed, we added two new arguments to the `InputField`
definition for `width` and `height`, called `gt` and `le`. They stand for
_greater than or equal to_ and _less than or equal to_.
These impose contraints on those fields, and will raise an exception if the
These impose constraints on those fields, and will raise an exception if the
values do not meet the constraints. Field constraints are provided by
**pydantic**, so anything you see in the **pydantic docs** will work.

View File

@@ -239,7 +239,7 @@ Consult the
get it set up.
Suggest using VSCode's included settings sync so that your remote dev host has
all the same app settings and extensions automagically.
all the same app settings and extensions automatically.
##### One remote dev gotcha

View File

@@ -2,7 +2,7 @@
## **What do I need to know to help?**
If you are looking to help to with a code contribution, InvokeAI uses several different technologies under the hood: Python (Pydantic, FastAPI, diffusers) and Typescript (React, Redux Toolkit, ChakraUI, Mantine, Konva). Familiarity with StableDiffusion and image generation concepts is helpful, but not essential.
If you are looking to help with a code contribution, InvokeAI uses several different technologies under the hood: Python (Pydantic, FastAPI, diffusers) and Typescript (React, Redux Toolkit, ChakraUI, Mantine, Konva). Familiarity with StableDiffusion and image generation concepts is helpful, but not essential.
## **Get Started**

View File

@@ -1,6 +1,6 @@
# Tutorials
Tutorials help new & existing users expand their abilty to use InvokeAI to the full extent of our features and services.
Tutorials help new & existing users expand their ability to use InvokeAI to the full extent of our features and services.
Currently, we have a set of tutorials available on our [YouTube channel](https://www.youtube.com/@invokeai), but as InvokeAI continues to evolve with new updates, we want to ensure that we are giving our users the resources they need to succeed.
@@ -8,4 +8,4 @@ Tutorials can be in the form of videos or article walkthroughs on a subject of y
## Contributing
Please reach out to @imic or @hipsterusername on [Discord](https://discord.gg/ZmtBAhwWhy) to help create tutorials for InvokeAI.
Please reach out to @imic or @hipsterusername on [Discord](https://discord.gg/ZmtBAhwWhy) to help create tutorials for InvokeAI.

View File

@@ -21,6 +21,7 @@ To use a community workflow, download the `.json` node graph file and load it in
+ [Clothing Mask](#clothing-mask)
+ [Contrast Limited Adaptive Histogram Equalization](#contrast-limited-adaptive-histogram-equalization)
+ [Depth Map from Wavefront OBJ](#depth-map-from-wavefront-obj)
+ [Enhance Detail](#enhance-detail)
+ [Film Grain](#film-grain)
+ [Generative Grammar-Based Prompt Nodes](#generative-grammar-based-prompt-nodes)
+ [GPT2RandomPromptMaker](#gpt2randompromptmaker)
@@ -81,7 +82,7 @@ Note: These are inherited from the core nodes so any update to the core nodes sh
**Example Usage:**
</br>
<img src="https://github.com/skunkworxdark/autostereogram_nodes/blob/main/images/spider.png" width="200" /> -> <img src="https://github.com/skunkworxdark/autostereogram_nodes/blob/main/images/spider-depth.png" width="200" /> -> <img src="https://github.com/skunkworxdark/autostereogram_nodes/raw/main/images/spider-dots.png" width="200" /> <img src="https://github.com/skunkworxdark/autostereogram_nodes/raw/main/images/spider-pattern.png" width="200" />
<img src="https://raw.githubusercontent.com/skunkworxdark/autostereogram_nodes/refs/heads/main/images/spider.png" width="200" /> -> <img src="https://raw.githubusercontent.com/skunkworxdark/autostereogram_nodes/refs/heads/main/images/spider-depth.png" width="200" /> -> <img src="https://raw.githubusercontent.com/skunkworxdark/autostereogram_nodes/refs/heads/main/images/spider-dots.png" width="200" /> <img src="https://raw.githubusercontent.com/skunkworxdark/autostereogram_nodes/refs/heads/main/images/spider-pattern.png" width="200" />
--------------------------------
### Average Images
@@ -142,6 +143,17 @@ To be imported, an .obj must use triangulated meshes, so make sure to enable tha
**Example Usage:**
</br><img src="https://raw.githubusercontent.com/dwringer/depth-from-obj-node/main/depth_from_obj_usage.jpg" width="500" />
--------------------------------
### Enhance Detail
**Description:** A single node that can enhance the detail in an image. Increase or decrease details in an image using a guided filter (as opposed to the typical Gaussian blur used by most sharpening filters.) Based on the `Enhance Detail` ComfyUI node from https://github.com/spacepxl/ComfyUI-Image-Filters
**Node Link:** https://github.com/skunkworxdark/enhance-detail-node
**Example Usage:**
</br>
<img src="https://raw.githubusercontent.com/skunkworxdark/enhance-detail-node/refs/heads/main/images/Comparison.png" />
--------------------------------
### Film Grain
@@ -308,7 +320,7 @@ View:
**Node Link:** https://github.com/helix4u/load_video_frame
**Output Example:**
<img src="https://raw.githubusercontent.com/helix4u/load_video_frame/main/_git_assets/testmp4_embed_converted.gif" width="500" />
<img src="https://raw.githubusercontent.com/helix4u/load_video_frame/refs/heads/main/_git_assets/dance1736978273.gif" width="500" />
--------------------------------
### Make 3D
@@ -349,7 +361,7 @@ See full docs here: https://github.com/skunkworxdark/Prompt-tools-nodes/edit/mai
**Output Examples**
<img src="https://github.com/skunkworxdark/match_histogram/assets/21961335/ed12f329-a0ef-444a-9bae-129ed60d6097" width="300" />
<img src="https://github.com/skunkworxdark/match_histogram/assets/21961335/ed12f329-a0ef-444a-9bae-129ed60d6097" />
--------------------------------
### Metadata Linked Nodes
@@ -407,7 +419,7 @@ View:
--------------------------------
### One Button Prompt
<img src="https://github.com/AIrjen/OneButtonPrompt_X_InvokeAI/blob/main/images/background.png" width="800" />
<img src="https://raw.githubusercontent.com/AIrjen/OneButtonPrompt_X_InvokeAI/refs/heads/main/images/background.png" width="800" />
**Description:** an extensive suite of auto prompt generation and prompt helper nodes based on extensive logic. Get creative with the best prompt generator in the world.
@@ -417,7 +429,7 @@ The main node generates interesting prompts based on a set of parameters. There
**Nodes:**
<img src="https://github.com/AIrjen/OneButtonPrompt_X_InvokeAI/blob/main/images/OBP_nodes_invokeai.png" width="800" />
<img src="https://raw.githubusercontent.com/AIrjen/OneButtonPrompt_X_InvokeAI/refs/heads/main/images/OBP_nodes_invokeai.png" width="800" />
--------------------------------
### Oobabooga
@@ -470,7 +482,7 @@ See full docs here: https://github.com/skunkworxdark/Prompt-tools-nodes/edit/mai
**Workflow Examples**
<img src="https://github.com/skunkworxdark/prompt-tools/blob/main/images/CSVToIndexStringNode.png" width="300" />
<img src="https://raw.githubusercontent.com/skunkworxdark/prompt-tools/refs/heads/main/images/CSVToIndexStringNode.png"/>
--------------------------------
### Remote Image
@@ -608,7 +620,7 @@ See full docs here: https://github.com/skunkworxdark/XYGrid_nodes/edit/main/READ
**Output Examples**
<img src="https://github.com/skunkworxdark/XYGrid_nodes/blob/main/images/collage.png" width="300" />
<img src="https://raw.githubusercontent.com/skunkworxdark/XYGrid_nodes/refs/heads/main/images/collage.png" />
--------------------------------

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1690630721,
"narHash": "sha256-Y04onHyBQT4Erfr2fc82dbJTfXGYrf4V0ysLUYnPOP8=",
"lastModified": 1727955264,
"narHash": "sha256-lrd+7mmb5NauRoMa8+J1jFKYVa+rc8aq2qc9+CxPDKc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d2b52322f35597c62abf56de91b0236746b2a03d",
"rev": "71cd616696bd199ef18de62524f3df3ffe8b9333",
"type": "github"
},
"original": {

View File

@@ -34,7 +34,7 @@
cudaPackages.cudnn
cudaPackages.cuda_nvrtc
cudatoolkit
pkgconfig
pkg-config
libconfig
cmake
blas
@@ -66,7 +66,7 @@
black
# Frontend.
yarn
pnpm_8
nodejs
];
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath buildInputs;

View File

@@ -83,15 +83,15 @@ async def create_workflow(
)
async def list_workflows(
page: int = Query(default=0, description="The page to get"),
per_page: int = Query(default=10, description="The number of workflows per page"),
per_page: Optional[int] = Query(default=None, description="The number of workflows per page"),
order_by: WorkflowRecordOrderBy = Query(
default=WorkflowRecordOrderBy.Name, description="The attribute to order by"
),
direction: SQLiteDirection = Query(default=SQLiteDirection.Ascending, description="The direction to order by"),
category: WorkflowCategory = Query(default=WorkflowCategory.User, description="The category of workflow to get"),
category: Optional[WorkflowCategory] = Query(default=None, description="The category of workflow to get"),
query: Optional[str] = Query(default=None, description="The text to query by (matches name and description)"),
) -> PaginatedResults[WorkflowRecordListItemDTO]:
"""Gets a page of workflows"""
return ApiDependencies.invoker.services.workflow_records.get_many(
page=page, per_page=per_page, order_by=order_by, direction=direction, query=query, category=category
order_by=order_by, direction=direction, page=page, per_page=per_page, query=query, category=category
)

View File

@@ -250,9 +250,9 @@ class InvokeAIAppConfig(BaseSettings):
)
if as_example:
file.write(
"# This is an example file with default and example settings. Use the values here as a baseline.\n\n"
)
file.write("# This is an example file with default and example settings.\n")
file.write("# You should not copy this whole file into your config.\n")
file.write("# Only add the settings you need to change to your config file.\n\n")
file.write("# Internal metadata - do not edit:\n")
file.write(yaml.dump(meta_dict, sort_keys=False))
file.write("\n")

View File

@@ -39,11 +39,11 @@ class WorkflowRecordsStorageBase(ABC):
@abstractmethod
def get_many(
self,
page: int,
per_page: int,
order_by: WorkflowRecordOrderBy,
direction: SQLiteDirection,
category: WorkflowCategory,
page: int,
per_page: Optional[int],
category: Optional[WorkflowCategory],
query: Optional[str],
) -> PaginatedResults[WorkflowRecordListItemDTO]:
"""Gets many workflows."""

View File

@@ -125,11 +125,11 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
def get_many(
self,
page: int,
per_page: int,
order_by: WorkflowRecordOrderBy,
direction: SQLiteDirection,
category: WorkflowCategory,
page: int = 0,
per_page: Optional[int] = None,
category: Optional[WorkflowCategory] = None,
query: Optional[str] = None,
) -> PaginatedResults[WorkflowRecordListItemDTO]:
try:
@@ -137,8 +137,7 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
# sanitize!
assert order_by in WorkflowRecordOrderBy
assert direction in SQLiteDirection
assert category in WorkflowCategory
count_query = "SELECT COUNT(*) FROM workflow_library WHERE category = ?"
count_query = "SELECT COUNT(*) FROM workflow_library"
main_query = """
SELECT
workflow_id,
@@ -149,32 +148,51 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
updated_at,
opened_at
FROM workflow_library
WHERE category = ?
"""
main_params: list[int | str] = [category.value]
count_params: list[int | str] = [category.value]
main_params: list[int | str] = []
count_params: list[int | str] = []
if category:
assert category in WorkflowCategory
main_query += " WHERE category = ?"
count_query += " WHERE category = ?"
main_params.append(category.value)
count_params.append(category.value)
stripped_query = query.strip() if query else None
if stripped_query:
wildcard_query = "%" + stripped_query + "%"
main_query += " AND name LIKE ? OR description LIKE ? "
count_query += " AND name LIKE ? OR description LIKE ?;"
if "WHERE" in main_query:
main_query += " AND (name LIKE ? OR description LIKE ?)"
count_query += " AND (name LIKE ? OR description LIKE ?)"
else:
main_query += " WHERE name LIKE ? OR description LIKE ?"
count_query += " WHERE name LIKE ? OR description LIKE ?"
main_params.extend([wildcard_query, wildcard_query])
count_params.extend([wildcard_query, wildcard_query])
main_query += f" ORDER BY {order_by.value} {direction.value} LIMIT ? OFFSET ?;"
main_params.extend([per_page, page * per_page])
main_query += f" ORDER BY {order_by.value} {direction.value}"
if per_page:
main_query += " LIMIT ? OFFSET ?"
main_params.extend([per_page, page * per_page])
self._cursor.execute(main_query, main_params)
rows = self._cursor.fetchall()
workflows = [WorkflowRecordListItemDTOValidator.validate_python(dict(row)) for row in rows]
self._cursor.execute(count_query, count_params)
total = self._cursor.fetchone()[0]
pages = total // per_page + (total % per_page > 0)
if per_page:
pages = total // per_page + (total % per_page > 0)
else:
pages = 1 # If no pagination, there is only one page
return PaginatedResults(
items=workflows,
page=page,
per_page=per_page,
per_page=per_page if per_page else total,
pages=pages,
total=total,
)

View File

@@ -16,7 +16,10 @@ def attention(q: Tensor, k: Tensor, v: Tensor, pe: Tensor) -> Tensor:
def rope(pos: Tensor, dim: int, theta: int) -> Tensor:
assert dim % 2 == 0
scale = torch.arange(0, dim, 2, dtype=torch.float64, device=pos.device) / dim
scale = (
torch.arange(0, dim, 2, dtype=torch.float32 if pos.device.type == "mps" else torch.float64, device=pos.device)
/ dim
)
omega = 1.0 / (theta**scale)
out = torch.einsum("...n,d->...nd", pos, omega)
out = torch.stack([torch.cos(out), -torch.sin(out), torch.sin(out), torch.cos(out)], dim=-1)

View File

@@ -4697,6 +4697,7 @@ packages:
loose-envify: 1.4.0
object-assign: 4.1.1
dev: false
bundledDependencies: false
/cross-fetch@4.0.0:
resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==}

View File

@@ -281,6 +281,7 @@
"gallery": "Gallery",
"alwaysShowImageSizeBadge": "Always Show Image Size Badge",
"assets": "Assets",
"assetsTab": "Files youve uploaded for use in your projects.",
"autoAssignBoardOnClick": "Auto-Assign Board on Click",
"autoSwitchNewImages": "Auto-Switch to New Images",
"copy": "Copy",
@@ -301,6 +302,7 @@
"gallerySettings": "Gallery Settings",
"go": "Go",
"image": "image",
"imagesTab": "Images youve created and saved within Invoke.",
"jump": "Jump",
"loading": "Loading",
"newestFirst": "Newest First",
@@ -699,7 +701,7 @@
"convert": "Convert",
"convertingModelBegin": "Converting Model. Please wait.",
"convertToDiffusers": "Convert To Diffusers",
"convertToDiffusersHelpText1": "This model will be converted to the \ud83e\udde8 Diffusers format.",
"convertToDiffusersHelpText1": "This model will be converted to the 🧨 Diffusers format.",
"convertToDiffusersHelpText2": "This process will replace your Model Manager entry with the Diffusers version of the same model.",
"convertToDiffusersHelpText3": "Your checkpoint file on disk WILL be deleted if it is in InvokeAI root folder. If it is in a custom location, then it WILL NOT be deleted.",
"convertToDiffusersHelpText4": "This is a one time process only. It might take around 30s-60s depending on the specifications of your computer.",
@@ -854,6 +856,8 @@
"ipAdapter": "IP-Adapter",
"loadingNodes": "Loading Nodes...",
"loadWorkflow": "Load Workflow",
"noWorkflows": "No Workflows",
"noMatchingWorkflows": "No Matching Workflows",
"noWorkflow": "No Workflow",
"mismatchedVersion": "Invalid node: node {{node}} of type {{type}} has mismatched version (try updating?)",
"missingTemplate": "Invalid node: node {{node}} of type {{type}} missing template (not installed?)",
@@ -870,6 +874,7 @@
"nodeType": "Node Type",
"noFieldsLinearview": "No fields added to Linear View",
"noFieldsViewMode": "This workflow has no selected fields to display. View the full workflow to configure values.",
"workflowHelpText": "Need Help? Check out our guide to <LinkComponent>Getting Started with Workflows</LinkComponent>",
"noNodeSelected": "No node selected",
"nodeOpacity": "Node Opacity",
"nodeVersion": "Node Version",
@@ -1516,6 +1521,7 @@
}
},
"workflows": {
"chooseWorkflowFromLibrary": "Choose Workflow from Library",
"defaultWorkflows": "Default Workflows",
"userWorkflows": "User Workflows",
"projectWorkflows": "Project Workflows",
@@ -1528,7 +1534,9 @@
"openWorkflow": "Open Workflow",
"updated": "Updated",
"uploadWorkflow": "Load from File",
"uploadAndSaveWorkflow": "Upload to Library",
"deleteWorkflow": "Delete Workflow",
"deleteWorkflow2": "Are you sure you want to delete this workflow? This cannot be undone.",
"unnamedWorkflow": "Unnamed Workflow",
"downloadWorkflow": "Save to File",
"saveWorkflow": "Save Workflow",
@@ -1553,7 +1561,6 @@
"loadWorkflow": "$t(common.load) Workflow",
"autoLayout": "Auto Layout"
},
"app": {},
"controlLayers": {
"regional": "Regional",
"global": "Global",
@@ -1632,14 +1639,14 @@
"viewProgressInViewer": "View progress and outputs in the <Btn>Image Viewer</Btn>.",
"viewProgressOnCanvas": "View progress and stage outputs on the <Btn>Canvas</Btn>.",
"rasterLayer_withCount_one": "$t(controlLayers.rasterLayer)",
"controlLayer_withCount_one": "$t(controlLayers.controlLayer)",
"inpaintMask_withCount_one": "$t(controlLayers.inpaintMask)",
"regionalGuidance_withCount_one": "$t(controlLayers.regionalGuidance)",
"globalReferenceImage_withCount_one": "$t(controlLayers.globalReferenceImage)",
"rasterLayer_withCount_other": "Raster Layers",
"controlLayer_withCount_one": "$t(controlLayers.controlLayer)",
"controlLayer_withCount_other": "Control Layers",
"inpaintMask_withCount_one": "$t(controlLayers.inpaintMask)",
"inpaintMask_withCount_other": "Inpaint Masks",
"regionalGuidance_withCount_one": "$t(controlLayers.regionalGuidance)",
"regionalGuidance_withCount_other": "Regional Guidance",
"globalReferenceImage_withCount_one": "$t(controlLayers.globalReferenceImage)",
"globalReferenceImage_withCount_other": "Global Reference Images",
"opacity": "Opacity",
"regionalGuidance_withCount_hidden": "Regional Guidance ({{count}} hidden)",
@@ -1968,7 +1975,7 @@
}
},
"newUserExperience": {
"toGetStarted": "To get started, enter a prompt in the box and click <StrongComponent>Invoke</StrongComponent> to generate your first image. You can choose to save your images directly to the <StrongComponent>Gallery</StrongComponent> or edit them to the <StrongComponent>Canvas</StrongComponent>.",
"toGetStarted": "To get started, enter a prompt in the box and click <StrongComponent>Invoke</StrongComponent> to generate your first image. Select a prompt template to improve results. You can choose to save your images directly to the <StrongComponent>Gallery</StrongComponent> or edit them to the <StrongComponent>Canvas</StrongComponent>.",
"gettingStartedSeries": "Want more guidance? Check out our <LinkComponent>Getting Started Series</LinkComponent> for tips on unlocking the full potential of the Invoke Studio."
},
"whatsNew": {

View File

@@ -912,6 +912,21 @@
"\"Optimiser\" définira la largeur et la hauteur aux dimensions optimales pour le modèle choisi."
],
"heading": "Aspect"
},
"refinerScheduler": {
"heading": "Planificateur"
},
"refinerPositiveAestheticScore": {
"paragraphs": [
"Ajoute un biais envers les générations pour qu'elles soient plus similaires aux images ayant un score esthétique élevé, en fonction des données d'entraînement."
],
"heading": "Score Esthétique Positif"
},
"refinerNegativeAestheticScore": {
"heading": "Score Esthétique Négatif",
"paragraphs": [
"Ajoute un biais envers les générations pour qu'elles soient plus similaires aux images ayant un faible score esthétique, en fonction des données d'entraînement."
]
}
},
"dynamicPrompts": {

View File

@@ -155,7 +155,9 @@
"move": "Sposta",
"gallery": "Galleria",
"openViewer": "Apri visualizzatore",
"closeViewer": "Chiudi visualizzatore"
"closeViewer": "Chiudi visualizzatore",
"imagesTab": "Immagini create e salvate in Invoke.",
"assetsTab": "File che hai caricato per usarli nei tuoi progetti."
},
"hotkeys": {
"searchHotkeys": "Cerca tasti di scelta rapida",
@@ -899,7 +901,7 @@
"clearWorkflowDesc": "Cancellare questo flusso di lavoro e avviarne uno nuovo?",
"clearWorkflow": "Cancella il flusso di lavoro",
"clearWorkflowDesc2": "Il tuo flusso di lavoro attuale presenta modifiche non salvate.",
"viewMode": "Utilizzare nella vista lineare",
"viewMode": "Usa la vista lineare",
"reorderLinearView": "Riordina la vista lineare",
"editMode": "Modifica nell'editor del flusso di lavoro",
"resetToDefaultValue": "Ripristina il valore predefinito",
@@ -917,7 +919,10 @@
"imageAccessError": "Impossibile trovare l'immagine {{image_name}}, ripristino ai valori predefiniti",
"boardAccessError": "Impossibile trovare la bacheca {{board_id}}, ripristino ai valori predefiniti",
"modelAccessError": "Impossibile trovare il modello {{key}}, ripristino ai valori predefiniti",
"saveToGallery": "Salva nella Galleria"
"saveToGallery": "Salva nella Galleria",
"noMatchingWorkflows": "Nessun flusso di lavoro corrispondente",
"noWorkflows": "Nessun flusso di lavoro",
"workflowHelpText": "Hai bisogno di aiuto? Consulta la nostra guida <LinkComponent>Introduzione ai flussi di lavoro</LinkComponent>"
},
"boards": {
"autoAddBoard": "Aggiungi automaticamente bacheca",
@@ -1562,7 +1567,10 @@
"loadFromGraph": "Carica il flusso di lavoro dal grafico",
"userWorkflows": "Flussi di lavoro utente",
"projectWorkflows": "Flussi di lavoro del progetto",
"defaultWorkflows": "Flussi di lavoro predefiniti"
"defaultWorkflows": "Flussi di lavoro predefiniti",
"uploadAndSaveWorkflow": "Carica nella libreria",
"chooseWorkflowFromLibrary": "Scegli il flusso di lavoro dalla libreria",
"deleteWorkflow2": "Vuoi davvero eliminare questo flusso di lavoro? Questa operazione non può essere annullata."
},
"accordions": {
"compositing": {
@@ -1813,7 +1821,9 @@
"hidingType": "Nascondere {{type}}",
"logDebugInfo": "Registro Info Debug",
"inpaintMasks_withCount_visible": "Maschere Inpaint ({{count}})",
"layer": "Livello",
"layer_one": "Livello",
"layer_many": "Livelli",
"layer_other": "Livelli",
"disableTransparencyEffect": "Disabilita l'effetto trasparenza",
"controlLayers_withCount_visible": "Livelli di controllo ({{count}})",
"transparency": "Trasparenza",
@@ -1980,7 +1990,7 @@
},
"newUserExperience": {
"gettingStartedSeries": "Desideri maggiori informazioni? Consulta la nostra <LinkComponent>Getting Started Series</LinkComponent> per suggerimenti su come sfruttare appieno il potenziale di Invoke Studio.",
"toGetStarted": "Per iniziare, inserisci un prompt nella casella e fai clic su <StrongComponent>Invoke</StrongComponent> per generare la tua prima immagine. Puoi scegliere di salvare le tue immagini direttamente nella <StrongComponent>Galleria</StrongComponent> o modificarle nella <StrongComponent>Tela</StrongComponent>."
"toGetStarted": "Per iniziare, inserisci un prompt nella casella e fai clic su <StrongComponent>Invoke</StrongComponent> per generare la tua prima immagine. Seleziona un modello di prompt per migliorare i risultati. Puoi scegliere di salvare le tue immagini direttamente nella <StrongComponent>Galleria</StrongComponent> o modificarle nella <StrongComponent>Tela</StrongComponent>."
},
"whatsNew": {
"canvasV2Announcement": {

View File

@@ -1871,7 +1871,9 @@
"outputOnlyMaskedRegions": "Вывод только маскированных областей",
"duplicate": "Дублировать",
"inpaintMasks_withCount_visible": "Маски перерисовки ({{count}})",
"layer": "Слой",
"layer_one": "Слой",
"layer_few": "",
"layer_many": "",
"prompt": "Запрос",
"negativePrompt": "Исключающий запрос",
"beginEndStepPercentShort": "Начало/конец %",

View File

@@ -9,11 +9,11 @@ import { imageDTOToImageObject } from 'features/controlLayers/store/util';
import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { sentImageToCanvas } from 'features/gallery/store/actions';
import { parseAndRecallAllMetadata } from 'features/metadata/util/handlers';
import { $isWorkflowListMenuIsOpen } from 'features/nodes/store/workflowListMenu';
import { $isStylePresetsMenuOpen, activeStylePresetIdChanged } from 'features/stylePresets/store/stylePresetSlice';
import { toast } from 'features/toast/toast';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow';
import { $workflowLibraryModal } from 'features/workflowLibrary/store/isWorkflowLibraryModalOpen';
import { useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { getImageDTO, getImageMetadata } from 'services/api/endpoints/images';
@@ -160,7 +160,7 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
case 'viewAllWorkflows':
// Go to the workflows tab and open the workflow library modal
store.dispatch(setActiveTab('workflows'));
$workflowLibraryModal.set(true);
$isWorkflowListMenuIsOpen.set(true);
break;
case 'viewAllStylePresets':
// Go to the canvas tab and open the style presets menu

View File

@@ -157,7 +157,7 @@ export const EntityListSelectedEntityActionBarOpacity = memo(() => {
clampValueOnBlur={false}
variant="outline"
>
<NumberInputField paddingInlineEnd={7} _focusVisible={{ zIndex: 0 }} />
<NumberInputField paddingInlineEnd={7} _focusVisible={{ zIndex: 0 }} title="" />
<PopoverTrigger>
<IconButton
aria-label="open-slider"

View File

@@ -26,16 +26,10 @@ export const RegionalGuidanceMenuItemsAddPromptsAndIPAdapter = memo(() => {
return (
<>
<MenuItem
onPointerUp={addRegionalGuidancePositivePrompt}
isDisabled={!validActions.canAddPositivePrompt || isBusy}
>
<MenuItem onClick={addRegionalGuidancePositivePrompt} isDisabled={!validActions.canAddPositivePrompt || isBusy}>
{t('controlLayers.addPositivePrompt')}
</MenuItem>
<MenuItem
onPointerUp={addRegionalGuidanceNegativePrompt}
isDisabled={!validActions.canAddNegativePrompt || isBusy}
>
<MenuItem onClick={addRegionalGuidanceNegativePrompt} isDisabled={!validActions.canAddNegativePrompt || isBusy}>
{t('controlLayers.addNegativePrompt')}
</MenuItem>
<MenuItem onClick={addRegionalGuidanceIPAdapter} isDisabled={isBusy}>

View File

@@ -164,7 +164,7 @@ export const ToolBrushWidth = memo(() => {
onKeyDown={onKeyDown}
clampValueOnBlur={false}
>
<NumberInputField paddingInlineEnd={7} />
<NumberInputField _focusVisible={{ zIndex: 0 }} title="" paddingInlineEnd={7} />
<PopoverTrigger>
<IconButton
aria-label="open-slider"

View File

@@ -167,7 +167,7 @@ export const ToolEraserWidth = memo(() => {
onKeyDown={onKeyDown}
clampValueOnBlur={false}
>
<NumberInputField paddingInlineEnd={7} />
<NumberInputField _focusVisible={{ zIndex: 0 }} title="" paddingInlineEnd={7} />
<PopoverTrigger>
<IconButton
aria-label="open-slider"

View File

@@ -132,7 +132,7 @@ export const CanvasToolbarScale = memo(() => {
onKeyDown={onKeyDown}
clampValueOnBlur={false}
>
<NumberInputField paddingInlineEnd={7} />
<NumberInputField paddingInlineEnd={7} title="" _focusVisible={{ zIndex: 0 }} />
<PopoverTrigger>
<IconButton
aria-label="open-slider"

View File

@@ -24,7 +24,7 @@ export const CanvasEntityDeleteButton = memo(() => {
variant="link"
alignSelf="stretch"
icon={<PiTrashSimpleFill />}
onPointerUp={onClick}
onClick={onClick}
colorScheme="error"
isDisabled={isBusy}
/>

View File

@@ -395,6 +395,7 @@ export class CanvasToolModule extends CanvasModuleBase {
const isMouseDown = getIsPrimaryMouseDown(e);
this.$isMouseDown.set(isMouseDown);
this.syncCursorPositions();
const cursorPos = this.$cursorPos.get();
const tool = this.$tool.get();
const settings = this.manager.stateApi.getSettings();

View File

@@ -1,10 +1,11 @@
import { Input, Text } from '@invoke-ai/ui-library';
import { Flex, IconButton, Input, Text } from '@invoke-ai/ui-library';
import { useBoolean } from 'common/hooks/useBoolean';
import { withResultAsync } from 'common/util/result';
import { toast } from 'features/toast/toast';
import type { ChangeEvent, KeyboardEvent } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPencilBold } from 'react-icons/pi';
import { useUpdateBoardMutation } from 'services/api/endpoints/boards';
import type { BoardDTO } from 'services/api/types';
@@ -16,6 +17,7 @@ type Props = {
export const BoardEditableTitle = memo(({ board, isSelected }: Props) => {
const { t } = useTranslation();
const isEditing = useBoolean(false);
const [isHovering, setIsHovering] = useState(false);
const [localTitle, setLocalTitle] = useState(board.board_name);
const ref = useRef<HTMLInputElement>(null);
const [updateBoard, updateBoardResult] = useUpdateBoardMutation();
@@ -24,6 +26,11 @@ export const BoardEditableTitle = memo(({ board, isSelected }: Props) => {
setLocalTitle(e.target.value);
}, []);
const onEdit = useCallback(() => {
isEditing.setTrue();
setIsHovering(false);
}, [isEditing]);
const onBlur = useCallback(async () => {
const trimmedTitle = localTitle.trim();
isEditing.setFalse();
@@ -58,6 +65,14 @@ export const BoardEditableTitle = memo(({ board, isSelected }: Props) => {
[board.board_name, isEditing, onBlur]
);
const handleMouseOver = useCallback(() => {
setIsHovering(true);
}, []);
const handleMouseOut = useCallback(() => {
setIsHovering(false);
}, []);
useEffect(() => {
if (isEditing.isTrue) {
ref.current?.focus();
@@ -67,17 +82,21 @@ export const BoardEditableTitle = memo(({ board, isSelected }: Props) => {
if (!isEditing.isTrue) {
return (
<Text
size="sm"
fontWeight="semibold"
userSelect="none"
color={isSelected ? 'base.100' : 'base.300'}
onDoubleClick={isEditing.setTrue}
cursor="text"
minW={16}
>
{localTitle}
</Text>
<Flex alignItems="center" gap={3} onMouseOver={handleMouseOver} onMouseOut={handleMouseOut}>
<Text
size="sm"
fontWeight="semibold"
userSelect="none"
color={isSelected ? 'base.100' : 'base.300'}
onDoubleClick={onEdit}
cursor="text"
>
{localTitle}
</Text>
{isHovering && (
<IconButton aria-label="edit name" icon={<PiPencilBold />} size="sm" variant="ghost" onClick={onEdit} />
)}
</Flex>
);
}

View File

@@ -9,6 +9,7 @@ import {
TabList,
Tabs,
Text,
Tooltip,
useDisclosure,
} from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
@@ -74,12 +75,16 @@ export const Gallery = () => {
{boardName}
</Text>
<Spacer />
<Tab sx={BASE_STYLES} _selected={SELECTED_STYLES} onClick={handleClickImages} data-testid="images-tab">
{t('parameters.images')}
</Tab>
<Tab sx={BASE_STYLES} _selected={SELECTED_STYLES} onClick={handleClickAssets} data-testid="assets-tab">
{t('gallery.assets')}
</Tab>
<Tooltip label={t('gallery.imagesTab')}>
<Tab sx={BASE_STYLES} _selected={SELECTED_STYLES} onClick={handleClickImages} data-testid="images-tab">
{t('parameters.images')}
</Tab>
</Tooltip>
<Tooltip label={t('gallery.assetsTab')}>
<Tab sx={BASE_STYLES} _selected={SELECTED_STYLES} onClick={handleClickAssets} data-testid="assets-tab">
{t('gallery.assets')}
</Tab>
</Tooltip>
<IconButton
size="sm"
variant="link"

View File

@@ -89,7 +89,7 @@ const ImageViewerCloseButton = memo(() => {
aria-label={t('gallery.closeViewer')}
icon={<PiXBold />}
variant="ghost"
onPointerUp={imageViewer.close}
onClick={imageViewer.close}
/>
);
});

View File

@@ -1,6 +1,7 @@
import { Flex, IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectWorkflowMode, workflowModeChanged } from 'features/nodes/store/workflowSlice';
import type { MouseEventHandler } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiEyeBold, PiPencilBold } from 'react-icons/pi';
@@ -10,13 +11,21 @@ export const ModeToggle = () => {
const mode = useAppSelector(selectWorkflowMode);
const { t } = useTranslation();
const onClickEdit = useCallback(() => {
dispatch(workflowModeChanged('edit'));
}, [dispatch]);
const onClickEdit = useCallback<MouseEventHandler<HTMLButtonElement>>(
(e) => {
e.stopPropagation();
dispatch(workflowModeChanged('edit'));
},
[dispatch]
);
const onClickView = useCallback(() => {
dispatch(workflowModeChanged('view'));
}, [dispatch]);
const onClickView = useCallback<MouseEventHandler<HTMLButtonElement>>(
(e) => {
e.stopPropagation();
dispatch(workflowModeChanged('view'));
},
[dispatch]
);
return (
<Flex justifyContent="flex-end">
@@ -26,7 +35,8 @@ export const ModeToggle = () => {
tooltip={t('nodes.editMode')}
onClick={onClickEdit}
icon={<PiPencilBold />}
colorScheme="invokeBlue"
variant="outline"
size="sm"
/>
)}
{mode === 'edit' && (
@@ -35,7 +45,8 @@ export const ModeToggle = () => {
tooltip={t('nodes.viewMode')}
onClick={onClickView}
icon={<PiEyeBold />}
colorScheme="invokeBlue"
variant="outline"
size="sm"
/>
)}
</Flex>

View File

@@ -1,26 +1,43 @@
import 'reactflow/dist/style.css';
import { Flex } from '@invoke-ai/ui-library';
import { Box, Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { selectWorkflowMode } from 'features/nodes/store/workflowSlice';
import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
import { $isWorkflowListMenuIsOpen } from 'features/nodes/store/workflowListMenu';
import { selectCleanEditor, selectWorkflowMode } from 'features/nodes/store/workflowSlice';
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
import WorkflowLibraryButton from 'features/workflowLibrary/components/WorkflowLibraryButton';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import type { CSSProperties } from 'react';
import { memo, useCallback, useRef } from 'react';
import { memo, useCallback, useEffect, useRef } from 'react';
import type { ImperativePanelGroupHandle } from 'react-resizable-panels';
import { Panel, PanelGroup } from 'react-resizable-panels';
import InspectorPanel from './inspector/InspectorPanel';
import { WorkflowViewMode } from './viewMode/WorkflowViewMode';
import WorkflowPanel from './workflow/WorkflowPanel';
import { WorkflowMenu } from './WorkflowMenu';
import { WorkflowName } from './WorkflowName';
import { WorkflowListMenu } from './WorkflowListMenu/WorkflowListMenu';
import { WorkflowListMenuTrigger } from './WorkflowListMenu/WorkflowListMenuTrigger';
const panelGroupStyles: CSSProperties = { height: '100%', width: '100%' };
const overlayScrollbarsStyles: CSSProperties = {
height: '100%',
width: '100%',
};
const NodeEditorPanelGroup = () => {
const mode = useAppSelector(selectWorkflowMode);
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
const isWorkflowListMenuOpen = useStore($isWorkflowListMenuIsOpen);
const isCleanEditor = useAppSelector(selectCleanEditor);
useEffect(() => {
if (isCleanEditor) {
$isWorkflowListMenuIsOpen.set(true);
}
}, [isCleanEditor]);
const handleDoubleClickHandle = useCallback(() => {
if (!panelGroupRef.current) {
@@ -31,32 +48,39 @@ const NodeEditorPanelGroup = () => {
return (
<Flex w="full" h="full" gap={2} flexDir="column">
<Flex w="full" justifyContent="space-between" alignItems="center" gap="4" padding={1}>
<Flex justifyContent="space-between" alignItems="center" gap="4">
<WorkflowLibraryButton />
<WorkflowName />
</Flex>
<WorkflowMenu />
</Flex>
<WorkflowListMenuTrigger />
<Flex w="full" h="full" position="relative">
<Box position="absolute" top={0} left={0} right={0} bottom={0}>
{isWorkflowListMenuOpen && (
<OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayScrollbarsParams.options}>
<Flex gap={2} flexDirection="column" h="full" w="full">
<WorkflowListMenu />
</Flex>
</OverlayScrollbarsComponent>
)}
{mode === 'view' && <WorkflowViewMode />}
{mode === 'edit' && (
<PanelGroup
ref={panelGroupRef}
id="workflow-panel-group"
autoSaveId="workflow-panel-group"
direction="vertical"
style={panelGroupStyles}
>
<Panel id="workflow" collapsible minSize={25}>
<WorkflowPanel />
</Panel>
<ResizeHandle onDoubleClick={handleDoubleClickHandle} />
<Panel id="inspector" collapsible minSize={25}>
<InspectorPanel />
</Panel>
</PanelGroup>
)}
<OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayScrollbarsParams.options}>
{mode === 'view' && <WorkflowViewMode />}
{mode === 'edit' && (
<PanelGroup
ref={panelGroupRef}
id="workflow-panel-group"
autoSaveId="workflow-panel-group"
direction="vertical"
style={panelGroupStyles}
>
<Panel id="workflow" collapsible minSize={25}>
<WorkflowPanel />
</Panel>
<ResizeHandle onDoubleClick={handleDoubleClickHandle} />
<Panel id="inspector" collapsible minSize={25}>
<InspectorPanel />
</Panel>
</PanelGroup>
)}
</OverlayScrollbarsComponent>
</Box>
</Flex>
</Flex>
);
};

View File

@@ -0,0 +1,59 @@
import { Flex, IconButton, Spacer, Text, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { ModeToggle } from 'features/nodes/components/sidePanel/ModeToggle';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { $isWorkflowListMenuIsOpen } from 'features/nodes/store/workflowListMenu';
import { selectWorkflowDescription, selectWorkflowMode, selectWorkflowName } from 'features/nodes/store/workflowSlice';
import type { MouseEventHandler } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
import SaveWorkflowButton from './SaveWorkflowButton';
export const ActiveWorkflow = () => {
const activeWorkflowName = useAppSelector(selectWorkflowName);
const activeWorkflowDescription = useAppSelector(selectWorkflowDescription);
const mode = useAppSelector(selectWorkflowMode);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const handleNewWorkflow = useCallback<MouseEventHandler<HTMLButtonElement>>(
(e) => {
e.stopPropagation();
dispatch(nodeEditorReset());
$isWorkflowListMenuIsOpen.set(false);
},
[dispatch]
);
return (
<Flex w="full" alignItems="center" gap={2} minW={0}>
{activeWorkflowName ? (
<Tooltip label={activeWorkflowDescription}>
<Text colorScheme="invokeBlue" fontWeight="semibold" fontSize="md" justifySelf="flex-start">
{activeWorkflowName}
</Text>
</Tooltip>
) : (
<Text fontSize="sm" fontWeight="semibold" color="base.300">
{t('workflows.chooseWorkflowFromLibrary')}
</Text>
)}
<Spacer />
{mode === 'edit' && <SaveWorkflowButton />}
<ModeToggle />
<Tooltip label={t('nodes.clearWorkflow')}>
<IconButton
onClick={handleNewWorkflow}
variant="outline"
size="sm"
aria-label={t('nodes.clearWorkflow')}
icon={<PiXBold />}
colorScheme="error"
/>
</Tooltip>
</Flex>
);
};

View File

@@ -0,0 +1,46 @@
import { IconButton } from '@invoke-ai/ui-library';
import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher';
import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog';
import { isWorkflowWithID, useSaveLibraryWorkflow } from 'features/workflowLibrary/hooks/useSaveWorkflow';
import type { MouseEventHandler } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFloppyDiskBold } from 'react-icons/pi';
const SaveWorkflowButton = () => {
const { t } = useTranslation();
const { onOpen } = useSaveWorkflowAsDialog();
const { saveWorkflow } = useSaveLibraryWorkflow();
const handleClickSave = useCallback<MouseEventHandler<HTMLButtonElement>>(
(e) => {
e.stopPropagation();
const builtWorkflow = $builtWorkflow.get();
if (!builtWorkflow) {
return;
}
if (isWorkflowWithID(builtWorkflow)) {
saveWorkflow();
} else {
onOpen();
}
},
[onOpen, saveWorkflow]
);
return (
<IconButton
tooltip={t('workflows.saveWorkflow')}
aria-label={t('workflows.saveWorkflow')}
icon={<PiFloppyDiskBold />}
onClick={handleClickSave}
pointerEvents="auto"
variant="outline"
size="sm"
/>
);
};
export default memo(SaveWorkflowButton);

View File

@@ -0,0 +1,82 @@
import { Button, Collapse, Flex, Icon, Spinner, Text } from '@invoke-ai/ui-library';
import { EMPTY_ARRAY } from 'app/store/constants';
import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { useCategorySections } from 'features/nodes/hooks/useCategorySections';
import {
selectWorkflowOrderBy,
selectWorkflowOrderDirection,
selectWorkflowSearchTerm,
} from 'features/nodes/store/workflowSlice';
import type { WorkflowCategory } from 'features/nodes/types/workflow';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretDownBold } from 'react-icons/pi';
import { useListWorkflowsQuery } from 'services/api/endpoints/workflows';
import { WorkflowListItem } from './WorkflowListItem';
export const WorkflowList = ({ category }: { category: WorkflowCategory }) => {
const searchTerm = useAppSelector(selectWorkflowSearchTerm);
const orderBy = useAppSelector(selectWorkflowOrderBy);
const direction = useAppSelector(selectWorkflowOrderDirection);
const { t } = useTranslation();
const queryArg = useMemo<Parameters<typeof useListWorkflowsQuery>[0]>(() => {
if (category !== 'default') {
return {
order_by: orderBy,
direction,
category: category,
};
}
return {
order_by: 'name' as const,
direction: 'ASC' as const,
category: category,
};
}, [category, direction, orderBy]);
const { data, isLoading } = useListWorkflowsQuery(queryArg, {
selectFromResult: ({ data, isLoading }) => {
const filteredData =
data?.items.filter((workflow) => workflow.name.toLowerCase().includes(searchTerm.toLowerCase())) || EMPTY_ARRAY;
return {
data: filteredData,
isLoading,
};
},
});
const { isOpen, onToggle } = useCategorySections(category);
return (
<Flex flexDir="column">
<Button variant="unstyled" onClick={onToggle}>
<Flex gap={2} alignItems="center">
<Icon boxSize={4} as={PiCaretDownBold} transform={isOpen ? undefined : 'rotate(-90deg)'} fill="base.500" />
<Text fontSize="sm" fontWeight="semibold" userSelect="none" color="base.500">
{t(`workflows.${category}Workflows`)}
</Text>
</Flex>
</Button>
<Collapse in={isOpen}>
{isLoading ? (
<Flex alignItems="center" justifyContent="center" p={20}>
<Spinner />
</Flex>
) : data.length ? (
data.map((workflow) => <WorkflowListItem workflow={workflow} key={workflow.workflow_id} />)
) : (
<IAINoContentFallback
fontSize="sm"
py={4}
label={searchTerm ? t('nodes.noMatchingWorkflows') : t('nodes.noWorkflows')}
icon={null}
/>
)}
</Collapse>
</Flex>
);
};

View File

@@ -0,0 +1,162 @@
import {
Badge,
ConfirmationAlertDialog,
Flex,
IconButton,
Spacer,
Text,
Tooltip,
useDisclosure,
} from '@invoke-ai/ui-library';
import { EMPTY_OBJECT } from 'app/store/constants';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import dateFormat, { masks } from 'dateformat';
import { $isWorkflowListMenuIsOpen } from 'features/nodes/store/workflowListMenu';
import { selectWorkflowId, workflowModeChanged } from 'features/nodes/store/workflowSlice';
import { useDeleteLibraryWorkflow } from 'features/workflowLibrary/hooks/useDeleteLibraryWorkflow';
import { useDownloadWorkflow } from 'features/workflowLibrary/hooks/useDownloadWorkflow';
import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow';
import type { MouseEvent } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PiDownloadSimpleBold, PiPencilBold, PiTrashBold } from 'react-icons/pi';
import type { WorkflowRecordListItemDTO } from 'services/api/types';
import { WorkflowListItemTooltip } from './WorkflowListItemTooltip';
export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListItemDTO }) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [isHovered, setIsHovered] = useState(false);
const handleMouseOver = useCallback(() => {
setIsHovered(true);
}, []);
const handleMouseOut = useCallback(() => {
setIsHovered(false);
}, []);
const workflowId = useAppSelector(selectWorkflowId);
const downloadWorkflow = useDownloadWorkflow();
const { deleteWorkflow, deleteWorkflowResult } = useDeleteLibraryWorkflow(EMPTY_OBJECT);
const { getAndLoadWorkflow } = useGetAndLoadLibraryWorkflow({
onSuccess: () => $isWorkflowListMenuIsOpen.set(false),
});
const isActive = useMemo(() => {
return workflowId === workflow.workflow_id;
}, [workflowId, workflow.workflow_id]);
const handleClickLoad = useCallback(() => {
getAndLoadWorkflow(workflow.workflow_id);
$isWorkflowListMenuIsOpen.set(false);
}, [workflow.workflow_id, getAndLoadWorkflow]);
const handleClickEdit = useCallback(async () => {
await getAndLoadWorkflow(workflow.workflow_id);
dispatch(workflowModeChanged('edit'));
$isWorkflowListMenuIsOpen.set(false);
}, [workflow.workflow_id, dispatch, getAndLoadWorkflow]);
const handleDeleteWorklow = useCallback(() => {
deleteWorkflow(workflow.workflow_id);
onClose();
}, [workflow.workflow_id, deleteWorkflow, onClose]);
const handleClickDelete = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
onOpen();
},
[onOpen]
);
return (
<>
<Flex
gap={4}
onClick={handleClickLoad}
cursor="pointer"
_hover={{ backgroundColor: 'base.750' }}
p={2}
ps={3}
borderRadius="base"
w="full"
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
alignItems="center"
>
<Tooltip label={<WorkflowListItemTooltip workflow={workflow} />}>
<Flex flexDir="column" gap={1}>
<Flex gap={4} alignItems="center">
<Text noOfLines={2}>{workflow.name}</Text>
{isActive && (
<Badge
color="invokeBlue.400"
borderColor="invokeBlue.700"
borderWidth={1}
bg="transparent"
flexShrink={0}
>
{t('workflows.opened')}
</Badge>
)}
</Flex>
{workflow.category !== 'default' && (
<Text fontSize="xs" variant="subtext" flexShrink={0} noOfLines={1}>
{t('common.updated')}: {dateFormat(workflow.updated_at, masks.shortDate)}{' '}
{dateFormat(workflow.updated_at, masks.shortTime)}
</Text>
)}
</Flex>
</Tooltip>
<Spacer />
<Flex alignItems="center" gap={1} opacity={isHovered ? 1 : 0}>
<IconButton
size="sm"
variant="ghost"
aria-label="Edit"
onClick={handleClickEdit}
isLoading={deleteWorkflowResult.isLoading}
icon={<PiPencilBold />}
/>
<IconButton
size="sm"
variant="ghost"
aria-label="Download"
onClick={downloadWorkflow}
icon={<PiDownloadSimpleBold />}
/>
{workflow.category !== 'default' && (
<IconButton
size="sm"
variant="ghost"
aria-label={t('stylePresets.deleteTemplate')}
onClick={handleClickDelete}
isLoading={deleteWorkflowResult.isLoading}
colorScheme="error"
icon={<PiTrashBold />}
/>
)}
</Flex>
</Flex>
<ConfirmationAlertDialog
isOpen={isOpen}
onClose={onClose}
title={t('workflows.deleteWorkflow')}
acceptCallback={handleDeleteWorklow}
acceptButtonText={t('common.delete')}
cancelButtonText={t('common.cancel')}
useInert={false}
>
<p>{t('workflows.deleteWorkflow2')}</p>
</ConfirmationAlertDialog>
</>
);
};

View File

@@ -0,0 +1,26 @@
import { Flex, Text } from '@invoke-ai/ui-library';
import dateFormat, { masks } from 'dateformat';
import { useTranslation } from 'react-i18next';
import type { WorkflowRecordListItemDTO } from 'services/api/types';
export const WorkflowListItemTooltip = ({ workflow }: { workflow: WorkflowRecordListItemDTO }) => {
const { t } = useTranslation();
return (
<Flex flexDir="column" gap={1}>
<Text>{workflow.description}</Text>
{workflow.category !== 'default' && (
<Flex flexDir="column">
<Text>
{t('workflows.opened')}: {dateFormat(workflow.opened_at, masks.shortDate)}
</Text>
<Text>
{t('common.updated')}: {dateFormat(workflow.updated_at, masks.shortDate)}
</Text>
<Text>
{t('common.created')}: {dateFormat(workflow.created_at, masks.shortDate)}
</Text>
</Flex>
)}
</Flex>
);
};

View File

@@ -0,0 +1,28 @@
import { Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $workflowCategories } from 'app/store/nanostores/workflowCategories';
import UploadWorkflowButton from 'features/workflowLibrary/components/UploadWorkflowButton';
import { WorkflowList } from './WorkflowList';
import WorkflowSearch from './WorkflowSearch';
import { WorkflowSortControl } from './WorkflowSortControl';
export const WorkflowListMenu = () => {
const workflowCategories = useStore($workflowCategories);
return (
<Flex flexDir="column" gap={2} padding={3} layerStyle="second" borderRadius="base">
<Flex alignItems="center" gap={2} w="full" justifyContent="space-between">
<WorkflowSearch />
<WorkflowSortControl />
<UploadWorkflowButton />
</Flex>
{workflowCategories.map((category) => (
<WorkflowList key={category} category={category} />
))}
</Flex>
);
};

View File

@@ -0,0 +1,44 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex, IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $isWorkflowListMenuIsOpen } from 'features/nodes/store/workflowListMenu';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretDownBold } from 'react-icons/pi';
import { ActiveWorkflow } from './ActiveWorkflow';
const _hover: SystemStyleObject = {
bg: 'base.750',
};
export const WorkflowListMenuTrigger = () => {
const isMenuOpen = useStore($isWorkflowListMenuIsOpen);
const { t } = useTranslation();
const handleToggle = useCallback(() => {
$isWorkflowListMenuIsOpen.set(!isMenuOpen);
}, [isMenuOpen]);
return (
<Flex
onClick={handleToggle}
backgroundColor="base.800"
justifyContent="space-between"
alignItems="center"
py={2}
px={3}
borderRadius="base"
gap={2}
role="button"
_hover={_hover}
transitionProperty="background-color"
transitionDuration="normal"
w="full"
cursor="pointer"
>
<ActiveWorkflow />
<IconButton aria-label={t('stylePresets.viewList')} variant="ghost" icon={<PiCaretDownBold />} size="sm" />
</Flex>
);
};

View File

@@ -0,0 +1,65 @@
import { IconButton, Input, InputGroup, InputRightElement } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectWorkflowSearchTerm, workflowSearchTermChanged } from 'features/nodes/store/workflowSlice';
import type { ChangeEvent, KeyboardEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
const WorkflowSearch = () => {
const dispatch = useAppDispatch();
const searchTerm = useAppSelector(selectWorkflowSearchTerm);
const { t } = useTranslation();
const handleWorkflowSearch = useCallback(
(newSearchTerm: string) => {
dispatch(workflowSearchTermChanged(newSearchTerm));
},
[dispatch]
);
const clearWorkflowSearch = useCallback(() => {
dispatch(workflowSearchTermChanged(''));
}, [dispatch]);
const handleKeydown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
// exit search mode on escape
if (e.key === 'Escape') {
clearWorkflowSearch();
}
},
[clearWorkflowSearch]
);
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
handleWorkflowSearch(e.target.value);
},
[handleWorkflowSearch]
);
return (
<InputGroup>
<Input
placeholder={t('stylePresets.searchByName')}
value={searchTerm}
onKeyDown={handleKeydown}
onChange={handleChange}
/>
{searchTerm && searchTerm.length && (
<InputRightElement h="full" pe={2}>
<IconButton
onClick={clearWorkflowSearch}
size="sm"
variant="link"
aria-label={t('boards.clearSearch')}
icon={<PiXBold />}
/>
</InputRightElement>
)}
</InputGroup>
);
};
export default memo(WorkflowSearch);

View File

@@ -0,0 +1,120 @@
import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
import {
Combobox,
Flex,
FormControl,
FormLabel,
IconButton,
Popover,
PopoverBody,
PopoverContent,
PopoverTrigger,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $projectId } from 'app/store/nanostores/projectId';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
selectWorkflowOrderBy,
selectWorkflowOrderDirection,
workflowOrderByChanged,
workflowOrderDirectionChanged,
} from 'features/nodes/store/workflowSlice';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiSortAscendingBold, PiSortDescendingBold } from 'react-icons/pi';
import { z } from 'zod';
const zOrderBy = z.enum(['opened_at', 'created_at', 'updated_at', 'name']);
type OrderBy = z.infer<typeof zOrderBy>;
const isOrderBy = (v: unknown): v is OrderBy => zOrderBy.safeParse(v).success;
const zDirection = z.enum(['ASC', 'DESC']);
type Direction = z.infer<typeof zDirection>;
const isDirection = (v: unknown): v is Direction => zDirection.safeParse(v).success;
export const WorkflowSortControl = () => {
const projectId = useStore($projectId);
const { t } = useTranslation();
const orderBy = useAppSelector(selectWorkflowOrderBy);
const direction = useAppSelector(selectWorkflowOrderDirection);
const ORDER_BY_OPTIONS: ComboboxOption[] = useMemo(
() => [
{ value: 'opened_at', label: t('workflows.opened') },
{ value: 'created_at', label: t('workflows.created') },
{ value: 'updated_at', label: t('workflows.updated') },
{ value: 'name', label: t('workflows.name') },
],
[t]
);
const DIRECTION_OPTIONS: ComboboxOption[] = useMemo(
() => [
{ value: 'ASC', label: t('workflows.ascending') },
{ value: 'DESC', label: t('workflows.descending') },
],
[t]
);
const dispatch = useAppDispatch();
const orderByOptions = useMemo(() => {
return projectId ? ORDER_BY_OPTIONS.filter((option) => option.value !== 'opened_at') : ORDER_BY_OPTIONS;
}, [projectId, ORDER_BY_OPTIONS]);
const onChangeOrderBy = useCallback<ComboboxOnChange>(
(v) => {
if (!isOrderBy(v?.value) || v.value === orderBy) {
return;
}
dispatch(workflowOrderByChanged(v.value));
},
[orderBy, dispatch]
);
const valueOrderBy = useMemo(() => {
return orderByOptions.find((o) => o.value === orderBy) || orderByOptions[0];
}, [orderBy, orderByOptions]);
const onChangeDirection = useCallback<ComboboxOnChange>(
(v) => {
if (!isDirection(v?.value) || v.value === direction) {
return;
}
dispatch(workflowOrderDirectionChanged(v.value));
},
[direction, dispatch]
);
const valueDirection = useMemo(
() => DIRECTION_OPTIONS.find((o) => o.value === direction),
[direction, DIRECTION_OPTIONS]
);
return (
<Popover placement="bottom">
<PopoverTrigger>
<IconButton
tooltip={`Sorting by ${valueOrderBy?.label} ${valueDirection?.label}`}
aria-label="Sort Workflow Library"
icon={direction === 'ASC' ? <PiSortAscendingBold /> : <PiSortDescendingBold />}
variant="ghost"
/>
</PopoverTrigger>
<PopoverContent>
<PopoverBody>
<Flex flexDir="column" gap={4}>
<FormControl orientation="horizontal" gap={1}>
<FormLabel>{t('common.orderBy')}</FormLabel>
<Combobox value={valueOrderBy} options={orderByOptions} onChange={onChangeOrderBy} />
</FormControl>
<FormControl orientation="horizontal" gap={1}>
<FormLabel>{t('common.direction')}</FormLabel>
<Combobox value={valueDirection} options={DIRECTION_OPTIONS} onChange={onChangeDirection} />
</FormControl>
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
);
};

View File

@@ -1,19 +0,0 @@
import { Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import SaveWorkflowButton from 'features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton';
import { selectWorkflowMode } from 'features/nodes/store/workflowSlice';
import { NewWorkflowButton } from 'features/workflowLibrary/components/NewWorkflowButton';
import { ModeToggle } from './ModeToggle';
export const WorkflowMenu = () => {
const mode = useAppSelector(selectWorkflowMode);
return (
<Flex gap="2" alignItems="center">
{mode === 'edit' && <SaveWorkflowButton />}
<NewWorkflowButton />
<ModeToggle />
</Flex>
);
};

View File

@@ -28,7 +28,7 @@ export const WorkflowName = () => {
)}
{isTouched && mode === 'edit' && (
<Tooltip label="Workflow has unsaved changes">
<Tooltip label={t('nodes.newWorkflowDesc2')}>
<Flex>
<Icon as={PiDotOutlineFill} boxSize="20px" sx={{ color: 'invokeYellow.500' }} />
</Flex>

View File

@@ -1,36 +1,39 @@
import { Button, Flex, Image, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { workflowModeChanged } from 'features/nodes/store/workflowSlice';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { $isWorkflowListMenuIsOpen } from 'features/nodes/store/workflowListMenu';
import { selectCleanEditor, workflowModeChanged } from 'features/nodes/store/workflowSlice';
import InvokeLogoSVG from 'public/assets/images/invoke-symbol-wht-lrg.svg';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
export const EmptyState = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isCleanEditor = useAppSelector(selectCleanEditor);
const onClick = useCallback(() => {
dispatch(workflowModeChanged('edit'));
}, [dispatch]);
const onClickLoadWorkflow = useCallback(() => {
$isWorkflowListMenuIsOpen.set(true);
}, []);
const onClickNewWorkflow = useCallback(() => {
dispatch(workflowModeChanged('edit'));
}, [dispatch]);
return (
<Flex
sx={{
w: 'full',
h: 'full',
userSelect: 'none',
}}
>
<Flex w="full" userSelect="none" justifyContent="center">
<Flex
sx={{
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'base',
flexDir: 'column',
gap: 5,
maxW: '230px',
margin: '0 auto',
}}
alignItems="center"
justifyContent="center"
borderRadius="base"
flexDir="column"
gap={5}
maxW="230px"
pt={24}
>
<Image
src={InvokeLogoSVG}
@@ -43,12 +46,45 @@ export const EmptyState = () => {
minH={16}
userSelect="none"
/>
<Text textAlign="center" fontSize="md">
{t('nodes.noFieldsViewMode')}
</Text>
<Button colorScheme="invokeBlue" onClick={onClick}>
{t('nodes.edit')}
</Button>
{isCleanEditor ? (
<>
<Flex gap={2}>
<Button size="sm" onClick={onClickNewWorkflow}>
{t('nodes.newWorkflow')}
</Button>
<Button size="sm" colorScheme="invokeBlue" onClick={onClickLoadWorkflow}>
{t('nodes.loadWorkflow')}
</Button>
</Flex>
<Text textAlign="center" fontSize="md">
<Trans
i18nKey="nodes.workflowHelpText"
size="sm"
components={{
LinkComponent: (
<Text
as="a"
color="white"
fontSize="md"
fontWeight="semibold"
href="https://support.invoke.ai/support/solutions/articles/151000159663-example-workflows"
target="_blank"
/>
),
}}
/>
</Text>
</>
) : (
<>
<Text textAlign="center" fontSize="md">
{t('nodes.noFieldsViewMode')}
</Text>
<Button size="sm" colorScheme="invokeBlue" onClick={onClick}>
{t('nodes.edit')}
</Button>
</>
)}
</Flex>
</Flex>
);

View File

@@ -23,7 +23,7 @@ export const WorkflowViewMode = () => {
return (
<Box position="relative" w="full" h="full">
<ScrollableContent>
<Flex position="relative" flexDir="column" alignItems="flex-start" p={1} gap={2} h="full" w="full">
<Flex position="relative" flexDir="column" alignItems="flex-start" p={1} gap={2} w="full" h="full">
{isLoading ? (
<IAINoContentFallback label={t('nodes.loadingNodes')} icon={null} />
) : fields.length ? (

View File

@@ -0,0 +1,18 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { categorySectionsChanged, selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
import { useCallback, useMemo } from 'react';
export const useCategorySections = (id: string) => {
const dispatch = useAppDispatch();
const selectIsOpen = useMemo(
() => createSelector(selectWorkflowSlice, (workflow) => workflow.categorySections[id] ?? true),
[id]
);
const isOpen = useAppSelector(selectIsOpen);
const onToggle = useCallback(() => {
dispatch(categorySectionsChanged({ id, isOpen: !isOpen }));
}, [id, dispatch, isOpen]);
return { isOpen, onToggle };
};

View File

@@ -1,3 +1,4 @@
import { logger } from 'app/logging/logger';
import { getStore } from 'app/store/nanostores/store';
import { deepClone } from 'common/util/deepClone';
import {
@@ -5,15 +6,21 @@ import {
$copiedNodes,
$cursorPos,
$edgesToCopiedNodes,
$templates,
edgesChanged,
nodesChanged,
} from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { findUnoccupiedPosition } from 'features/nodes/store/util/findUnoccupiedPosition';
import { isEqual, uniqWith } from 'lodash-es';
import { validateConnection } from 'features/nodes/store/util/validateConnection';
import { t } from 'i18next';
import { isEqual, isNil, uniqWith } from 'lodash-es';
import type { EdgeChange, NodeChange } from 'reactflow';
import { assert } from 'tsafe';
import { v4 as uuidv4 } from 'uuid';
const log = logger('workflows');
const copySelection = () => {
// Use the imperative API here so we don't have to pass the whole slice around
const { getState } = getStore();
@@ -29,6 +36,7 @@ const copySelection = () => {
const _pasteSelection = (withEdgesToCopiedNodes?: boolean) => {
const { getState, dispatch } = getStore();
const { nodes, edges } = selectNodesSlice(getState());
const templates = $templates.get();
const cursorPos = $cursorPos.get();
const copiedNodes = deepClone($copiedNodes.get());
@@ -101,12 +109,44 @@ const _pasteSelection = (withEdgesToCopiedNodes?: boolean) => {
});
}
});
// When we validate the new edges, we need to include the copied nodes as well as the existing nodes,
// else the edges will all fail bc they point to nodes that don't exist yet
const validationNodes = [...nodes, ...copiedNodes];
// As an edge is validated, we will need to add it to the list of edges used for validation, because
// validation may depend on the existence of other edges
const validationEdges = [...edges];
// Add new edges
copiedEdges.forEach((e) => {
const { source, sourceHandle, target, targetHandle } = e;
// We need a type guard here to work around reactflow types
assert(!isNil(sourceHandle));
assert(!isNil(targetHandle));
// Validate the edge before adding it
const validationResult = validateConnection(
{ source, sourceHandle, target, targetHandle },
validationNodes,
validationEdges,
templates,
null,
true
);
// If the edge is invalid, log a warning and skip it
if (!validationResult.isValid) {
log.warn(
{ edge: { source, sourceHandle, target, targetHandle } },
`Invalid edge, cannot paste: ${t(validationResult.messageTKey)}`
);
return;
}
edgeChanges.push({
type: 'add',
item: e,
});
// Add the edge to the list of edges used for validation so that subsequent edges can depend on it
validationEdges.push(e);
});
if (nodeChanges.length > 0) {
dispatch(nodesChanged(nodeChanges));

View File

@@ -12,6 +12,7 @@ import type {
} from 'features/nodes/types/invocation';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import type { HandleType } from 'reactflow';
import type { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types';
export type Templates = Record<string, InvocationTemplate>;
export type NodeExecutionStates = Record<string, NodeExecutionState | undefined>;
@@ -39,4 +40,8 @@ export type WorkflowsState = Omit<WorkflowV3, 'nodes' | 'edges'> & {
isTouched: boolean;
mode: WorkflowMode;
originalExposedFieldValues: FieldIdentifierWithValue[];
searchTerm: string;
orderBy?: WorkflowRecordOrderBy;
orderDirection: SQLiteDirection;
categorySections: Record<string, boolean>;
};

View File

@@ -0,0 +1,6 @@
import { atom } from 'nanostores';
/**
* Tracks the state for the workflow list menu.
*/
export const $isWorkflowListMenuIsOpen = atom<boolean>(false);

View File

@@ -13,6 +13,9 @@ import type { FieldIdentifier } from 'features/nodes/types/field';
import { isInvocationNode } from 'features/nodes/types/invocation';
import type { WorkflowCategory, WorkflowV3 } from 'features/nodes/types/workflow';
import { isEqual, omit, uniqBy } from 'lodash-es';
import type { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types';
import { selectNodesSlice } from './selectors';
const blankWorkflow: Omit<WorkflowV3, 'nodes' | 'edges'> = {
name: '',
@@ -32,6 +35,10 @@ const initialWorkflowState: WorkflowState = {
isTouched: false,
mode: 'view',
originalExposedFieldValues: [],
searchTerm: '',
orderBy: undefined, // initial value is decided in component
orderDirection: 'DESC',
categorySections: {},
...blankWorkflow,
};
@@ -42,6 +49,19 @@ export const workflowSlice = createSlice({
workflowModeChanged: (state, action: PayloadAction<WorkflowMode>) => {
state.mode = action.payload;
},
workflowSearchTermChanged: (state, action: PayloadAction<string>) => {
state.searchTerm = action.payload;
},
workflowOrderByChanged: (state, action: PayloadAction<WorkflowRecordOrderBy>) => {
state.orderBy = action.payload;
},
workflowOrderDirectionChanged: (state, action: PayloadAction<SQLiteDirection>) => {
state.orderDirection = action.payload;
},
categorySectionsChanged: (state, action: PayloadAction<{ id: string; isOpen: boolean }>) => {
const { id, isOpen } = action.payload;
state.categorySections[id] = isOpen;
},
workflowExposedFieldAdded: (state, action: PayloadAction<FieldIdentifierWithValue>) => {
state.exposedFields = uniqBy(
state.exposedFields.concat(omit(action.payload, 'value')),
@@ -207,6 +227,10 @@ export const {
workflowContactChanged,
workflowIDChanged,
workflowSaved,
workflowSearchTermChanged,
workflowOrderByChanged,
workflowOrderDirectionChanged,
categorySectionsChanged,
} = workflowSlice.actions;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
@@ -232,3 +256,14 @@ export const selectWorkflowName = createWorkflowSelector((workflow) => workflow.
export const selectWorkflowId = createWorkflowSelector((workflow) => workflow.id);
export const selectWorkflowMode = createWorkflowSelector((workflow) => workflow.mode);
export const selectWorkflowIsTouched = createWorkflowSelector((workflow) => workflow.isTouched);
export const selectWorkflowSearchTerm = createWorkflowSelector((workflow) => workflow.searchTerm);
export const selectWorkflowOrderBy = createWorkflowSelector((workflow) => workflow.orderBy);
export const selectWorkflowOrderDirection = createWorkflowSelector((workflow) => workflow.orderDirection);
export const selectWorkflowDescription = createWorkflowSelector((workflow) => workflow.description);
export const selectCleanEditor = createSelector([selectNodesSlice, selectWorkflowSlice], (nodes, workflow) => {
const noNodes = !nodes.nodes.length;
const isTouched = workflow.isTouched;
const savedWorkflow = !!workflow.id;
return noNodes && !isTouched && !savedWorkflow;
});

View File

@@ -1,26 +0,0 @@
import { IconButton } from '@invoke-ai/ui-library';
import { NewWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFilePlusBold } from 'react-icons/pi';
export const NewWorkflowButton = memo(() => {
const { t } = useTranslation();
const renderButton = useCallback(
(onClick: () => void) => (
<IconButton
aria-label={t('nodes.newWorkflow')}
tooltip={t('nodes.newWorkflow')}
icon={<PiFilePlusBold />}
onClick={onClick}
pointerEvents="auto"
/>
),
[t]
);
return <NewWorkflowConfirmationAlertDialog renderButton={renderButton} />;
});
NewWorkflowButton.displayName = 'NewWorkflowButton';

View File

@@ -1,17 +1,27 @@
import { Button } from '@invoke-ai/ui-library';
import { IconButton } from '@invoke-ai/ui-library';
import { $isWorkflowListMenuIsOpen } from 'features/nodes/store/workflowListMenu';
import { useLoadWorkflowFromFile } from 'features/workflowLibrary/hooks/useLoadWorkflowFromFile';
import { useWorkflowLibraryModal } from 'features/workflowLibrary/store/isWorkflowLibraryModalOpen';
import { memo, useCallback, useRef } from 'react';
import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
import { PiUploadSimpleBold } from 'react-icons/pi';
import { useSaveWorkflowAsDialog } from './SaveWorkflowAsDialog/useSaveWorkflowAsDialog';
const UploadWorkflowButton = () => {
const { t } = useTranslation();
const resetRef = useRef<() => void>(null);
const workflowLibraryModal = useWorkflowLibraryModal();
const loadWorkflowFromFile = useLoadWorkflowFromFile({ resetRef, onSuccess: workflowLibraryModal.setFalse });
const { onOpen } = useSaveWorkflowAsDialog();
const loadWorkflowFromFile = useLoadWorkflowFromFile({
resetRef,
onSuccess: () => {
$isWorkflowListMenuIsOpen.set(false);
onOpen();
},
});
const onDropAccepted = useCallback(
(files: File[]) => {
if (!files[0]) {
@@ -30,15 +40,15 @@ const UploadWorkflowButton = () => {
});
return (
<>
<Button
aria-label={t('workflows.uploadWorkflow')}
tooltip={t('workflows.uploadWorkflow')}
leftIcon={<PiUploadSimpleBold />}
<IconButton
aria-label={t('workflows.uploadAndSaveWorkflow')}
tooltip={t('workflows.uploadAndSaveWorkflow')}
icon={<PiUploadSimpleBold />}
{...getRootProps()}
pointerEvents="auto"
>
{t('workflows.uploadWorkflow')}
</Button>
variant="ghost"
/>
<input {...getInputProps()} />
</>
);

View File

@@ -1,28 +0,0 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useWorkflowLibraryModal } from 'features/workflowLibrary/store/isWorkflowLibraryModalOpen';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFolderOpenBold } from 'react-icons/pi';
import WorkflowLibraryModal from './WorkflowLibraryModal';
const WorkflowLibraryButton = () => {
const { t } = useTranslation();
const workflowLibraryModal = useWorkflowLibraryModal();
return (
<>
<IconButton
aria-label={t('workflows.workflowLibrary')}
tooltip={t('workflows.workflowLibrary')}
icon={<PiFolderOpenBold />}
onClick={workflowLibraryModal.setTrue}
pointerEvents="auto"
/>
<WorkflowLibraryModal />
</>
);
};
export default memo(WorkflowLibraryButton);

View File

@@ -1,13 +0,0 @@
import WorkflowLibraryList from 'features/workflowLibrary/components/WorkflowLibraryList';
import WorkflowLibraryListWrapper from 'features/workflowLibrary/components/WorkflowLibraryListWrapper';
import { memo } from 'react';
const WorkflowLibraryContent = () => {
return (
<WorkflowLibraryListWrapper>
<WorkflowLibraryList />
</WorkflowLibraryListWrapper>
);
};
export default memo(WorkflowLibraryContent);

View File

@@ -1,252 +0,0 @@
import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
import {
Box,
Button,
ButtonGroup,
Combobox,
Divider,
Flex,
FormControl,
FormLabel,
IconButton,
Input,
InputGroup,
InputRightElement,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $projectId } from 'app/store/nanostores/projectId';
import { $workflowCategories } from 'app/store/nanostores/workflowCategories';
import { IAINoContentFallback, IAINoContentFallbackWithSpinner } from 'common/components/IAIImageFallback';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import type { WorkflowCategory } from 'features/nodes/types/workflow';
import WorkflowLibraryListItem from 'features/workflowLibrary/components/WorkflowLibraryListItem';
import WorkflowLibraryPagination from 'features/workflowLibrary/components/WorkflowLibraryPagination';
import type { ChangeEvent, KeyboardEvent } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
import { useListWorkflowsQuery } from 'services/api/endpoints/workflows';
import type { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types';
import { useDebounce } from 'use-debounce';
import { z } from 'zod';
import UploadWorkflowButton from './UploadWorkflowButton';
const PER_PAGE = 10;
const zOrderBy = z.enum(['opened_at', 'created_at', 'updated_at', 'name']);
type OrderBy = z.infer<typeof zOrderBy>;
const isOrderBy = (v: unknown): v is OrderBy => zOrderBy.safeParse(v).success;
const zDirection = z.enum(['ASC', 'DESC']);
type Direction = z.infer<typeof zDirection>;
const isDirection = (v: unknown): v is Direction => zDirection.safeParse(v).success;
const WorkflowLibraryList = () => {
const { t } = useTranslation();
const workflowCategories = useStore($workflowCategories);
const [selectedCategory, setSelectedCategory] = useState<WorkflowCategory>('user');
const [page, setPage] = useState(0);
const [query, setQuery] = useState('');
const projectId = useStore($projectId);
const ORDER_BY_OPTIONS: ComboboxOption[] = useMemo(
() => [
{ value: 'opened_at', label: t('workflows.opened') },
{ value: 'created_at', label: t('workflows.created') },
{ value: 'updated_at', label: t('workflows.updated') },
{ value: 'name', label: t('workflows.name') },
],
[t]
);
const DIRECTION_OPTIONS: ComboboxOption[] = useMemo(
() => [
{ value: 'ASC', label: t('workflows.ascending') },
{ value: 'DESC', label: t('workflows.descending') },
],
[t]
);
const orderByOptions = useMemo(() => {
return projectId ? ORDER_BY_OPTIONS.filter((option) => option.value !== 'opened_at') : ORDER_BY_OPTIONS;
}, [projectId, ORDER_BY_OPTIONS]);
const [order_by, setOrderBy] = useState<WorkflowRecordOrderBy>(orderByOptions[0]?.value as WorkflowRecordOrderBy);
const [direction, setDirection] = useState<SQLiteDirection>('DESC');
const [debouncedQuery] = useDebounce(query, 500);
const queryArg = useMemo<Parameters<typeof useListWorkflowsQuery>[0]>(() => {
if (selectedCategory !== 'default') {
return {
page,
per_page: PER_PAGE,
order_by,
direction,
category: selectedCategory,
query: debouncedQuery,
};
}
return {
page,
per_page: PER_PAGE,
order_by: 'name' as const,
direction: 'ASC' as const,
category: selectedCategory,
query: debouncedQuery,
};
}, [selectedCategory, debouncedQuery, direction, order_by, page]);
const { data, isLoading, isError, isFetching } = useListWorkflowsQuery(queryArg);
const onChangeOrderBy = useCallback<ComboboxOnChange>(
(v) => {
if (!isOrderBy(v?.value) || v.value === order_by) {
return;
}
setOrderBy(v.value);
setPage(0);
},
[order_by]
);
const valueOrderBy = useMemo(() => orderByOptions.find((o) => o.value === order_by), [order_by, orderByOptions]);
const onChangeDirection = useCallback<ComboboxOnChange>(
(v) => {
if (!isDirection(v?.value) || v.value === direction) {
return;
}
setDirection(v.value);
setPage(0);
},
[direction]
);
const valueDirection = useMemo(
() => DIRECTION_OPTIONS.find((o) => o.value === direction),
[direction, DIRECTION_OPTIONS]
);
const resetFilterText = useCallback(() => {
setQuery('');
setPage(0);
}, []);
const handleKeydownFilterText = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
// exit search mode on escape
if (e.key === 'Escape') {
resetFilterText();
e.preventDefault();
setPage(0);
}
},
[resetFilterText]
);
const handleChangeFilterText = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
setPage(0);
}, []);
const handleSetCategory = useCallback((category: WorkflowCategory) => {
setSelectedCategory(category);
setPage(0);
}, []);
return (
<>
<Flex gap={4} alignItems="center" h={16} flexShrink={0} flexGrow={0} justifyContent="space-between">
<ButtonGroup alignSelf="flex-end">
{workflowCategories.map((category) => (
<Button
key={category}
variant={selectedCategory === category ? undefined : 'ghost'}
onClick={handleSetCategory.bind(null, category)}
isChecked={selectedCategory === category}
>
{t(`workflows.${category}Workflows`)}
</Button>
))}
</ButtonGroup>
{selectedCategory !== 'default' && (
<>
<FormControl
isDisabled={isFetching}
sx={{
flexDir: 'column',
alignItems: 'flex-start',
gap: 1,
maxW: 56,
}}
>
<FormLabel>{t('common.orderBy')}</FormLabel>
<Combobox value={valueOrderBy} options={orderByOptions} onChange={onChangeOrderBy} />
</FormControl>
<FormControl
isDisabled={isFetching}
sx={{
flexDir: 'column',
alignItems: 'flex-start',
gap: 1,
maxW: 56,
}}
>
<FormLabel>{t('common.direction')}</FormLabel>
<Combobox value={valueDirection} options={DIRECTION_OPTIONS} onChange={onChangeDirection} />
</FormControl>
</>
)}
<InputGroup w="20rem" alignSelf="flex-end">
<Input
placeholder={t('workflows.searchWorkflows')}
value={query}
onKeyDown={handleKeydownFilterText}
onChange={handleChangeFilterText}
data-testid="workflow-search-input"
minW={64}
/>
{query.trim().length && (
<InputRightElement h="full" pe={2}>
<IconButton
onClick={resetFilterText}
size="sm"
variant="link"
aria-label={t('workflows.clearWorkflowSearchFilter')}
icon={<PiXBold />}
/>
</InputRightElement>
)}
</InputGroup>
</Flex>
<Divider />
{isLoading ? (
<IAINoContentFallbackWithSpinner label={t('workflows.loading')} />
) : !data || isError ? (
<IAINoContentFallback label={t('workflows.problemLoading')} />
) : data.items.length ? (
<ScrollableContent>
<Flex w="full" h="full" flexDir="column">
{data.items.map((w) => (
<WorkflowLibraryListItem key={w.workflow_id} workflowDTO={w} />
))}
</Flex>
</ScrollableContent>
) : (
<IAINoContentFallback label={t('workflows.noWorkflows')} />
)}
<Divider />
<Flex w="full">
<Box flex="1">
<UploadWorkflowButton />
</Box>
<Box flex="1" textAlign="center">
{data && <WorkflowLibraryPagination data={data} page={page} setPage={setPage} />}
</Box>
<Box flex="1"></Box>
</Flex>
</>
);
};
export default memo(WorkflowLibraryList);

View File

@@ -1,98 +0,0 @@
import { Button, Flex, Heading, Spacer, Text } from '@invoke-ai/ui-library';
import { EMPTY_OBJECT } from 'app/store/constants';
import { useAppSelector } from 'app/store/storeHooks';
import dateFormat, { masks } from 'dateformat';
import { selectWorkflowId } from 'features/nodes/store/workflowSlice';
import { useDeleteLibraryWorkflow } from 'features/workflowLibrary/hooks/useDeleteLibraryWorkflow';
import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow';
import { useWorkflowLibraryModal } from 'features/workflowLibrary/store/isWorkflowLibraryModalOpen';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { WorkflowRecordListItemDTO } from 'services/api/types';
type Props = {
workflowDTO: WorkflowRecordListItemDTO;
};
const WorkflowLibraryListItem = ({ workflowDTO }: Props) => {
const { t } = useTranslation();
const workflowId = useAppSelector(selectWorkflowId);
const workflowLibraryModal = useWorkflowLibraryModal();
const { deleteWorkflow, deleteWorkflowResult } = useDeleteLibraryWorkflow(EMPTY_OBJECT);
const { getAndLoadWorkflow, getAndLoadWorkflowResult } = useGetAndLoadLibraryWorkflow({
onSuccess: workflowLibraryModal.setFalse,
});
const handleDeleteWorkflow = useCallback(() => {
deleteWorkflow(workflowDTO.workflow_id);
}, [deleteWorkflow, workflowDTO.workflow_id]);
const handleGetAndLoadWorkflow = useCallback(() => {
getAndLoadWorkflow(workflowDTO.workflow_id);
}, [getAndLoadWorkflow, workflowDTO.workflow_id]);
const isOpen = useMemo(() => workflowId === workflowDTO.workflow_id, [workflowId, workflowDTO.workflow_id]);
return (
<Flex key={workflowDTO.workflow_id} w="full" p={2} borderRadius="base" _hover={{ bg: 'base.750' }}>
<Flex w="full" alignItems="center" gap={2} h={12}>
<Flex flexDir="column" flexGrow={1} h="full">
<Flex alignItems="center" w="full" h="50%">
<Heading size="sm" noOfLines={1} variant={isOpen ? 'invokeBlue' : undefined}>
{workflowDTO.name || t('workflows.unnamedWorkflow')}
</Heading>
<Spacer />
{workflowDTO.category !== 'default' && (
<Text fontSize="sm" variant="subtext" flexShrink={0} noOfLines={1}>
{t('common.updated')}: {dateFormat(workflowDTO.updated_at, masks.shortDate)}{' '}
{dateFormat(workflowDTO.updated_at, masks.shortTime)}
</Text>
)}
</Flex>
<Flex alignItems="center" w="full" h="50%">
{workflowDTO.description ? (
<Text fontSize="sm" noOfLines={1}>
{workflowDTO.description}
</Text>
) : (
<Text fontSize="sm" variant="subtext" fontStyle="italic" noOfLines={1}>
{t('workflows.noDescription')}
</Text>
)}
<Spacer />
{workflowDTO.category !== 'default' && (
<Text fontSize="sm" variant="subtext" flexShrink={0} noOfLines={1}>
{t('common.created')}: {dateFormat(workflowDTO.created_at, masks.shortDate)}{' '}
{dateFormat(workflowDTO.created_at, masks.shortTime)}
</Text>
)}
</Flex>
</Flex>
<Button
flexShrink={0}
isDisabled={isOpen}
onClick={handleGetAndLoadWorkflow}
isLoading={getAndLoadWorkflowResult.isLoading}
aria-label={t('workflows.openWorkflow')}
>
{t('common.load')}
</Button>
{workflowDTO.category !== 'default' && (
<Button
flexShrink={0}
colorScheme="error"
isDisabled={isOpen}
onClick={handleDeleteWorkflow}
isLoading={deleteWorkflowResult.isLoading}
aria-label={t('workflows.deleteWorkflow')}
>
{t('common.delete')}
</Button>
)}
</Flex>
</Flex>
);
};
export default memo(WorkflowLibraryListItem);

View File

@@ -1,13 +0,0 @@
import { Flex } from '@invoke-ai/ui-library';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
const WorkflowLibraryListWrapper = (props: PropsWithChildren) => {
return (
<Flex w="full" h="full" flexDir="column" layerStyle="second" py={2} px={4} gap={2} borderRadius="base">
{props.children}
</Flex>
);
};
export default memo(WorkflowLibraryListWrapper);

View File

@@ -1,34 +0,0 @@
import {
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@invoke-ai/ui-library';
import WorkflowLibraryContent from 'features/workflowLibrary/components/WorkflowLibraryContent';
import { useWorkflowLibraryModal } from 'features/workflowLibrary/store/isWorkflowLibraryModalOpen';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
const WorkflowLibraryModal = () => {
const { t } = useTranslation();
const workflowLibraryModal = useWorkflowLibraryModal();
return (
<Modal isOpen={workflowLibraryModal.isTrue} onClose={workflowLibraryModal.setFalse} isCentered useInert={false}>
<ModalOverlay />
<ModalContent w="80%" h="80%" minW="unset" minH="unset" maxW="1200px" maxH="664px">
<ModalHeader>{t('workflows.workflowLibrary')}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<WorkflowLibraryContent />
</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
);
};
export default memo(WorkflowLibraryModal);

View File

@@ -1,80 +0,0 @@
import { Button, ButtonGroup, IconButton } from '@invoke-ai/ui-library';
import type { Dispatch, SetStateAction } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
import type { paths } from 'services/api/schema';
const PAGES_TO_DISPLAY = 7;
type PageData = {
page: number;
onClick: () => void;
};
type Props = {
page: number;
setPage: Dispatch<SetStateAction<number>>;
data: paths['/api/v1/workflows/']['get']['responses']['200']['content']['application/json'];
};
const WorkflowLibraryPagination = ({ page, setPage, data }: Props) => {
const { t } = useTranslation();
const handlePrevPage = useCallback(() => {
setPage((p) => Math.max(p - 1, 0));
}, [setPage]);
const handleNextPage = useCallback(() => {
setPage((p) => Math.min(p + 1, data.pages - 1));
}, [data.pages, setPage]);
const pages: PageData[] = useMemo(() => {
const pages = [];
let first = data.pages > PAGES_TO_DISPLAY ? Math.max(0, page - Math.floor(PAGES_TO_DISPLAY / 2)) : 0;
const last = data.pages > PAGES_TO_DISPLAY ? Math.min(data.pages, first + PAGES_TO_DISPLAY) : data.pages;
if (last - first < PAGES_TO_DISPLAY && data.pages > PAGES_TO_DISPLAY) {
first = last - PAGES_TO_DISPLAY;
}
for (let i = first; i < last; i++) {
pages.push({
page: i,
onClick: () => setPage(i),
});
}
return pages;
}, [data.pages, page, setPage]);
return (
<ButtonGroup>
<IconButton
variant="ghost"
onClick={handlePrevPage}
isDisabled={page === 0}
aria-label={t('common.prevPage')}
icon={<PiCaretLeftBold />}
/>
{pages.map((p) => (
<Button
w={10}
isDisabled={data.pages === 1}
onClick={p.page === page ? undefined : p.onClick}
variant={p.page === page ? 'solid' : 'ghost'}
key={p.page}
transitionDuration="0s" // the delay in animation looks jank
>
{p.page + 1}
</Button>
))}
<IconButton
variant="ghost"
onClick={handleNextPage}
isDisabled={page === data.pages - 1}
aria-label={t('common.nextPage')}
icon={<PiCaretRightBold />}
/>
</ButtonGroup>
);
};
export default memo(WorkflowLibraryPagination);

View File

@@ -1,3 +0,0 @@
import { buildUseBoolean } from 'common/hooks/useBoolean';
export const [useWorkflowLibraryModal, $workflowLibraryModal] = buildUseBoolean(false);

View File

@@ -1 +1 @@
__version__ = "5.1.0"
__version__ = "5.1.1"

View File

@@ -1,5 +1,5 @@
[build-system]
requires = ["setuptools~=65.5", "pip~=22.3", "wheel"]
requires = ["setuptools", "pip", "wheel"]
build-backend = "setuptools.build_meta"
[project]
@@ -41,10 +41,10 @@ dependencies = [
"diffusers[torch]==0.27.2",
"gguf==0.10.0",
"invisible-watermark==0.2.0", # needed to install SDXL base and refiner using their repo_ids
"mediapipe==0.10.7", # needed for "mediapipeface" controlnet model
"mediapipe>=0.10.7", # needed for "mediapipeface" controlnet model
"numpy==1.26.4", # >1.24.0 is needed to use the 'strict' argument to np.testing.assert_array_equal()
"onnx==1.15.0",
"onnxruntime==1.16.3",
"onnx>=1.15.0",
"onnxruntime>=1.16.3",
"opencv-python==4.9.0.80",
"pytorch-lightning==2.1.3",
"safetensors==0.4.3",