Compare commits

...

36 Commits

Author SHA1 Message Date
psychedelicious
8a8f4c593f wip 2025-04-02 06:42:01 +10:00
psychedelicious
29c78f0e5e wip 2025-04-01 15:45:59 +10:00
psychedelicious
501534e2e1 chore(ui): typegen 2025-04-01 08:49:28 +10:00
psychedelicious
50c7318004 feat(app): add is_published to workflow models 2025-04-01 08:48:06 +10:00
psychedelicious
7f14597012 refactor(app): clean up compose_mode_from_fields util 2025-04-01 08:46:27 +10:00
psychedelicious
dbe68b364f feat(ui): publish toast links to project dashboard 2025-04-01 08:22:48 +10:00
psychedelicious
0c7aa85a5c feat(ui): add badge to queue indicating if run is validation run 2025-04-01 08:22:48 +10:00
psychedelicious
703e1c8001 feat(ui): publish toasts do not auto-close 2025-04-01 08:22:48 +10:00
psychedelicious
b056c93ea3 feat(ui): disable invoke button during publish operation 2025-04-01 08:22:48 +10:00
psychedelicious
4289241943 feat(ui): "isInDeployFlow" -> "isInPublishFlow" 2025-04-01 08:22:48 +10:00
psychedelicious
51f5abf5f9 feat(ui): wip publish flow 2025-04-01 08:22:48 +10:00
psychedelicious
e59fa59ad7 feat(ui): wip publish flow 2025-04-01 08:22:48 +10:00
psychedelicious
2407cb64b3 feat(app): truncate invalid model config warning to 64 chars
Previously it logged the whole config and flooded the terminal output.
2025-04-01 08:22:48 +10:00
psychedelicious
70f704ab44 feat(ui): publish button works 2025-04-01 08:22:48 +10:00
psychedelicious
b786032b89 feat(ui): make validation run logic conditional 2025-04-01 08:22:48 +10:00
psychedelicious
e8cc06cc92 feat(ui): disable all workflow editor interaction while in deploy flow 2025-04-01 08:22:48 +10:00
psychedelicious
8e6c56c93d wip 2025-04-01 08:22:48 +10:00
psychedelicious
69d4ee7f93 chore(ui): bump @xyflow/react to latest 2025-04-01 08:22:48 +10:00
psychedelicious
567fd3e0da refactor(ui): standardize more workflow editor hooks to use Safe and OrThrow suffixes for clarity 2025-04-01 08:22:47 +10:00
psychedelicious
0b8f88e554 wip 2025-04-01 08:22:47 +10:00
psychedelicious
60f0c4bf99 refactor(ui): standardize more workflow editor hooks to use Safe and OrThrow suffixes for clarity 2025-04-01 08:22:47 +10:00
psychedelicious
900ec92ef1 tidy(ui): remove extraneous scrollable container 2025-04-01 08:22:47 +10:00
psychedelicious
2594768479 revert(ui): remove api_fields from zod workflow schema 2025-04-01 08:22:47 +10:00
psychedelicious
91ab81eca9 chore(ui): typegen 2025-04-01 08:22:47 +10:00
psychedelicious
b20c745c6e revert(app): remove api_fields from workflow pydantic model 2025-04-01 08:22:47 +10:00
psychedelicious
e41a37bca0 refactor(ui): generalize node field dnd to drag node fields vs node field form elements 2025-04-01 08:22:47 +10:00
psychedelicious
9ca44f27a5 feat(ui): rough out state mgmt for workflow api fields 2025-04-01 08:22:47 +10:00
psychedelicious
b9ddf67853 refactor(ui): rejiggle enqueue actions to support api validation runs 2025-04-01 08:22:47 +10:00
psychedelicious
afe088045f chore(ui): rename type BatchConfig -> EnqueueBatchArg 2025-04-01 08:22:47 +10:00
psychedelicious
09ca61a962 chore(ui): typegen 2025-04-01 08:22:47 +10:00
psychedelicious
dd69a96c03 feat(queue): move session count calculation in to Batch class, cache it, add pydantic validator for validation runs 2025-04-01 08:22:46 +10:00
psychedelicious
4a54e594d0 tests(ui): update test for workflow types 2025-04-01 08:22:46 +10:00
psychedelicious
936ed1960a feat(ui): add api_fields to zod schemas 2025-04-01 08:22:46 +10:00
psychedelicious
9fac7986c7 chore(ui): typegen 2025-04-01 08:22:46 +10:00
psychedelicious
e4b603f44e feat(app): add api_fields to workflow pydantic schema 2025-04-01 08:22:46 +10:00
psychedelicious
7edfe6edcf chore(ui): bump tsafe dep 2025-04-01 08:22:46 +10:00
117 changed files with 1945 additions and 404 deletions

View File

