mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-01-15 12:08:23 -05:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5806a4bc73 | ||
|
|
734631bfe4 | ||
|
|
8d6996cdf0 | ||
|
|
965d6be1f4 | ||
|
|
e31f253b90 | ||
|
|
5a94575603 | ||
|
|
1c3d06dc83 | ||
|
|
09b19e3640 | ||
|
|
1e0a4dfa3c | ||
|
|
5a1ab4aa9c | ||
|
|
d5c872292f | ||
|
|
0d7edbce25 | ||
|
|
e20d964b59 | ||
|
|
ee95321801 | ||
|
|
179c6d206c | ||
|
|
ffecd83815 | ||
|
|
f1c538fafc | ||
|
|
ed88b096f3 | ||
|
|
a28cabdf97 | ||
|
|
db25be3ba2 | ||
|
|
3b9d1e8218 | ||
|
|
05d9ba8fa0 | ||
|
|
3eee1ba113 | ||
|
|
7882e9beae | ||
|
|
7c9779b496 | ||
|
|
5832228fea | ||
|
|
1d32e70a75 | ||
|
|
9092280583 | ||
|
|
96dd1d5102 | ||
|
|
969f8b8e8d | ||
|
|
ccb5f90556 | ||
|
|
4770d9895d | ||
|
|
aeb2275bd8 | ||
|
|
aff5524457 | ||
|
|
825c564089 | ||
|
|
9b97c57f00 | ||
|
|
4b3a201790 |
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
6
flake.lock
generated
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
1
invokeai/frontend/web/pnpm-lock.yaml
generated
1
invokeai/frontend/web/pnpm-lock.yaml
generated
@@ -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==}
|
||||
|
||||
@@ -281,6 +281,7 @@
|
||||
"gallery": "Gallery",
|
||||
"alwaysShowImageSizeBadge": "Always Show Image Size Badge",
|
||||
"assets": "Assets",
|
||||
"assetsTab": "Files you’ve 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 you’ve 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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1871,7 +1871,9 @@
|
||||
"outputOnlyMaskedRegions": "Вывод только маскированных областей",
|
||||
"duplicate": "Дублировать",
|
||||
"inpaintMasks_withCount_visible": "Маски перерисовки ({{count}})",
|
||||
"layer": "Слой",
|
||||
"layer_one": "Слой",
|
||||
"layer_few": "",
|
||||
"layer_many": "",
|
||||
"prompt": "Запрос",
|
||||
"negativePrompt": "Исключающий запрос",
|
||||
"beginEndStepPercentShort": "Начало/конец %",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -24,7 +24,7 @@ export const CanvasEntityDeleteButton = memo(() => {
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
icon={<PiTrashSimpleFill />}
|
||||
onPointerUp={onClick}
|
||||
onClick={onClick}
|
||||
colorScheme="error"
|
||||
isDisabled={isBusy}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -89,7 +89,7 @@ const ImageViewerCloseButton = memo(() => {
|
||||
aria-label={t('gallery.closeViewer')}
|
||||
icon={<PiXBold />}
|
||||
variant="ghost"
|
||||
onPointerUp={imageViewer.close}
|
||||
onClick={imageViewer.close}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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));
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
/**
|
||||
* Tracks the state for the workflow list menu.
|
||||
*/
|
||||
export const $isWorkflowListMenuIsOpen = atom<boolean>(false);
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
@@ -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()} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -1,3 +0,0 @@
|
||||
import { buildUseBoolean } from 'common/hooks/useBoolean';
|
||||
|
||||
export const [useWorkflowLibraryModal, $workflowLibraryModal] = buildUseBoolean(false);
|
||||
@@ -1 +1 @@
|
||||
__version__ = "5.1.0"
|
||||
__version__ = "5.1.1"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user