@@ -1,10 +1,13 @@
from typing import Optional
import json
from typing import Any, Optional
from fastapi import Body, Path, Query
from fastapi.routing import APIRouter
from pydantic import BaseModel
from pydantic import BaseModel, Field
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.invocations.fields import BoardField
from invokeai.app.invocations.model import ModelIdentifierField
from invokeai.app.services.session_processor.session_processor_common import SessionProcessorStatus
from invokeai.app.services.session_queue.session_queue_common import (
QUEUE_ITEM_STATUS,
@@ -15,6 +18,7 @@ from invokeai.app.services.session_queue.session_queue_common import (
CancelByDestinationResult,
ClearResult,
EnqueueBatchResult,
FieldIdentifier,
PruneResult,
RetryItemsResult,
SessionQueueCountsByDestination,
@@ -22,6 +26,7 @@ from invokeai.app.services.session_queue.session_queue_common import (
SessionQueueItemDTO,
SessionQueueStatus,
)
from invokeai.app.services.shared.compose_pydantic_model import compose_model_from_fields
from invokeai.app.services.shared.pagination import CursorPaginatedResults
session_queue_router = APIRouter(prefix="/v1/queue", tags=["queue"])
@@ -34,6 +39,17 @@ class SessionQueueAndProcessorStatus(BaseModel):
processor: SessionProcessorStatus
class SimpleModelIdentifer(BaseModel):
id: str = Field(description="The model id")
model_field_overrides = {ModelIdentifierField: (SimpleModelIdentifer, Field(description="The model identifier"))}
def model_field_filter(field_type: type[Any]) -> bool:
return field_type not in {BoardField, Optional[BoardField]}
@session_queue_router.post(
"/{queue_id}/enqueue_batch",
operation_id="enqueue_batch",
@@ -45,9 +61,52 @@ async def enqueue_batch(
queue_id: str = Path(description="The queue id to perform this operation on"),
batch: Batch = Body(description="Batch to process"),
prepend: bool = Body(default=False, description="Whether or not to prepend this batch in the queue"),
is_api_validation_run: bool = Body(
default=False,
description="Whether or not this is a validation run.",
),
api_input_fields: Optional[list[FieldIdentifier]] = Body(
default=None, description="The fields that were used as input to the API"
),
api_output_fields: Optional[list[FieldIdentifier]] = Body(
default=None, description="The fields that were used as output from the API"
),
) -> EnqueueBatchResult:
"""Processes a batch and enqueues the output graphs for execution."""
if is_api_validation_run:
session_count = batch.get_session_count()
assert session_count == 1, "API validation run only supports single session batches"
if api_input_fields:
composed_model = compose_model_from_fields(
g=batch.graph,
field_identifiers=api_input_fields,
composed_model_class_name="APIInputModel",
model_field_overrides=model_field_overrides,
model_field_filter=model_field_filter,
)
json_schema = composed_model.model_json_schema(mode="validation")
print("API Input Model")
print(json.dumps(json_schema))
if api_output_fields:
composed_model = compose_model_from_fields(
g=batch.graph,
field_identifiers=api_output_fields,
composed_model_class_name="APIOutputModel",
)
json_schema = composed_model.model_json_schema(mode="validation")
print("API Output Model")
print(json.dumps(json_schema))
print("graph")
print(batch.graph.model_dump_json())
if batch.workflow is not None:
print("workflow")
print(batch.workflow.model_dump_json())
return await ApiDependencies.invoker.services.session_queue.enqueue_batch(
queue_id=queue_id, batch=batch, prepend=prepend
)

View File

@@ -1,4 +1,5 @@
import io
import random
import traceback
from typing import Optional
@@ -24,6 +25,37 @@ from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_common import
IMAGE_MAX_AGE = 31536000
workflows_router = APIRouter(prefix="/v1/workflows", tags=["workflows"])
ids = {
"6614752a-0420-4d81-98fc-e110069d4f38": random.choice([True, False]),
"default_5e8b008d-c697-45d0-8883-085a954c6ace": random.choice([True, False]),
"4b2b297a-0d47-4f43-8113-ebbf3f403089": random.choice([True, False]),
"d0ce602a-049e-4368-97ae-977b49eed042": random.choice([True, False]),
"f170a187-fd74-40b8-ba9c-00de173ea4b9": random.choice([True, False]),
"default_f96e794f-eb3e-4d01-a960-9b4e43402bcf": random.choice([True, False]),
"default_cbf0e034-7b54-4b2c-b670-3b1e2e4b4a88": random.choice([True, False]),
"default_dec5a2e9-f59c-40d9-8869-a056751d79b8": random.choice([True, False]),
"default_dbe46d95-22aa-43fb-9c16-94400d0ce2fd": random.choice([True, False]),
"default_d7a1c60f-ca2f-4f90-9e33-75a826ca6d8f": random.choice([True, False]),
"default_e71d153c-2089-43c7-bd2c-f61f37d4c1c1": random.choice([True, False]),
"default_7dde3e36-d78f-4152-9eea-00ef9c8124ed": random.choice([True, False]),
"default_444fe292-896b-44fd-bfc6-c0b5d220fffc": random.choice([True, False]),
"default_2d05e719-a6b9-4e64-9310-b875d3b2f9d2": random.choice([True, False]),
"acae7e87-070b-4999-9074-c5b593c86618": random.choice([True, False]),
"3008fc77-1521-49c7-ba95-94c5a4508d1d": random.choice([True, False]),
"default_686bb1d0-d086-4c70-9fa3-2f600b922023": random.choice([True, False]),
"36905c46-e768-4dc3-8ecd-e55fe69bf03c": random.choice([True, False]),
"7c3e4951-183b-40ef-a890-28eef4d50097": random.choice([True, False]),
"7a053b2f-64e4-4152-80e9-296006e77131": random.choice([True, False]),
"27d4f1be-4156-46e9-8d22-d0508cd72d4f": random.choice([True, False]),
"e881dc06-70d2-438f-b007-6f3e0c3c0e78": random.choice([True, False]),
"265d2244-a1d7-495c-a2eb-88217f5eae37": random.choice([True, False]),
"caebcbc7-2bf0-41c4-b553-106b585fddda": random.choice([True, False]),
"a7998705-474e-417d-bd37-a2a9480beedf": random.choice([True, False]),
"554d94b5-94b3-4d8e-8aed-51ebfc9deea5": random.choice([True, False]),
"e6898540-c1bc-408b-b944-c1e242cddbcd": random.choice([True, False]),
"363b0960-ab2c-4902-8df3-f592d6194bb3": random.choice([True, False]),
}
@workflows_router.get(
"/i/{workflow_id}",
@@ -39,6 +71,8 @@ async def get_workflow(
try:
thumbnail_url = ApiDependencies.invoker.services.workflow_thumbnails.get_url(workflow_id)
workflow = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
workflow.is_published = ids.get(workflow_id, False)
workflow.workflow.is_published = ids.get(workflow_id, False)
return WorkflowRecordWithThumbnailDTO(thumbnail_url=thumbnail_url, **workflow.model_dump())
except WorkflowNotFoundError:
raise HTTPException(status_code=404, detail="Workflow not found")
@@ -106,10 +140,11 @@ async def list_workflows(
tags: Optional[list[str]] = Query(default=None, description="The tags of workflow to get"),
query: Optional[str] = Query(default=None, description="The text to query by (matches name and description)"),
has_been_opened: Optional[bool] = Query(default=None, description="Whether to include/exclude recent workflows"),
is_published: Optional[bool] = Query(default=None, description="Whether to include/exclude published workflows"),
) -> PaginatedResults[WorkflowRecordListItemWithThumbnailDTO]:
"""Gets a page of workflows"""
workflows_with_thumbnails: list[WorkflowRecordListItemWithThumbnailDTO] = []
workflows = ApiDependencies.invoker.services.workflow_records.get_many(
workflow_record_list_items = ApiDependencies.invoker.services.workflow_records.get_many(
order_by=order_by,
direction=direction,
page=page,
@@ -118,20 +153,23 @@ async def list_workflows(
categories=categories,
tags=tags,
has_been_opened=has_been_opened,
is_published=is_published,
)
for workflow in workflows.items:
for item in workflow_record_list_items.items:
data = item.model_dump()
data["is_published"] = ids.get(item.workflow_id, False)
workflows_with_thumbnails.append(
WorkflowRecordListItemWithThumbnailDTO(
thumbnail_url=ApiDependencies.invoker.services.workflow_thumbnails.get_url(workflow.workflow_id),
**workflow.model_dump(),
thumbnail_url=ApiDependencies.invoker.services.workflow_thumbnails.get_url(item.workflow_id),
**data,
)
)
return PaginatedResults[WorkflowRecordListItemWithThumbnailDTO](
items=workflows_with_thumbnails,
total=workflows.total,
page=workflows.page,
pages=workflows.pages,
per_page=workflows.per_page,
total=workflow_record_list_items.total,
page=workflow_record_list_items.page,
pages=workflow_record_list_items.pages,
per_page=workflow_record_list_items.per_page,
)

View File

@@ -302,7 +302,10 @@ class ModelRecordServiceSQL(ModelRecordServiceBase):
# We catch this error so that the app can still run if there are invalid model configs in the database.
# One reason that an invalid model config might be in the database is if someone had to rollback from a
# newer version of the app that added a new model type.
self._logger.warning(f"Found an invalid model config in the database. Ignoring this model. ({row[0]})")
row_data = f"{row[0][:64]}..." if len(row[0]) > 64 else row[0]
self._logger.warning(
f"Found an invalid model config in the database. Ignoring this model. ({row_data})"
)
else:
results.append(model_config)

View File

@@ -33,7 +33,12 @@ class SessionQueueBase(ABC):
pass
@abstractmethod
def enqueue_batch(self, queue_id: str, batch: Batch, prepend: bool) -> Coroutine[Any, Any, EnqueueBatchResult]:
def enqueue_batch(
self,
queue_id: str,
batch: Batch,
prepend: bool,
) -> Coroutine[Any, Any, EnqueueBatchResult]:
"""Enqueues all permutations of a batch for execution."""
pass

View File

@@ -157,6 +157,28 @@ class Batch(BaseModel):
v.validate_self()
return v
def get_session_count(self) -> int:
"""
Calculates the number of sessions that would be created by the batch, without incurring the overhead of actually
creating them, as is done in `create_session_nfv_tuples()`.
The count is used to communicate to the user how many sessions were _requested_ to be created, as opposed to how
many were _actually_ created (which may be less due to the maximum number of sessions).
If the session count has already been calculated, return the cached value.
"""
if not self.data:
return self.runs
data = []
for batch_datum_list in self.data:
to_zip = []
for batch_datum in batch_datum_list:
batch_data_items = range(len(batch_datum.items))
to_zip.append(batch_data_items)
data.append(list(zip(*to_zip, strict=True)))
data_product = list(product(*data))
return len(data_product) * self.runs
model_config = ConfigDict(
json_schema_extra={
"required": [
@@ -201,6 +223,12 @@ def get_workflow(queue_item_dict: dict) -> Optional[WorkflowWithoutID]:
return None
class FieldIdentifier(BaseModel):
kind: Literal["input", "output"] = Field(description="The kind of field")
node_id: str = Field(description="The ID of the node")
field_name: str = Field(description="The name of the field")
class SessionQueueItemWithoutGraph(BaseModel):
"""Session queue item without the full graph. Used for serialization."""
@@ -237,6 +265,16 @@ class SessionQueueItemWithoutGraph(BaseModel):
retried_from_item_id: Optional[int] = Field(
default=None, description="The item_id of the queue item that this item was retried from"
)
is_api_validation_run: bool = Field(
default=False,
description="Whether this queue item is an API validation run.",
)
api_input_fields: Optional[list[FieldIdentifier]] = Field(
default=None, description="The fields that were used as input to the API"
)
api_output_fields: Optional[list[FieldIdentifier]] = Field(
default=None, description="The nodes that were used as output from the API"
)
@classmethod
def queue_item_dto_from_dict(cls, queue_item_dict: dict) -> "SessionQueueItemDTO":
@@ -536,28 +574,6 @@ def create_session_nfv_tuples(batch: Batch, maximum: int) -> Generator[tuple[str
count += 1
def calc_session_count(batch: Batch) -> int:
"""
Calculates the number of sessions that would be created by the batch, without incurring the overhead of actually
creating them, as is done in `create_session_nfv_tuples()`.
The count is used to communicate to the user how many sessions were _requested_ to be created, as opposed to how
many were _actually_ created (which may be less due to the maximum number of sessions).
"""
# TODO: Should this be a class method on Batch?
if not batch.data:
return batch.runs
data = []
for batch_datum_list in batch.data:
to_zip = []
for batch_datum in batch_datum_list:
batch_data_items = range(len(batch_datum.items))
to_zip.append(batch_data_items)
data.append(list(zip(*to_zip, strict=True)))
data_product = list(product(*data))
return len(data_product) * batch.runs
ValueToInsertTuple: TypeAlias = tuple[
str, # queue_id
str, # session (as stringified JSON)

View File

@@ -28,7 +28,6 @@ from invokeai.app.services.session_queue.session_queue_common import (
SessionQueueItemNotFoundError,
SessionQueueStatus,
ValueToInsertTuple,
calc_session_count,
prepare_values_to_insert,
)
from invokeai.app.services.shared.graph import GraphExecutionState
@@ -118,7 +117,8 @@ class SqliteSessionQueue(SessionQueueBase):
if prepend:
priority = self._get_highest_priority(queue_id) + 1
requested_count = calc_session_count(batch)
requested_count = batch.get_session_count()
values_to_insert = prepare_values_to_insert(
queue_id=queue_id,
batch=batch,

View File

@@ -0,0 +1,204 @@
from copy import deepcopy
from typing import Any, Callable, TypeAlias, get_args
from pydantic import BaseModel, ConfigDict, create_model
from pydantic.fields import FieldInfo
from invokeai.app.services.session_queue.session_queue_common import FieldIdentifier
from invokeai.app.services.shared.graph import Graph
DictOfFieldsMetadata: TypeAlias = dict[str, tuple[type[Any], FieldInfo]]
class ComposedFieldMetadata(BaseModel):
node_id: str
field_name: str
field_type_class_name: str
def dedupe_field_name(field_metadata: DictOfFieldsMetadata, field_name: str) -> str:
"""Given a field name, return a name that is not already in the field metadata.
If the field name is not in the field metadata, return the field name.
If the field name is in the field metadata, generate a new name by appending an underscore and integer to the field name, starting with 2.
"""
if field_name not in field_metadata:
return field_name
i = 2
while True:
new_field_name = f"{field_name}_{i}"
if new_field_name not in field_metadata:
return new_field_name
i += 1
def compose_model_from_fields(
g: Graph,
field_identifiers: list[FieldIdentifier],
composed_model_class_name: str = "ComposedModel",
model_field_overrides: dict[type[Any], tuple[type[Any], FieldInfo]] | None = None,
model_field_filter: Callable[[type[Any]], bool] | None = None,
) -> type[BaseModel]:
"""Given a graph and a list of field identifiers, create a new pydantic model composed of the fields of the nodes in the graph.
The resultant model can be used to validate a JSON payload that contains the fields of the nodes in the graph, or generate an
OpenAPI schema for the model.
Args:
g: The graph containing the nodes whose fields will be composed into the new model.
field_identifiers: A list of FieldIdentifier instances, each representing a field on a node in the graph.
model_name: The name of the composed model.
kind: The kind of model to create. Must be "input" or "output". Defaults to "input".
model_field_overrides: A dictionary mapping type annotations to tuples of (new_type_annotation, new_field_info).
This can be used to override the type annotation and field info of a field in the composed model. For example,
if `ModelIdentifierField` should be replaced by a string, the dictionary would look like this:
```python
{ModelIdentifierField: (str, Field(description="The model id."))}
```
model_field_filter: A function that takes a type annotation and returns True if the field should be included in the composed model.
If None, all fields will be included. For example, to omit `BoardField` fields, the filter would look like this:
```python
def model_field_filter(field_type: type[Any]) -> bool:
return field_type not in {BoardField}
```
Optional fields - or any other complex field types like unions - must be explicitly included in the filter. For example,
to omit `BoardField` _and_ `Optional[BoardField]`:
```python
def model_field_filter(field_type: type[Any]) -> bool:
return field_type not in {BoardField, Optional[BoardField]}
```
Note that the filter is applied to the type annotation of the field, not the field itself.
Example usage:
```python
# Create some nodes.
add_node = AddInvocation()
sub_node = SubtractInvocation()
color_node = ColorInvocation()
# Create a graph with the nodes.
g = Graph(
nodes={
add_node.id: add_node,
sub_node.id: sub_node,
color_node.id: color_node,
}
)
# Select the fields to compose.
fields_to_compose = [
FieldIdentifier(node_id=add_node.id, field_name="a"),
FieldIdentifier(node_id=sub_node.id, field_name="a"), # this will be deduped to "a_2"
FieldIdentifier(node_id=add_node.id, field_name="b"),
FieldIdentifier(node_id=color_node.id, field_name="color"),
]
# Compose the model from the fields.
composed_model = compose_model_from_fields(g, fields_to_compose, model_name="ComposedModel")
# Generate the OpenAPI schema for the model.
json_schema = composed_model.model_json_schema(mode="validation")
```
"""
# Temp storage for the composed fields. Pydantic needs a type annotation and instance of FieldInfo to create a model.
field_metadata: DictOfFieldsMetadata = {}
model_field_overrides = model_field_overrides or {}
# The list of required fields. This is used to ensure the composed model's fields retain their required state.
required: list[str] = []
for field_identifier in field_identifiers:
node_id = field_identifier.node_id
field_name = field_identifier.field_name
# Pull the node instance from the graph so we can introspect it.
node_instance = g.nodes[node_id]
if field_identifier.kind == "input":
# Get the class of the node. This will be a BaseInvocation subclass, e.g. AddInvocation, DenoiseLatentsInvocation, etc.
pydantic_model = type(node_instance)
else:
# Otherwise the the type of the node's output class. This will be a BaseInvocationOutput subclass, e.g. IntegerOutput, ImageOutput, etc.
pydantic_model = type(node_instance).get_output_annotation()
# Get the FieldInfo instance for the field. For example:
# a: int = Field(..., description="The first number to add.")
# ^^^^^ The return value of this Field call is the FieldInfo instance (Field is a function).
og_field_info = pydantic_model.model_fields[field_name]
# Get the type annotation of the field. For example:
# a: int = Field(..., description="The first number to add.")
# ^^^ this is the type annotation
og_field_type = og_field_info.annotation
# Apparently pydantic allows fields without type annotations. We don't support that.
assert og_field_type is not None, (
f"{field_identifier.kind.capitalize()} field {field_name} on node {node_id} has no type annotation."
)
# Now that we have the type annotation, we can apply the filter to see if we should include the field in the composed model.
if model_field_filter and not model_field_filter(og_field_type):
continue
# Ok, we want this type of field. Retrieve any overrides for the field type. This is a dictionary mapping
# type annotations to tuples of (override_type_annotation, override_field_info).
(override_field_type, override_field_info) = model_field_overrides.get(og_field_type, (None, None))
# The override tuple's first element is the new type annotation, if it exists.
composed_field_type = override_field_type if override_field_type is not None else og_field_type
# Create a deep copy of the FieldInfo instance (or override it if it exists) so we can modify it without
# affecting the original. This is important because we are going to modify the FieldInfo instance and
# don't want to affect the original model's schema.
composed_field_info = deepcopy(override_field_info if override_field_info is not None else og_field_info)
json_schema_extra = og_field_info.json_schema_extra if isinstance(og_field_info.json_schema_extra, dict) else {}
# The field's original required state is stored in the json_schema_extra dict. For more information about why,
# see the definition of `InputField` in invokeai/app/invocations/fields.py.
#
# Add the field to the required list if it is required, which we will use when creating the composed model.
if json_schema_extra.get("orig_required", False):
required.append(field_name)
# Invocation fields have some extra metadata, used by the UI to render the field in the frontend. This data is
# included in the OpenAPI schema for each field. For example, we add a "ui_order" field, which the UI uses to
# sort fields when rendering them.
#
# The composed model's OpenAPI schema should not have this information. It should only have a standard OpenAPI
# schema for the field. We need to strip out the UI-specific metadata from the FieldInfo instance before adding
# it to the composed model.
#
# We will replace this metadata with some custom metadata:
# - node_id: The id of the node that this field belongs to.
# - field_name: The name of the field on the node.
# - original_data_type: The original data type of the field.
field_type_class = get_args(og_field_type)[0] if hasattr(og_field_type, "__args__") else og_field_type
field_type_class_name = field_type_class.__name__
composed_field_metadata = ComposedFieldMetadata(
node_id=node_id,
field_name=field_name,
field_type_class_name=field_type_class_name,
)
composed_field_info.json_schema_extra = {
"composed_field_extra": composed_field_metadata.model_dump(),
}
# Override the name, title and description if overrides are provided. Dedupe the field name if necessary.
final_field_name = dedupe_field_name(field_metadata, field_name)
# Store the field metadata.
field_metadata.update({final_field_name: (composed_field_type, composed_field_info)})
# Splat in the composed fields to create the new model. There are type errors here because create_model's kwargs are not typed,
# and for some reason pydantic's ConfigDict doesn't like lists in `json_schema_extra`. Anyways, the inputs here are correct.
return create_model(
composed_model_class_name,
**field_metadata,
__config__=ConfigDict(json_schema_extra={"required": required}),
)

View File

@@ -47,6 +47,7 @@ class WorkflowRecordsStorageBase(ABC):
query: Optional[str],
tags: Optional[list[str]],
has_been_opened: Optional[bool],
is_published: Optional[bool],
) -> PaginatedResults[WorkflowRecordListItemDTO]:
"""Gets many workflows."""
pass
@@ -56,6 +57,7 @@ class WorkflowRecordsStorageBase(ABC):
self,
categories: list[WorkflowCategory],
has_been_opened: Optional[bool] = None,
is_published: Optional[bool] = None,
) -> dict[str, int]:
"""Gets a dictionary of counts for each of the provided categories."""
pass
@@ -66,6 +68,7 @@ class WorkflowRecordsStorageBase(ABC):
tags: list[str],
categories: Optional[list[WorkflowCategory]] = None,
has_been_opened: Optional[bool] = None,
is_published: Optional[bool] = None,
) -> dict[str, int]:
"""Gets a dictionary of counts for each of the provided tags."""
pass

View File

@@ -67,6 +67,7 @@ class WorkflowWithoutID(BaseModel):
# This is typed as optional to prevent errors when pulling workflows from the DB. The frontend adds a default form if
# it is None.
form: dict[str, JsonValue] | None = Field(default=None, description="The form of the workflow.")
is_published: bool | None = Field(default=None, description="Whether the workflow is published or not.")
model_config = ConfigDict(extra="ignore")
@@ -101,6 +102,7 @@ class WorkflowRecordDTOBase(BaseModel):
opened_at: Optional[Union[datetime.datetime, str]] = Field(
default=None, description="The opened timestamp of the workflow."
)
is_published: bool | None = Field(default=None, description="Whether the workflow is published or not.")
class WorkflowRecordDTO(WorkflowRecordDTOBase):

View File

@@ -119,6 +119,7 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
query: Optional[str] = None,
tags: Optional[list[str]] = None,
has_been_opened: Optional[bool] = None,
is_published: Optional[bool] = None,
) -> PaginatedResults[WorkflowRecordListItemDTO]:
# sanitize!
assert order_by in WorkflowRecordOrderBy
@@ -241,6 +242,7 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
tags: list[str],
categories: Optional[list[WorkflowCategory]] = None,
has_been_opened: Optional[bool] = None,
is_published: Optional[bool] = None,
) -> dict[str, int]:
if not tags:
return {}
@@ -292,6 +294,7 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
self,
categories: list[WorkflowCategory],
has_been_opened: Optional[bool] = None,
is_published: Optional[bool] = None,
) -> dict[str, int]:
cursor = self._conn.cursor()
result: dict[str, int] = {}

View File

@@ -62,7 +62,7 @@
"@nanostores/react": "^0.7.3",
"@reduxjs/toolkit": "2.6.1",
"@roarr/browser-log-writer": "^1.3.0",
"@xyflow/react": "^12.4.2",
"@xyflow/react": "^12.5.1",
"async-mutex": "^0.5.0",
"chakra-react-select": "^4.9.2",
"cmdk": "^1.0.0",
@@ -150,7 +150,7 @@
"prettier": "^3.3.3",
"rollup-plugin-visualizer": "^5.12.0",
"storybook": "^8.3.4",
"tsafe": "^1.7.5",
"tsafe": "^1.8.5",
"type-fest": "^4.26.1",
"typescript": "^5.6.2",
"vite": "^6.1.0",

View File

@@ -36,8 +36,8 @@ dependencies:
specifier: ^1.3.0
version: 1.3.0
'@xyflow/react':
specifier: ^12.4.2
version: 12.4.2(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
specifier: ^12.5.1
version: 12.5.1(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
async-mutex:
specifier: ^0.5.0
version: 0.5.0
@@ -284,8 +284,8 @@ devDependencies:
specifier: ^8.3.4
version: 8.3.4
tsafe:
specifier: ^1.7.5
version: 1.7.5
specifier: ^1.8.5
version: 1.8.5
type-fest:
specifier: ^4.26.1
version: 4.26.1
@@ -3323,7 +3323,7 @@ packages:
/@types/d3-drag@3.0.7:
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
dependencies:
'@types/d3-selection': 3.0.10
'@types/d3-selection': 3.0.11
dev: false
/@types/d3-interpolate@3.0.4:
@@ -3332,21 +3332,21 @@ packages:
'@types/d3-color': 3.1.3
dev: false
/@types/d3-selection@3.0.10:
resolution: {integrity: sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==}
/@types/d3-selection@3.0.11:
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
dev: false
/@types/d3-transition@3.0.8:
resolution: {integrity: sha512-ew63aJfQ/ms7QQ4X7pk5NxQ9fZH/z+i24ZfJ6tJSfqxJMrYLiK01EAs2/Rtw/JreGUsS3pLPNV644qXFGnoZNQ==}
/@types/d3-transition@3.0.9:
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
dependencies:
'@types/d3-selection': 3.0.10
'@types/d3-selection': 3.0.11
dev: false
/@types/d3-zoom@3.0.8:
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
dependencies:
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.10
'@types/d3-selection': 3.0.11
dev: false
/@types/diff-match-patch@1.0.36:
@@ -3951,28 +3951,28 @@ packages:
resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==}
dev: false
/@xyflow/react@12.4.2(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-AFJKVc/fCPtgSOnRst3xdYJwiEcUN9lDY7EO/YiRvFHYCJGgfzg+jpvZjkTOnBLGyrMJre9378pRxAc3fsR06A==}
/@xyflow/react@12.5.1(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-jMKQVqGwCz0x6pUyvxTIuCMbyehfua7CfEEWDj29zQSHigQpCy0/5d8aOmZrqK4cwur/pVHLQomT6Rm10gXfHg==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@xyflow/system': 0.0.50
'@xyflow/system': 0.0.53
classcat: 5.0.5
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
zustand: 4.5.5(@types/react@18.3.11)(react@18.3.1)
zustand: 4.5.6(@types/react@18.3.11)(react@18.3.1)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@xyflow/system@0.0.50:
resolution: {integrity: sha512-HVUZd4LlY88XAaldFh2nwVxDOcdIBxGpQ5txzwfJPf+CAjj2BfYug1fHs2p4yS7YO8H6A3EFJQovBE8YuHkAdg==}
/@xyflow/system@0.0.53:
resolution: {integrity: sha512-QTWieiTtvNYyQAz1fxpzgtUGXNpnhfh6vvZa7dFWpWS2KOz6bEHODo/DTK3s07lDu0Bq0Db5lx/5M5mNjb9VDQ==}
dependencies:
'@types/d3-drag': 3.0.7
'@types/d3-selection': 3.0.10
'@types/d3-transition': 3.0.8
'@types/d3-selection': 3.0.11
'@types/d3-transition': 3.0.9
'@types/d3-zoom': 3.0.8
d3-drag: 3.0.0
d3-selection: 3.0.0
@@ -8791,8 +8791,8 @@ packages:
resolution: {integrity: sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA==}
dev: false
/tsafe@1.7.5:
resolution: {integrity: sha512-tbNyyBSbwfbilFfiuXkSOj82a6++ovgANwcoqBAcO9/REPoZMEQoE8kWPeO0dy5A2D/2Lajr8Ohue5T0ifIvLQ==}
/tsafe@1.8.5:
resolution: {integrity: sha512-LFWTWQrW6rwSY+IBNFl2ridGfUzVsPwrZ26T4KUJww/py8rzaQ/SY+MIz6YROozpUCaRcuISqagmlwub9YT9kw==}
dev: true
/tsconfck@3.1.5(typescript@5.6.2):
@@ -9123,6 +9123,14 @@ packages:
react: 18.3.1
dev: false
/use-sync-external-store@1.4.0(react@18.3.1):
resolution: {integrity: sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
dependencies:
react: 18.3.1
dev: false
/util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
dev: true
@@ -9567,8 +9575,8 @@ packages:
/zod@3.23.8:
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
/zustand@4.5.5(@types/react@18.3.11)(react@18.3.1):
resolution: {integrity: sha512-+0PALYNJNgK6hldkgDq2vLrw5f6g/jCInz52n9RTpropGgeAf/ioFUCdtsjCqu4gNhW9D01rUQBROoRjdzyn2Q==}
/zustand@4.5.6(@types/react@18.3.11)(react@18.3.1):
resolution: {integrity: sha512-ibr/n1hBzLLj5Y+yUcU7dYw8p6WnIVzdJbnX+1YpaScvZVF2ziugqHs+LAmHw4lWO9c/zRj+K1ncgWDQuthEdQ==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
@@ -9584,5 +9592,5 @@ packages:
dependencies:
'@types/react': 18.3.11
react: 18.3.1
use-sync-external-store: 1.2.2(react@18.3.1)
use-sync-external-store: 1.4.0(react@18.3.1)
dev: false

View File

@@ -1783,7 +1783,37 @@
"textPlaceholder": "Empty Text",
"workflowBuilderAlphaWarning": "The workflow builder is currently in alpha. There may be breaking changes before the stable release.",
"minimum": "Minimum",
"maximum": "Maximum"
"maximum": "Maximum",
"publish": "Publish",
"published": "Published",
"unpublish": "Unpublish",
"workflowLocked": "Workflow Locked",
"workflowLockedPublished": "Published workflows are locked for editing.\nYou can unpublish the workflow to edit it, or make a copy of it.",
"workflowLockedDuringPublishing": "Workflow is locked while configuring for publishing.",
"selectOutputNode": "Select Output Node",
"changeOutputNode": "Change Output Node",
"publishedWorkflowOutputs": "Outputs",
"publishedWorkflowInputs": "Inputs",
"unpublishableInputs": "These unpublishable inputs will be omitted",
"noPublishableInputs": "No publishable inputs",
"noOutputNodeSelected": "No output node selected",
"cannotPublish": "Cannot publish workflow",
"publishWarnings": "Warnings",
"errorWorkflowHasUnsavedChanges": "Workflow has unsaved changes",
"errorWorkflowHasBatchOrGeneratorNodes": "Workflow has batch and/or generator nodes",
"errorWorkflowHasInvalidGraph": "Workflow graph invalid (hover Invoke button for details)",
"errorWorkflowHasNoOutputNode": "No output node selected",
"warningWorkflowHasNoPublishableInputFields": "No publishable input fields selected - published workflow will run with only default values",
"warningWorkflowHasUnpublishableInputFields": "Workflow has some unpublishable inputs - these will be omitted from the published workflow",
"publishFailed": "Publish failed",
"publishFailedDesc": "There was a problem publishing the workflow. Please try again.",
"publishSuccess": "Your workflow is being published",
"publishSuccessDesc": "Check your <LinkComponent>Project Dashboard</LinkComponent> to see its progress.",
"publishInProgress": "Publishing in progress",
"publishedWorkflowIsLocked": "Published workflow is locked",
"publishingValidationRun": "Publishing Validation Run",
"publishingValidationRunInProgress": "Publishing validation run in progress.",
"publishedWorkflowsLocked": "Published workflows are locked and cannot be edited or run. Either unpublish the workflow or save a copy to edit or run this workflow."
}
},
"controlLayers": {

View File

@@ -1,7 +0,0 @@
import { createAction } from '@reduxjs/toolkit';
import type { TabName } from 'features/ui/store/uiTypes';
export const enqueueRequested = createAction<{
tabName: TabName;
prepend: boolean;
}>('app/enqueueRequested');

View File

@@ -10,7 +10,6 @@ import { addDeleteBoardAndImagesFulfilledListener } from 'app/store/middleware/l
import { addBoardIdSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/boardIdSelected';
import { addBulkDownloadListeners } from 'app/store/middleware/listenerMiddleware/listeners/bulkDownload';
import { addEnqueueRequestedLinear } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear';
import { addEnqueueRequestedNodes } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes';
import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked';
import { addGalleryOffsetChangedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged';
import { addGetOpenAPISchemaListener } from 'app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema';
@@ -63,7 +62,6 @@ addGalleryImageClickedListener(startAppListening);
addGalleryOffsetChangedListener(startAppListening);
// User Invoked
addEnqueueRequestedNodes(startAppListening);
addEnqueueRequestedLinear(startAppListening);
addEnqueueRequestedUpscale(startAppListening);
addAnyEnqueuedListener(startAppListening);

View File

@@ -5,7 +5,7 @@ import { buildAdHocPostProcessingGraph } from 'features/nodes/util/graph/buildAd
import { toast } from 'features/toast/toast';
import { t } from 'i18next';
import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue';
import type { BatchConfig, ImageDTO } from 'services/api/types';
import type { EnqueueBatchArg, ImageDTO } from 'services/api/types';
import type { JsonObject } from 'type-fest';
const log = logger('queue');
@@ -19,7 +19,7 @@ export const addAdHocPostProcessingRequestedListener = (startAppListening: AppSt
const { imageDTO } = action.payload;
const state = getState();
const enqueueBatchArg: BatchConfig = {
const enqueueBatchArg: EnqueueBatchArg = {
prepend: true,
batch: {
graph: await buildAdHocPostProcessingGraph({

View File

@@ -1,5 +1,5 @@
import { createAction } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
import { enqueueRequested } from 'app/store/actions';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { extractMessageFromAssertionError } from 'common/util/extractMessageFromAssertionError';
import { withResult, withResultAsync } from 'common/util/result';
@@ -17,10 +17,11 @@ import { assert, AssertionError } from 'tsafe';
const log = logger('generation');
export const enqueueRequestedCanvas = createAction<{ prepend: boolean }>('app/enqueueRequestedCanvas');
export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) => {
startAppListening({
predicate: (action): action is ReturnType<typeof enqueueRequested> =>
enqueueRequested.match(action) && action.payload.tabName === 'canvas',
actionCreator: enqueueRequestedCanvas,
effect: async (action, { getState, dispatch }) => {
log.debug('Enqueue requested');
const state = getState();

View File

@@ -1,5 +1,5 @@
import { createAction } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
import { enqueueRequested } from 'app/store/actions';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { parseify } from 'common/util/serialize';
import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig';
@@ -9,10 +9,11 @@ import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endp
const log = logger('generation');
export const enqueueRequestedUpscaling = createAction<{ prepend: boolean }>('app/enqueueRequestedUpscaling');
export const addEnqueueRequestedUpscale = (startAppListening: AppStartListening) => {
startAppListening({
predicate: (action): action is ReturnType<typeof enqueueRequested> =>
enqueueRequested.match(action) && action.payload.tabName === 'upscaling',
actionCreator: enqueueRequestedUpscaling,
effect: async (action, { getState, dispatch }) => {
const state = getState();
const { prepend } = action.payload;

View File

@@ -3,6 +3,7 @@ import { autoBatchEnhancer, combineReducers, configureStore } from '@reduxjs/too
import { logger } from 'app/logging/logger';
import { idbKeyValDriver } from 'app/store/enhancers/reduxRemember/driver';
import { errorHandler } from 'app/store/enhancers/reduxRemember/errors';
import { getDebugLoggerMiddleware } from 'app/store/middleware/debugLoggerMiddleware';
import { deepClone } from 'common/util/deepClone';
import { changeBoardModalSlice } from 'features/changeBoardModal/store/slice';
import { canvasSettingsPersistConfig, canvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice';
@@ -175,6 +176,7 @@ export const createStore = (uniqueStoreKey?: string, persist = true) =>
.concat(api.middleware)
.concat(dynamicMiddlewares)
.concat(authToastMiddleware)
.concat(getDebugLoggerMiddleware())
.prepend(listenerMiddleware.middleware),
enhancers: (getDefaultEnhancers) => {
const _enhancers = getDefaultEnhancers().concat(autoBatchEnhancer());

View File

@@ -28,7 +28,8 @@ export type AppFeature =
| 'starterModels'
| 'hfToken'
| 'retryQueueItem'
| 'cancelAndClearAll';
| 'cancelAndClearAll'
| 'deployWorkflow';
/**
* A disable-able Stable Diffusion feature
*/

View File

@@ -14,7 +14,7 @@ export const useGlobalHotkeys = () => {
useRegisteredHotkeys({
id: 'invoke',
category: 'app',
callback: queue.queueBack,
callback: queue.enqueueBack,
options: {
enabled: !queue.isDisabled && !queue.isLoading,
preventDefault: true,
@@ -26,7 +26,7 @@ export const useGlobalHotkeys = () => {
useRegisteredHotkeys({
id: 'invokeFront',
category: 'app',
callback: queue.queueFront,
callback: queue.enqueueFront,
options: {
enabled: !queue.isDisabled && !queue.isLoading,
preventDefault: true,

View File

@@ -54,7 +54,7 @@ import { atom, computed } from 'nanostores';
import type { Logger } from 'roarr';
import { getImageDTO } from 'services/api/endpoints/images';
import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue';
import type { BatchConfig, ImageDTO, S } from 'services/api/types';
import type { EnqueueBatchArg, ImageDTO, S } from 'services/api/types';
import { QueueError } from 'services/events/errors';
import type { Param0 } from 'tsafe';
import { assert } from 'tsafe';
@@ -291,7 +291,7 @@ export class CanvasStateApiModule extends CanvasModuleBase {
*/
const origin = getPrefixedId(graph.id);
const batch: BatchConfig = {
const batch: EnqueueBatchArg = {
prepend,
batch: {
graph: graph.getGraph(),

View File

@@ -2,7 +2,9 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { AddNodeCmdk } from 'features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk';
import TopPanel from 'features/nodes/components/flow/panels/TopPanel/TopPanel';
import { TopCenterPanel } from 'features/nodes/components/flow/panels/TopPanel/TopCenterPanel';
import { TopLeftPanel } from 'features/nodes/components/flow/panels/TopPanel/TopLeftPanel';
import { TopRightPanel } from 'features/nodes/components/flow/panels/TopPanel/TopRightPanel';
import WorkflowEditorSettings from 'features/nodes/components/flow/panels/TopRightPanel/WorkflowEditorSettings';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -32,7 +34,9 @@ const NodeEditor = () => {
<>
<Flow />
<AddNodeCmdk />
<TopPanel />
<TopLeftPanel />
<TopCenterPanel />
<TopRightPanel />
<BottomLeftPanel />
<MinimapPanel />
</>

View File

@@ -18,6 +18,7 @@ import { CommandEmpty, CommandItem, CommandList, CommandRoot } from 'cmdk';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { useBuildNode } from 'features/nodes/hooks/useBuildNode';
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import {
$addNodeCmdk,
$cursorPos,
@@ -146,6 +147,7 @@ export const AddNodeCmdk = memo(() => {
const [searchTerm, setSearchTerm] = useState('');
const addNode = useAddNode();
const tab = useAppSelector(selectActiveTab);
const isLocked = useIsWorkflowEditorLocked();
// Filtering the list is expensive - debounce the search term to avoid stutters
const [debouncedSearchTerm] = useDebounce(searchTerm, 300);
const isOpen = useStore($addNodeCmdk);
@@ -160,8 +162,8 @@ export const AddNodeCmdk = memo(() => {
id: 'addNode',
category: 'workflows',
callback: open,
options: { enabled: tab === 'workflows', preventDefault: true },
dependencies: [open, tab],
options: { enabled: tab === 'workflows' && !isLocked, preventDefault: true },
dependencies: [open, tab, isLocked],
});
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {

View File

@@ -4,6 +4,7 @@ import type {
EdgeChange,
HandleType,
NodeChange,
NodeMouseHandler,
OnEdgesChange,
OnInit,
OnMoveEnd,
@@ -16,8 +17,10 @@ import type {
import { Background, ReactFlow, useStore as useReactFlowStore, useUpdateNodeInternals } from '@xyflow/react';
import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks';
import { useFocusRegion, useIsRegionFocused } from 'common/hooks/focus';
import { $isSelectingOutputNode, $outputNodeId } from 'features/nodes/components/sidePanel/workflow/publish';
import { useConnection } from 'features/nodes/hooks/useConnection';
import { useIsValidConnection } from 'features/nodes/hooks/useIsValidConnection';
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import { useNodeCopyPaste } from 'features/nodes/hooks/useNodeCopyPaste';
import { useSyncExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
import {
@@ -44,7 +47,7 @@ import {
import { connectionToEdge } from 'features/nodes/store/util/reactFlowUtil';
import { selectSelectionMode, selectShouldSnapToGrid } from 'features/nodes/store/workflowSettingsSlice';
import { NO_DRAG_CLASS, NO_PAN_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation';
import { type AnyEdge, type AnyNode, isInvocationNode } from 'features/nodes/types/invocation';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import type { CSSProperties, MouseEvent } from 'react';
import { memo, useCallback, useMemo, useRef } from 'react';
@@ -92,6 +95,8 @@ export const Flow = memo(() => {
const updateNodeInternals = useUpdateNodeInternals();
const store = useAppStore();
const isWorkflowsFocused = useIsRegionFocused('workflows');
const isLocked = useIsWorkflowEditorLocked();
useFocusRegion('workflows', flowWrapper);
useSyncExecutionState();
@@ -215,7 +220,7 @@ export const Flow = memo(() => {
id: 'copySelection',
category: 'workflows',
callback: copySelection,
options: { preventDefault: true },
options: { enabled: isWorkflowsFocused && !isLocked, preventDefault: true },
dependencies: [copySelection],
});
@@ -244,24 +249,24 @@ export const Flow = memo(() => {
id: 'selectAll',
category: 'workflows',
callback: selectAll,
options: { enabled: isWorkflowsFocused, preventDefault: true },
dependencies: [selectAll, isWorkflowsFocused],
options: { enabled: isWorkflowsFocused && !isLocked, preventDefault: true },
dependencies: [selectAll, isWorkflowsFocused, isLocked],
});
useRegisteredHotkeys({
id: 'pasteSelection',
category: 'workflows',
callback: pasteSelection,
options: { enabled: isWorkflowsFocused, preventDefault: true },
dependencies: [pasteSelection],
options: { enabled: isWorkflowsFocused && !isLocked, preventDefault: true },
dependencies: [pasteSelection, isLocked, isWorkflowsFocused],
});
useRegisteredHotkeys({
id: 'pasteSelectionWithEdges',
category: 'workflows',
callback: pasteSelectionWithEdges,
options: { enabled: isWorkflowsFocused, preventDefault: true },
dependencies: [pasteSelectionWithEdges],
options: { enabled: isWorkflowsFocused && !isLocked, preventDefault: true },
dependencies: [pasteSelectionWithEdges, isLocked, isWorkflowsFocused],
});
useRegisteredHotkeys({
@@ -270,8 +275,8 @@ export const Flow = memo(() => {
callback: () => {
dispatch(undo());
},
options: { enabled: isWorkflowsFocused && mayUndo, preventDefault: true },
dependencies: [mayUndo],
options: { enabled: isWorkflowsFocused && !isLocked && mayUndo, preventDefault: true },
dependencies: [mayUndo, isLocked, isWorkflowsFocused],
});
useRegisteredHotkeys({
@@ -280,8 +285,8 @@ export const Flow = memo(() => {
callback: () => {
dispatch(redo());
},
options: { enabled: isWorkflowsFocused && mayRedo, preventDefault: true },
dependencies: [mayRedo],
options: { enabled: isWorkflowsFocused && !isLocked && mayRedo, preventDefault: true },
dependencies: [mayRedo, isLocked, isWorkflowsFocused],
});
const onEscapeHotkey = useCallback(() => {
@@ -318,10 +323,22 @@ export const Flow = memo(() => {
id: 'deleteSelection',
category: 'workflows',
callback: deleteSelection,
options: { preventDefault: true, enabled: isWorkflowsFocused },
dependencies: [deleteSelection, isWorkflowsFocused],
options: { preventDefault: true, enabled: isWorkflowsFocused && !isLocked },
dependencies: [deleteSelection, isWorkflowsFocused, isLocked],
});
const onNodeClick = useCallback<NodeMouseHandler<AnyNode>>((e, node) => {
if (!$isSelectingOutputNode.get()) {
return;
}
if (!isInvocationNode(node)) {
return;
}
const { id } = node.data;
$outputNodeId.set(id);
$isSelectingOutputNode.set(false);
}, []);
return (
<ReactFlow<AnyNode, AnyEdge>
id="workflow-editor"
@@ -332,6 +349,7 @@ export const Flow = memo(() => {
nodes={nodes}
edges={edges}
onInit={onInit}
onNodeClick={onNodeClick}
onMouseMove={onMouseMove}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
@@ -344,6 +362,12 @@ export const Flow = memo(() => {
onMoveEnd={handleMoveEnd}
connectionLineComponent={CustomConnectionLine}
isValidConnection={isValidConnection}
edgesFocusable={!isLocked}
edgesReconnectable={!isLocked}
nodesDraggable={!isLocked}
nodesConnectable={!isLocked}
nodesFocusable={!isLocked}
elementsSelectable={!isLocked}
minZoom={0.1}
snapToGrid={shouldSnapToGrid}
snapGrid={snapGrid}

View File

@@ -1,5 +1,5 @@
import { Handle, Position } from '@xyflow/react';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow';
import { map } from 'lodash-es';
import type { CSSProperties } from 'react';
import { memo } from 'react';
@@ -19,7 +19,7 @@ const collapsedHandleStyles: CSSProperties = {
};
const InvocationNodeCollapsedHandles = ({ nodeId }: Props) => {
const template = useNodeTemplate(nodeId);
const template = useNodeTemplateOrThrow(nodeId);
if (!template) {
return null;

View File

@@ -1,9 +1,9 @@
import { Flex, Icon, Text, Tooltip } from '@invoke-ai/ui-library';
import { compare } from 'compare-versions';
import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel';
import { useNodeNeedsUpdate } from 'features/nodes/hooks/useNodeNeedsUpdate';
import { useInvocationNodeNotes } from 'features/nodes/hooks/useNodeNotes';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow';
import { useNodeUserTitleSafe } from 'features/nodes/hooks/useNodeUserTitleSafe';
import { useNodeVersion } from 'features/nodes/hooks/useNodeVersion';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -27,9 +27,9 @@ InvocationNodeInfoIcon.displayName = 'InvocationNodeInfoIcon';
const TooltipContent = memo(({ nodeId }: { nodeId: string }) => {
const notes = useInvocationNodeNotes(nodeId);
const label = useNodeLabel(nodeId);
const label = useNodeUserTitleSafe(nodeId);
const version = useNodeVersion(nodeId);
const nodeTemplate = useNodeTemplate(nodeId);
const nodeTemplate = useNodeTemplateOrThrow(nodeId);
const { t } = useTranslation();
const title = useMemo(() => {

View File

@@ -8,7 +8,7 @@ import {
Textarea,
} from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useInputFieldDescriptionSafe } from 'features/nodes/hooks/useInputFieldDescriptionSafe';
import { useInputFieldUserDescriptionSafe } from 'features/nodes/hooks/useInputFieldUserDescriptionSafe';
import { fieldDescriptionChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_PAN_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { ChangeEvent } from 'react';
@@ -48,7 +48,7 @@ InputFieldDescriptionPopover.displayName = 'InputFieldDescriptionPopover';
const Content = memo(({ nodeId, fieldName }: Props) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const description = useInputFieldDescriptionSafe(nodeId, fieldName);
const description = useInputFieldUserDescriptionSafe(nodeId, fieldName);
const onChange = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => {
dispatch(fieldDescriptionChanged({ nodeId, fieldName, val: e.target.value }));

View File

@@ -7,7 +7,7 @@ import { InputFieldResetToDefaultValueIconButton } from 'features/nodes/componen
import { useNodeFieldDnd } from 'features/nodes/components/sidePanel/builder/dnd-hooks';
import { useInputFieldIsConnected } from 'features/nodes/hooks/useInputFieldIsConnected';
import { useInputFieldIsInvalid } from 'features/nodes/hooks/useInputFieldIsInvalid';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import { memo, useRef } from 'react';
@@ -100,7 +100,7 @@ const DirectField = memo(({ nodeId, fieldName, isInvalid, isConnected, fieldTemp
const draggableRef = useRef<HTMLDivElement>(null);
const dragHandleRef = useRef<HTMLDivElement>(null);
const isDragging = useNodeFieldDnd({ nodeId, fieldName }, fieldTemplate, draggableRef, dragHandleRef);
const isDragging = useNodeFieldDnd(nodeId, fieldName, fieldTemplate, draggableRef, dragHandleRef);
return (
<InputFieldWrapper>

View File

@@ -7,7 +7,8 @@ import {
useIsConnectionInProgress,
useIsConnectionStartField,
} from 'features/nodes/hooks/useFieldConnectionState';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import type { FieldInputTemplate } from 'features/nodes/types/field';
@@ -105,9 +106,16 @@ type HandleCommonProps = {
};
const IdleHandle = memo(({ fieldTemplate, fieldTypeName, fieldColor, isModelField }: HandleCommonProps) => {
const isLocked = useIsWorkflowEditorLocked();
return (
<Tooltip label={fieldTypeName} placement="start" openDelay={HANDLE_TOOLTIP_OPEN_DELAY}>
<Handle type="target" id={fieldTemplate.name} position={Position.Left} style={handleStyles}>
<Handle
type="target"
id={fieldTemplate.name}
position={Position.Left}
style={handleStyles}
isConnectable={!isLocked}
>
<Box
sx={sx}
data-cardinality={fieldTemplate.type.cardinality}
@@ -130,6 +138,7 @@ const ConnectionInProgressHandle = memo(
const { t } = useTranslation();
const isConnectionStartField = useIsConnectionStartField(nodeId, fieldName, 'target');
const connectionError = useConnectionErrorTKey(nodeId, fieldName, 'target');
const isLocked = useIsWorkflowEditorLocked();
const tooltip = useMemo(() => {
if (connectionError !== null) {
@@ -140,7 +149,13 @@ const ConnectionInProgressHandle = memo(
return (
<Tooltip label={tooltip} placement="start" openDelay={HANDLE_TOOLTIP_OPEN_DELAY}>
<Handle type="target" id={fieldTemplate.name} position={Position.Left} style={handleStyles}>
<Handle
type="target"
id={fieldTemplate.name}
position={Position.Left}
style={handleStyles}
isConnectable={!isLocked}
>
<Box
sx={sx}
data-cardinality={fieldTemplate.type.cardinality}

View File

@@ -17,7 +17,7 @@ import { StringFieldDropdown } from 'features/nodes/components/flow/nodes/Invoca
import { StringFieldInput } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/StringFieldInput';
import { StringFieldTextarea } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/StringFieldTextarea';
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import {
isBoardFieldInputInstance,
isBoardFieldInputTemplate,

View File

@@ -9,8 +9,8 @@ import {
useIsConnectionStartField,
} from 'features/nodes/hooks/useFieldConnectionState';
import { useInputFieldIsConnected } from 'features/nodes/hooks/useInputFieldIsConnected';
import { useInputFieldLabelSafe } from 'features/nodes/hooks/useInputFieldLabelSafe';
import { useInputFieldTemplateTitle } from 'features/nodes/hooks/useInputFieldTemplateTitle';
import { useInputFieldTemplateTitleOrThrow } from 'features/nodes/hooks/useInputFieldTemplateTitleOrThrow';
import { useInputFieldUserTitleSafe } from 'features/nodes/hooks/useInputFieldUserTitleSafe';
import { fieldLabelChanged } from 'features/nodes/store/nodesSlice';
import { HANDLE_TOOLTIP_OPEN_DELAY, NO_FIT_ON_DOUBLE_CLICK_CLASS } from 'features/nodes/types/constants';
import type { MouseEvent } from 'react';
@@ -43,8 +43,8 @@ interface Props {
export const InputFieldTitle = memo((props: Props) => {
const { nodeId, fieldName, isInvalid, isDragging } = props;
const inputRef = useRef<HTMLInputElement>(null);
const label = useInputFieldLabelSafe(nodeId, fieldName);
const fieldTemplateTitle = useInputFieldTemplateTitle(nodeId, fieldName);
const label = useInputFieldUserTitleSafe(nodeId, fieldName);
const fieldTemplateTitle = useInputFieldTemplateTitleOrThrow(nodeId, fieldName);
const { t } = useTranslation();
const isConnected = useInputFieldIsConnected(nodeId, fieldName);
const isConnectionStartField = useIsConnectionStartField(nodeId, fieldName, 'target');

View File

@@ -1,7 +1,7 @@
import { Flex, ListItem, Text, UnorderedList } from '@invoke-ai/ui-library';
import { useInputFieldErrors } from 'features/nodes/hooks/useInputFieldErrors';
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
import { startCase } from 'lodash-es';
import { memo, useMemo } from 'react';

View File

@@ -7,6 +7,7 @@ import {
useIsConnectionInProgress,
useIsConnectionStartField,
} from 'features/nodes/hooks/useFieldConnectionState';
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import { useOutputFieldTemplate } from 'features/nodes/hooks/useOutputFieldTemplate';
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
@@ -105,9 +106,17 @@ type HandleCommonProps = {
};
const IdleHandle = memo(({ fieldTemplate, fieldTypeName, fieldColor, isModelField }: HandleCommonProps) => {
const isLocked = useIsWorkflowEditorLocked();
return (
<Tooltip label={fieldTypeName} placement="start" openDelay={HANDLE_TOOLTIP_OPEN_DELAY}>
<Handle type="source" id={fieldTemplate.name} position={Position.Right} style={handleStyles}>
<Handle
type="source"
id={fieldTemplate.name}
position={Position.Right}
style={handleStyles}
isConnectable={!isLocked}
>
<Box
sx={sx}
data-cardinality={fieldTemplate.type.cardinality}
@@ -130,6 +139,7 @@ const ConnectionInProgressHandle = memo(
const { t } = useTranslation();
const isConnectionStartField = useIsConnectionStartField(nodeId, fieldName, 'target');
const connectionErrorTKey = useConnectionErrorTKey(nodeId, fieldName, 'target');
const isLocked = useIsWorkflowEditorLocked();
const tooltip = useMemo(() => {
if (connectionErrorTKey !== null) {
@@ -140,7 +150,13 @@ const ConnectionInProgressHandle = memo(
return (
<Tooltip label={tooltip} placement="start" openDelay={HANDLE_TOOLTIP_OPEN_DELAY}>
<Handle type="source" id={fieldTemplate.name} position={Position.Right} style={handleStyles}>
<Handle
type="source"
id={fieldTemplate.name}
position={Position.Right}
style={handleStyles}
isConnectable={!isLocked}
>
<Box
sx={sx}
data-cardinality={fieldTemplate.type.cardinality}

View File

@@ -3,8 +3,8 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { useEditable } from 'common/hooks/useEditable';
import { useBatchGroupColorToken } from 'features/nodes/hooks/useBatchGroupColorToken';
import { useBatchGroupId } from 'features/nodes/hooks/useBatchGroupId';
import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel';
import { useNodeTemplateTitle } from 'features/nodes/hooks/useNodeTemplateTitle';
import { useNodeTemplateTitleSafe } from 'features/nodes/hooks/useNodeTemplateTitleSafe';
import { useNodeUserTitleSafe } from 'features/nodes/hooks/useNodeUserTitleSafe';
import { nodeLabelChanged } from 'features/nodes/store/nodesSlice';
import { NO_FIT_ON_DOUBLE_CLICK_CLASS } from 'features/nodes/types/constants';
import { memo, useCallback, useMemo, useRef } from 'react';
@@ -17,10 +17,10 @@ type Props = {
const NodeTitle = ({ nodeId, title }: Props) => {
const dispatch = useAppDispatch();
const label = useNodeLabel(nodeId);
const label = useNodeUserTitleSafe(nodeId);
const batchGroupId = useBatchGroupId(nodeId);
const batchGroupColorToken = useBatchGroupColorToken(batchGroupId);
const templateTitle = useNodeTemplateTitle(nodeId);
const templateTitle = useNodeTemplateTitleSafe(nodeId);
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);

View File

@@ -1,6 +1,7 @@
import type { ChakraProps, SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, useGlobalMenuClose } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import { useMouseOverFormField, useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
import { useNodeExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
import { useZoomToNode } from 'features/nodes/hooks/useZoomToNode';
@@ -62,6 +63,12 @@ const containerSx: SystemStyleObject = {
display: 'block',
shadow: '0 0 0 3px var(--invoke-colors-blue-300)',
},
'&[data-is-editor-locked="true"]': {
'& *': {
cursor: 'not-allowed',
pointerEvents: 'none',
},
},
};
const shadowsSx: SystemStyleObject = {
@@ -98,7 +105,8 @@ const NodeWrapper = (props: NodeWrapperProps) => {
const { nodeId, width, children, selected } = props;
const mouseOverNode = useMouseOverNode(nodeId);
const mouseOverFormField = useMouseOverFormField(nodeId);
const zoomToNode = useZoomToNode();
const zoomToNode = useZoomToNode(nodeId);
const isLocked = useIsWorkflowEditorLocked();
const executionState = useNodeExecutionState(nodeId);
const isInProgress = executionState?.status === zNodeStatus.enum.IN_PROGRESS;
@@ -126,9 +134,9 @@ const NodeWrapper = (props: NodeWrapperProps) => {
// This target is marked as not fitting the view on double click
return;
}
zoomToNode(nodeId);
zoomToNode();
},
[nodeId, zoomToNode]
[zoomToNode]
);
return (
@@ -141,6 +149,7 @@ const NodeWrapper = (props: NodeWrapperProps) => {
sx={containerSx}
width={width || NODE_WIDTH}
opacity={opacity}
data-is-editor-locked={isLocked}
data-is-selected={selected}
data-is-mouse-over-form-field={mouseOverFormField.isMouseOverFormField}
>

View File

@@ -0,0 +1,15 @@
import { Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { WorkflowName } from 'features/nodes/components/sidePanel/WorkflowName';
import { selectWorkflowName } from 'features/nodes/store/workflowSlice';
import { memo } from 'react';
export const TopCenterPanel = memo(() => {
const name = useAppSelector(selectWorkflowName);
return (
<Flex gap={2} top={2} left="50%" transform="translateX(-50%)" position="absolute" pointerEvents="none">
{!!name.length && <WorkflowName />}
</Flex>
);
});
TopCenterPanel.displayName = 'TopCenterPanel';

View File

@@ -0,0 +1,54 @@
import { Alert, AlertDescription, AlertIcon, AlertTitle, Box, Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import AddNodeButton from 'features/nodes/components/flow/panels/TopPanel/AddNodeButton';
import UpdateNodesButton from 'features/nodes/components/flow/panels/TopPanel/UpdateNodesButton';
import { $isInPublishFlow, useIsValidationRunInProgress } from 'features/nodes/components/sidePanel/workflow/publish';
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import { selectWorkflowIsPublished } from 'features/nodes/store/workflowSlice';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
export const TopLeftPanel = memo(() => {
const isLocked = useIsWorkflowEditorLocked();
const isInPublishFlow = useStore($isInPublishFlow);
const isPublished = useAppSelector(selectWorkflowIsPublished);
const isValidationRunInProgress = useIsValidationRunInProgress();
const { t } = useTranslation();
return (
<Flex gap={2} top={2} left={2} position="absolute" alignItems="flex-start" pointerEvents="none">
{!isLocked && (
<Flex gap="2">
<AddNodeButton />
<UpdateNodesButton />
</Flex>
)}
{isLocked && (
<Alert status="info" borderRadius="base" fontSize="sm" shadow="md" w="fit-content">
<AlertIcon />
<Box>
<AlertTitle>{t('workflows.builder.workflowLocked')}</AlertTitle>
{isValidationRunInProgress && (
<AlertDescription whiteSpace="pre-wrap">
{t('workflows.builder.publishingValidationRunInProgress')}
</AlertDescription>
)}
{isInPublishFlow && !isValidationRunInProgress && (
<AlertDescription whiteSpace="pre-wrap">
{t('workflows.builder.workflowLockedDuringPublishing')}
</AlertDescription>
)}
{isPublished && (
<AlertDescription whiteSpace="pre-wrap">
{t('workflows.builder.workflowLockedPublished')}
</AlertDescription>
)}
</Box>
</Alert>
)}
</Flex>
);
});
TopLeftPanel.displayName = 'TopLeftPanel';

View File

@@ -1,40 +0,0 @@
import { Flex, IconButton, Spacer } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import AddNodeButton from 'features/nodes/components/flow/panels/TopPanel/AddNodeButton';
import ClearFlowButton from 'features/nodes/components/flow/panels/TopPanel/ClearFlowButton';
import SaveWorkflowButton from 'features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton';
import UpdateNodesButton from 'features/nodes/components/flow/panels/TopPanel/UpdateNodesButton';
import { useWorkflowEditorSettingsModal } from 'features/nodes/components/flow/panels/TopRightPanel/WorkflowEditorSettings';
import { WorkflowName } from 'features/nodes/components/sidePanel/WorkflowName';
import { selectWorkflowName } from 'features/nodes/store/workflowSlice';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiGearSixFill } from 'react-icons/pi';
const TopCenterPanel = () => {
const name = useAppSelector(selectWorkflowName);
const modal = useWorkflowEditorSettingsModal();
const { t } = useTranslation();
return (
<Flex gap={2} top={2} left={2} right={2} position="absolute" alignItems="flex-start" pointerEvents="none">
<Flex gap="2">
<AddNodeButton />
<UpdateNodesButton />
</Flex>
<Spacer />
{!!name.length && <WorkflowName />}
<Spacer />
<ClearFlowButton />
<SaveWorkflowButton />
<IconButton
pointerEvents="auto"
aria-label={t('workflows.workflowEditorMenu')}
icon={<PiGearSixFill />}
onClick={modal.setTrue}
/>
</Flex>
);
};
export default memo(TopCenterPanel);

View File

@@ -0,0 +1,34 @@
import { Flex, IconButton } from '@invoke-ai/ui-library';
import ClearFlowButton from 'features/nodes/components/flow/panels/TopPanel/ClearFlowButton';
import SaveWorkflowButton from 'features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton';
import { useWorkflowEditorSettingsModal } from 'features/nodes/components/flow/panels/TopRightPanel/WorkflowEditorSettings';
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiGearSixFill } from 'react-icons/pi';
export const TopRightPanel = memo(() => {
const modal = useWorkflowEditorSettingsModal();
const isLocked = useIsWorkflowEditorLocked();
const { t } = useTranslation();
if (isLocked) {
return null;
}
return (
<Flex gap={2} top={2} right={2} position="absolute" alignItems="flex-end" pointerEvents="none">
<ClearFlowButton />
<SaveWorkflowButton />
<IconButton
pointerEvents="auto"
aria-label={t('workflows.workflowEditorMenu')}
icon={<PiGearSixFill />}
onClick={modal.setTrue}
/>
</Flex>
);
});
TopRightPanel.displayName = 'TopRightPanel';

View File

@@ -1,5 +1,4 @@
import { Box } from '@invoke-ai/ui-library';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { HorizontalResizeHandle } from 'features/ui/components/tabs/ResizeHandle';
import type { CSSProperties } from 'react';
import { memo, useCallback, useRef } from 'react';
@@ -23,23 +22,21 @@ export const EditModeLeftPanelContent = memo(() => {
return (
<Box position="relative" w="full" h="full">
<ScrollableContent>
<PanelGroup
ref={panelGroupRef}
id="workflow-panel-group"
autoSaveId="workflow-panel-group"
direction="vertical"
style={panelGroupStyles}
>
<Panel id="workflow" collapsible minSize={25}>
<WorkflowFieldsLinearViewPanel />
</Panel>
<HorizontalResizeHandle onDoubleClick={handleDoubleClickHandle} />
<Panel id="inspector" collapsible minSize={25}>
<WorkflowNodeInspectorPanel />
</Panel>
</PanelGroup>
</ScrollableContent>
<PanelGroup
ref={panelGroupRef}
id="workflow-panel-group"
autoSaveId="workflow-panel-group"
direction="vertical"
style={panelGroupStyles}
>
<Panel id="workflow" collapsible minSize={25}>
<WorkflowFieldsLinearViewPanel />
</Panel>
<HorizontalResizeHandle onDoubleClick={handleDoubleClickHandle} />
<Panel id="inspector" collapsible minSize={25}>
<WorkflowNodeInspectorPanel />
</Panel>
</PanelGroup>
</Box>
);
});

View File

@@ -0,0 +1,25 @@
import { Button, Flex, Heading, Text } from '@invoke-ai/ui-library';
import { useSaveOrSaveAsWorkflow } from 'features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCopyBold, PiLockOpenBold } from 'react-icons/pi';
export const PublishedWorkflowPanelContent = memo(() => {
const { t } = useTranslation();
const saveAs = useSaveOrSaveAsWorkflow();
return (
<Flex flexDir="column" w="full" h="full" gap={2} alignItems="center">
<Heading size="md" pt={32}>
{t('workflows.builder.workflowLocked')}
</Heading>
<Text fontSize="md">{t('workflows.builder.publishedWorkflowsLocked')}</Text>
<Button size="md" onClick={saveAs} variant="ghost" leftIcon={<PiCopyBold />}>
{t('common.saveAs')}
</Button>
<Button size="md" onClick={undefined} variant="ghost" leftIcon={<PiLockOpenBold />}>
{t('workflows.builder.unpublish')}
</Button>
</Flex>
);
});
PublishedWorkflowPanelContent.displayName = 'PublishedWorkflowPanelContent';

View File

@@ -2,7 +2,7 @@ import { Flex, Spacer } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { WorkflowListMenuTrigger } from 'features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListMenuTrigger';
import { WorkflowViewEditToggleButton } from 'features/nodes/components/sidePanel/WorkflowViewEditToggleButton';
import { selectWorkflowMode } from 'features/nodes/store/workflowSlice';
import { selectWorkflowIsPublished, selectWorkflowMode } from 'features/nodes/store/workflowSlice';
import { WorkflowLibraryMenu } from 'features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu';
import { memo } from 'react';
@@ -10,12 +10,13 @@ import SaveWorkflowButton from './SaveWorkflowButton';
export const ActiveWorkflowNameAndActions = memo(() => {
const mode = useAppSelector(selectWorkflowMode);
const isPublished = useAppSelector(selectWorkflowIsPublished);
return (
<Flex w="full" alignItems="center" gap={1} minW={0}>
<WorkflowListMenuTrigger />
<Spacer />
{mode === 'edit' && <SaveWorkflowButton />}
{mode === 'edit' && !isPublished && <SaveWorkflowButton />}
<WorkflowViewEditToggleButton />
<WorkflowLibraryMenu />
</Flex>

View File

@@ -1,22 +1,30 @@
import { Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { EditModeLeftPanelContent } from 'features/nodes/components/sidePanel/EditModeLeftPanelContent';
import { PublishedWorkflowPanelContent } from 'features/nodes/components/sidePanel/PublishedWorkflowPanelContent';
import { $isInPublishFlow } from 'features/nodes/components/sidePanel/workflow/publish';
import { PublishWorkflowPanelContent } from 'features/nodes/components/sidePanel/workflow/PublishWorkflowPanelContent';
import { ActiveWorkflowDescription } from 'features/nodes/components/sidePanel/WorkflowListMenu/ActiveWorkflowDescription';
import { ActiveWorkflowNameAndActions } from 'features/nodes/components/sidePanel/WorkflowListMenu/ActiveWorkflowNameAndActions';
import { selectWorkflowMode } from 'features/nodes/store/workflowSlice';
import { selectWorkflowIsPublished, selectWorkflowMode } from 'features/nodes/store/workflowSlice';
import { memo } from 'react';
import { ViewModeLeftPanelContent } from './viewMode/ViewModeLeftPanelContent';
const WorkflowsTabLeftPanel = () => {
const mode = useAppSelector(selectWorkflowMode);
const isPublished = useAppSelector(selectWorkflowIsPublished);
const isInPublishFlow = useStore($isInPublishFlow);
return (
<Flex w="full" h="full" gap={2} flexDir="column">
<ActiveWorkflowNameAndActions />
{mode === 'view' && <ActiveWorkflowDescription />}
{mode === 'view' && <ViewModeLeftPanelContent />}
{mode === 'edit' && <EditModeLeftPanelContent />}
{isInPublishFlow && <PublishWorkflowPanelContent />}
{!isInPublishFlow && <ActiveWorkflowNameAndActions />}
{!isInPublishFlow && !isPublished && mode === 'view' && <ActiveWorkflowDescription />}
{!isInPublishFlow && !isPublished && mode === 'view' && <ViewModeLeftPanelContent />}
{!isInPublishFlow && !isPublished && mode === 'edit' && <EditModeLeftPanelContent />}
{isPublished && <PublishedWorkflowPanelContent />}
</Flex>
);
};

View File

@@ -67,11 +67,8 @@ FormElementEditModeHeader.displayName = 'FormElementEditModeHeader';
const ZoomToNodeButton = memo(({ element }: { element: NodeFieldElement }) => {
const { t } = useTranslation();
const { nodeId } = element.data.fieldIdentifier;
const zoomToNode = useZoomToNode();
const zoomToNode = useZoomToNode(nodeId);
const mouseOverFormField = useMouseOverFormField(nodeId);
const onClick = useCallback(() => {
zoomToNode(nodeId);
}, [nodeId, zoomToNode]);
return (
<IconButton
@@ -79,7 +76,7 @@ const ZoomToNodeButton = memo(({ element }: { element: NodeFieldElement }) => {
onMouseOut={mouseOverFormField.handleMouseOut}
tooltip={t('workflows.builder.zoomToNode')}
aria-label={t('workflows.builder.zoomToNode')}
onClick={onClick}
onClick={zoomToNode}
icon={<PiGpsFixBold />}
variant="link"
size="sm"

View File

@@ -2,8 +2,8 @@ import { FormHelperText, Textarea } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { linkifyOptions, linkifySx } from 'common/components/linkify';
import { useEditable } from 'common/hooks/useEditable';
import { useInputFieldDescriptionSafe } from 'features/nodes/hooks/useInputFieldDescriptionSafe';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { useInputFieldUserDescriptionSafe } from 'features/nodes/hooks/useInputFieldUserDescriptionSafe';
import { fieldDescriptionChanged } from 'features/nodes/store/nodesSlice';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import Linkify from 'linkify-react';
@@ -13,7 +13,7 @@ export const NodeFieldElementDescriptionEditable = memo(({ el }: { el: NodeField
const { data } = el;
const { fieldIdentifier } = data;
const dispatch = useAppDispatch();
const description = useInputFieldDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const description = useInputFieldUserDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const inputRef = useRef<HTMLTextAreaElement>(null);

View File

@@ -39,7 +39,7 @@ export const NodeFieldElementEditMode = memo(({ el }: { el: NodeFieldElement })
return (
<Flex ref={draggableRef} id={id} className={NODE_FIELD_CLASS_NAME} sx={sx} data-parent-layout={containerCtx.layout}>
<NodeFieldElementEditModeContent dragHandleRef={dragHandleRef} el={el} isDragging={isDragging} />
<NodeFieldElementOverlay element={el} />
<NodeFieldElementOverlay nodeId={el.data.fieldIdentifier.nodeId} />
<DndListDropIndicator activeDropRegion={activeDropRegion} gap="var(--invoke-space-4)" />
</Flex>
);
@@ -105,9 +105,9 @@ const nodeFieldOverlaySx: SystemStyleObject = {
},
};
const NodeFieldElementOverlay = memo(({ element }: { element: NodeFieldElement }) => {
const mouseOverNode = useMouseOverNode(element.data.fieldIdentifier.nodeId);
const mouseOverFormField = useMouseOverFormField(element.data.fieldIdentifier.nodeId);
export const NodeFieldElementOverlay = memo(({ nodeId }: { nodeId: string }) => {
const mouseOverNode = useMouseOverNode(nodeId);
const mouseOverFormField = useMouseOverFormField(nodeId);
return (
<Box

View File

@@ -1,14 +1,14 @@
import { Flex, FormLabel, Spacer } from '@invoke-ai/ui-library';
import { NodeFieldElementResetToInitialValueIconButton } from 'features/nodes/components/flow/nodes/Invocation/fields/NodeFieldElementResetToInitialValueIconButton';
import { useInputFieldLabelSafe } from 'features/nodes/hooks/useInputFieldLabelSafe';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { useInputFieldUserTitleSafe } from 'features/nodes/hooks/useInputFieldUserTitleSafe';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import { memo, useMemo } from 'react';
export const NodeFieldElementLabel = memo(({ el }: { el: NodeFieldElement }) => {
const { data } = el;
const { fieldIdentifier } = data;
const label = useInputFieldLabelSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const label = useInputFieldUserTitleSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const _label = useMemo(() => label || fieldTemplate.title, [label, fieldTemplate.title]);

View File

@@ -2,8 +2,8 @@ import { Flex, FormLabel, Input, Spacer } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useEditable } from 'common/hooks/useEditable';
import { NodeFieldElementResetToInitialValueIconButton } from 'features/nodes/components/flow/nodes/Invocation/fields/NodeFieldElementResetToInitialValueIconButton';
import { useInputFieldLabelSafe } from 'features/nodes/hooks/useInputFieldLabelSafe';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { useInputFieldUserTitleSafe } from 'features/nodes/hooks/useInputFieldUserTitleSafe';
import { fieldLabelChanged } from 'features/nodes/store/nodesSlice';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import { memo, useCallback, useRef } from 'react';
@@ -12,7 +12,7 @@ export const NodeFieldElementLabelEditable = memo(({ el }: { el: NodeFieldElemen
const { data } = el;
const { fieldIdentifier } = data;
const dispatch = useAppDispatch();
const label = useInputFieldLabelSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const label = useInputFieldUserTitleSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const inputRef = useRef<HTMLInputElement>(null);

View File

@@ -15,7 +15,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { NodeFieldElementFloatSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementFloatSettings';
import { NodeFieldElementIntegerSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementIntegerSettings';
import { NodeFieldElementStringSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementStringSettings';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice';
import {
isFloatFieldInputTemplate,

View File

@@ -5,8 +5,9 @@ import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/
import { InputFieldRenderer } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer';
import { useContainerContext } from 'features/nodes/components/sidePanel/builder/contexts';
import { NodeFieldElementLabel } from 'features/nodes/components/sidePanel/builder/NodeFieldElementLabel';
import { useInputFieldDescriptionSafe } from 'features/nodes/hooks/useInputFieldDescriptionSafe';
import { useInputFieldTemplateOrThrow, useInputFieldTemplateSafe } from 'features/nodes/hooks/useInputFieldTemplate';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { useInputFieldTemplateSafe } from 'features/nodes/hooks/useInputFieldTemplateSafe';
import { useInputFieldUserDescriptionSafe } from 'features/nodes/hooks/useInputFieldUserDescriptionSafe';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import { NODE_FIELD_CLASS_NAME } from 'features/nodes/types/workflow';
import Linkify from 'linkify-react';
@@ -36,7 +37,7 @@ const useFormatFallbackLabel = () => {
export const NodeFieldElementViewMode = memo(({ el }: { el: NodeFieldElement }) => {
const { id, data } = el;
const { fieldIdentifier, showDescription } = data;
const description = useInputFieldDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const description = useInputFieldUserDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const containerCtx = useContainerContext();
const formatFallbackLabel = useFormatFallbackLabel();
@@ -69,7 +70,7 @@ NodeFieldElementViewMode.displayName = 'NodeFieldElementViewMode';
const NodeFieldElementViewModeContent = memo(({ el }: { el: NodeFieldElement }) => {
const { data } = el;
const { fieldIdentifier, showDescription } = data;
const description = useInputFieldDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const description = useInputFieldUserDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const _description = useMemo(

View File

@@ -1,4 +1,6 @@
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import type { DropTargetRecord } from '@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types';
import type { ElementDragPayload } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import {
draggable,
dropTargetForElements,
@@ -33,7 +35,7 @@ import {
selectFormRootElementId,
selectWorkflowSlice,
} from 'features/nodes/store/workflowSlice';
import type { FieldIdentifier, FieldInputTemplate, StatefulFieldValue } from 'features/nodes/types/field';
import type { FieldInputTemplate, StatefulFieldValue } from 'features/nodes/types/field';
import type { ElementId, FormElement } from 'features/nodes/types/workflow';
import { buildNodeFieldElement, isContainerElement } from 'features/nodes/types/workflow';
import type { RefObject } from 'react';
@@ -58,6 +60,27 @@ const isFormElementDndData = (data: Record<string | symbol, unknown>): data is F
return uniqueFormElementDndKey in data;
};
const uniqueNodeFieldDndKey = Symbol('node-field');
type NodeFieldDndData = {
[uniqueNodeFieldDndKey]: true;
nodeId: string;
fieldName: string;
fieldTemplate: FieldInputTemplate;
};
export const buildNodeFieldDndData = (
nodeId: string,
fieldName: string,
fieldTemplate: FieldInputTemplate
): NodeFieldDndData => ({
[uniqueNodeFieldDndKey]: true,
nodeId,
fieldName,
fieldTemplate,
});
const isNodeFieldDndData = (data: Record<string | symbol, unknown>): data is NodeFieldDndData => {
return uniqueNodeFieldDndKey in data;
};
/**
* Flashes an element by changing its background color. Used to indicate that an element has been moved.
* @param elementId The id of the element to flash
@@ -133,6 +156,27 @@ const useGetInitialValue = () => {
return _getInitialValue;
};
const getSourceElement = (source: ElementDragPayload) => {
if (isNodeFieldDndData(source.data)) {
const { nodeId, fieldName, fieldTemplate } = source.data;
return buildNodeFieldElement(nodeId, fieldName, fieldTemplate.type);
}
if (isFormElementDndData(source.data)) {
return source.data.element;
}
return null;
};
const getTargetElement = (target: DropTargetRecord) => {
if (isFormElementDndData(target.data)) {
return target.data.element;
}
return null;
};
/**
* Singleton hook that monitors for builder dnd events and dispatches actions accordingly.
*/
@@ -156,20 +200,20 @@ export const useBuilderDndMonitor = () => {
useEffect(() => {
return monitorForElements({
canMonitor: ({ source }) => isFormElementDndData(source.data),
canMonitor: ({ source }) => isFormElementDndData(source.data) || isNodeFieldDndData(source.data),
onDrop: ({ location, source }) => {
const target = location.current.dropTargets[0];
if (!target) {
return;
}
if (!isFormElementDndData(source.data) || !isFormElementDndData(target.data)) {
const sourceElement = getSourceElement(source);
const targetElement = getTargetElement(target);
if (!sourceElement || !targetElement) {
return;
}
const sourceElement = source.data.element;
const targetElement = target.data.element;
if (sourceElement.id === targetElement.id) {
// Dropping on self is a no-op
return;
@@ -359,8 +403,15 @@ export const useFormElementDnd = (
element: draggableElement,
// TODO(psyche): This causes a kinda jittery behaviour - need a better heuristic to determine stickiness
getIsSticky: () => false,
canDrop: ({ source }) =>
isFormElementDndData(source.data) && source.data.element.id !== getElement(elementId).parentId,
canDrop: ({ source }) => {
if (isNodeFieldDndData(source.data)) {
return true;
}
if (isFormElementDndData(source.data)) {
return source.data.element.id !== getElement(elementId).parentId;
}
return false;
},
getData: ({ input }) => {
const element = getElement(elementId);
@@ -423,8 +474,16 @@ export const useRootElementDropTarget = (droppableRef: RefObject<HTMLDivElement>
dropTargetForElements({
element: droppableElement,
getIsSticky: () => false,
canDrop: ({ source }) =>
getElement(rootElementId, isContainerElement).data.children.length === 0 && isFormElementDndData(source.data),
canDrop: ({ source }) => {
const rootElement = getElement(rootElementId, isContainerElement);
if (rootElement.data.children.length !== 0) {
return false;
}
if (isNodeFieldDndData(source.data) || isFormElementDndData(source.data)) {
return true;
}
return false;
},
getData: ({ input }) => {
const element = getElement(rootElementId, isContainerElement);
@@ -455,7 +514,8 @@ export const useRootElementDropTarget = (droppableRef: RefObject<HTMLDivElement>
/**
* Hook that provides dnd functionality for node fields.
*
* @param fieldIdentifier The identifier of the node field
* @param nodeId: The id of the node
* @param fieldName: The name of the field
* @param fieldTemplate The template of the node field, required to build the form element
* @param draggableRef The ref of the draggable HTML element
* @param dragHandleRef The ref of the drag handle HTML element
@@ -463,7 +523,8 @@ export const useRootElementDropTarget = (droppableRef: RefObject<HTMLDivElement>
* @returns Whether the node field is currently being dragged
*/
export const useNodeFieldDnd = (
fieldIdentifier: FieldIdentifier,
nodeId: string,
fieldName: string,
fieldTemplate: FieldInputTemplate,
draggableRef: RefObject<HTMLElement>,
dragHandleRef: RefObject<HTMLElement>
@@ -481,12 +542,7 @@ export const useNodeFieldDnd = (
draggable({
element: draggableElement,
dragHandle: dragHandleElement,
getInitialData: () => {
const { nodeId, fieldName } = fieldIdentifier;
const { type } = fieldTemplate;
const element = buildNodeFieldElement(nodeId, fieldName, type);
return buildFormElementDndData(element);
},
getInitialData: () => buildNodeFieldDndData(nodeId, fieldName, fieldTemplate),
onDragStart: () => {
setIsDragging(true);
},
@@ -495,7 +551,7 @@ export const useNodeFieldDnd = (
},
})
);
}, [dragHandleRef, draggableRef, fieldIdentifier, fieldTemplate]);
}, [dragHandleRef, draggableRef, fieldName, fieldTemplate, nodeId]);
return isDragging;
};

View File

@@ -1,6 +1,6 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { formElementAdded, selectFormRootElementId } from 'features/nodes/store/workflowSlice';
import { buildNodeFieldElement } from 'features/nodes/types/workflow';
import { useCallback } from 'react';

View File

@@ -5,7 +5,7 @@ import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableCon
import { InvocationNodeNotesTextarea } from 'features/nodes/components/flow/nodes/Invocation/InvocationNodeNotesTextarea';
import { TemplateGate } from 'features/nodes/components/sidePanel/inspector/NodeTemplateGate';
import { useNodeNeedsUpdate } from 'features/nodes/hooks/useNodeNeedsUpdate';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow';
import { useNodeVersion } from 'features/nodes/hooks/useNodeVersion';
import { selectLastSelectedNodeId } from 'features/nodes/store/selectors';
import { memo } from 'react';
@@ -36,7 +36,7 @@ export default memo(InspectorDetailsTab);
const Content = memo(({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation();
const version = useNodeVersion(nodeId);
const template = useNodeTemplate(nodeId);
const template = useNodeTemplateOrThrow(nodeId);
const needsUpdate = useNodeNeedsUpdate(nodeId);
return (

View File

@@ -5,7 +5,7 @@ import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableCon
import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer';
import { TemplateGate } from 'features/nodes/components/sidePanel/inspector/NodeTemplateGate';
import { useNodeExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow';
import { selectLastSelectedNodeId } from 'features/nodes/store/selectors';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -37,7 +37,7 @@ const getKey = (result: AnyInvocationOutput, i: number) => `${result.type}-${i}`
const Content = memo(({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation();
const template = useNodeTemplate(nodeId);
const template = useNodeTemplateOrThrow(nodeId);
const nes = useNodeExecutionState(nodeId);
if (!nes || nes.outputs.length === 0) {

View File

@@ -1,8 +1,8 @@
import { Flex, Input, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useEditable } from 'common/hooks/useEditable';
import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel';
import { useNodeTemplateTitle } from 'features/nodes/hooks/useNodeTemplateTitle';
import { useNodeTemplateTitleSafe } from 'features/nodes/hooks/useNodeTemplateTitleSafe';
import { useNodeUserTitleSafe } from 'features/nodes/hooks/useNodeUserTitleSafe';
import { nodeLabelChanged } from 'features/nodes/store/nodesSlice';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@@ -14,8 +14,8 @@ type Props = {
const InspectorTabEditableNodeTitle = ({ nodeId, title }: Props) => {
const dispatch = useAppDispatch();
const label = useNodeLabel(nodeId);
const templateTitle = useNodeTemplateTitle(nodeId);
const label = useNodeUserTitleSafe(nodeId);
const templateTitle = useNodeTemplateTitleSafe(nodeId);
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const onChange = useCallback(

View File

@@ -2,7 +2,7 @@ import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer';
import { TemplateGate } from 'features/nodes/components/sidePanel/inspector/NodeTemplateGate';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow';
import { selectLastSelectedNodeId } from 'features/nodes/store/selectors';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -29,7 +29,7 @@ export default memo(NodeTemplateInspector);
const Content = memo(({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation();
const template = useNodeTemplate(nodeId);
const template = useNodeTemplateOrThrow(nodeId);
return <DataViewer data={template} label={t('nodes.nodeTemplate')} bg="base.850" color="base.200" />;
});

View File

@@ -1,4 +1,4 @@
import { useNodeTemplateSafe } from 'features/nodes/hooks/useNodeTemplate';
import { useNodeTemplateSafe } from 'features/nodes/hooks/useNodeTemplateSafe';
import type { PropsWithChildren, ReactNode } from 'react';
import { memo } from 'react';

View File

@@ -0,0 +1,429 @@
import {
Button,
ButtonGroup,
Divider,
Flex,
ListItem,
Spacer,
Text,
Tooltip,
UnorderedList,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { logger } from 'app/logging/logger';
import { $projectUrl } from 'app/store/nanostores/projectId';
import { useAppSelector } from 'app/store/storeHooks';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { withResultAsync } from 'common/util/result';
import { parseify } from 'common/util/serialize';
import { ExternalLink } from 'features/gallery/components/ImageViewer/NoContentForViewer';
import { NodeFieldElementOverlay } from 'features/nodes/components/sidePanel/builder/NodeFieldElementEditMode';
import {
$isInPublishFlow,
$isReadyToDoValidationRun,
$isSelectingOutputNode,
$outputNodeId,
$validationRunBatchId,
usePublishInputs,
} from 'features/nodes/components/sidePanel/workflow/publish';
import { useInputFieldTemplateTitleOrThrow } from 'features/nodes/hooks/useInputFieldTemplateTitleOrThrow';
import { useInputFieldUserTitleOrThrow } from 'features/nodes/hooks/useInputFieldUserTitleOrThrow';
import { useMouseOverFormField } from 'features/nodes/hooks/useMouseOverNode';
import { useNodeTemplateTitleOrThrow } from 'features/nodes/hooks/useNodeTemplateTitleOrThrow';
import { useNodeUserTitleOrThrow } from 'features/nodes/hooks/useNodeUserTitleOrThrow';
import { useOutputFieldNames } from 'features/nodes/hooks/useOutputFieldNames';
import { useOutputFieldTemplate } from 'features/nodes/hooks/useOutputFieldTemplate';
import { useZoomToNode } from 'features/nodes/hooks/useZoomToNode';
import { selectHasBatchOrGeneratorNodes } from 'features/nodes/store/selectors';
import { selectIsWorkflowSaved } from 'features/nodes/store/workflowSlice';
import { useEnqueueWorkflows } from 'features/queue/hooks/useEnqueueWorkflows';
import { $isReadyToEnqueue } from 'features/queue/store/readiness';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { toast } from 'features/toast/toast';
import type { PropsWithChildren } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { PiLightningFill, PiSignOutBold, PiXBold } from 'react-icons/pi';
import { serializeError } from 'serialize-error';
import { assert } from 'tsafe';
const log = logger('generation');
export const PublishWorkflowPanelContent = memo(() => {
return (
<Flex flexDir="column" gap={2} h="full">
<ButtonGroup isAttached={false} size="sm" variant="ghost">
<SelectOutputNodeButton />
<Spacer />
<CancelPublishButton />
<PublishWorkflowButton />
</ButtonGroup>
<ScrollableContent>
<Flex flexDir="column" gap={2} w="full" h="full">
<OutputFields />
<PublishableInputFields />
<UnpublishableInputFields />
</Flex>
</ScrollableContent>
</Flex>
);
});
PublishWorkflowPanelContent.displayName = 'DeployWorkflowPanelContent';
const OutputFields = memo(() => {
const { t } = useTranslation();
const outputNodeId = useStore($outputNodeId);
if (!outputNodeId) {
return (
<Flex flexDir="column" borderWidth={1} borderRadius="base" gap={2} p={2}>
<Text fontWeight="semibold" color="error.300">
{t('workflows.builder.noOutputNodeSelected')}
</Text>
</Flex>
);
}
return <OutputFieldsContent outputNodeId={outputNodeId} />;
});
OutputFields.displayName = 'OutputFields';
const OutputFieldsContent = memo(({ outputNodeId }: { outputNodeId: string }) => {
const { t } = useTranslation();
const outputFieldNames = useOutputFieldNames(outputNodeId);
return (
<Flex flexDir="column" borderWidth={1} borderRadius="base" gap={2} p={2}>
<Text fontWeight="semibold">{t('workflows.builder.publishedWorkflowOutputs')}</Text>
<Divider />
{outputFieldNames.map((fieldName) => (
<NodeOutputFieldPreview key={`${outputNodeId}-${fieldName}`} nodeId={outputNodeId} fieldName={fieldName} />
))}
</Flex>
);
});
OutputFieldsContent.displayName = 'OutputFieldsContent';
const PublishableInputFields = memo(() => {
const { t } = useTranslation();
const inputs = usePublishInputs();
if (inputs.publishable.length === 0) {
return (
<Flex flexDir="column" borderWidth={1} borderRadius="base" gap={2} p={2}>
<Text fontWeight="semibold" color="warning.300">
{t('workflows.builder.noPublishableInputs')}
</Text>
</Flex>
);
}
return (
<Flex flexDir="column" borderWidth={1} borderRadius="base" gap={2} p={2}>
<Text fontWeight="semibold">{t('workflows.builder.publishedWorkflowInputs')}</Text>
<Divider />
{inputs.publishable.map(({ nodeId, fieldName }) => {
return <NodeInputFieldPreview key={`${nodeId}-${fieldName}`} nodeId={nodeId} fieldName={fieldName} />;
})}
</Flex>
);
});
PublishableInputFields.displayName = 'PublishableInputFields';
const UnpublishableInputFields = memo(() => {
const { t } = useTranslation();
const inputs = usePublishInputs();
if (inputs.unpublishable.length === 0) {
return null;
}
return (
<Flex flexDir="column" borderWidth={1} borderRadius="base" gap={2} p={2}>
<Text fontWeight="semibold" color="warning.300">
{t('workflows.builder.unpublishableInputs')}
</Text>
<Divider />
{inputs.unpublishable.map(({ nodeId, fieldName }) => {
return <NodeInputFieldPreview key={`${nodeId}-${fieldName}`} nodeId={nodeId} fieldName={fieldName} />;
})}
</Flex>
);
});
UnpublishableInputFields.displayName = 'UnpublishableInputFields';
const SelectOutputNodeButton = memo(() => {
const { t } = useTranslation();
const outputNodeId = useStore($outputNodeId);
const isSelectingOutputNode = useStore($isSelectingOutputNode);
const onClick = useCallback(() => {
$outputNodeId.set(null);
$isSelectingOutputNode.set(true);
}, []);
return (
<Button leftIcon={<PiSignOutBold />} isDisabled={isSelectingOutputNode} onClick={onClick}>
{outputNodeId ? t('workflows.builder.changeOutputNode') : t('workflows.builder.selectOutputNode')}
</Button>
);
});
SelectOutputNodeButton.displayName = 'SelectOutputNodeButton';
const CancelPublishButton = memo(() => {
const { t } = useTranslation();
const onClick = useCallback(() => {
$isInPublishFlow.set(false);
$isSelectingOutputNode.set(false);
$outputNodeId.set(null);
}, []);
return (
<Button leftIcon={<PiXBold />} onClick={onClick}>
{t('common.cancel')}
</Button>
);
});
CancelPublishButton.displayName = 'CancelDeployButton';
const PublishWorkflowButton = memo(() => {
const { t } = useTranslation();
const isReadyToDoValidationRun = useStore($isReadyToDoValidationRun);
const isReadyToEnqueue = useStore($isReadyToEnqueue);
const isWorkflowSaved = useAppSelector(selectIsWorkflowSaved);
const hasBatchOrGeneratorNodes = useAppSelector(selectHasBatchOrGeneratorNodes);
const outputNodeId = useStore($outputNodeId);
const isSelectingOutputNode = useStore($isSelectingOutputNode);
const inputs = usePublishInputs();
const projectUrl = useStore($projectUrl);
const enqueue = useEnqueueWorkflows();
const onClick = useCallback(async () => {
const result = await withResultAsync(() => enqueue(true, true));
if (result.isErr()) {
toast({
id: 'TOAST_PUBLISH_FAILED',
status: 'error',
title: t('workflows.builder.publishFailed'),
description: t('workflows.builder.publishFailedDesc'),
duration: null,
});
log.error({ error: serializeError(result.error) }, 'Failed to enqueue batch');
} else {
toast({
id: 'TOAST_PUBLISH_SUCCESSFUL',
status: 'success',
title: t('workflows.builder.publishSuccess'),
description: (
<Trans
i18nKey="workflows.builder.publishSuccessDesc"
components={{
LinkComponent: <ExternalLink href={projectUrl ?? ''} />,
}}
/>
),
duration: null,
});
assert(result.value.enqueueResult.batch.batch_id);
$validationRunBatchId.set(result.value.enqueueResult.batch.batch_id);
log.debug(parseify(result.value), 'Enqueued batch');
}
}, [enqueue, projectUrl, t]);
return (
<PublishTooltip
isWorkflowSaved={isWorkflowSaved}
hasBatchOrGeneratorNodes={hasBatchOrGeneratorNodes}
isReadyToEnqueue={isReadyToEnqueue}
hasOutputNode={outputNodeId !== null && !isSelectingOutputNode}
hasPublishableInputs={inputs.publishable.length > 0}
hasUnpublishableInputs={inputs.unpublishable.length > 0}
>
<Button
leftIcon={<PiLightningFill />}
isDisabled={
!isReadyToDoValidationRun ||
!isReadyToEnqueue ||
hasBatchOrGeneratorNodes ||
!(outputNodeId !== null && !isSelectingOutputNode)
}
onClick={onClick}
>
{t('workflows.builder.publish')}
</Button>
</PublishTooltip>
);
});
PublishWorkflowButton.displayName = 'DoValidationRunButton';
const NodeInputFieldPreview = memo(({ nodeId, fieldName }: { nodeId: string; fieldName: string }) => {
const mouseOverFormField = useMouseOverFormField(nodeId);
const nodeUserTitle = useNodeUserTitleOrThrow(nodeId);
const nodeTemplateTitle = useNodeTemplateTitleOrThrow(nodeId);
const fieldUserTitle = useInputFieldUserTitleOrThrow(nodeId, fieldName);
const fieldTemplateTitle = useInputFieldTemplateTitleOrThrow(nodeId, fieldName);
const zoomToNode = useZoomToNode(nodeId);
return (
<Flex
flexDir="column"
position="relative"
p={2}
borderRadius="base"
onMouseOver={mouseOverFormField.handleMouseOver}
onMouseOut={mouseOverFormField.handleMouseOut}
onClick={zoomToNode}
>
<Text fontWeight="semibold">{`${nodeUserTitle || nodeTemplateTitle} -> ${fieldUserTitle || fieldTemplateTitle}`}</Text>
<Text variant="subtext">{`${nodeId} -> ${fieldName}`}</Text>
<NodeFieldElementOverlay nodeId={nodeId} />
</Flex>
);
});
NodeInputFieldPreview.displayName = 'NodeInputFieldPreview';
const NodeOutputFieldPreview = memo(({ nodeId, fieldName }: { nodeId: string; fieldName: string }) => {
const mouseOverFormField = useMouseOverFormField(nodeId);
const nodeUserTitle = useNodeUserTitleOrThrow(nodeId);
const nodeTemplateTitle = useNodeTemplateTitleOrThrow(nodeId);
const fieldTemplate = useOutputFieldTemplate(nodeId, fieldName);
const zoomToNode = useZoomToNode(nodeId);
return (
<Flex
flexDir="column"
position="relative"
p={2}
borderRadius="base"
onMouseOver={mouseOverFormField.handleMouseOver}
onMouseOut={mouseOverFormField.handleMouseOut}
onClick={zoomToNode}
>
<Text fontWeight="semibold">{`${nodeUserTitle || nodeTemplateTitle} -> ${fieldTemplate.title}`}</Text>
<Text variant="subtext">{`${nodeId} -> ${fieldName}`}</Text>
<NodeFieldElementOverlay nodeId={nodeId} />
</Flex>
);
});
NodeOutputFieldPreview.displayName = 'NodeOutputFieldPreview';
export const StartPublishFlowButton = memo(() => {
const { t } = useTranslation();
const deployWorkflowIsEnabled = useFeatureStatus('deployWorkflow');
const isReadyToEnqueue = useStore($isReadyToEnqueue);
const isWorkflowSaved = useAppSelector(selectIsWorkflowSaved);
const hasBatchOrGeneratorNodes = useAppSelector(selectHasBatchOrGeneratorNodes);
const inputs = usePublishInputs();
const onClick = useCallback(() => {
$isInPublishFlow.set(true);
}, []);
return (
<PublishTooltip
isWorkflowSaved={isWorkflowSaved}
hasBatchOrGeneratorNodes={hasBatchOrGeneratorNodes}
isReadyToEnqueue={isReadyToEnqueue}
hasOutputNode={true}
hasPublishableInputs={inputs.publishable.length > 0}
hasUnpublishableInputs={inputs.unpublishable.length > 0}
>
<Button
onClick={onClick}
leftIcon={<PiLightningFill />}
variant="ghost"
size="sm"
isDisabled={!deployWorkflowIsEnabled || !isWorkflowSaved || hasBatchOrGeneratorNodes}
>
{t('workflows.builder.publish')}
</Button>
</PublishTooltip>
);
});
StartPublishFlowButton.displayName = 'StartPublishFlowButton';
const PublishTooltip = memo(
({
isWorkflowSaved,
hasBatchOrGeneratorNodes,
isReadyToEnqueue,
hasOutputNode,
hasPublishableInputs,
hasUnpublishableInputs,
children,
}: PropsWithChildren<{
isWorkflowSaved: boolean;
hasBatchOrGeneratorNodes: boolean;
isReadyToEnqueue: boolean;
hasOutputNode: boolean;
hasPublishableInputs: boolean;
hasUnpublishableInputs: boolean;
}>) => {
const { t } = useTranslation();
const warnings = useMemo(() => {
const _warnings: string[] = [];
if (!hasPublishableInputs) {
_warnings.push(t('workflows.builder.warningWorkflowHasNoPublishableInputFields'));
}
if (hasUnpublishableInputs) {
_warnings.push(t('workflows.builder.warningWorkflowHasUnpublishableInputFields'));
}
return _warnings;
}, [hasPublishableInputs, hasUnpublishableInputs, t]);
const errors = useMemo(() => {
const _errors: string[] = [];
if (!isWorkflowSaved) {
_errors.push(t('workflows.builder.errorWorkflowHasUnsavedChanges'));
}
if (hasBatchOrGeneratorNodes) {
_errors.push(t('workflows.builder.errorWorkflowHasBatchOrGeneratorNodes'));
}
if (!isReadyToEnqueue) {
_errors.push(t('workflows.builder.errorWorkflowHasInvalidGraph'));
}
if (!hasOutputNode) {
_errors.push(t('workflows.builder.errorWorkflowHasNoOutputNode'));
}
return _errors;
}, [hasBatchOrGeneratorNodes, hasOutputNode, isReadyToEnqueue, isWorkflowSaved, t]);
if (errors.length === 0 && warnings.length === 0) {
return children;
}
return (
<Tooltip
label={
<Flex flexDir="column">
{errors.length > 0 && (
<>
<Text color="error.700" fontWeight="semibold">
{t('workflows.builder.cannotPublish')}:
</Text>
<UnorderedList>
{errors.map((problem, index) => (
<ListItem key={index}>{problem}</ListItem>
))}
</UnorderedList>
</>
)}
{warnings.length > 0 && (
<>
<Text color="warning.700" fontWeight="semibold">
{t('workflows.builder.publishWarnings')}:
</Text>
<UnorderedList>
{warnings.map((problem, index) => (
<ListItem key={index}>{problem}</ListItem>
))}
</UnorderedList>
</>
)}
</Flex>
}
>
{children}
</Tooltip>
);
}
);
PublishTooltip.displayName = 'PublishTooltip';

View File

@@ -0,0 +1,23 @@
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiLockBold } from 'react-icons/pi';
export const LockedWorkflowIcon = memo(() => {
const { t } = useTranslation();
return (
<Tooltip label={t('workflows.builder.publishedWorkflowsLocked')} closeOnScroll>
<IconButton
size="sm"
cursor='not-allowed'
variant="link"
alignSelf="stretch"
aria-label={t('workflows.builder.publishedWorkflowsLocked')}
icon={<PiLockBold />}
/>
</Tooltip>
);
});
LockedWorkflowIcon.displayName = 'LockedWorkflowIcon';

View File

@@ -26,6 +26,7 @@ import {
workflowLibraryTagToggled,
workflowLibraryViewChanged,
} from 'features/nodes/store/workflowLibrarySlice';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { NewWorkflowButton } from 'features/workflowLibrary/components/NewWorkflowButton';
import { UploadWorkflowButton } from 'features/workflowLibrary/components/UploadWorkflowButton';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
@@ -39,13 +40,12 @@ export const WorkflowLibrarySideNav = () => {
const { t } = useTranslation();
const categoryOptions = useStore($workflowLibraryCategoriesOptions);
const view = useAppSelector(selectWorkflowLibraryView);
const deployWorkflow = useFeatureStatus('deployWorkflow');
return (
<Flex h="full" minH={0} overflow="hidden" flexDir="column" w={64} gap={0}>
<Flex flexDir="column" w="full" pb={2}>
<Flex flexDir="column" w="full" pb={2} gap={2}>
<WorkflowLibraryViewButton view="recent">{t('workflows.recentlyOpened')}</WorkflowLibraryViewButton>
</Flex>
<Flex flexDir="column" w="full" pb={2}>
<WorkflowLibraryViewButton view="yours">{t('workflows.yourWorkflows')}</WorkflowLibraryViewButton>
{categoryOptions.includes('project') && (
<Collapse in={view === 'yours' || view === 'shared' || view === 'private'}>
@@ -60,6 +60,9 @@ export const WorkflowLibrarySideNav = () => {
</Flex>
</Collapse>
)}
{deployWorkflow && (
<WorkflowLibraryViewButton view="published">{t('workflows.publishedWorkflows')}</WorkflowLibraryViewButton>
)}
</Flex>
<Flex h="full" minH={0} overflow="hidden" flexDir="column">
<BrowseWorkflowsButton />

View File

@@ -36,6 +36,8 @@ const getCategories = (view: WorkflowLibraryView): WorkflowCategory[] => {
return ['user'];
case 'shared':
return ['project'];
case 'published':
return ['user', 'project', 'default'];
default:
assert<Equals<typeof view, never>>(false);
}
@@ -66,6 +68,7 @@ const useInfiniteQueryAry = () => {
query: debouncedSearchTerm,
tags: view === 'defaults' ? selectedTags : [],
has_been_opened: getHasBeenOpened(view),
is_published: view === 'published' ? true : undefined,
} satisfies Parameters<typeof useListWorkflowsInfiniteInfiniteQuery>[0];
}, [orderBy, direction, view, debouncedSearchTerm, selectedTags]);

View File

@@ -1,6 +1,7 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Badge, Flex, Icon, Image, Spacer, Text } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { LockedWorkflowIcon } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/LockedWorkflowIcon';
import { ShareWorkflowButton } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/ShareWorkflow';
import { selectWorkflowId, workflowModeChanged } from 'features/nodes/store/workflowSlice';
import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
@@ -54,7 +55,6 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
position="relative"
role="button"
onClick={handleClickLoad}
cursor="pointer"
bg="base.750"
borderRadius="base"
w="full"
@@ -81,7 +81,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
<Flex gap={2} alignItems="flex-start" justifyContent="space-between" w="full">
<Text noOfLines={2}>{workflow.name}</Text>
<Flex gap={2} alignItems="center">
{isActive && (
{isActive && !workflow.is_published && (
<Badge
color="invokeBlue.400"
borderColor="invokeBlue.700"
@@ -93,6 +93,18 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
{t('workflows.opened')}
</Badge>
)}
{workflow.is_published && (
<Badge
color="invokeGreen.400"
borderColor="invokeGreen.700"
borderWidth={1}
bg="transparent"
flexShrink={0}
variant="subtle"
>
{t('workflows.builder.published')}
</Badge>
)}
{workflow.category === 'project' && <Icon as={PiUsersBold} color="base.200" />}
{workflow.category === 'default' && (
<Image
@@ -119,8 +131,10 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
</Text>
)}
<Spacer />
{workflow.category === 'default' && <ViewWorkflow workflowId={workflow.workflow_id} />}
{workflow.category !== 'default' && (
{workflow.category === 'default' && !workflow.is_published && (
<ViewWorkflow workflowId={workflow.workflow_id} />
)}
{workflow.category !== 'default' && !workflow.is_published && (
<>
<EditWorkflow workflowId={workflow.workflow_id} />
<DownloadWorkflow workflowId={workflow.workflow_id} />
@@ -128,6 +142,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
</>
)}
{workflow.category === 'project' && <ShareWorkflowButton workflow={workflow} />}
{workflow.is_published && <LockedWorkflowIcon />}
</Flex>
</Flex>
</Flex>

View File

@@ -1,5 +1,7 @@
import { Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
import { Spacer, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
import { WorkflowBuilder } from 'features/nodes/components/sidePanel/builder/WorkflowBuilder';
import { StartPublishFlowButton } from 'features/nodes/components/sidePanel/workflow/PublishWorkflowPanelContent';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -8,12 +10,15 @@ import WorkflowJSONTab from './WorkflowJSONTab';
const WorkflowFieldsLinearViewPanel = () => {
const { t } = useTranslation();
const deployWorkflowIsEnabled = useFeatureStatus('deployWorkflow');
return (
<Tabs variant="enclosed" display="flex" w="full" h="full" flexDir="column">
<TabList>
<Tab>{t('workflows.builder.builder')}</Tab>
<Tab>{t('common.details')}</Tab>
<Tab>JSON</Tab>
<Spacer />
{deployWorkflowIsEnabled && <StartPublishFlowButton />}
</TabList>
<TabPanels h="full" pt={2}>

View File

@@ -0,0 +1,90 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppSelector } from 'app/store/storeHooks';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import type { Templates } from 'features/nodes/store/types';
import { selectWorkflowFormNodeFieldFieldIdentifiersDeduped } from 'features/nodes/store/workflowSlice';
import type { FieldIdentifier } from 'features/nodes/types/field';
import { isBoardFieldType } from 'features/nodes/types/field';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { atom, computed } from 'nanostores';
import { useMemo } from 'react';
import { useGetBatchStatusQuery } from 'services/api/endpoints/queue';
import { assert } from 'tsafe';
export const $isInPublishFlow = atom(false);
export const $outputNodeId = atom<string | null>(null);
export const $isSelectingOutputNode = atom(false);
export const $isReadyToDoValidationRun = computed(
[$isInPublishFlow, $outputNodeId, $isSelectingOutputNode],
(isInPublishFlow, outputNodeId, isSelectingOutputNode) => {
return isInPublishFlow && outputNodeId !== null && !isSelectingOutputNode;
}
);
export const $validationRunBatchId = atom<string | null>(null);
export const useIsValidationRunInProgress = () => {
const validationRunBatchId = useStore($validationRunBatchId);
const { isValidationRunInProgress } = useGetBatchStatusQuery(
validationRunBatchId ? { batch_id: validationRunBatchId } : skipToken,
{
selectFromResult: ({ currentData }) => {
if (!currentData) {
return { isValidationRunInProgress: false };
}
if (currentData && currentData.in_progress > 0) {
return { isValidationRunInProgress: true };
}
return { isValidationRunInProgress: false };
},
}
);
return validationRunBatchId !== null || isValidationRunInProgress;
};
export const selectFieldIdentifiersWithInvocationTypes = createSelector(
selectWorkflowFormNodeFieldFieldIdentifiersDeduped,
selectNodesSlice,
(fieldIdentifiers, nodes) => {
const result: { nodeId: string; fieldName: string; type: string }[] = [];
for (const fieldIdentifier of fieldIdentifiers) {
const node = nodes.nodes.find((node) => node.id === fieldIdentifier.nodeId);
assert(isInvocationNode(node), `Node ${fieldIdentifier.nodeId} not found`);
result.push({ nodeId: fieldIdentifier.nodeId, fieldName: fieldIdentifier.fieldName, type: node.data.type });
}
return result;
}
);
export const getPublishInputs = (fieldIdentifiers: (FieldIdentifier & { type: string })[], templates: Templates) => {
// Certain field types are not allowed to be input fields on a published workflow
const publishable: FieldIdentifier[] = [];
const unpublishable: FieldIdentifier[] = [];
for (const fieldIdentifier of fieldIdentifiers) {
const fieldTemplate = templates[fieldIdentifier.type]?.inputs[fieldIdentifier.fieldName];
if (!fieldTemplate) {
unpublishable.push(fieldIdentifier);
continue;
}
if (isBoardFieldType(fieldTemplate.type)) {
unpublishable.push(fieldIdentifier);
continue;
}
publishable.push(fieldIdentifier);
}
return { publishable, unpublishable };
};
export const usePublishInputs = () => {
const templates = useStore($templates);
const fieldIdentifiersWithInvocationTypes = useAppSelector(selectFieldIdentifiersWithInvocationTypes);
const fieldIdentifiers = useMemo(
() => getPublishInputs(fieldIdentifiersWithInvocationTypes, templates),
[fieldIdentifiersWithInvocationTypes, templates]
);
return fieldIdentifiers;
};

View File

@@ -1,6 +1,6 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { fieldValueReset } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { isInvocationNode } from 'features/nodes/types/invocation';

View File

@@ -1,10 +1,11 @@
import { useNodeData } from 'features/nodes/hooks/useNodeData';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import { isSingleOrCollection } from 'features/nodes/types/field';
import { TEMPLATE_BUILDER_MAP } from 'features/nodes/util/schema/buildFieldInputTemplate';
import { useMemo } from 'react';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
const isConnectionInputField = (field: FieldInputTemplate) => {
return (
(field.input === 'connection' && !isSingleOrCollection(field.type)) || !(field.type.name in TEMPLATE_BUILDER_MAP)
@@ -19,7 +20,7 @@ const isAnyOrDirectInputField = (field: FieldInputTemplate) => {
};
export const useInputFieldNamesMissing = (nodeId: string) => {
const template = useNodeTemplate(nodeId);
const template = useNodeTemplateOrThrow(nodeId);
const node = useNodeData(nodeId);
const fieldNames = useMemo(() => {
const instanceFields = new Set(Object.keys(node.inputs));
@@ -30,7 +31,7 @@ export const useInputFieldNamesMissing = (nodeId: string) => {
};
export const useInputFieldNamesAnyOrDirect = (nodeId: string) => {
const template = useNodeTemplate(nodeId);
const template = useNodeTemplateOrThrow(nodeId);
const fieldNames = useMemo(() => {
const anyOrDirectFields: string[] = [];
for (const [fieldName, fieldTemplate] of Object.entries(template.inputs)) {
@@ -44,7 +45,7 @@ export const useInputFieldNamesAnyOrDirect = (nodeId: string) => {
};
export const useInputFieldNamesConnection = (nodeId: string) => {
const template = useNodeTemplate(nodeId);
const template = useNodeTemplateOrThrow(nodeId);
const fieldNames = useMemo(() => {
const connectionFields: string[] = [];
for (const [fieldName, fieldTemplate] of Object.entries(template.inputs)) {

View File

@@ -1,8 +1,9 @@
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import { useMemo } from 'react';
import { assert } from 'tsafe';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
/**
* Returns the template for a specific input field of a node.
*
@@ -13,7 +14,7 @@ import { assert } from 'tsafe';
* @throws Will throw an error if the template for the input field is not found.
*/
export const useInputFieldTemplateOrThrow = (nodeId: string, fieldName: string): FieldInputTemplate => {
const template = useNodeTemplate(nodeId);
const template = useNodeTemplateOrThrow(nodeId);
const fieldTemplate = useMemo(() => {
const _fieldTemplate = template.inputs[fieldName];
assert(_fieldTemplate, `Template for input field ${fieldName} not found.`);
@@ -21,17 +22,3 @@ export const useInputFieldTemplateOrThrow = (nodeId: string, fieldName: string):
}, [fieldName, template.inputs]);
return fieldTemplate;
};
/**
* Returns the template for a specific input field of a node.
*
* **Note:** This function is a safe version of `useInputFieldTemplate` and will not throw an error if the template is not found.
*
* @param nodeId - The ID of the node.
* @param fieldName - The name of the input field.
*/
export const useInputFieldTemplateSafe = (nodeId: string, fieldName: string): FieldInputTemplate | null => {
const template = useNodeTemplate(nodeId);
const fieldTemplate = useMemo(() => template.inputs[fieldName] ?? null, [fieldName, template.inputs]);
return fieldTemplate;
};

View File

@@ -0,0 +1,17 @@
import { useNodeTemplateSafe } from 'features/nodes/hooks/useNodeTemplateSafe';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import { useMemo } from 'react';
/**
* Returns the template for a specific input field of a node.
*
* **Note:** This function is a safe version of `useInputFieldTemplate` and will not throw an error if the template is not found.
*
* @param nodeId - The ID of the node.
* @param fieldName - The name of the input field.
*/
export const useInputFieldTemplateSafe = (nodeId: string, fieldName: string): FieldInputTemplate | null => {
const template = useNodeTemplateSafe(nodeId);
const fieldTemplate = useMemo(() => template?.inputs[fieldName] ?? null, [fieldName, template?.inputs]);
return fieldTemplate;
};

View File

@@ -1,9 +1,10 @@
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { useMemo } from 'react';
import { assert } from 'tsafe';
export const useInputFieldTemplateTitle = (nodeId: string, fieldName: string): string => {
const template = useNodeTemplate(nodeId);
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
export const useInputFieldTemplateTitleOrThrow = (nodeId: string, fieldName: string): string => {
const template = useNodeTemplateOrThrow(nodeId);
const title = useMemo(() => {
const fieldTemplate = template.inputs[fieldName];

View File

@@ -0,0 +1,9 @@
import { useMemo } from 'react';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
export const useInputFieldTemplateTitleSafe = (nodeId: string, fieldName: string): string => {
const template = useNodeTemplateOrThrow(nodeId);
const title = useMemo(() => template.inputs[fieldName]?.title ?? '', [fieldName, template.inputs]);
return title;
};

View File

@@ -0,0 +1,22 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectFieldInputInstance, selectNodesSlice } from 'features/nodes/store/selectors';
import { useMemo } from 'react';
/**
* Gets the user-defined description of an input field for a given node.
*
* If the node doesn't exist or is not an invocation node, an error is thrown.
*
* @param nodeId The ID of the node
* @param fieldName The name of the field
*/
export const useInputFieldUserDescriptionOrThrow = (nodeId: string, fieldName: string) => {
const selector = useMemo(
() => createSelector(selectNodesSlice, (nodes) => selectFieldInputInstance(nodes, nodeId, fieldName).description),
[fieldName, nodeId]
);
const description = useAppSelector(selector);
return description;
};

View File

@@ -11,7 +11,7 @@ import { useMemo } from 'react';
* @param nodeId The ID of the node
* @param fieldName The name of the field
*/
export const useInputFieldDescriptionSafe = (nodeId: string, fieldName: string) => {
export const useInputFieldUserDescriptionSafe = (nodeId: string, fieldName: string) => {
const selector = useMemo(
() =>
createSelector(

View File

@@ -0,0 +1,23 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectFieldInputInstance, selectNodesSlice } from 'features/nodes/store/selectors';
import { useMemo } from 'react';
/**
* Gets the user-defined title of an input field for a given node.
*
* If the node doesn't exist or is not an invocation node, an error is thrown.
*
* @param nodeId The ID of the node
* @param fieldName The name of the field
*/
export const useInputFieldUserTitleOrThrow = (nodeId: string, fieldName: string): string => {
const selector = useMemo(
() => createSelector(selectNodesSlice, (nodes) => selectFieldInputInstance(nodes, nodeId, fieldName).label),
[fieldName, nodeId]
);
const title = useAppSelector(selector);
return title;
};

View File

@@ -4,21 +4,21 @@ import { selectFieldInputInstanceSafe, selectNodesSlice } from 'features/nodes/s
import { useMemo } from 'react';
/**
* Gets the user-defined label of an input field for a given node.
* Gets the user-defined title of an input field for a given node.
*
* If the node doesn't exist or is not an invocation node, an empty string is returned.
*
* @param nodeId The ID of the node
* @param fieldName The name of the field
*/
export const useInputFieldLabelSafe = (nodeId: string, fieldName: string): string => {
export const useInputFieldUserTitleSafe = (nodeId: string, fieldName: string): string => {
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) => selectFieldInputInstanceSafe(nodes, nodeId, fieldName)?.label ?? ''),
[fieldName, nodeId]
);
const label = useAppSelector(selector);
const title = useAppSelector(selector);
return label;
return title;
};

View File

@@ -1,9 +1,10 @@
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { isBatchNodeType, isGeneratorNodeType } from 'features/nodes/types/invocation';
import { useMemo } from 'react';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
export const useIsExecutableNode = (nodeId: string) => {
const template = useNodeTemplate(nodeId);
const template = useNodeTemplateOrThrow(nodeId);
const isExecutableNode = useMemo(
() => !isBatchNodeType(template.type) && !isGeneratorNodeType(template.type),
[template]

View File

@@ -0,0 +1,13 @@
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { $isInPublishFlow, useIsValidationRunInProgress } from 'features/nodes/components/sidePanel/workflow/publish';
import { selectWorkflowIsPublished } from 'features/nodes/store/workflowSlice';
export const useIsWorkflowEditorLocked = () => {
const isInPublishFlow = useStore($isInPublishFlow);
const isPublished = useAppSelector(selectWorkflowIsPublished);
const isValidationRunInProgress = useIsValidationRunInProgress();
const isLocked = isInPublishFlow || isPublished || isValidationRunInProgress;
return isLocked;
};

View File

@@ -1,9 +1,10 @@
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import type { Classification } from 'features/nodes/types/common';
import { useMemo } from 'react';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
export const useNodeClassification = (nodeId: string): Classification => {
const template = useNodeTemplate(nodeId);
const template = useNodeTemplateOrThrow(nodeId);
const classification = useMemo(() => template.classification, [template]);
return classification;
};

View File

@@ -1,9 +1,10 @@
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { some } from 'lodash-es';
import { useMemo } from 'react';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
export const useNodeHasImageOutput = (nodeId: string): boolean => {
const template = useNodeTemplate(nodeId);
const template = useNodeTemplateOrThrow(nodeId);
const hasImageOutput = useMemo(
() =>
some(

View File

@@ -1,12 +1,13 @@
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { useNodeType } from 'features/nodes/hooks/useNodeType';
import { useNodeVersion } from 'features/nodes/hooks/useNodeVersion';
import { useMemo } from 'react';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
export const useNodeNeedsUpdate = (nodeId: string) => {
const type = useNodeType(nodeId);
const version = useNodeVersion(nodeId);
const template = useNodeTemplate(nodeId);
const template = useNodeTemplateOrThrow(nodeId);
const needsUpdate = useMemo(() => {
if (type !== template.type) {
return true;

View File

@@ -5,7 +5,7 @@ import type { InvocationTemplate } from 'features/nodes/types/invocation';
import { useMemo } from 'react';
import { assert } from 'tsafe';
export const useNodeTemplate = (nodeId: string): InvocationTemplate => {
export const useNodeTemplateOrThrow = (nodeId: string): InvocationTemplate => {
const templates = useStore($templates);
const type = useNodeType(nodeId);
const template = useMemo(() => {
@@ -15,10 +15,3 @@ export const useNodeTemplate = (nodeId: string): InvocationTemplate => {
}, [templates, type]);
return template;
};
export const useNodeTemplateSafe = (nodeId: string): InvocationTemplate | null => {
const templates = useStore($templates);
const type = useNodeType(nodeId);
const template = useMemo(() => templates[type] ?? null, [templates, type]);
return template;
};

View File

@@ -0,0 +1,12 @@
import { useStore } from '@nanostores/react';
import { useNodeType } from 'features/nodes/hooks/useNodeType';
import { $templates } from 'features/nodes/store/nodesSlice';
import type { InvocationTemplate } from 'features/nodes/types/invocation';
import { useMemo } from 'react';
export const useNodeTemplateSafe = (nodeId: string): InvocationTemplate | null => {
const templates = useStore($templates);
const type = useNodeType(nodeId);
const template = useMemo(() => templates[type] ?? null, [templates, type]);
return template;
};

View File

@@ -0,0 +1,25 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { useMemo } from 'react';
import { assert } from 'tsafe';
export const useNodeTemplateTitleOrThrow = (nodeId: string): string => {
const templates = useStore($templates);
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = nodesSlice.nodes.find((node) => node.id === nodeId);
assert(isInvocationNode(node), 'Node not found');
const template = templates[node.data.type];
assert(template, 'Template not found');
return template.title;
}),
[nodeId, templates]
);
const title = useAppSelector(selector);
return title;
};

View File

@@ -6,7 +6,7 @@ import { selectNodesSlice } from 'features/nodes/store/selectors';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { useMemo } from 'react';
export const useNodeTemplateTitle = (nodeId: string): string | null => {
export const useNodeTemplateTitleSafe = (nodeId: string): string | null => {
const templates = useStore($templates);
const selector = useMemo(
() =>

View File

@@ -0,0 +1,21 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { useMemo } from 'react';
import { assert } from 'tsafe';
export const useNodeUserTitleOrThrow = (nodeId: string) => {
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = nodesSlice.nodes.find((node) => node.id === nodeId);
assert(isInvocationNode(node), 'Node not found');
return node.data.label;
}),
[nodeId]
);
const title = useAppSelector(selector);
return title;
};

View File

@@ -3,16 +3,16 @@ import { useAppSelector } from 'app/store/storeHooks';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { useMemo } from 'react';
export const useNodeLabel = (nodeId: string) => {
export const useNodeUserTitleSafe = (nodeId: string) => {
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = nodesSlice.nodes.find((node) => node.id === nodeId);
return node?.data.label;
return node?.data.label ?? null;
}),
[nodeId]
);
const label = useAppSelector(selector);
return label;
const title = useAppSelector(selector);
return title;
};

View File

@@ -1,10 +1,11 @@
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { getSortedFilteredFieldNames } from 'features/nodes/util/node/getSortedFilteredFieldNames';
import { map } from 'lodash-es';
import { useMemo } from 'react';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
export const useOutputFieldNames = (nodeId: string): string[] => {
const template = useNodeTemplate(nodeId);
const template = useNodeTemplateOrThrow(nodeId);
const fieldNames = useMemo(() => getSortedFilteredFieldNames(map(template.outputs)), [template.outputs]);
return fieldNames;
};

View File

@@ -1,10 +1,11 @@
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import type { FieldOutputTemplate } from 'features/nodes/types/field';
import { useMemo } from 'react';
import { assert } from 'tsafe';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
export const useOutputFieldTemplate = (nodeId: string, fieldName: string): FieldOutputTemplate => {
const template = useNodeTemplate(nodeId);
const template = useNodeTemplateOrThrow(nodeId);
const fieldTemplate = useMemo(() => {
const _fieldTemplate = template.outputs[fieldName];
assert(_fieldTemplate, `Template for output field ${fieldName} not found`);

View File

@@ -4,14 +4,14 @@ import { useCallback } from 'react';
const log = logger('workflows');
export const useZoomToNode = () => {
const zoomToNode = useCallback((nodeId: string) => {
export const useZoomToNode = (nodeId: string) => {
const zoomToNode = useCallback(() => {
const flow = $flow.get();
if (!flow) {
log.warn('No flow instance found, cannot zoom to node');
return;
}
flow.fitView({ duration: 300, maxZoom: 1.5, nodes: [{ id: nodeId }] });
}, []);
}, [nodeId]);
return zoomToNode;
};

View File

@@ -4,7 +4,7 @@ import type { RootState } from 'app/store/store';
import type { NodesState } from 'features/nodes/store/types';
import type { FieldInputInstance } from 'features/nodes/types/field';
import type { AnyNode, InvocationNode, InvocationNodeData } from 'features/nodes/types/invocation';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { isBatchNode, isGeneratorNode, isInvocationNode } from 'features/nodes/types/invocation';
import { assert } from 'tsafe';
export const selectNode = (nodesSlice: NodesState, nodeId: string): AnyNode => {
@@ -81,3 +81,7 @@ export const selectMayRedo = createSelector(
(state: RootState) => state.nodes,
(nodes) => nodes.future.length > 0
);
export const selectHasBatchOrGeneratorNodes = createSelector(selectNodes, (nodes) =>
nodes.filter(isInvocationNode).some((node) => isBatchNode(node) || isGeneratorNode(node))
);

View File

@@ -5,7 +5,7 @@ import type { WorkflowCategory } from 'features/nodes/types/workflow';
import { atom, computed } from 'nanostores';
import type { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types';
export type WorkflowLibraryView = 'recent' | 'yours' | 'private' | 'shared' | 'defaults';
export type WorkflowLibraryView = 'recent' | 'yours' | 'private' | 'shared' | 'defaults' | 'published';
type WorkflowLibraryState = {
view: WorkflowLibraryView;

View File

@@ -33,7 +33,7 @@ import {
isNodeFieldElement,
isTextElement,
} from 'features/nodes/types/workflow';
import { isEqual } from 'lodash-es';
import { isEqual, uniqBy } from 'lodash-es';
import { useMemo } from 'react';
import { selectNodesSlice } from './selectors';
@@ -67,8 +67,11 @@ const getBlankWorkflow = (): Omit<WorkflowV3, 'nodes' | 'edges'> => {
notes: '',
exposedFields: [],
meta: { version: '3.0.0', category: 'user' },
id: undefined,
form: getDefaultForm(),
// Even though these values are `undefined`, the keys _must_ be present for the presistence layer to rehydrate
// them correctly. It uses a merge strategy that relies on the keys being present.
id: undefined,
is_published: undefined,
};
};
@@ -123,6 +126,9 @@ export const workflowSlice = createSlice({
workflowIDChanged: (state, action: PayloadAction<string>) => {
state.id = action.payload;
},
workflowIsPublishedChanged(state, action: PayloadAction<boolean>) {
state.is_published = action.payload;
},
workflowSaved: (state) => {
state.isTouched = false;
},
@@ -285,6 +291,7 @@ export const {
workflowVersionChanged,
workflowContactChanged,
workflowIDChanged,
workflowIsPublishedChanged,
workflowSaved,
formReset,
formElementAdded,
@@ -350,6 +357,10 @@ export const selectWorkflowMode = createWorkflowSelector((workflow) => workflow.
export const selectWorkflowIsTouched = createWorkflowSelector((workflow) => workflow.isTouched);
export const selectWorkflowDescription = createWorkflowSelector((workflow) => workflow.description);
export const selectWorkflowForm = createWorkflowSelector((workflow) => workflow.form);
export const selectWorkflowIsPublished = createWorkflowSelector((workflow) => workflow.is_published);
export const selectIsWorkflowSaved = createSelector(selectWorkflowId, selectWorkflowIsTouched, (id, isTouched) => {
return id !== undefined && !isTouched;
});
export const selectCleanEditor = createSelector([selectNodesSlice, selectWorkflowSlice], (nodes, workflow) => {
const noNodes = !nodes.nodes.length;
@@ -375,6 +386,14 @@ export const selectFormInitialValues = createWorkflowSelector((workflow) => work
export const selectNodeFieldElements = createWorkflowSelector((workflow) =>
Object.values(workflow.form.elements).filter(isNodeFieldElement)
);
export const selectWorkflowFormNodeFieldFieldIdentifiersDeduped = createSelector(
selectNodeFieldElements,
(nodeFieldElements) =>
uniqBy(nodeFieldElements, (el) => `${el.data.fieldIdentifier.nodeId}-${el.data.fieldIdentifier.fieldName}`).map(
(el) => el.data.fieldIdentifier
)
);
const buildSelectElement = (id: string) => createWorkflowSelector((workflow) => workflow.form?.elements[id]);
export const useElement = (id: string): FormElement | undefined => {
const selector = useMemo(() => buildSelectElement(id), [id]);

View File

@@ -153,6 +153,9 @@ const zBoardFieldType = zFieldTypeBase.extend({
name: z.literal('BoardField'),
originalType: zStatelessFieldType.optional(),
});
export const isBoardFieldType = (fieldType: FieldType): fieldType is z.infer<typeof zBoardFieldType> =>
fieldType.name === zBoardFieldType.shape.name.value;
const zColorFieldType = zFieldTypeBase.extend({
name: z.literal('ColorField'),
originalType: zStatelessFieldType.optional(),

View File

@@ -100,7 +100,7 @@ export const isGeneratorNodeType = (type: string) =>
export const isBatchNode = (node: InvocationNode) => isBatchNodeType(node.data.type);
const isGeneratorNode = (node: InvocationNode) => isGeneratorNodeType(node.data.type);
export const isGeneratorNode = (node: InvocationNode) => isGeneratorNodeType(node.data.type);
export const isExecutableNode = (node: InvocationNode) => {
return !isBatchNode(node) && !isGeneratorNode(node);

View File

@@ -3,6 +3,7 @@ import type { WorkflowCategory, WorkflowV3, XYPosition } from 'features/nodes/ty
import type { S } from 'services/api/types';
import type { Equals, Extends } from 'tsafe';
import { assert } from 'tsafe';
import type { SetRequired } from 'type-fest';
import { describe, test } from 'vitest';
/**
@@ -13,6 +14,5 @@ import { describe, test } from 'vitest';
describe('Workflow types', () => {
test('XYPosition', () => assert<Equals<XYPosition, ReactFlowXYPosition>>());
test('WorkflowCategory', () => assert<Equals<WorkflowCategory, S['WorkflowCategory']>>());
// @ts-expect-error TODO(psyche): Need to revise server types!
test('WorkflowV3', () => assert<Extends<WorkflowV3, S['Workflow']>>());
test('WorkflowV3', () => assert<Extends<SetRequired<WorkflowV3, 'id'>, S['Workflow']>>());
});

View File

@@ -377,6 +377,7 @@ export const zWorkflowV3 = z.object({
}),
// Use the validated form schema!
form: zValidatedBuilderForm,
is_published: z.boolean().nullish(),
});
export type WorkflowV3 = z.infer<typeof zWorkflowV3>;
// #endregion

View File

@@ -3,7 +3,7 @@ import { generateSeeds } from 'common/util/generateSeeds';
import type { Graph } from 'features/nodes/util/graph/generation/Graph';
import { range } from 'lodash-es';
import type { components } from 'services/api/schema';
import type { Batch, BatchConfig, Invocation } from 'services/api/types';
import type { Batch, EnqueueBatchArg, Invocation } from 'services/api/types';
export const prepareLinearUIBatch = (
state: RootState,
@@ -13,7 +13,7 @@ export const prepareLinearUIBatch = (
posCond: Invocation<'compel' | 'sdxl_compel_prompt' | 'flux_text_encoder' | 'sd3_text_encoder'>,
origin: 'canvas' | 'workflows' | 'upscaling',
destination: 'canvas' | 'gallery'
): BatchConfig => {
): EnqueueBatchArg => {
const { iterations, model, shouldRandomizeSeed, seed, shouldConcatPrompts } = state.params;
const { prompts, seedBehaviour } = state.dynamicPrompts;
@@ -99,7 +99,7 @@ export const prepareLinearUIBatch = (
data.push(firstBatchDatumList);
const enqueueBatchArg: BatchConfig = {
const enqueueBatchArg: EnqueueBatchArg = {
prepend,
batch: {
graph: g.getGraph(),

View File

@@ -37,7 +37,7 @@ const getBoardField = (field: BoardFieldInputInstance, state: RootState): BoardF
/**
* Builds a graph from the node editor state.
*/
export const buildNodesGraph = (state: RootState, templates: Templates): Graph => {
export const buildNodesGraph = (state: RootState, templates: Templates): Required<Graph> => {
const { nodes, edges } = selectNodesSlice(state);
// Exclude all batch nodes - we will handle these in the batch setup in a diff function

View File

@@ -78,6 +78,8 @@ const migrateV2toV3 = (workflowToMigrate: WorkflowV2): WorkflowV3 => {
/**
* Parses a workflow and migrates it to the latest version if necessary.
*
* This function will return a new workflow object, so the original workflow is not modified.
*/
export const parseAndMigrateWorkflow = (data: unknown): WorkflowV3 => {
const workflowVersionResult = zWorkflowMetaVersion.safeParse(data);

View File

@@ -6,8 +6,10 @@ import { selectSendToCanvas } from 'features/controlLayers/store/canvasSettingsS
import { selectIterations } from 'features/controlLayers/store/paramsSlice';
import { selectDynamicPromptsIsLoading } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import { $isInPublishFlow } from 'features/nodes/components/sidePanel/workflow/publish';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import type { NodesState } from 'features/nodes/store/types';
import { selectWorkflowIsPublished } from 'features/nodes/store/workflowSlice';
import type { BatchSizeResult } from 'features/nodes/util/node/resolveBatchValue';
import { getBatchSize } from 'features/nodes/util/node/resolveBatchValue';
import type { Reason } from 'features/queue/store/readiness';
@@ -175,6 +177,8 @@ const IsReadyText = memo(({ isReady, prepend }: { isReady: boolean; prepend: boo
const { t } = useTranslation();
const isLoadingDynamicPrompts = useAppSelector(selectDynamicPromptsIsLoading);
const [_, enqueueMutation] = useEnqueueBatchMutation(enqueueMutationFixedCacheKeyOptions);
const isInPublishFlow = useStore($isInPublishFlow);
const isPublished = useAppSelector(selectWorkflowIsPublished);
const text = useMemo(() => {
if (enqueueMutation.isLoading) {
@@ -183,6 +187,12 @@ const IsReadyText = memo(({ isReady, prepend }: { isReady: boolean; prepend: boo
if (isLoadingDynamicPrompts) {
return t('dynamicPrompts.loading');
}
if (isInPublishFlow) {
return t('workflows.builder.publishInProgress');
}
if (isPublished) {
return t('workflows.builder.publishedWorkflowIsLocked');
}
if (isReady) {
if (prepend) {
return t('queue.queueFront');
@@ -190,7 +200,7 @@ const IsReadyText = memo(({ isReady, prepend }: { isReady: boolean; prepend: boo
return t('queue.queueBack');
}
return t('queue.notReady');
}, [enqueueMutation.isLoading, isLoadingDynamicPrompts, isReady, prepend, t]);
}, [enqueueMutation.isLoading, isLoadingDynamicPrompts, isInPublishFlow, isPublished, isReady, t, prepend]);
return <Text fontWeight="semibold">{text}</Text>;
});

Some files were not shown because too many files have changed in this diff Show More