mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-01-15 08:28:14 -05:00
Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2ff83092f | ||
|
|
d2db38a5b9 | ||
|
|
fa988a6273 | ||
|
|
149f60946c | ||
|
|
ee9d620a36 | ||
|
|
4e8ce4abab | ||
|
|
d40f2fa37c | ||
|
|
933f4f6857 | ||
|
|
f499b2db7b | ||
|
|
706aaf7460 | ||
|
|
4a706d00bb | ||
|
|
2a8bff601f | ||
|
|
3f0e3192f6 | ||
|
|
c65147e2ff | ||
|
|
1c14e257a3 | ||
|
|
fe24217082 | ||
|
|
aee847065c | ||
|
|
525da3257c | ||
|
|
559654f0ca | ||
|
|
5d33874d58 | ||
|
|
0063315139 | ||
|
|
1cbd609860 | ||
|
|
047c643295 | ||
|
|
d1e03aa1c5 | ||
|
|
1bb8edf57e | ||
|
|
a3e78f0db6 | ||
|
|
1ccf43aa1e | ||
|
|
a290975fae | ||
|
|
43c2116d64 | ||
|
|
9d0a24ead3 | ||
|
|
d61a3d2950 | ||
|
|
7b63858802 | ||
|
|
fae23a744f | ||
|
|
7c574719e5 | ||
|
|
43a212dd47 | ||
|
|
a103bc8a0a | ||
|
|
1a42fbf541 | ||
|
|
d550067dd4 | ||
|
|
7003bcad62 | ||
|
|
ef95f4962c | ||
|
|
2e13bbbe1b | ||
|
|
43349cb5ce | ||
|
|
d037eea42a | ||
|
|
42c5be16d1 | ||
|
|
c7c4453a92 | ||
|
|
c71ddf6e5d | ||
|
|
c33ed68f78 | ||
|
|
48e389f155 | ||
|
|
5c423fece4 | ||
|
|
3f86049802 | ||
|
|
47d395d0a8 | ||
|
|
b666ef41ff | ||
|
|
375f62380b | ||
|
|
42c4462edc | ||
|
|
7591adebd5 | ||
|
|
9d9b2f73db | ||
|
|
abaae39c29 | ||
|
|
b1c9f59c30 | ||
|
|
7bcbe180df | ||
|
|
a626387a0b | ||
|
|
759229e3c8 | ||
|
|
ad4b81ba21 | ||
|
|
637b629b95 | ||
|
|
4aaa807415 | ||
|
|
e884be5042 | ||
|
|
13e129bef2 | ||
|
|
157904522f | ||
|
|
3045cd7b3a | ||
|
|
e9e2bab4ee | ||
|
|
6cd794d860 | ||
|
|
c9b0307bcd | ||
|
|
55aee034b0 | ||
|
|
e81ef0a090 | ||
|
|
1a806739f2 | ||
|
|
067aeeac23 | ||
|
|
47b37d946f | ||
|
|
ddfdeca8bd | ||
|
|
55b2a4388d |
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy
|
||||
from invokeai.app.services.boards.boards_common import BoardDTO
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory
|
||||
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
|
||||
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
|
||||
|
||||
@@ -87,7 +88,9 @@ async def delete_board(
|
||||
try:
|
||||
if include_images is True:
|
||||
deleted_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
|
||||
board_id=board_id
|
||||
board_id=board_id,
|
||||
categories=None,
|
||||
is_intermediate=None,
|
||||
)
|
||||
ApiDependencies.invoker.services.images.delete_images_on_board(board_id=board_id)
|
||||
ApiDependencies.invoker.services.boards.delete(board_id=board_id)
|
||||
@@ -98,7 +101,9 @@ async def delete_board(
|
||||
)
|
||||
else:
|
||||
deleted_board_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
|
||||
board_id=board_id
|
||||
board_id=board_id,
|
||||
categories=None,
|
||||
is_intermediate=None,
|
||||
)
|
||||
ApiDependencies.invoker.services.boards.delete(board_id=board_id)
|
||||
return DeleteBoardResult(
|
||||
@@ -142,10 +147,14 @@ async def list_boards(
|
||||
)
|
||||
async def list_all_board_image_names(
|
||||
board_id: str = Path(description="The id of the board"),
|
||||
categories: list[ImageCategory] | None = Query(default=None, description="The categories of image to include."),
|
||||
is_intermediate: bool | None = Query(default=None, description="Whether to list intermediate images."),
|
||||
) -> list[str]:
|
||||
"""Gets a list of images for a board"""
|
||||
|
||||
image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
|
||||
board_id,
|
||||
categories,
|
||||
is_intermediate,
|
||||
)
|
||||
return image_names
|
||||
|
||||
@@ -36,6 +36,7 @@ from invokeai.app.api.routers import (
|
||||
workflows,
|
||||
)
|
||||
from invokeai.app.api.sockets import SocketIO
|
||||
from invokeai.app.invocations.load_custom_nodes import load_custom_nodes
|
||||
from invokeai.app.services.config.config_default import get_config
|
||||
from invokeai.app.util.custom_openapi import get_openapi_func
|
||||
from invokeai.backend.util.devices import TorchDevice
|
||||
@@ -63,6 +64,11 @@ loop = asyncio.new_event_loop()
|
||||
# the correct port when the server starts in the lifespan handler.
|
||||
port = app_config.port
|
||||
|
||||
# Load custom nodes. This must be done after importing the Graph class, which itself imports all modules from the
|
||||
# invocations module. The ordering here is implicit, but important - we want to load custom nodes after all the
|
||||
# core nodes have been imported so that we can catch when a custom node clobbers a core node.
|
||||
load_custom_nodes(custom_nodes_path=app_config.custom_nodes_path)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
|
||||
@@ -1,33 +1,5 @@
|
||||
import shutil
|
||||
import sys
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
from invokeai.app.services.config.config_default import get_config
|
||||
|
||||
custom_nodes_path = Path(get_config().custom_nodes_path)
|
||||
custom_nodes_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
custom_nodes_init_path = str(custom_nodes_path / "__init__.py")
|
||||
custom_nodes_readme_path = str(custom_nodes_path / "README.md")
|
||||
|
||||
# copy our custom nodes __init__.py to the custom nodes directory
|
||||
shutil.copy(Path(__file__).parent / "custom_nodes/init.py", custom_nodes_init_path)
|
||||
shutil.copy(Path(__file__).parent / "custom_nodes/README.md", custom_nodes_readme_path)
|
||||
|
||||
# set the same permissions as the destination directory, in case our source is read-only,
|
||||
# so that the files are user-writable
|
||||
for p in custom_nodes_path.glob("**/*"):
|
||||
p.chmod(custom_nodes_path.stat().st_mode)
|
||||
|
||||
# Import custom nodes, see https://docs.python.org/3/library/importlib.html#importing-programmatically
|
||||
spec = spec_from_file_location("custom_nodes", custom_nodes_init_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Could not load custom nodes from {custom_nodes_init_path}")
|
||||
module = module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# add core nodes to __all__
|
||||
python_files = filter(lambda f: not f.name.startswith("_"), Path(__file__).parent.glob("*.py"))
|
||||
__all__ = [f.stem for f in python_files] # type: ignore
|
||||
|
||||
@@ -44,8 +44,6 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = InvokeAILogger.get_logger()
|
||||
|
||||
CUSTOM_NODE_PACK_SUFFIX = "__invokeai-custom-node"
|
||||
|
||||
|
||||
class InvalidVersionError(ValueError):
|
||||
pass
|
||||
@@ -240,6 +238,11 @@ class BaseInvocation(ABC, BaseModel):
|
||||
"""Gets the invocation's output annotation (i.e. the return annotation of its `invoke()` method)."""
|
||||
return signature(cls.invoke).return_annotation
|
||||
|
||||
@classmethod
|
||||
def get_invocation_for_type(cls, invocation_type: str) -> BaseInvocation | None:
|
||||
"""Gets the invocation class for a given invocation type."""
|
||||
return cls.get_invocations_map().get(invocation_type)
|
||||
|
||||
@staticmethod
|
||||
def json_schema_extra(schema: dict[str, Any], model_class: Type[BaseInvocation]) -> None:
|
||||
"""Adds various UI-facing attributes to the invocation's OpenAPI schema."""
|
||||
@@ -446,8 +449,27 @@ def invocation(
|
||||
if re.compile(r"^\S+$").match(invocation_type) is None:
|
||||
raise ValueError(f'"invocation_type" must consist of non-whitespace characters, got "{invocation_type}"')
|
||||
|
||||
# The node pack is the module name - will be "invokeai" for built-in nodes
|
||||
node_pack = cls.__module__.split(".")[0]
|
||||
|
||||
# Handle the case where an existing node is being clobbered by the one we are registering
|
||||
if invocation_type in BaseInvocation.get_invocation_types():
|
||||
raise ValueError(f'Invocation type "{invocation_type}" already exists')
|
||||
clobbered_invocation = BaseInvocation.get_invocation_for_type(invocation_type)
|
||||
# This should always be true - we just checked if the invocation type was in the set
|
||||
assert clobbered_invocation is not None
|
||||
|
||||
clobbered_node_pack = clobbered_invocation.UIConfig.node_pack
|
||||
|
||||
if clobbered_node_pack == "invokeai":
|
||||
# The node being clobbered is a core node
|
||||
raise ValueError(
|
||||
f'Cannot load node "{invocation_type}" from node pack "{node_pack}" - a core node with the same type already exists'
|
||||
)
|
||||
else:
|
||||
# The node being clobbered is a custom node
|
||||
raise ValueError(
|
||||
f'Cannot load node "{invocation_type}" from node pack "{node_pack}" - a node with the same type already exists in node pack "{clobbered_node_pack}"'
|
||||
)
|
||||
|
||||
validate_fields(cls.model_fields, invocation_type)
|
||||
|
||||
@@ -457,8 +479,7 @@ def invocation(
|
||||
uiconfig["tags"] = tags
|
||||
uiconfig["category"] = category
|
||||
uiconfig["classification"] = classification
|
||||
# The node pack is the module name - will be "invokeai" for built-in nodes
|
||||
uiconfig["node_pack"] = cls.__module__.split(".")[0]
|
||||
uiconfig["node_pack"] = node_pack
|
||||
|
||||
if version is not None:
|
||||
try:
|
||||
|
||||
@@ -64,13 +64,50 @@ class ImageBatchInvocation(BaseBatchInvocation):
|
||||
"""Create a batched generation, where the workflow is executed once for each image in the batch."""
|
||||
|
||||
images: list[ImageField] = InputField(
|
||||
default=[], min_length=1, description="The images to batch over", input=Input.Direct
|
||||
default=[],
|
||||
min_length=1,
|
||||
description="The images to batch over",
|
||||
)
|
||||
|
||||
def invoke(self, context: InvocationContext) -> ImageOutput:
|
||||
raise NotExecutableNodeError()
|
||||
|
||||
|
||||
@invocation_output("image_generator_output")
|
||||
class ImageGeneratorOutput(BaseInvocationOutput):
|
||||
"""Base class for nodes that output a collection of boards"""
|
||||
|
||||
images: list[ImageField] = OutputField(description="The generated images")
|
||||
|
||||
|
||||
class ImageGeneratorField(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
@invocation(
|
||||
"image_generator",
|
||||
title="Image Generator",
|
||||
tags=["primitives", "board", "image", "batch", "special"],
|
||||
category="primitives",
|
||||
version="1.0.0",
|
||||
classification=Classification.Special,
|
||||
)
|
||||
class ImageGenerator(BaseInvocation):
|
||||
"""Generated a collection of images for use in a batched generation"""
|
||||
|
||||
generator: ImageGeneratorField = InputField(
|
||||
description="The image generator.",
|
||||
input=Input.Direct,
|
||||
title="Generator Type",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
raise NotExecutableNodeError()
|
||||
|
||||
def invoke(self, context: InvocationContext) -> ImageGeneratorOutput:
|
||||
raise NotExecutableNodeError()
|
||||
|
||||
|
||||
@invocation(
|
||||
"string_batch",
|
||||
title="String Batch",
|
||||
|
||||
@@ -10,10 +10,12 @@ from pathlib import Path
|
||||
from invokeai.backend.util.logging import InvokeAILogger
|
||||
|
||||
logger = InvokeAILogger.get_logger()
|
||||
loaded_count = 0
|
||||
loaded_packs: list[str] = []
|
||||
failed_packs: list[str] = []
|
||||
|
||||
custom_nodes_dir = Path(__file__).parent
|
||||
|
||||
for d in Path(__file__).parent.iterdir():
|
||||
for d in custom_nodes_dir.iterdir():
|
||||
# skip files
|
||||
if not d.is_dir():
|
||||
continue
|
||||
@@ -47,12 +49,16 @@ for d in Path(__file__).parent.iterdir():
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
loaded_count += 1
|
||||
loaded_packs.append(module_name)
|
||||
except Exception:
|
||||
failed_packs.append(module_name)
|
||||
full_error = traceback.format_exc()
|
||||
logger.error(f"Failed to load node pack {module_name}:\n{full_error}")
|
||||
logger.error(f"Failed to load node pack {module_name} (may have partially loaded):\n{full_error}")
|
||||
|
||||
del init, module_name
|
||||
|
||||
loaded_count = len(loaded_packs)
|
||||
if loaded_count > 0:
|
||||
logger.info(f"Loaded {loaded_count} node packs from {Path(__file__).parent}")
|
||||
logger.info(
|
||||
f"Loaded {loaded_count} node pack{'s' if loaded_count != 1 else ''} from {custom_nodes_dir}: {', '.join(loaded_packs)}"
|
||||
)
|
||||
|
||||
40
invokeai/app/invocations/load_custom_nodes.py
Normal file
40
invokeai/app/invocations/load_custom_nodes.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import shutil
|
||||
import sys
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_custom_nodes(custom_nodes_path: Path):
|
||||
"""
|
||||
Loads all custom nodes from the custom_nodes_path directory.
|
||||
|
||||
This function copies a custom __init__.py file to the custom_nodes_path directory, effectively turning it into a
|
||||
python module.
|
||||
|
||||
The custom __init__.py file itself imports all the custom node packs as python modules from the custom_nodes_path
|
||||
directory.
|
||||
|
||||
Then,the custom __init__.py file is programmatically imported using importlib. As it executes, it imports all the
|
||||
custom node packs as python modules.
|
||||
"""
|
||||
custom_nodes_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
custom_nodes_init_path = str(custom_nodes_path / "__init__.py")
|
||||
custom_nodes_readme_path = str(custom_nodes_path / "README.md")
|
||||
|
||||
# copy our custom nodes __init__.py to the custom nodes directory
|
||||
shutil.copy(Path(__file__).parent / "custom_nodes/init.py", custom_nodes_init_path)
|
||||
shutil.copy(Path(__file__).parent / "custom_nodes/README.md", custom_nodes_readme_path)
|
||||
|
||||
# set the same permissions as the destination directory, in case our source is read-only,
|
||||
# so that the files are user-writable
|
||||
for p in custom_nodes_path.glob("**/*"):
|
||||
p.chmod(custom_nodes_path.stat().st_mode)
|
||||
|
||||
# Import custom nodes, see https://docs.python.org/3/library/importlib.html#importing-programmatically
|
||||
spec = spec_from_file_location("custom_nodes", custom_nodes_init_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Could not load custom nodes from {custom_nodes_init_path}")
|
||||
module = module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
@@ -284,6 +284,7 @@ class CoreMetadataInvocation(BaseInvocation):
|
||||
tags=["metadata"],
|
||||
category="metadata",
|
||||
version="1.0.0",
|
||||
classification=Classification.Deprecated,
|
||||
)
|
||||
class MetadataFieldExtractorInvocation(BaseInvocation):
|
||||
"""Extracts the text value from an image's metadata given a key.
|
||||
|
||||
1164
invokeai/app/invocations/metadata_linked.py
Normal file
1164
invokeai/app/invocations/metadata_linked.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -265,13 +265,9 @@ class ImageInvocation(BaseInvocation):
|
||||
image: ImageField = InputField(description="The image to load")
|
||||
|
||||
def invoke(self, context: InvocationContext) -> ImageOutput:
|
||||
image = context.images.get_pil(self.image.image_name)
|
||||
image_dto = context.images.get_dto(self.image.image_name)
|
||||
|
||||
return ImageOutput(
|
||||
image=ImageField(image_name=self.image.image_name),
|
||||
width=image.width,
|
||||
height=image.height,
|
||||
)
|
||||
return ImageOutput.build(image_dto=image_dto)
|
||||
|
||||
|
||||
@invocation(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory
|
||||
|
||||
|
||||
class BoardImageRecordStorageBase(ABC):
|
||||
"""Abstract base class for the one-to-many board-image relationship record storage."""
|
||||
@@ -26,6 +28,8 @@ class BoardImageRecordStorageBase(ABC):
|
||||
def get_all_board_image_names_for_board(
|
||||
self,
|
||||
board_id: str,
|
||||
categories: list[ImageCategory] | None,
|
||||
is_intermediate: bool | None,
|
||||
) -> list[str]:
|
||||
"""Gets all board images for a board, as a list of the image names."""
|
||||
pass
|
||||
|
||||
@@ -3,7 +3,11 @@ import threading
|
||||
from typing import Optional, cast
|
||||
|
||||
from invokeai.app.services.board_image_records.board_image_records_base import BoardImageRecordStorageBase
|
||||
from invokeai.app.services.image_records.image_records_common import ImageRecord, deserialize_image_record
|
||||
from invokeai.app.services.image_records.image_records_common import (
|
||||
ImageCategory,
|
||||
ImageRecord,
|
||||
deserialize_image_record,
|
||||
)
|
||||
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
|
||||
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
|
||||
|
||||
@@ -97,17 +101,54 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
|
||||
self._lock.release()
|
||||
return OffsetPaginatedResults(items=images, offset=offset, limit=limit, total=count)
|
||||
|
||||
def get_all_board_image_names_for_board(self, board_id: str) -> list[str]:
|
||||
def get_all_board_image_names_for_board(
|
||||
self,
|
||||
board_id: str,
|
||||
categories: list[ImageCategory] | None,
|
||||
is_intermediate: bool | None,
|
||||
) -> list[str]:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
SELECT image_name
|
||||
FROM board_images
|
||||
WHERE board_id = ?;
|
||||
""",
|
||||
(board_id,),
|
||||
)
|
||||
|
||||
params: list[str | bool] = []
|
||||
|
||||
# Base query is a join between images and board_images
|
||||
stmt = """
|
||||
SELECT images.image_name
|
||||
FROM images
|
||||
LEFT JOIN board_images ON board_images.image_name = images.image_name
|
||||
WHERE 1=1
|
||||
AND board_images.board_id = ?
|
||||
"""
|
||||
params.append(board_id)
|
||||
|
||||
# Add the category filter
|
||||
if categories is not None:
|
||||
# Convert the enum values to unique list of strings
|
||||
category_strings = [c.value for c in set(categories)]
|
||||
# Create the correct length of placeholders
|
||||
placeholders = ",".join("?" * len(category_strings))
|
||||
stmt += f"""--sql
|
||||
AND images.image_category IN ( {placeholders} )
|
||||
"""
|
||||
|
||||
# Unpack the included categories into the query params
|
||||
for c in category_strings:
|
||||
params.append(c)
|
||||
|
||||
# Add the is_intermediate filter
|
||||
if is_intermediate is not None:
|
||||
stmt += """--sql
|
||||
AND images.is_intermediate = ?
|
||||
"""
|
||||
params.append(is_intermediate)
|
||||
|
||||
# Put a ring on it
|
||||
stmt += ";"
|
||||
|
||||
# Execute the query
|
||||
self._cursor.execute(stmt, params)
|
||||
|
||||
result = cast(list[sqlite3.Row], self._cursor.fetchall())
|
||||
image_names = [r[0] for r in result]
|
||||
return image_names
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory
|
||||
|
||||
|
||||
class BoardImagesServiceABC(ABC):
|
||||
"""High-level service for board-image relationship management."""
|
||||
@@ -26,6 +28,8 @@ class BoardImagesServiceABC(ABC):
|
||||
def get_all_board_image_names_for_board(
|
||||
self,
|
||||
board_id: str,
|
||||
categories: list[ImageCategory] | None,
|
||||
is_intermediate: bool | None,
|
||||
) -> list[str]:
|
||||
"""Gets all board images for a board, as a list of the image names."""
|
||||
pass
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import Optional
|
||||
|
||||
from invokeai.app.services.board_images.board_images_base import BoardImagesServiceABC
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
|
||||
|
||||
@@ -26,8 +27,14 @@ class BoardImagesService(BoardImagesServiceABC):
|
||||
def get_all_board_image_names_for_board(
|
||||
self,
|
||||
board_id: str,
|
||||
categories: list[ImageCategory] | None,
|
||||
is_intermediate: bool | None,
|
||||
) -> list[str]:
|
||||
return self.__invoker.services.board_image_records.get_all_board_image_names_for_board(board_id)
|
||||
return self.__invoker.services.board_image_records.get_all_board_image_names_for_board(
|
||||
board_id,
|
||||
categories,
|
||||
is_intermediate,
|
||||
)
|
||||
|
||||
def get_board_for_image(
|
||||
self,
|
||||
|
||||
@@ -63,7 +63,11 @@ class BulkDownloadService(BulkDownloadBase):
|
||||
return [self._invoker.services.images.get_dto(image_name) for image_name in image_names]
|
||||
|
||||
def _board_handler(self, board_id: str) -> list[ImageDTO]:
|
||||
image_names = self._invoker.services.board_image_records.get_all_board_image_names_for_board(board_id)
|
||||
image_names = self._invoker.services.board_image_records.get_all_board_image_names_for_board(
|
||||
board_id,
|
||||
categories=None,
|
||||
is_intermediate=None,
|
||||
)
|
||||
return self._image_handler(image_names)
|
||||
|
||||
def generate_item_id(self, board_id: Optional[str]) -> str:
|
||||
|
||||
@@ -265,7 +265,11 @@ class ImageService(ImageServiceABC):
|
||||
|
||||
def delete_images_on_board(self, board_id: str):
|
||||
try:
|
||||
image_names = self.__invoker.services.board_image_records.get_all_board_image_names_for_board(board_id)
|
||||
image_names = self.__invoker.services.board_image_records.get_all_board_image_names_for_board(
|
||||
board_id,
|
||||
categories=None,
|
||||
is_intermediate=None,
|
||||
)
|
||||
for image_name in image_names:
|
||||
self.__invoker.services.image_files.delete(image_name)
|
||||
self.__invoker.services.image_records.delete_many(image_names)
|
||||
@@ -278,7 +282,7 @@ class ImageService(ImageServiceABC):
|
||||
self.__invoker.services.logger.error("Failed to delete image files")
|
||||
raise
|
||||
except Exception as e:
|
||||
self.__invoker.services.logger.error("Problem deleting image records and files")
|
||||
self.__invoker.services.logger.error(f"Problem deleting image records and files: {str(e)}")
|
||||
raise e
|
||||
|
||||
def delete_intermediates(self) -> int:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime
|
||||
import json
|
||||
from itertools import chain, product
|
||||
from typing import Generator, Iterable, Literal, NamedTuple, Optional, TypeAlias, Union, cast
|
||||
from typing import Generator, Literal, Optional, TypeAlias, Union, cast
|
||||
|
||||
from pydantic import (
|
||||
AliasChoices,
|
||||
@@ -406,61 +406,143 @@ class IsFullResult(BaseModel):
|
||||
# region Util
|
||||
|
||||
|
||||
def populate_graph(graph: Graph, node_field_values: Iterable[NodeFieldValue]) -> Graph:
|
||||
def create_session_nfv_tuples(batch: Batch, maximum: int) -> Generator[tuple[str, str, str], None, None]:
|
||||
"""
|
||||
Populates the given graph with the given batch data items.
|
||||
"""
|
||||
graph_clone = graph.model_copy(deep=True)
|
||||
for item in node_field_values:
|
||||
node = graph_clone.get_node(item.node_path)
|
||||
if node is None:
|
||||
continue
|
||||
setattr(node, item.field_name, item.value)
|
||||
graph_clone.update_node(item.node_path, node)
|
||||
return graph_clone
|
||||
Given a batch and a maximum number of sessions to create, generate a tuple of session_id, session_json, and
|
||||
field_values_json for each session.
|
||||
|
||||
The batch has a "source" graph and a data property. The data property is a list of lists of BatchDatum objects.
|
||||
Each BatchDatum has a field identifier (e.g. a node id and field name), and a list of values to substitute into
|
||||
the field.
|
||||
|
||||
def create_session_nfv_tuples(
|
||||
batch: Batch, maximum: int
|
||||
) -> Generator[tuple[GraphExecutionState, list[NodeFieldValue], Optional[WorkflowWithoutID]], None, None]:
|
||||
"""
|
||||
Create all graph permutations from the given batch data and graph. Yields tuples
|
||||
of the form (graph, batch_data_items) where batch_data_items is the list of BatchDataItems
|
||||
that was applied to the graph.
|
||||
This structure allows us to create a new graph for every possible permutation of BatchDatum objects:
|
||||
- Each BatchDatum can be "expanded" into a dict of node-field-value tuples - one for each item in the BatchDatum.
|
||||
- Zip each inner list of expanded BatchDatum objects together. Call this a "batch_data_list".
|
||||
- Take the cartesian product of all zipped batch_data_lists, resulting in a list of permutations of BatchDatum
|
||||
- Take the cartesian product of all zipped batch_data_lists, resulting in a list of lists of BatchDatum objects.
|
||||
Each inner list now represents the substitution values for a single permutation (session).
|
||||
- For each permutation, substitute the values into the graph
|
||||
|
||||
This function is optimized for performance, as it is used to generate a large number of sessions at once.
|
||||
|
||||
Args:
|
||||
batch: The batch to generate sessions from
|
||||
maximum: The maximum number of sessions to generate
|
||||
|
||||
Returns:
|
||||
A generator that yields tuples of session_id, session_json, and field_values_json for each session. The
|
||||
generator will stop early if the maximum number of sessions is reached.
|
||||
"""
|
||||
|
||||
# TODO: Should this be a class method on Batch?
|
||||
|
||||
data: list[list[tuple[NodeFieldValue]]] = []
|
||||
data: list[list[tuple[dict]]] = []
|
||||
batch_data_collection = batch.data if batch.data is not None else []
|
||||
for batch_datum_list in batch_data_collection:
|
||||
# each batch_datum_list needs to be convered to NodeFieldValues and then zipped
|
||||
|
||||
node_field_values_to_zip: list[list[NodeFieldValue]] = []
|
||||
for batch_datum_list in batch_data_collection:
|
||||
node_field_values_to_zip: list[list[dict]] = []
|
||||
# Expand each BatchDatum into a list of dicts - one for each item in the BatchDatum
|
||||
for batch_datum in batch_datum_list:
|
||||
node_field_values = [
|
||||
NodeFieldValue(node_path=batch_datum.node_path, field_name=batch_datum.field_name, value=item)
|
||||
# Note: A tuple here is slightly faster than a dict, but we need the object in dict form to be inserted
|
||||
# in the session_queue table anyways. So, overall creating NFVs as dicts is faster.
|
||||
{"node_path": batch_datum.node_path, "field_name": batch_datum.field_name, "value": item}
|
||||
for item in batch_datum.items
|
||||
]
|
||||
node_field_values_to_zip.append(node_field_values)
|
||||
# Zip the dicts together to create a list of dicts for each permutation
|
||||
data.append(list(zip(*node_field_values_to_zip, strict=True))) # type: ignore [arg-type]
|
||||
|
||||
# create generator to yield session,nfv tuples
|
||||
# We serialize the graph and session once, then mutate the graph dict in place for each session.
|
||||
#
|
||||
# This sounds scary, but it's actually fine.
|
||||
#
|
||||
# The batch prep logic injects field values into the same fields for each generated session.
|
||||
#
|
||||
# For example, after the product operation, we'll end up with a list of node-field-value tuples like this:
|
||||
# [
|
||||
# (
|
||||
# {"node_path": "1", "field_name": "a", "value": 1},
|
||||
# {"node_path": "2", "field_name": "b", "value": 2},
|
||||
# {"node_path": "3", "field_name": "c", "value": 3},
|
||||
# ),
|
||||
# (
|
||||
# {"node_path": "1", "field_name": "a", "value": 4},
|
||||
# {"node_path": "2", "field_name": "b", "value": 5},
|
||||
# {"node_path": "3", "field_name": "c", "value": 6},
|
||||
# )
|
||||
# ]
|
||||
#
|
||||
# Note that each tuple has the same length, and each tuple substitutes values in for exactly the same node fields.
|
||||
# No matter the complexity of the batch, this property holds true.
|
||||
#
|
||||
# This means each permutation's substitution can be done in-place on the same graph dict, because it overwrites the
|
||||
# previous mutation. We only need to serialize the graph once, and then we can mutate it in place for each session.
|
||||
#
|
||||
# Previously, we had created new Graph objects for each session, but this was very slow for large (1k+ session
|
||||
# batches). We then tried dumping the graph to dict and using deep-copy to create a new dict for each session,
|
||||
# but this was also slow.
|
||||
#
|
||||
# Overall, we achieved a 100x speedup by mutating the graph dict in place for each session over creating new Graph
|
||||
# objects for each session.
|
||||
#
|
||||
# We will also mutate the session dict in place, setting a new ID for each session and setting the mutated graph
|
||||
# dict as the session's graph.
|
||||
|
||||
# Dump the batch's graph to a dict once
|
||||
graph_as_dict = batch.graph.model_dump(warnings=False, exclude_none=True)
|
||||
|
||||
# We must provide a Graph object when creating the "dummy" session dict, but we don't actually use it. It will be
|
||||
# overwritten for each session by the mutated graph_as_dict.
|
||||
session_dict = GraphExecutionState(graph=Graph()).model_dump(warnings=False, exclude_none=True)
|
||||
|
||||
# Now we can create a generator that yields the session_id, session_json, and field_values_json for each session.
|
||||
count = 0
|
||||
|
||||
# Each batch may have multiple runs, so we need to generate the same number of sessions for each run. The total is
|
||||
# still limited by the maximum number of sessions.
|
||||
for _ in range(batch.runs):
|
||||
for d in product(*data):
|
||||
if count >= maximum:
|
||||
# We've reached the maximum number of sessions we may generate
|
||||
return
|
||||
|
||||
# Flatten the list of lists of dicts into a single list of dicts
|
||||
# TODO(psyche): Is the a more efficient way to do this?
|
||||
flat_node_field_values = list(chain.from_iterable(d))
|
||||
graph = populate_graph(batch.graph, flat_node_field_values)
|
||||
yield (GraphExecutionState(graph=graph), flat_node_field_values, batch.workflow)
|
||||
|
||||
# Need a fresh ID for each session
|
||||
session_id = uuid_string()
|
||||
|
||||
# Mutate the session dict in place
|
||||
session_dict["id"] = session_id
|
||||
|
||||
# Substitute the values into the graph
|
||||
for nfv in flat_node_field_values:
|
||||
graph_as_dict["nodes"][nfv["node_path"]][nfv["field_name"]] = nfv["value"]
|
||||
|
||||
# Mutate the session dict in place
|
||||
session_dict["graph"] = graph_as_dict
|
||||
|
||||
# Serialize the session and field values
|
||||
# Note the use of pydantic's to_jsonable_python to handle serialization of any python object, including sets.
|
||||
session_json = json.dumps(session_dict, default=to_jsonable_python)
|
||||
field_values_json = json.dumps(flat_node_field_values, default=to_jsonable_python)
|
||||
|
||||
# Yield the session_id, session_json, and field_values_json
|
||||
yield (session_id, session_json, field_values_json)
|
||||
|
||||
# Increment the count so we know when to stop
|
||||
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 generating them. Adapted from `create_sessions().
|
||||
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:
|
||||
@@ -476,42 +558,75 @@ def calc_session_count(batch: Batch) -> int:
|
||||
return len(data_product) * batch.runs
|
||||
|
||||
|
||||
class SessionQueueValueToInsert(NamedTuple):
|
||||
"""A tuple of values to insert into the session_queue table"""
|
||||
|
||||
# Careful with the ordering of this - it must match the insert statement
|
||||
queue_id: str # queue_id
|
||||
session: str # session json
|
||||
session_id: str # session_id
|
||||
batch_id: str # batch_id
|
||||
field_values: Optional[str] # field_values json
|
||||
priority: int # priority
|
||||
workflow: Optional[str] # workflow json
|
||||
origin: str | None
|
||||
destination: str | None
|
||||
retried_from_item_id: int | None = None
|
||||
ValueToInsertTuple: TypeAlias = tuple[
|
||||
str, # queue_id
|
||||
str, # session (as stringified JSON)
|
||||
str, # session_id
|
||||
str, # batch_id
|
||||
str | None, # field_values (optional, as stringified JSON)
|
||||
int, # priority
|
||||
str | None, # workflow (optional, as stringified JSON)
|
||||
str | None, # origin (optional)
|
||||
str | None, # destination (optional)
|
||||
int | None, # retried_from_item_id (optional, this is always None for new items)
|
||||
]
|
||||
"""A type alias for the tuple of values to insert into the session queue table."""
|
||||
|
||||
|
||||
ValuesToInsert: TypeAlias = list[SessionQueueValueToInsert]
|
||||
def prepare_values_to_insert(
|
||||
queue_id: str, batch: Batch, priority: int, max_new_queue_items: int
|
||||
) -> list[ValueToInsertTuple]:
|
||||
"""
|
||||
Given a batch, prepare the values to insert into the session queue table. The list of tuples can be used with an
|
||||
`executemany` statement to insert multiple rows at once.
|
||||
|
||||
Args:
|
||||
queue_id: The ID of the queue to insert the items into
|
||||
batch: The batch to prepare the values for
|
||||
priority: The priority of the queue items
|
||||
max_new_queue_items: The maximum number of queue items to insert
|
||||
|
||||
def prepare_values_to_insert(queue_id: str, batch: Batch, priority: int, max_new_queue_items: int) -> ValuesToInsert:
|
||||
values_to_insert: ValuesToInsert = []
|
||||
for session, field_values, workflow in create_session_nfv_tuples(batch, max_new_queue_items):
|
||||
# sessions must have unique id
|
||||
session.id = uuid_string()
|
||||
Returns:
|
||||
A list of tuples to insert into the session queue table. Each tuple contains the following values:
|
||||
- queue_id
|
||||
- session (as stringified JSON)
|
||||
- session_id
|
||||
- batch_id
|
||||
- field_values (optional, as stringified JSON)
|
||||
- priority
|
||||
- workflow (optional, as stringified JSON)
|
||||
- origin (optional)
|
||||
- destination (optional)
|
||||
- retried_from_item_id (optional, this is always None for new items)
|
||||
"""
|
||||
|
||||
# A tuple is a fast and memory-efficient way to store the values to insert. Previously, we used a NamedTuple, but
|
||||
# measured a ~5% performance improvement by using a normal tuple instead. For very large batches (10k+ items), the
|
||||
# this difference becomes noticeable.
|
||||
#
|
||||
# So, despite the inferior DX with normal tuples, we use one here for performance reasons.
|
||||
|
||||
values_to_insert: list[ValueToInsertTuple] = []
|
||||
|
||||
# pydantic's to_jsonable_python handles serialization of any python object, including sets, which json.dumps does
|
||||
# not support by default. Apparently there are sets somewhere in the graph.
|
||||
|
||||
# The same workflow is used for all sessions in the batch - serialize it once
|
||||
workflow_json = json.dumps(batch.workflow, default=to_jsonable_python) if batch.workflow else None
|
||||
|
||||
for session_id, session_json, field_values_json in create_session_nfv_tuples(batch, max_new_queue_items):
|
||||
values_to_insert.append(
|
||||
SessionQueueValueToInsert(
|
||||
queue_id=queue_id,
|
||||
session=session.model_dump_json(warnings=False, exclude_none=True), # as json
|
||||
session_id=session.id,
|
||||
batch_id=batch.batch_id,
|
||||
# must use pydantic_encoder bc field_values is a list of models
|
||||
field_values=json.dumps(field_values, default=to_jsonable_python) if field_values else None, # as json
|
||||
priority=priority,
|
||||
workflow=json.dumps(workflow, default=to_jsonable_python) if workflow else None, # as json
|
||||
origin=batch.origin,
|
||||
destination=batch.destination,
|
||||
(
|
||||
queue_id,
|
||||
session_json,
|
||||
session_id,
|
||||
batch.batch_id,
|
||||
field_values_json,
|
||||
priority,
|
||||
workflow_json,
|
||||
batch.origin,
|
||||
batch.destination,
|
||||
None,
|
||||
)
|
||||
)
|
||||
return values_to_insert
|
||||
|
||||
@@ -27,7 +27,6 @@ from invokeai.app.services.session_queue.session_queue_common import (
|
||||
SessionQueueItemDTO,
|
||||
SessionQueueItemNotFoundError,
|
||||
SessionQueueStatus,
|
||||
SessionQueueValueToInsert,
|
||||
calc_session_count,
|
||||
prepare_values_to_insert,
|
||||
)
|
||||
@@ -772,7 +771,7 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
try:
|
||||
self.__lock.acquire()
|
||||
|
||||
values_to_insert: list[SessionQueueValueToInsert] = []
|
||||
values_to_insert: list[tuple] = []
|
||||
retried_item_ids: list[int] = []
|
||||
|
||||
for item_id in item_ids:
|
||||
@@ -798,17 +797,17 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
else queue_item.item_id
|
||||
)
|
||||
|
||||
value_to_insert = SessionQueueValueToInsert(
|
||||
queue_id=queue_item.queue_id,
|
||||
batch_id=queue_item.batch_id,
|
||||
destination=queue_item.destination,
|
||||
field_values=field_values_json,
|
||||
origin=queue_item.origin,
|
||||
priority=queue_item.priority,
|
||||
workflow=workflow_json,
|
||||
session=cloned_session_json,
|
||||
session_id=cloned_session.id,
|
||||
retried_from_item_id=retried_from_item_id,
|
||||
value_to_insert = (
|
||||
queue_item.queue_id,
|
||||
queue_item.batch_id,
|
||||
queue_item.destination,
|
||||
field_values_json,
|
||||
queue_item.origin,
|
||||
queue_item.priority,
|
||||
workflow_json,
|
||||
cloned_session_json,
|
||||
cloned_session.id,
|
||||
retried_from_item_id,
|
||||
)
|
||||
values_to_insert.append(value_to_insert)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from torch import Tensor
|
||||
|
||||
from invokeai.app.invocations.constants import IMAGE_MODES
|
||||
from invokeai.app.invocations.fields import MetadataField, WithBoard, WithMetadata
|
||||
from invokeai.app.services.board_records.board_records_common import BoardRecordOrderBy
|
||||
from invokeai.app.services.boards.boards_common import BoardDTO
|
||||
from invokeai.app.services.config.config_default import InvokeAIAppConfig
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
|
||||
@@ -16,6 +17,7 @@ from invokeai.app.services.images.images_common import ImageDTO
|
||||
from invokeai.app.services.invocation_services import InvocationServices
|
||||
from invokeai.app.services.model_records.model_records_base import UnknownModelException
|
||||
from invokeai.app.services.session_processor.session_processor_common import ProgressImage
|
||||
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
|
||||
from invokeai.app.util.step_callback import flux_step_callback, stable_diffusion_step_callback
|
||||
from invokeai.backend.model_manager.config import (
|
||||
AnyModel,
|
||||
@@ -102,7 +104,9 @@ class BoardsInterface(InvocationContextInterface):
|
||||
Returns:
|
||||
A list of all boards.
|
||||
"""
|
||||
return self._services.boards.get_all()
|
||||
return self._services.boards.get_all(
|
||||
order_by=BoardRecordOrderBy.CreatedAt, direction=SQLiteDirection.Descending
|
||||
)
|
||||
|
||||
def add_image_to_board(self, board_id: str, image_name: str) -> None:
|
||||
"""Adds an image to a board.
|
||||
@@ -122,7 +126,11 @@ class BoardsInterface(InvocationContextInterface):
|
||||
Returns:
|
||||
A list of all image names for the board.
|
||||
"""
|
||||
return self._services.board_images.get_all_board_image_names_for_board(board_id)
|
||||
return self._services.board_images.get_all_board_image_names_for_board(
|
||||
board_id,
|
||||
categories=None,
|
||||
is_intermediate=None,
|
||||
)
|
||||
|
||||
|
||||
class LoggerInterface(InvocationContextInterface):
|
||||
@@ -283,7 +291,7 @@ class ImagesInterface(InvocationContextInterface):
|
||||
Returns:
|
||||
The local path of the image or thumbnail.
|
||||
"""
|
||||
return self._services.images.get_path(image_name, thumbnail)
|
||||
return Path(self._services.images.get_path(image_name, thumbnail))
|
||||
|
||||
|
||||
class TensorsInterface(InvocationContextInterface):
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
"back": "Back",
|
||||
"batch": "Batch Manager",
|
||||
"beta": "Beta",
|
||||
"board": "Board",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"copy": "Copy",
|
||||
@@ -311,6 +312,7 @@
|
||||
},
|
||||
"gallery": {
|
||||
"gallery": "Gallery",
|
||||
"images": "Images",
|
||||
"assets": "Assets",
|
||||
"alwaysShowImageSizeBadge": "Always Show Image Size Badge",
|
||||
"assetsTab": "Files you’ve uploaded for use in your projects.",
|
||||
@@ -881,11 +883,15 @@
|
||||
"parseString": "Parse String",
|
||||
"splitOn": "Split On",
|
||||
"noBatchGroup": "no group",
|
||||
"generatorImagesCategory": "Category",
|
||||
"generatorImages_one": "{{count}} image",
|
||||
"generatorImages_other": "{{count}} images",
|
||||
"generatorNRandomValues_one": "{{count}} random value",
|
||||
"generatorNRandomValues_other": "{{count}} random values",
|
||||
"generatorNoValues": "empty",
|
||||
"generatorLoading": "loading",
|
||||
"generatorLoadFromFile": "Load from File",
|
||||
"generatorImagesFromBoard": "Images from Board",
|
||||
"dynamicPromptsRandom": "Dynamic Prompts (Random)",
|
||||
"dynamicPromptsCombinatorial": "Dynamic Prompts (Combinatorial)",
|
||||
"addNode": "Add Node",
|
||||
@@ -1080,7 +1086,7 @@
|
||||
"emptyBatches": "empty batches",
|
||||
"batchNodeNotConnected": "Batch node not connected: {{label}}",
|
||||
"batchNodeEmptyCollection": "Some batch nodes have empty collections",
|
||||
"invalidBatchConfigurationCannotCalculate": "Invalid batch configuration; cannot calculate",
|
||||
"collectionEmpty": "empty collection",
|
||||
"collectionTooFewItems": "too few items, minimum {{minItems}}",
|
||||
"collectionTooManyItems": "too many items, maximum {{maxItems}}",
|
||||
"collectionStringTooLong": "too long, max {{maxLength}}",
|
||||
@@ -1090,6 +1096,7 @@
|
||||
"collectionNumberGTExclusiveMax": "{{value}} >= {{exclusiveMaximum}} (exc max)",
|
||||
"collectionNumberLTExclusiveMin": "{{value}} <= {{exclusiveMinimum}} (exc min)",
|
||||
"collectionNumberNotMultipleOf": "{{value}} not multiple of {{multipleOf}}",
|
||||
"batchNodeCollectionSizeMismatchNoGroupId": "Batch group collection size mismatch",
|
||||
"batchNodeCollectionSizeMismatch": "Collection size mismatch on Batch {{batchGroupId}}",
|
||||
"noModelSelected": "No model selected",
|
||||
"noT5EncoderModelSelected": "No T5 Encoder model selected for FLUX generation",
|
||||
@@ -1719,12 +1726,15 @@
|
||||
"row": "Row",
|
||||
"column": "Column",
|
||||
"nodeField": "Node Field",
|
||||
"zoomToNode": "Zoom to Node",
|
||||
"nodeFieldTooltip": "To add a node field, click the small plus sign button on the field in the Workflow Editor, or drag the field by its name into the form.",
|
||||
"addToForm": "Add to Form",
|
||||
"label": "Label",
|
||||
"description": "Description",
|
||||
"showDescription": "Show Description",
|
||||
"component": "Component",
|
||||
"numberInput": "Number Input",
|
||||
"singleLine": "Single Line",
|
||||
"multiLine": "Multi Line",
|
||||
"slider": "Slider",
|
||||
"both": "Both",
|
||||
"emptyRootPlaceholderViewMode": "Click Edit to start building a form for this workflow.",
|
||||
@@ -2291,12 +2301,8 @@
|
||||
"whatsNew": {
|
||||
"whatsNewInInvoke": "What's New in Invoke",
|
||||
"items": [
|
||||
"Improved VRAM setting defaults",
|
||||
"On-demand model cache clearing",
|
||||
"Expanded FLUX LoRA compatibility",
|
||||
"Canvas Adjust Image filter",
|
||||
"Cancel all but current queue item",
|
||||
"Copy from and paste to Canvas"
|
||||
"Workflow Editor: New drag-and-drop form builder for easier workflow creation.",
|
||||
"Other improvements: Faster batch queuing, better upscaling, improved color picker, and metadata nodes."
|
||||
],
|
||||
"readReleaseNotes": "Read Release Notes",
|
||||
"watchRecentReleaseVideos": "Watch Recent Release Videos",
|
||||
|
||||
@@ -699,7 +699,6 @@
|
||||
"batchNodeEmptyCollection": "Alcuni nodi lotto hanno raccolte vuote",
|
||||
"emptyBatches": "lotti vuoti",
|
||||
"batchNodeCollectionSizeMismatch": "Le dimensioni della raccolta nel Lotto {{batchGroupId}} non corrispondono",
|
||||
"invalidBatchConfigurationCannotCalculate": "Configurazione lotto non valida; impossibile calcolare",
|
||||
"collectionStringTooShort": "troppo corto, minimo {{minLength}}",
|
||||
"collectionNumberNotMultipleOf": "{{value}} non è multiplo di {{multipleOf}}",
|
||||
"collectionNumberLTMin": "{{value}} < {{minimum}} (incr min)",
|
||||
@@ -2174,7 +2173,11 @@
|
||||
"pasteToCanvas": "Tela",
|
||||
"pasteToCanvasDesc": "Nuovo livello (nella Tela)",
|
||||
"pastedTo": "Incollato su {{destination}}",
|
||||
"regionCopiedToClipboard": "{{region}} Copiato negli appunti"
|
||||
"regionCopiedToClipboard": "{{region}} Copiato negli appunti",
|
||||
"errors": {
|
||||
"unableToFindImage": "Impossibile trovare l'immagine",
|
||||
"unableToLoadImage": "Impossibile caricare l'immagine"
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"tabs": {
|
||||
|
||||
@@ -32,29 +32,29 @@
|
||||
"learnMore": "もっと学ぶ",
|
||||
"random": "ランダム",
|
||||
"batch": "バッチマネージャー",
|
||||
"advanced": "高度な設定",
|
||||
"advanced": "高度",
|
||||
"created": "作成済",
|
||||
"green": "緑",
|
||||
"blue": "青",
|
||||
"alpha": "アルファ",
|
||||
"outpaint": "アウトペイント",
|
||||
"outpaint": "outpaint",
|
||||
"unknown": "不明",
|
||||
"updated": "更新済",
|
||||
"add": "追加",
|
||||
"ai": "AI",
|
||||
"ai": "ai",
|
||||
"copyError": "$t(gallery.copy) エラー",
|
||||
"data": "データ",
|
||||
"template": "テンプレート",
|
||||
"red": "赤",
|
||||
"or": "または",
|
||||
"checkpoint": "チェックポイント",
|
||||
"checkpoint": "Checkpoint",
|
||||
"direction": "方向",
|
||||
"simple": "シンプル",
|
||||
"save": "保存",
|
||||
"saveAs": "名前をつけて保存",
|
||||
"somethingWentWrong": "何かの問題が発生しました",
|
||||
"details": "詳細",
|
||||
"inpaint": "インペイント",
|
||||
"inpaint": "inpaint",
|
||||
"delete": "削除",
|
||||
"nextPage": "次のページ",
|
||||
"copy": "コピー",
|
||||
@@ -70,12 +70,12 @@
|
||||
"unknownError": "未知のエラー",
|
||||
"orderBy": "並び順:",
|
||||
"enabled": "有効",
|
||||
"notInstalled": "未インストール",
|
||||
"notInstalled": "未 $t(common.installed)",
|
||||
"positivePrompt": "ポジティブプロンプト",
|
||||
"negativePrompt": "ネガティブプロンプト",
|
||||
"selected": "選択済み",
|
||||
"aboutDesc": "Invokeを業務で利用する場合はマークしてください:",
|
||||
"beta": "ベータ",
|
||||
"beta": "Beta",
|
||||
"disabled": "無効",
|
||||
"editor": "エディタ",
|
||||
"safetensors": "Safetensors",
|
||||
@@ -93,7 +93,27 @@
|
||||
"reset": "リセット",
|
||||
"none": "なし",
|
||||
"new": "新規",
|
||||
"close": "閉じる"
|
||||
"close": "閉じる",
|
||||
"warnings": "警告",
|
||||
"dontShowMeThese": "次回から表示しない",
|
||||
"goTo": "移動",
|
||||
"generating": "生成中",
|
||||
"loadingModel": "モデルをロード中",
|
||||
"layout": "レイアウト",
|
||||
"step": "ステップ",
|
||||
"start": "開始",
|
||||
"count": "回数",
|
||||
"end": "終了",
|
||||
"min": "最小",
|
||||
"max": "最大",
|
||||
"values": "値",
|
||||
"resetToDefaults": "デフォルトに戻す",
|
||||
"row": "行",
|
||||
"column": "列",
|
||||
"board": "ボード",
|
||||
"seed": "シード",
|
||||
"combinatorial": "組み合わせ",
|
||||
"aboutHeading": "想像力をこの手に"
|
||||
},
|
||||
"gallery": {
|
||||
"galleryImageSize": "画像のサイズ",
|
||||
@@ -109,7 +129,7 @@
|
||||
"currentlyInUse": "この画像は現在下記の機能を使用しています:",
|
||||
"drop": "ドロップ",
|
||||
"dropOrUpload": "$t(gallery.drop) またはアップロード",
|
||||
"deleteImage_other": "画像を削除",
|
||||
"deleteImage_other": "画像 {{count}} 枚を削除",
|
||||
"deleteImagePermanent": "削除された画像は復元できません。",
|
||||
"download": "ダウンロード",
|
||||
"unableToLoad": "ギャラリーをロードできません",
|
||||
@@ -155,7 +175,12 @@
|
||||
"displayBoardSearch": "ボード検索",
|
||||
"displaySearch": "画像を検索",
|
||||
"boardsSettings": "ボード設定",
|
||||
"imagesSettings": "ギャラリー画像設定"
|
||||
"imagesSettings": "ギャラリー画像設定",
|
||||
"selectAllOnPage": "ページ上のすべてを選択",
|
||||
"images": "画像",
|
||||
"assetsTab": "プロジェクトで使用するためにアップロードされたファイル。",
|
||||
"imagesTab": "Invoke内で作成および保存された画像。",
|
||||
"assets": "アセット"
|
||||
},
|
||||
"hotkeys": {
|
||||
"searchHotkeys": "ホットキーを検索",
|
||||
@@ -180,44 +205,121 @@
|
||||
},
|
||||
"canvas": {
|
||||
"redo": {
|
||||
"title": "やり直し"
|
||||
"title": "やり直し",
|
||||
"desc": "最後のキャンバス操作をやり直します。"
|
||||
},
|
||||
"transformSelected": {
|
||||
"title": "変形"
|
||||
"title": "変形",
|
||||
"desc": "選択したレイヤーを変形します。"
|
||||
},
|
||||
"undo": {
|
||||
"title": "取り消し"
|
||||
"title": "取り消し",
|
||||
"desc": "最後のキャンバス操作を取り消します。"
|
||||
},
|
||||
"selectEraserTool": {
|
||||
"title": "消しゴムツール"
|
||||
"title": "消しゴムツール",
|
||||
"desc": "消しゴムツールを選択します。"
|
||||
},
|
||||
"cancelTransform": {
|
||||
"title": "変形をキャンセル"
|
||||
"title": "変形をキャンセル",
|
||||
"desc": "保留中の変形をキャンセルします。"
|
||||
},
|
||||
"resetSelected": {
|
||||
"title": "レイヤーをリセット"
|
||||
"title": "レイヤーをリセット",
|
||||
"desc": "選択したレイヤーをリセットします。この操作はInpaint MaskおよびRegional Guidanceにのみ適用されます。"
|
||||
},
|
||||
"applyTransform": {
|
||||
"title": "変形を適用"
|
||||
"title": "変形を適用",
|
||||
"desc": "保留中の変形を選択したレイヤーに適用します。"
|
||||
},
|
||||
"selectColorPickerTool": {
|
||||
"title": "スポイトツール"
|
||||
"title": "スポイトツール",
|
||||
"desc": "スポイトツールを選択します。"
|
||||
},
|
||||
"fitBboxToCanvas": {
|
||||
"title": "バウンディングボックスをキャンバスにフィット"
|
||||
"title": "バウンディングボックスをキャンバスにフィット",
|
||||
"desc": "バウンディングボックスがキャンバスに収まるように表示を拡大、位置調整します。"
|
||||
},
|
||||
"selectBrushTool": {
|
||||
"title": "ブラシツール"
|
||||
"title": "ブラシツール",
|
||||
"desc": "ブラシツールを選択します。"
|
||||
},
|
||||
"selectMoveTool": {
|
||||
"title": "移動ツール"
|
||||
"title": "移動ツール",
|
||||
"desc": "移動ツールを選択します。"
|
||||
},
|
||||
"selectBboxTool": {
|
||||
"title": "バウンディングボックスツール"
|
||||
"title": "バウンディングボックスツール",
|
||||
"desc": "バウンディングボックスツールを選択します。"
|
||||
},
|
||||
"title": "キャンバス",
|
||||
"fitLayersToCanvas": {
|
||||
"title": "レイヤーをキャンバスにフィット"
|
||||
"title": "レイヤーをキャンバスにフィット",
|
||||
"desc": "すべての表示レイヤーがキャンバスに収まるように表示を拡大、位置調整します。"
|
||||
},
|
||||
"setZoomTo400Percent": {
|
||||
"desc": "キャンバスのズームを400%に設定します。",
|
||||
"title": "400%にズーム"
|
||||
},
|
||||
"setZoomTo800Percent": {
|
||||
"title": "800%にズーム",
|
||||
"desc": "キャンバスのズームを800%に設定します。"
|
||||
},
|
||||
"quickSwitch": {
|
||||
"title": "レイヤーのクイックスイッチ",
|
||||
"desc": "最後に選択した2つのレイヤー間を切り替えます。レイヤーがブックマークされている場合、常にそのレイヤーと最後に選択したブックマークされていないレイヤーの間を切り替えます。"
|
||||
},
|
||||
"nextEntity": {
|
||||
"title": "次のレイヤー",
|
||||
"desc": "リスト内の次のレイヤーを選択します。"
|
||||
},
|
||||
"filterSelected": {
|
||||
"title": "フィルター",
|
||||
"desc": "選択したレイヤーをフィルターします。RasterおよびControlレイヤーにのみ適用されます。"
|
||||
},
|
||||
"prevEntity": {
|
||||
"desc": "リスト内の前のレイヤーを選択します。",
|
||||
"title": "前のレイヤー"
|
||||
},
|
||||
"setFillToWhite": {
|
||||
"title": "ツール色を白に設定",
|
||||
"desc": "現在のツールの色を白色に設定します。"
|
||||
},
|
||||
"selectViewTool": {
|
||||
"title": "表示ツール",
|
||||
"desc": "表示ツールを選択します。"
|
||||
},
|
||||
"setZoomTo100Percent": {
|
||||
"title": "100%にズーム",
|
||||
"desc": "キャンバスのズームを100%に設定します。"
|
||||
},
|
||||
"deleteSelected": {
|
||||
"desc": "選択したレイヤーを削除します。",
|
||||
"title": "レイヤーを削除"
|
||||
},
|
||||
"cancelFilter": {
|
||||
"desc": "保留中のフィルターをキャンセルします。",
|
||||
"title": "フィルターをキャンセル"
|
||||
},
|
||||
"applyFilter": {
|
||||
"title": "フィルターを適用",
|
||||
"desc": "保留中のフィルターを選択したレイヤーに適用します。"
|
||||
},
|
||||
"setZoomTo200Percent": {
|
||||
"title": "200%にズーム",
|
||||
"desc": "キャンバスのズームを200%に設定します。"
|
||||
},
|
||||
"decrementToolWidth": {
|
||||
"title": "ツール幅を縮小する",
|
||||
"desc": "選択中のブラシまたは消しゴムツールの幅を減少させます。"
|
||||
},
|
||||
"incrementToolWidth": {
|
||||
"desc": "選択中のブラシまたは消しゴムツールの幅を増加させます。",
|
||||
"title": "ツール幅を増加する"
|
||||
},
|
||||
"selectRectTool": {
|
||||
"title": "矩形ツール",
|
||||
"desc": "矩形ツールを選択します。"
|
||||
}
|
||||
},
|
||||
"workflows": {
|
||||
@@ -226,7 +328,8 @@
|
||||
},
|
||||
"redo": {
|
||||
"title": "やり直し"
|
||||
}
|
||||
},
|
||||
"title": "ワークフロー"
|
||||
},
|
||||
"app": {
|
||||
"toggleLeftPanel": {
|
||||
@@ -235,13 +338,56 @@
|
||||
},
|
||||
"title": "アプリケーション",
|
||||
"invoke": {
|
||||
"title": "Invoke"
|
||||
"title": "生成",
|
||||
"desc": "生成をキューに追加し、キューの末尾に加えます。"
|
||||
},
|
||||
"cancelQueueItem": {
|
||||
"title": "キャンセル"
|
||||
"title": "キャンセル",
|
||||
"desc": "現在処理中のキュー項目をキャンセルします。"
|
||||
},
|
||||
"clearQueue": {
|
||||
"title": "キューをクリア"
|
||||
"title": "キューをクリア",
|
||||
"desc": "すべてのキュー項目をキャンセルして消去します。"
|
||||
},
|
||||
"selectCanvasTab": {
|
||||
"desc": "キャンバスタブを選択します。",
|
||||
"title": "キャンバスタブを選択"
|
||||
},
|
||||
"selectUpscalingTab": {
|
||||
"desc": "アップスケーリングタブを選択します。",
|
||||
"title": "アップスケーリングタブを選択"
|
||||
},
|
||||
"toggleRightPanel": {
|
||||
"desc": "右パネルを表示または非表示。",
|
||||
"title": "右パネルをトグル"
|
||||
},
|
||||
"selectModelsTab": {
|
||||
"title": "モデルタブを選択",
|
||||
"desc": "モデルタブを選択します。"
|
||||
},
|
||||
"invokeFront": {
|
||||
"desc": "生成をキューに追加し、キューの先頭に加えます。",
|
||||
"title": "生成(先頭)"
|
||||
},
|
||||
"resetPanelLayout": {
|
||||
"title": "パネルレイアウトをリセット",
|
||||
"desc": "左パネルと右パネルをデフォルトのサイズとレイアウトにリセットします。"
|
||||
},
|
||||
"togglePanels": {
|
||||
"desc": "左パネルと右パネルを合わせて表示または非表示。",
|
||||
"title": "パネルをトグル"
|
||||
},
|
||||
"selectWorkflowsTab": {
|
||||
"desc": "ワークフロータブを選択します。",
|
||||
"title": "ワークフロータブを選択"
|
||||
},
|
||||
"selectQueueTab": {
|
||||
"title": "キュータブを選択",
|
||||
"desc": "キュータブを選択します。"
|
||||
},
|
||||
"focusPrompt": {
|
||||
"title": "プロンプトにフォーカス",
|
||||
"desc": "カーソルをポジティブプロンプト欄に移動します。"
|
||||
}
|
||||
},
|
||||
"hotkeys": "ホットキー"
|
||||
@@ -255,13 +401,13 @@
|
||||
"name": "名前",
|
||||
"description": "概要",
|
||||
"config": "コンフィグ",
|
||||
"repo_id": "Repo ID",
|
||||
"repo_id": "リポジトリID",
|
||||
"width": "幅",
|
||||
"height": "高さ",
|
||||
"addModel": "モデルを追加",
|
||||
"availableModels": "モデルを有効化",
|
||||
"search": "検索",
|
||||
"load": "Load",
|
||||
"load": "ロード",
|
||||
"active": "active",
|
||||
"selected": "選択済",
|
||||
"delete": "削除",
|
||||
@@ -281,7 +427,7 @@
|
||||
"modelConverted": "モデル変換が完了しました",
|
||||
"predictionType": "予測タイプ(SD 2.x モデルおよび一部のSD 1.x モデル用)",
|
||||
"selectModel": "モデルを選択",
|
||||
"advanced": "高度な設定",
|
||||
"advanced": "高度",
|
||||
"modelDeleted": "モデルが削除されました",
|
||||
"convertToDiffusersHelpText2": "このプロセスでは、モデルマネージャーのエントリーを同じモデルのディフューザーバージョンに置き換えます。",
|
||||
"modelUpdateFailed": "モデル更新が失敗しました",
|
||||
@@ -294,7 +440,19 @@
|
||||
"convertToDiffusersHelpText4": "これは一回限りのプロセスです。コンピュータの仕様によっては、約30秒から60秒かかる可能性があります。",
|
||||
"cancel": "キャンセル",
|
||||
"uploadImage": "画像をアップロード",
|
||||
"addModels": "モデルを追加"
|
||||
"addModels": "モデルを追加",
|
||||
"modelName": "モデル名",
|
||||
"source": "ソース",
|
||||
"path": "パス",
|
||||
"modelSettings": "モデル設定",
|
||||
"vae": "VAE",
|
||||
"huggingFace": "HuggingFace",
|
||||
"huggingFaceRepoID": "HuggingFace リポジトリID",
|
||||
"metadata": "メタデータ",
|
||||
"loraModels": "LoRA",
|
||||
"edit": "編集",
|
||||
"install": "インストール",
|
||||
"huggingFacePlaceholder": "owner/model-name"
|
||||
},
|
||||
"parameters": {
|
||||
"images": "画像",
|
||||
@@ -305,7 +463,7 @@
|
||||
"shuffle": "シャッフル",
|
||||
"strength": "強度",
|
||||
"upscaling": "アップスケーリング",
|
||||
"scale": "Scale",
|
||||
"scale": "スケール",
|
||||
"scaleBeforeProcessing": "処理前のスケール",
|
||||
"scaledWidth": "幅のスケール",
|
||||
"scaledHeight": "高さのスケール",
|
||||
@@ -314,7 +472,7 @@
|
||||
"useSeed": "シード値を使用",
|
||||
"useAll": "すべてを使用",
|
||||
"info": "情報",
|
||||
"showOptionsPanel": "オプションパネルを表示",
|
||||
"showOptionsPanel": "サイドパネルを表示 (O or T)",
|
||||
"iterations": "生成回数",
|
||||
"general": "基本設定",
|
||||
"setToOptimalSize": "サイズをモデルに最適化",
|
||||
@@ -328,16 +486,28 @@
|
||||
"useSize": "サイズを使用",
|
||||
"postProcessing": "ポストプロセス (Shift + U)",
|
||||
"denoisingStrength": "ノイズ除去強度",
|
||||
"recallMetadata": "メタデータを再使用"
|
||||
"recallMetadata": "メタデータを再使用",
|
||||
"copyImage": "画像をコピー",
|
||||
"positivePromptPlaceholder": "ポジティブプロンプト",
|
||||
"negativePromptPlaceholder": "ネガティブプロンプト",
|
||||
"type": "タイプ",
|
||||
"cancel": {
|
||||
"cancel": "キャンセル"
|
||||
},
|
||||
"cfgScale": "CFGスケール",
|
||||
"tileSize": "タイルサイズ",
|
||||
"coherenceMode": "モード"
|
||||
},
|
||||
"settings": {
|
||||
"models": "モデル",
|
||||
"displayInProgress": "生成中の画像を表示する",
|
||||
"displayInProgress": "生成中の画像を表示",
|
||||
"confirmOnDelete": "削除時に確認",
|
||||
"resetWebUI": "WebUIをリセット",
|
||||
"resetWebUIDesc1": "WebUIのリセットは、画像と保存された設定のキャッシュをリセットするだけです。画像を削除するわけではありません。",
|
||||
"resetWebUIDesc2": "もしギャラリーに画像が表示されないなど、何か問題が発生した場合はGitHubにissueを提出する前にリセットを試してください。",
|
||||
"resetComplete": "WebUIはリセットされました。F5を押して再読み込みしてください。"
|
||||
"resetComplete": "WebUIはリセットされました。",
|
||||
"ui": "ユーザーインターフェイス",
|
||||
"beta": "ベータ"
|
||||
},
|
||||
"toast": {
|
||||
"uploadFailed": "アップロード失敗",
|
||||
@@ -345,7 +515,8 @@
|
||||
"imageUploadFailed": "画像のアップロードに失敗しました",
|
||||
"uploadFailedInvalidUploadDesc": "画像はPNGかJPGである必要があります。",
|
||||
"sentToUpscale": "アップスケーラーに転送しました",
|
||||
"imageUploaded": "画像をアップロードしました"
|
||||
"imageUploaded": "画像をアップロードしました",
|
||||
"serverError": "サーバーエラー"
|
||||
},
|
||||
"accessibility": {
|
||||
"invokeProgressBar": "進捗バー",
|
||||
@@ -356,7 +527,7 @@
|
||||
"menu": "メニュー",
|
||||
"createIssue": "問題を報告",
|
||||
"resetUI": "$t(accessibility.reset) UI",
|
||||
"mode": "モード:",
|
||||
"mode": "モード",
|
||||
"about": "Invoke について",
|
||||
"submitSupportTicket": "サポート依頼を送信する",
|
||||
"uploadImages": "画像をアップロード",
|
||||
@@ -373,7 +544,19 @@
|
||||
"positivePrompt": "ポジティブプロンプト",
|
||||
"strength": "Image to Image 強度",
|
||||
"recallParameters": "パラメータを再使用",
|
||||
"recallParameter": "{{label}} を再使用"
|
||||
"recallParameter": "{{label}} を再使用",
|
||||
"imageDimensions": "画像サイズ",
|
||||
"imageDetails": "画像の詳細",
|
||||
"model": "モデル",
|
||||
"allPrompts": "すべてのプロンプト",
|
||||
"cfgScale": "CFGスケール",
|
||||
"createdBy": "作成:",
|
||||
"metadata": "メタデータ",
|
||||
"height": "高さ",
|
||||
"negativePrompt": "ネガティブプロンプト",
|
||||
"generationMode": "生成モード",
|
||||
"vae": "VAE",
|
||||
"cfgRescaleMultiplier": "$t(parameters.cfgRescaleMultiplier)"
|
||||
},
|
||||
"queue": {
|
||||
"queueEmpty": "キューが空です",
|
||||
@@ -405,7 +588,7 @@
|
||||
"batchQueuedDesc_other": "{{count}} セッションをキューの{{direction}}に追加しました",
|
||||
"graphQueued": "グラフをキューに追加しました",
|
||||
"batch": "バッチ",
|
||||
"clearQueueAlertDialog": "キューをクリアすると、処理中のアイテムは直ちにキャンセルされ、キューは完全にクリアされます。",
|
||||
"clearQueueAlertDialog": "キューをクリアすると、処理中の項目は直ちにキャンセルされ、キューは完全にクリアされます。保留中のフィルターもキャンセルされます。",
|
||||
"pending": "保留中",
|
||||
"resumeFailed": "処理の再開に問題があります",
|
||||
"clear": "クリア",
|
||||
@@ -423,7 +606,7 @@
|
||||
"enqueueing": "バッチをキューに追加",
|
||||
"cancelBatchFailed": "バッチのキャンセルに問題があります",
|
||||
"clearQueueAlertDialog2": "キューをクリアしてもよろしいですか?",
|
||||
"item": "アイテム",
|
||||
"item": "項目",
|
||||
"graphFailedToQueue": "グラフをキューに追加できませんでした",
|
||||
"batchFieldValues": "バッチの詳細",
|
||||
"openQueue": "キューを開く",
|
||||
@@ -439,7 +622,17 @@
|
||||
"upscaling": "アップスケール",
|
||||
"generation": "生成",
|
||||
"other": "その他",
|
||||
"gallery": "ギャラリー"
|
||||
"gallery": "ギャラリー",
|
||||
"cancelAllExceptCurrentQueueItemAlertDialog2": "すべての保留中のキュー項目をキャンセルしてもよいですか?",
|
||||
"cancelAllExceptCurrentTooltip": "現在の項目を除いてすべてキャンセル",
|
||||
"origin": "先頭",
|
||||
"destination": "宛先",
|
||||
"confirm": "確認",
|
||||
"retryItem": "項目をリトライ",
|
||||
"batchSize": "バッチサイズ",
|
||||
"retryFailed": "項目のリトライに問題があります",
|
||||
"cancelAllExceptCurrentQueueItemAlertDialog": "現在の項目を除くすべてのキュー項目をキャンセルすると、保留中の項目は停止しますが、進行中の項目は完了します。",
|
||||
"retrySucceeded": "項目がリトライされました"
|
||||
},
|
||||
"models": {
|
||||
"noMatchingModels": "一致するモデルがありません",
|
||||
@@ -448,13 +641,14 @@
|
||||
"noModelsAvailable": "使用可能なモデルがありません",
|
||||
"selectModel": "モデルを選択してください",
|
||||
"concepts": "コンセプト",
|
||||
"addLora": "LoRAを追加"
|
||||
"addLora": "LoRAを追加",
|
||||
"lora": "LoRA"
|
||||
},
|
||||
"nodes": {
|
||||
"addNode": "ノードを追加",
|
||||
"boolean": "ブーリアン",
|
||||
"addNodeToolTip": "ノードを追加 (Shift+A, Space)",
|
||||
"missingTemplate": "テンプレートが見つかりません",
|
||||
"missingTemplate": "Invalid node: タイプ {{type}} のノード {{node}} にテンプレートがありません(未インストール?)",
|
||||
"loadWorkflow": "ワークフローを読み込み",
|
||||
"hideLegendNodes": "フィールドタイプの凡例を非表示",
|
||||
"float": "浮動小数点",
|
||||
@@ -465,7 +659,7 @@
|
||||
"currentImageDescription": "ノードエディタ内の現在の画像を表示",
|
||||
"downloadWorkflow": "ワークフローのJSONをダウンロード",
|
||||
"fieldTypesMustMatch": "フィールドタイプが一致している必要があります",
|
||||
"edge": "輪郭",
|
||||
"edge": "エッジ",
|
||||
"animatedEdgesHelp": "選択したエッジおよび選択したノードに接続されたエッジをアニメーション化します",
|
||||
"cannotDuplicateConnection": "重複した接続は作れません",
|
||||
"noWorkflow": "ワークフローがありません",
|
||||
@@ -484,7 +678,19 @@
|
||||
"cannotConnectToSelf": "自身のノードには接続できません",
|
||||
"colorCodeEdges": "カラー-Code Edges",
|
||||
"loadingNodes": "ノードを読み込み中...",
|
||||
"scheduler": "スケジューラー"
|
||||
"scheduler": "スケジューラー",
|
||||
"version": "バージョン",
|
||||
"edit": "編集",
|
||||
"nodeVersion": "ノードバージョン",
|
||||
"workflowTags": "タグ",
|
||||
"string": "文字列",
|
||||
"workflowVersion": "バージョン",
|
||||
"workflowAuthor": "作者",
|
||||
"ipAdapter": "IP-Adapter",
|
||||
"notes": "ノート",
|
||||
"workflow": "ワークフロー",
|
||||
"workflowName": "名前",
|
||||
"workflowNotes": "ノート"
|
||||
},
|
||||
"boards": {
|
||||
"autoAddBoard": "自動追加するボード",
|
||||
@@ -506,7 +712,7 @@
|
||||
"deleteBoard": "ボードの削除",
|
||||
"deleteBoardAndImages": "ボードと画像の削除",
|
||||
"deleteBoardOnly": "ボードのみ削除",
|
||||
"deletedBoardsCannotbeRestored": "削除されたボードは復元できません",
|
||||
"deletedBoardsCannotbeRestored": "削除されたボードは復元できません。\"ボードのみ削除\"を選択すると画像は未分類に移動されます。",
|
||||
"movingImagesToBoard_other": "{{count}} の画像をボードに移動:",
|
||||
"hideBoards": "ボードを隠す",
|
||||
"assetsWithCount_other": "{{count}} のアセット",
|
||||
@@ -518,7 +724,12 @@
|
||||
"archiveBoard": "ボードをアーカイブ",
|
||||
"archived": "アーカイブ完了",
|
||||
"unarchiveBoard": "アーカイブされていないボード",
|
||||
"imagesWithCount_other": "{{count}} の画像"
|
||||
"imagesWithCount_other": "{{count}} の画像",
|
||||
"updateBoardError": "ボード更新エラー",
|
||||
"selectedForAutoAdd": "自動追加に選択済み",
|
||||
"deletedPrivateBoardsCannotbeRestored": "削除されたボードは復元できません。\"ボードのみ削除\"を選択すると画像はその作成者のプライベートな未分類に移動されます。",
|
||||
"noBoards": "{{boardType}} ボードがありません",
|
||||
"viewBoards": "ボードを表示"
|
||||
},
|
||||
"invocationCache": {
|
||||
"invocationCache": "呼び出しキャッシュ",
|
||||
@@ -570,6 +781,48 @@
|
||||
},
|
||||
"paramAspect": {
|
||||
"heading": "縦横比"
|
||||
},
|
||||
"refinerSteps": {
|
||||
"heading": "ステップ"
|
||||
},
|
||||
"paramVAE": {
|
||||
"heading": "VAE"
|
||||
},
|
||||
"scale": {
|
||||
"heading": "スケール"
|
||||
},
|
||||
"refinerScheduler": {
|
||||
"heading": "スケジューラー"
|
||||
},
|
||||
"compositingCoherenceMode": {
|
||||
"heading": "モード"
|
||||
},
|
||||
"paramModel": {
|
||||
"heading": "モデル"
|
||||
},
|
||||
"paramHeight": {
|
||||
"heading": "高さ"
|
||||
},
|
||||
"paramSteps": {
|
||||
"heading": "ステップ"
|
||||
},
|
||||
"ipAdapterMethod": {
|
||||
"heading": "モード"
|
||||
},
|
||||
"paramSeed": {
|
||||
"heading": "シード"
|
||||
},
|
||||
"paramIterations": {
|
||||
"heading": "生成回数"
|
||||
},
|
||||
"controlNet": {
|
||||
"heading": "ControlNet"
|
||||
},
|
||||
"paramWidth": {
|
||||
"heading": "幅"
|
||||
},
|
||||
"lora": {
|
||||
"heading": "LoRA"
|
||||
}
|
||||
},
|
||||
"accordions": {
|
||||
@@ -579,7 +832,8 @@
|
||||
"coherenceTab": "コヒーレンスパス"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "高度な設定"
|
||||
"title": "高度",
|
||||
"options": "$t(accordions.advanced.title) オプション"
|
||||
},
|
||||
"control": {
|
||||
"title": "コントロール"
|
||||
@@ -608,7 +862,10 @@
|
||||
},
|
||||
"ui": {
|
||||
"tabs": {
|
||||
"queue": "キュー"
|
||||
"queue": "キュー",
|
||||
"canvas": "キャンバス",
|
||||
"workflows": "ワークフロー",
|
||||
"models": "モデル"
|
||||
}
|
||||
},
|
||||
"controlLayers": {
|
||||
@@ -674,15 +931,32 @@
|
||||
"createPromptTemplate": "プロンプトテンプレートを作成",
|
||||
"promptTemplateCleared": "プロンプトテンプレートをクリアしました",
|
||||
"searchByName": "名前で検索",
|
||||
"toggleViewMode": "表示モードを切り替え"
|
||||
"toggleViewMode": "表示モードを切り替え",
|
||||
"negativePromptColumn": "'negative_prompt'",
|
||||
"preview": "プレビュー",
|
||||
"nameColumn": "'name'"
|
||||
},
|
||||
"upscaling": {
|
||||
"upscaleModel": "アップスケールモデル",
|
||||
"postProcessingModel": "ポストプロセスモデル",
|
||||
"upscale": "アップスケール"
|
||||
"upscale": "アップスケール",
|
||||
"scale": "スケール"
|
||||
},
|
||||
"sdxl": {
|
||||
"denoisingStrength": "ノイズ除去強度",
|
||||
"scheduler": "スケジューラー"
|
||||
"scheduler": "スケジューラー",
|
||||
"loading": "ロード中...",
|
||||
"steps": "ステップ"
|
||||
},
|
||||
"modelCache": {
|
||||
"clear": "モデルキャッシュを消去",
|
||||
"clearSucceeded": "モデルキャッシュを消去しました",
|
||||
"clearFailed": "モデルキャッシュの消去中に問題が発生"
|
||||
},
|
||||
"workflows": {
|
||||
"workflows": "ワークフロー",
|
||||
"ascending": "昇順",
|
||||
"name": "名前",
|
||||
"descending": "降順"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
"compareImage": "So Sánh Ảnh",
|
||||
"compareHelp4": "Nhấn <Kbd>Z</Kbd> hoặc <Kbd>Esc</Kbd> để thoát.",
|
||||
"compareHelp3": "Nhấn <Kbd>C</Kbd> để đổi ảnh được so sánh.",
|
||||
"compareHelp1": "Giữ <Kbd>Alt</Kbd> khi bấm vào ảnh trong thư viện hoặc dùng phím mũi tên để đổi ảnh dùng cho so sánh.",
|
||||
"compareHelp1": "Giữ <Kbd>Alt</Kbd> khi bấm vào ảnh trong thư viện ảnh hoặc dùng phím mũi tên để đổi ảnh dùng cho so sánh.",
|
||||
"showArchivedBoards": "Hiển Thị Bảng Được Lưu Trữ",
|
||||
"drop": "Thả",
|
||||
"copy": "Sao Chép",
|
||||
@@ -76,11 +76,11 @@
|
||||
"deleteImagePermanent": "Ảnh đã xoá không thể phục hồi.",
|
||||
"exitSearch": "Thoát Tìm Kiếm Hình Ảnh",
|
||||
"exitBoardSearch": "Thoát Tìm Kiểm Bảng",
|
||||
"gallery": "Thư Viện",
|
||||
"gallery": "Thư Viện Ảnh",
|
||||
"galleryImageSize": "Kích Thước Ảnh",
|
||||
"downloadSelection": "Tải xuống Phần Được Lựa Chọn",
|
||||
"bulkDownloadRequested": "Chuẩn Bị Tải Xuống",
|
||||
"unableToLoad": "Không Thể Tải Thư viện",
|
||||
"unableToLoad": "Không Thể Tải Thư viện Ảnh",
|
||||
"newestFirst": "Mới Nhất Trước",
|
||||
"showStarredImagesFirst": "Hiển Thị Ảnh Gắn Sao Trước",
|
||||
"bulkDownloadRequestedDesc": "Yêu cầu tải xuống đang được chuẩn bị. Vui lòng chờ trong giây lát.",
|
||||
@@ -103,7 +103,7 @@
|
||||
"displaySearch": "Tìm Kiếm Hình Ảnh",
|
||||
"selectAnImageToCompare": "Chọn Ảnh Để So Sánh",
|
||||
"slider": "Thanh Trượt",
|
||||
"gallerySettings": "Cài Đặt Thư Viện",
|
||||
"gallerySettings": "Cài Đặt Thư Viện Ảnh",
|
||||
"image": "hình ảnh",
|
||||
"noImageSelected": "Không Có Ảnh Được Chọn",
|
||||
"noImagesInGallery": "Không Có Ảnh Để Hiển Thị",
|
||||
@@ -117,7 +117,7 @@
|
||||
"unstarImage": "Ngừng Gắn Sao Cho Ảnh",
|
||||
"compareHelp2": "Nhấn <Kbd>M</Kbd> để tuần hoàn trong chế độ so sánh.",
|
||||
"boardsSettings": "Thiết Lập Bảng",
|
||||
"imagesSettings": "Cài Đặt Thư Viện Ảnh",
|
||||
"imagesSettings": "Cài Đặt Ảnh Trong Thư Viện Ảnh",
|
||||
"assets": "Tài Nguyên"
|
||||
},
|
||||
"common": {
|
||||
@@ -230,7 +230,10 @@
|
||||
"max": "Tối Đa",
|
||||
"resetToDefaults": "Đặt Lại Về Mặc Định",
|
||||
"seed": "Hạt Giống",
|
||||
"combinatorial": "Tổ Hợp"
|
||||
"combinatorial": "Tổ Hợp",
|
||||
"column": "Cột",
|
||||
"layout": "Bố Cục",
|
||||
"row": "Hàng"
|
||||
},
|
||||
"prompt": {
|
||||
"addPromptTrigger": "Thêm Prompt Trigger",
|
||||
@@ -285,7 +288,7 @@
|
||||
"cancelBatch": "Huỷ Bỏ Lô",
|
||||
"status": "Trạng Thái",
|
||||
"pending": "Đang Chờ",
|
||||
"gallery": "Thư Viện",
|
||||
"gallery": "Thư Viện Ảnh",
|
||||
"front": "trước",
|
||||
"batch": "Lô",
|
||||
"origin": "Nguồn Gốc",
|
||||
@@ -305,10 +308,13 @@
|
||||
"graphQueued": "Đồ Thị Đã Vào Hàng",
|
||||
"batchQueuedDesc_other": "Thêm {{count}} phiên vào {{direction}} của hàng",
|
||||
"batchSize": "Kích Thước Lô",
|
||||
"cancelAllExceptCurrentQueueItemAlertDialog": "Huỷ tất cả mục đang xếp hàng ngoại trừ mục hiện tại, sẽ dừng các mục đang chờ nhưng cho phép các mục đang chạy được hoàn tất.",
|
||||
"cancelAllExceptCurrentQueueItemAlertDialog": "Huỷ tất cả mục đang xếp hàng ngoại trừ việc nó sẽ dừng các mục đang chờ nhưng cho phép các mục đang chạy được hoàn tất.",
|
||||
"cancelAllExceptCurrentQueueItemAlertDialog2": "Bạn có chắc muốn huỷ tất cả mục đang chờ?",
|
||||
"cancelAllExceptCurrentTooltip": "Huỷ Bỏ Tất Cả Ngoại Trừ Mục Hiện Tại",
|
||||
"confirm": "Đồng Ý"
|
||||
"confirm": "Đồng Ý",
|
||||
"retrySucceeded": "Mục Đã Thử Lại",
|
||||
"retryFailed": "Có Vấn Đề Khi Thử Lại Mục",
|
||||
"retryItem": "Thử Lại Mục"
|
||||
},
|
||||
"hotkeys": {
|
||||
"canvas": {
|
||||
@@ -514,16 +520,16 @@
|
||||
},
|
||||
"gallery": {
|
||||
"galleryNavRight": {
|
||||
"desc": "Sang phải theo mạng lưới thư viện, chọn hình ảnh đó. Nếu đến cuối hàng, qua hàng tiếp theo. Nếu đến hình ảnh cuối cùng, qua trang tiếp theo.",
|
||||
"desc": "Sang phải theo mạng lưới thư viện ảnh, chọn hình ảnh đó. Nếu đến cuối hàng, qua hàng tiếp theo. Nếu đến hình ảnh cuối cùng, qua trang tiếp theo.",
|
||||
"title": "Sang Phải"
|
||||
},
|
||||
"galleryNavDown": {
|
||||
"title": "Đi Xuống",
|
||||
"desc": "Đi xuống theo mạng lưới thư viện, chọn hình ảnh đó. Nếu xuống cuối cùng trang, sang trang tiếp theo."
|
||||
"desc": "Đi xuống theo mạng lưới thư viện ảnh, chọn hình ảnh đó. Nếu xuống cuối cùng trang, sang trang tiếp theo."
|
||||
},
|
||||
"galleryNavLeft": {
|
||||
"title": "Sang Trái",
|
||||
"desc": "Sang trái theo mạng lưới thư viện, chọn hình ảnh đó. Nếu đến đầu hàng, về lại hàng trước đó. Nếu đến hình ảnh đầu tiên, về lại trang trước đó."
|
||||
"desc": "Sang trái theo mạng lưới thư viện ảnh, chọn hình ảnh đó. Nếu đến đầu hàng, về lại hàng trước đó. Nếu đến hình ảnh đầu tiên, về lại trang trước đó."
|
||||
},
|
||||
"galleryNavUpAlt": {
|
||||
"title": "Đi Lên (So Sánh Ảnh)",
|
||||
@@ -535,7 +541,7 @@
|
||||
},
|
||||
"galleryNavUp": {
|
||||
"title": "Đi Lên",
|
||||
"desc": "Đi lên theo mạng lưới thư viện, chọn hình ảnh đó. Nếu lên trên cùng trang, về lại trang trước đó."
|
||||
"desc": "Đi lên theo mạng lưới thư viện ảnh, chọn hình ảnh đó. Nếu lên trên cùng trang, về lại trang trước đó."
|
||||
},
|
||||
"galleryNavRightAlt": {
|
||||
"title": "Sang Phải (So Sánh Ảnh)",
|
||||
@@ -545,7 +551,7 @@
|
||||
"title": "Chọn Tất Cả Trên Trang",
|
||||
"desc": "Chọn tất cả ảnh trên trang hiện tại."
|
||||
},
|
||||
"title": "Thư Viện",
|
||||
"title": "Thư Viện Ảnh",
|
||||
"galleryNavDownAlt": {
|
||||
"title": "Đi Xuống (So Sánh Ảnh)",
|
||||
"desc": "Giống với \"Đi Xuống\", nhưng là chọn ảnh được so sánh, mở chế độ so sánh nếu chưa được mở."
|
||||
@@ -964,7 +970,7 @@
|
||||
"versionUnknown": " Phiên Bản Không Rõ",
|
||||
"workflowContact": "Thông Tin Liên Lạc",
|
||||
"workflowName": "Tên",
|
||||
"saveToGallery": "Lưu Vào Thư Viện",
|
||||
"saveToGallery": "Lưu Vào Thư Viện Ảnh",
|
||||
"connectionWouldCreateCycle": "Kết nối này sẽ tạo ra vòng lặp",
|
||||
"addNode": "Thêm Node",
|
||||
"unsupportedAnyOfLength": "quá nhiều dữ liệu hợp nhất: {{count}}",
|
||||
@@ -995,7 +1001,16 @@
|
||||
"generatorLoading": "đang tải",
|
||||
"generatorLoadFromFile": "Tải Từ Tệp",
|
||||
"dynamicPromptsRandom": "Dynamic Prompts (Ngẫu Nhiên)",
|
||||
"dynamicPromptsCombinatorial": "Dynamic Prompts (Tổ Hợp)"
|
||||
"dynamicPromptsCombinatorial": "Dynamic Prompts (Tổ Hợp)",
|
||||
"missingSourceOrTargetNode": "Thiếu nguồn hoặc node mục tiêu",
|
||||
"missingSourceOrTargetHandle": "Thiếu nguồn hoặc mục tiêu xử lý",
|
||||
"deletedMissingNodeFieldFormElement": "Xóa vùng nhập bị thiếu: vùng {{fieldName}} của node {{nodeId}}",
|
||||
"description": "Mô Tả",
|
||||
"loadWorkflowDesc": "Tải workflow?",
|
||||
"loadWorkflowDesc2": "Workflow hiện tại của bạn có những điều chỉnh chưa được lưu.",
|
||||
"loadingTemplates": "Đang Tải {{name}}",
|
||||
"nodeName": "Tên Node",
|
||||
"unableToUpdateNode": "Cập nhật node thất bại: node {{node}} thuộc dạng {{type}} (có thể cần xóa và tạo lại)"
|
||||
},
|
||||
"popovers": {
|
||||
"paramCFGRescaleMultiplier": {
|
||||
@@ -1479,8 +1494,7 @@
|
||||
"batchNodeCollectionSizeMismatch": "Kích cỡ tài nguyên không phù hợp với Lô {{batchGroupId}}",
|
||||
"emptyBatches": "lô trống",
|
||||
"batchNodeNotConnected": "Node Hàng Loạt chưa được kết nối: {{label}}",
|
||||
"batchNodeEmptyCollection": "Một vài node hàng loạt có tài nguyên rỗng",
|
||||
"invalidBatchConfigurationCannotCalculate": "Thiết lập lô không hợp lệ; không thể tính toán"
|
||||
"batchNodeEmptyCollection": "Một vài node hàng loạt có tài nguyên rỗng"
|
||||
},
|
||||
"cfgScale": "Thang CFG",
|
||||
"useSeed": "Dùng Hạt Giống",
|
||||
@@ -1582,14 +1596,14 @@
|
||||
"clearIntermediates": "Dọn Sạch Sản Phẩm Trung Gian",
|
||||
"clearIntermediatesDisabled": "Hàng đợi phải trống để dọn dẹp các sản phẩm trung gian",
|
||||
"clearIntermediatesDesc1": "Dọn dẹp các sản phẩm trung gian sẽ làm mới trạng thái của Canvas và ControlNet.",
|
||||
"clearIntermediatesDesc2": "Các sản phẩm ảnh trung gian là sản phẩm phụ trong quá trình tạo sinh, khác với ảnh trong thư viện. Xoá sản phẩm trung gian sẽ giúp làm trống ổ đĩa.",
|
||||
"clearIntermediatesDesc2": "Các sản phẩm ảnh trung gian là sản phẩm phụ trong quá trình tạo sinh, khác với ảnh trong thư viện ảnh. Xoá sản phẩm trung gian sẽ giúp làm trống ổ đĩa.",
|
||||
"resetWebUI": "Khởi Động Lại Giao Diện Web",
|
||||
"showProgressInViewer": "Hiển Thị Hình Ảnh Đang Xử Lý Trong Trình Xem",
|
||||
"ui": "Giao Diện Người Dùng",
|
||||
"clearIntermediatesDesc3": "Ảnh trong thư viện sẽ không bị xoá.",
|
||||
"clearIntermediatesDesc3": "Ảnh trong thư viện ảnh sẽ không bị xoá.",
|
||||
"informationalPopoversDisabled": "Hộp Thoại Hỗ Trợ Thông Tin Đã Tắt",
|
||||
"resetComplete": "Giao diện web đã được khởi động lại.",
|
||||
"resetWebUIDesc2": "Nếu ảnh không được xuất hiện trong thư viện hoặc điều gì đó không ổn đang diễn ra, hãy thử khởi động lại trước khi báo lỗi trên Github.",
|
||||
"resetWebUIDesc2": "Nếu ảnh không được xuất hiện trong thư viện ảnh hoặc điều gì đó không ổn đang diễn ra, hãy thử khởi động lại trước khi báo lỗi trên Github.",
|
||||
"displayInProgress": "Hiển Thị Hình Ảnh Đang Xử Lý",
|
||||
"intermediatesClearedFailed": "Có Vấn Đề Khi Dọn Sạch Sản Phẩm Trung Gian",
|
||||
"enableInvisibleWatermark": "Bật Chế Độ Ẩn Watermark",
|
||||
@@ -1617,7 +1631,7 @@
|
||||
"width": "Chiều Rộng",
|
||||
"negativePrompt": "Lệnh Tiêu Cực",
|
||||
"removeBookmark": "Bỏ Đánh Dấu",
|
||||
"saveBboxToGallery": "Lưu Hộp Giới Hạn Vào Thư Viện",
|
||||
"saveBboxToGallery": "Lưu Hộp Giới Hạn Vào Thư Viện Ảnh",
|
||||
"global": "Toàn Vùng",
|
||||
"pullBboxIntoReferenceImageError": "Có Vấn Đề Khi Chuyển Hộp Giới Hạn Thành Ảnh Mẫu",
|
||||
"clearHistory": "Xoá Lịch Sử",
|
||||
@@ -1625,12 +1639,12 @@
|
||||
"mergeVisibleOk": "Đã gộp layer",
|
||||
"saveLayerToAssets": "Lưu Layer Vào Khu Tài Nguyên",
|
||||
"canvas": "Canvas",
|
||||
"savedToGalleryOk": "Đã Lưu Vào Thư Viện",
|
||||
"savedToGalleryOk": "Đã Lưu Vào Thư Viện Ảnh",
|
||||
"addGlobalReferenceImage": "Thêm $t(controlLayers.globalReferenceImage)",
|
||||
"clipToBbox": "Chuyển Nét Thành Hộp Giới Hạn",
|
||||
"moveToFront": "Chuyển Lên Trước",
|
||||
"mergeVisible": "Gộp Layer Đang Hiển Thị",
|
||||
"savedToGalleryError": "Lỗi khi lưu vào thư viện",
|
||||
"savedToGalleryError": "Lỗi khi lưu vào thư viện ảnh",
|
||||
"moveToBack": "Chuyển Về Sau",
|
||||
"moveBackward": "Chuyển Xuống Cuối",
|
||||
"newGlobalReferenceImageError": "Có Vấn Đề Khi Tạo Ảnh Mẫu Toàn Vùng",
|
||||
@@ -1650,7 +1664,7 @@
|
||||
"regional": "Khu Vực",
|
||||
"regionIsEmpty": "Vùng được chọn trống",
|
||||
"bookmark": "Đánh Dấu Để Đổi Nhanh",
|
||||
"saveCanvasToGallery": "Lưu Canvas Vào Thư Viện",
|
||||
"saveCanvasToGallery": "Lưu Canvas Vào Thư Viện Ảnh",
|
||||
"cropLayerToBbox": "Xén Layer Vào Hộp Giới Hạn",
|
||||
"mergeDown": "Gộp Xuống",
|
||||
"mergeVisibleError": "Lỗi khi gộp layer",
|
||||
@@ -1718,11 +1732,11 @@
|
||||
"pullBboxIntoLayer": "Chuyển Hộp Giới Hạn Vào Layer",
|
||||
"addInpaintMask": "Thêm $t(controlLayers.inpaintMask)",
|
||||
"addRegionalGuidance": "Thêm $t(controlLayers.regionalGuidance)",
|
||||
"sendToGallery": "Chuyển Tới Thư Viện",
|
||||
"sendToGallery": "Đã Chuyển Tới Thư Viện Ảnh",
|
||||
"unlocked": "Mở Khoá",
|
||||
"addReferenceImage": "Thêm $t(controlLayers.referenceImage)",
|
||||
"sendingToCanvas": "Chuyển Ảnh Tạo Sinh Vào Canvas",
|
||||
"sendingToGallery": "Chuyển Ảnh Tạo Sinh Vào Thư Viện",
|
||||
"sendingToGallery": "Chuyển Ảnh Tạo Sinh Vào Thư Viện Ảnh",
|
||||
"viewProgressOnCanvas": "Xem quá trình xử lý và ảnh đầu ra trong <Btn>Canvas</Btn>.",
|
||||
"inpaintMask_withCount_other": "Lớp Phủ Inpaint",
|
||||
"regionalGuidance_withCount_other": "Chỉ Dẫn Khu Vực",
|
||||
@@ -1733,7 +1747,7 @@
|
||||
"copyRasterLayerTo": "Sao Chép $t(controlLayers.rasterLayer) Tới",
|
||||
"copyControlLayerTo": "Sao Chép $t(controlLayers.controlLayer) Tới",
|
||||
"newRegionalGuidance": "$t(controlLayers.regionalGuidance) Mới",
|
||||
"newGallerySessionDesc": "Nó sẽ dọn sạch canvas và các thiết lập trừ model được chọn. Các ảnh được tạo sinh sẽ được chuyển đến thư viện.",
|
||||
"newGallerySessionDesc": "Nó sẽ dọn sạch canvas và các thiết lập trừ model được chọn. Các ảnh được tạo sinh sẽ được chuyển đến thư viện ảnh.",
|
||||
"stagingOnCanvas": "Hiển thị hình ảnh lên",
|
||||
"pullBboxIntoReferenceImage": "Chuyển Hộp Giới Hạn Vào Ảnh Mẫu",
|
||||
"maskFill": "Lấp Đầy Lớp Phủ",
|
||||
@@ -1755,8 +1769,8 @@
|
||||
"deleteReferenceImage": "Xoá Ảnh Mẫu",
|
||||
"inpaintMasks_withCount_visible": "Lớp Phủ Inpaint ({{count}})",
|
||||
"disableTransparencyEffect": "Tắt Hiệu Ứng Trong Suốt",
|
||||
"newGallerySession": "Phiên Thư Viện Mới",
|
||||
"sendToGalleryDesc": "Bấm 'Kích Hoạt' sẽ tiến hành tạo sinh và lưu ảnh vào thư viện.",
|
||||
"newGallerySession": "Phiên Thư Viện Ảnh Mới",
|
||||
"sendToGalleryDesc": "Bấm 'Kích Hoạt' sẽ tiến hành tạo sinh và lưu ảnh vào thư viện ảnh.",
|
||||
"opacity": "Độ Mờ Đục",
|
||||
"rectangle": "Hình Chữ Nhật",
|
||||
"addNegativePrompt": "Thêm $t(controlLayers.negativePrompt)",
|
||||
@@ -1791,13 +1805,13 @@
|
||||
"process": "Xử Lý"
|
||||
},
|
||||
"canvasContextMenu": {
|
||||
"saveBboxToGallery": "Lưu Hộp Giới Hạn Vào Thư Viện",
|
||||
"saveBboxToGallery": "Lưu Hộp Giới Hạn Vào Thư Viện Ảnh",
|
||||
"newGlobalReferenceImage": "Ảnh Mẫu Toàn Vùng Mới",
|
||||
"cropCanvasToBbox": "Xén Canvas Vào Hộp Giới Hạn",
|
||||
"newRegionalGuidance": "Chỉ Dẫn Khu Vực Mới",
|
||||
"saveToGalleryGroup": "Lưu Vào Thư Viện",
|
||||
"saveToGalleryGroup": "Lưu Vào Thư Viện Ảnh",
|
||||
"newInpaintMask": "Lớp Phủ Inpaint Mới",
|
||||
"saveCanvasToGallery": "Lưu Canvas Vào Thư Viện",
|
||||
"saveCanvasToGallery": "Lưu Canvas Vào Thư Viện Ảnh",
|
||||
"newRegionalReferenceImage": "Ảnh Mẫu Khu Vực Mới",
|
||||
"newControlLayer": "Layer Điều Khiển Được Mới",
|
||||
"newRasterLayer": "Layer Dạng Raster Mới",
|
||||
@@ -1808,7 +1822,7 @@
|
||||
"copyBboxToClipboard": "Sao Chép Hộp Giới Hạn Vào Clipboard"
|
||||
},
|
||||
"stagingArea": {
|
||||
"saveToGallery": "Lưu Vào Thư Viện",
|
||||
"saveToGallery": "Lưu Vào Thư Viện Ảnh",
|
||||
"accept": "Chấp Nhận",
|
||||
"discard": "Bỏ Đi",
|
||||
"previous": "Trước",
|
||||
@@ -2090,7 +2104,7 @@
|
||||
"enableLogging": "Bật Chế Độ Ghi Log",
|
||||
"logNamespaces": {
|
||||
"models": "Models",
|
||||
"gallery": "Thư Viện",
|
||||
"gallery": "Thư Viện Ảnh",
|
||||
"config": "Cấu Hình",
|
||||
"queue": "Queue",
|
||||
"workflows": "Workflow",
|
||||
@@ -2178,7 +2192,7 @@
|
||||
},
|
||||
"ui": {
|
||||
"tabs": {
|
||||
"gallery": "Thư Viện",
|
||||
"gallery": "Thư Viện Ảnh",
|
||||
"models": "Models",
|
||||
"generation": "Generation (Máy Tạo Sinh)",
|
||||
"upscaling": "Upscale (Nâng Cấp Chất Lượng Hình Ảnh)",
|
||||
@@ -2210,7 +2224,7 @@
|
||||
"savingWorkflow": "Đang Lưu Workflow...",
|
||||
"ascending": "Tăng Dần",
|
||||
"loading": "Đang Tải Workflow",
|
||||
"chooseWorkflowFromLibrary": "Chọn Workflow Từ Túi Đồ",
|
||||
"chooseWorkflowFromLibrary": "Chọn Workflow Từ Thư Viện",
|
||||
"workflows": "Workflow",
|
||||
"copyShareLinkForWorkflow": "Sao Chép Liên Kết Chia Sẻ Cho Workflow",
|
||||
"openWorkflow": "Mở Workflow",
|
||||
@@ -2230,11 +2244,38 @@
|
||||
"convertGraph": "Chuyển Đổi Đồ Thị",
|
||||
"saveWorkflowToProject": "Lưu Workflow Vào Dự Án",
|
||||
"workflowName": "Tên Workflow",
|
||||
"workflowLibrary": "Túi Đồ",
|
||||
"workflowLibrary": "Thư Viện",
|
||||
"opened": "Ngày Mở",
|
||||
"deleteWorkflow": "Xoá Workflow",
|
||||
"workflowEditorMenu": "Menu Biên Tập Viên Workflow",
|
||||
"uploadAndSaveWorkflow": "Tải Lên Túi Đồ"
|
||||
"uploadAndSaveWorkflow": "Tải Lên Thư Viện",
|
||||
"openLibrary": "Mở Thư Viện",
|
||||
"builder": {
|
||||
"resetAllNodeFields": "Khởi Động Lại Tất Cả Vùng Cho Node",
|
||||
"builder": "Trình Tạo Vùng Nhập",
|
||||
"layout": "Bố Cục",
|
||||
"row": "Hàng",
|
||||
"zoomToNode": "Phóng To Vào Node",
|
||||
"addToForm": "Thêm Vào Vùng Nhập",
|
||||
"label": "Nhãn Tên",
|
||||
"showDescription": "Hiện Dòng Mô Tả",
|
||||
"component": "Thành Phần",
|
||||
"numberInput": "Nhập Số",
|
||||
"singleLine": "Một Dòng",
|
||||
"multiLine": "Nhiều Dòng",
|
||||
"slider": "Thanh Trượt",
|
||||
"both": "Cả Hai",
|
||||
"emptyRootPlaceholderViewMode": "Chọn Chỉnh Sửa để bắt đầu tạo nên một vùng nhập cho workflow này.",
|
||||
"emptyRootPlaceholderEditMode": "Kéo thành phần vùng nhập hoặc vùng cho node vào đây để bắt đầu.",
|
||||
"containerPlaceholder": "Hộp Chứa Trống",
|
||||
"headingPlaceholder": "Đầu Dòng Trống",
|
||||
"textPlaceholder": "Mô Tả Trống",
|
||||
"column": "Cột",
|
||||
"deleteAllElements": "Xóa Tất Cả Thành Phần Vùng Nhập",
|
||||
"nodeField": "Vùng Cho Node",
|
||||
"nodeFieldTooltip": "Để thêm vùng cho node, bấm vào dấu cộng nhỏ trên vùng trong Vùng Biên Tập Workflow, hoặc kéo vùng theo tên của nó vào vùng nhập.",
|
||||
"workflowBuilderAlphaWarning": "Trình tạo workflow đang trong giai đoạn alpha. Nó có thể xuất hiện những thay đổi đột ngột trước khi chính thức được phát hành."
|
||||
}
|
||||
},
|
||||
"upscaling": {
|
||||
"missingUpscaleInitialImage": "Thiếu ảnh dùng để upscale",
|
||||
@@ -2257,9 +2298,9 @@
|
||||
"incompatibleBaseModelDesc": "Upscale chỉ hỗ trợ cho model phiên bản SD1.5 và SDXL. Đổi model chính để bật lại tính năng upscale."
|
||||
},
|
||||
"newUserExperience": {
|
||||
"toGetStartedLocal": "Để bắt đầu, hãy chắc chắn đã tải xuống hoặc thêm vào model cần để chạy Invoke. Sau đó, nhập lệnh vào hộp và nhấp chuột vào <StrongComponent>Kích Hoạt</StrongComponent> để tạo ra bức ảnh đầu tiên. Chọn một mẫu trình bày cho lệnh để cải thiện kết quả. Bạn có thể chọn để lưu ảnh trực tiếp vào <StrongComponent>Thư Viện</StrongComponent> hoặc chỉnh sửa chúng ở <StrongComponent>Canvas</StrongComponent>.",
|
||||
"toGetStartedLocal": "Để bắt đầu, hãy chắc chắn đã tải xuống hoặc thêm vào model cần để chạy Invoke. Sau đó, nhập lệnh vào hộp và nhấp chuột vào <StrongComponent>Kích Hoạt</StrongComponent> để tạo ra bức ảnh đầu tiên. Chọn một mẫu trình bày cho lệnh để cải thiện kết quả. Bạn có thể chọn để lưu ảnh trực tiếp vào <StrongComponent>Thư Viện Ảnh</StrongComponent> hoặc chỉnh sửa chúng ở <StrongComponent>Canvas</StrongComponent>.",
|
||||
"gettingStartedSeries": "Cần thêm hướng dẫn? Xem thử <LinkComponent>Bắt Đầu Làm Quen</LinkComponent> để biết thêm mẹo khai thác toàn bộ tiềm năng của Invoke Studio.",
|
||||
"toGetStarted": "Để bắt đầu, hãy nhập lệnh vào hộp và nhấp chuột vào <StrongComponent>Kích Hoạt</StrongComponent> để tạo ra bức ảnh đầu tiên. Chọn một mẫu trình bày cho lệnh để cải thiện kết quả. Bạn có thể chọn để lưu ảnh trực tiếp vào <StrongComponent>Thư Viện</StrongComponent> hoặc chỉnh sửa chúng ở <StrongComponent>Canvas</StrongComponent>.",
|
||||
"toGetStarted": "Để bắt đầu, hãy nhập lệnh vào hộp và nhấp chuột vào <StrongComponent>Kích Hoạt</StrongComponent> để tạo ra bức ảnh đầu tiên. Chọn một mẫu trình bày cho lệnh để cải thiện kết quả. Bạn có thể chọn để lưu ảnh trực tiếp vào <StrongComponent>Thư Viện Ảnh</StrongComponent> hoặc chỉnh sửa chúng ở <StrongComponent>Canvas</StrongComponent>.",
|
||||
"noModelsInstalled": "Dường như bạn chưa tải model nào cả! Bạn có thể <DownloadStarterModelsButton>tải xuống các model khởi đầu</DownloadStarterModelsButton> hoặc <ImportModelsButton>nhập vào thêm model</ImportModelsButton>.",
|
||||
"lowVRAMMode": "Cho hiệu suất tốt nhất, hãy làm theo <LinkComponent>hướng dẫn VRAM Thấp</LinkComponent> của chúng tôi."
|
||||
},
|
||||
@@ -2306,8 +2347,8 @@
|
||||
"title": "Upscale (Nâng Cấp Chất Lượng Hình Ảnh)"
|
||||
},
|
||||
"howDoIGenerateAndSaveToTheGallery": {
|
||||
"title": "Làm Sao Để Tôi Tạo Sinh Và Lưu Vào Thư Viện?",
|
||||
"description": "Các bước để tạo sinh và lưu ảnh vào thư viện."
|
||||
"title": "Làm Sao Để Tôi Tạo Sinh Và Lưu Vào Thư Viện Ảnh?",
|
||||
"description": "Các bước để tạo sinh và lưu ảnh vào thư viện ảnh."
|
||||
},
|
||||
"howDoIEditOnTheCanvas": {
|
||||
"description": "Hướng dẫn chỉnh sửa ảnh trực tiếp trên canvas.",
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Box, useGlobalModifiersInit } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { GlobalImageHotkeys } from 'app/components/GlobalImageHotkeys';
|
||||
import type { StudioInitAction } from 'app/hooks/useStudioInitAction';
|
||||
import { useStudioInitAction } from 'app/hooks/useStudioInitAction';
|
||||
import { $didStudioInit, useStudioInitAction } from 'app/hooks/useStudioInitAction';
|
||||
import { useSyncQueueStatus } from 'app/hooks/useSyncQueueStatus';
|
||||
import { useLogger } from 'app/logging/useLogger';
|
||||
import { useSyncLoggingConfig } from 'app/logging/useSyncLoggingConfig';
|
||||
import { appStarted } from 'app/store/middleware/listenerMiddleware/listeners/appStarted';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import type { PartialAppConfig } from 'app/types/invokeai';
|
||||
import Loading from 'common/components/Loading/Loading';
|
||||
import { useFocusRegionWatcher } from 'common/hooks/focus';
|
||||
import { useClearStorage } from 'common/hooks/useClearStorage';
|
||||
import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys';
|
||||
@@ -55,6 +57,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
|
||||
const didStudioInit = useStore($didStudioInit);
|
||||
const clearStorage = useClearStorage();
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
@@ -67,6 +70,7 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
|
||||
<ErrorBoundary onReset={handleReset} FallbackComponent={AppErrorBoundaryFallback}>
|
||||
<Box id="invoke-app-wrapper" w="100dvw" h="100dvh" position="relative" overflow="hidden">
|
||||
<AppContent />
|
||||
{!didStudioInit && <Loading />}
|
||||
</Box>
|
||||
<HookIsolator config={config} studioInitAction={studioInitAction} />
|
||||
<DeleteImageModal />
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'i18n';
|
||||
|
||||
import type { Middleware } from '@reduxjs/toolkit';
|
||||
import type { StudioInitAction } from 'app/hooks/useStudioInitAction';
|
||||
import { $didStudioInit } from 'app/hooks/useStudioInitAction';
|
||||
import type { LoggingOverrides } from 'app/logging/logger';
|
||||
import { $loggingOverrides, configureLogging } from 'app/logging/logger';
|
||||
import { $authToken } from 'app/store/nanostores/authToken';
|
||||
@@ -87,6 +88,12 @@ const InvokeAIUI = ({
|
||||
);
|
||||
}, [loggingOverrides]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (studioInitAction) {
|
||||
$didStudioInit.set(false);
|
||||
}
|
||||
}, [studioInitAction]);
|
||||
|
||||
useEffect(() => {
|
||||
// configure API client token
|
||||
if (token) {
|
||||
|
||||
@@ -16,7 +16,8 @@ import { $isStylePresetsMenuOpen, activeStylePresetIdChanged } from 'features/st
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/store/uiSlice';
|
||||
import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { atom } from 'nanostores';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getImageDTO, getImageMetadata } from 'services/api/endpoints/images';
|
||||
import { getStylePreset } from 'services/api/endpoints/stylePresets';
|
||||
@@ -32,6 +33,9 @@ type StudioDestinationAction = _StudioInitAction<
|
||||
{ destination: 'generation' | 'canvas' | 'workflows' | 'upscaling' | 'viewAllWorkflows' | 'viewAllStylePresets' }
|
||||
>;
|
||||
|
||||
// Use global state to show loader until we are ready to render the studio.
|
||||
export const $didStudioInit = atom(false);
|
||||
|
||||
export type StudioInitAction =
|
||||
| LoadWorkflowAction
|
||||
| SelectStylePresetAction
|
||||
@@ -51,8 +55,6 @@ export type StudioInitAction =
|
||||
export const useStudioInitAction = (action?: StudioInitAction) => {
|
||||
useAssertSingleton('useStudioInitAction');
|
||||
const { t } = useTranslation();
|
||||
// Use a ref to ensure that we only perform the action once
|
||||
const didInit = useRef(false);
|
||||
const didParseOpenAPISchema = useStore($hasTemplates);
|
||||
const store = useAppStore();
|
||||
const { getAndLoadWorkflow } = useGetAndLoadLibraryWorkflow();
|
||||
@@ -102,16 +104,16 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
|
||||
}
|
||||
const metadata = getImageMetadataResult.value;
|
||||
// This shows a toast
|
||||
parseAndRecallAllMetadata(metadata, true);
|
||||
await parseAndRecallAllMetadata(metadata, true);
|
||||
store.dispatch(setActiveTab('canvas'));
|
||||
},
|
||||
[store, t]
|
||||
);
|
||||
|
||||
const handleLoadWorkflow = useCallback(
|
||||
(workflowId: string) => {
|
||||
async (workflowId: string) => {
|
||||
// This shows a toast
|
||||
getAndLoadWorkflow(workflowId);
|
||||
await getAndLoadWorkflow(workflowId);
|
||||
store.dispatch(setActiveTab('workflows'));
|
||||
},
|
||||
[getAndLoadWorkflow, store]
|
||||
@@ -176,36 +178,48 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
|
||||
[store]
|
||||
);
|
||||
|
||||
const handleStudioInitAction = useCallback(
|
||||
async (action: StudioInitAction) => {
|
||||
// This cannot be in the useEffect below because we need to await some of the actions before setting didStudioInit.
|
||||
switch (action.type) {
|
||||
case 'loadWorkflow':
|
||||
await handleLoadWorkflow(action.data.workflowId);
|
||||
break;
|
||||
case 'selectStylePreset':
|
||||
await handleSelectStylePreset(action.data.stylePresetId);
|
||||
break;
|
||||
|
||||
case 'sendToCanvas':
|
||||
await handleSendToCanvas(action.data.imageName);
|
||||
break;
|
||||
|
||||
case 'useAllParameters':
|
||||
await handleUseAllMetadata(action.data.imageName);
|
||||
break;
|
||||
|
||||
case 'goToDestination':
|
||||
handleGoToDestination(action.data.destination);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
$didStudioInit.set(true);
|
||||
},
|
||||
[handleGoToDestination, handleLoadWorkflow, handleSelectStylePreset, handleSendToCanvas, handleUseAllMetadata]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (didInit.current || !action || !didParseOpenAPISchema) {
|
||||
if ($didStudioInit.get() || !didParseOpenAPISchema) {
|
||||
return;
|
||||
}
|
||||
|
||||
didInit.current = true;
|
||||
|
||||
switch (action.type) {
|
||||
case 'loadWorkflow':
|
||||
handleLoadWorkflow(action.data.workflowId);
|
||||
break;
|
||||
case 'selectStylePreset':
|
||||
handleSelectStylePreset(action.data.stylePresetId);
|
||||
break;
|
||||
|
||||
case 'sendToCanvas':
|
||||
handleSendToCanvas(action.data.imageName);
|
||||
break;
|
||||
|
||||
case 'useAllParameters':
|
||||
handleUseAllMetadata(action.data.imageName);
|
||||
break;
|
||||
|
||||
case 'goToDestination':
|
||||
handleGoToDestination(action.data.destination);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
if (!action) {
|
||||
$didStudioInit.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
handleStudioInitAction(action);
|
||||
}, [
|
||||
handleSendToCanvas,
|
||||
handleUseAllMetadata,
|
||||
@@ -214,5 +228,6 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
|
||||
handleGoToDestination,
|
||||
handleLoadWorkflow,
|
||||
didParseOpenAPISchema,
|
||||
handleStudioInitAction,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { enqueueRequested } from 'app/store/actions';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import { $templates } from 'features/nodes/store/nodesSlice';
|
||||
import { selectNodesSlice } from 'features/nodes/store/selectors';
|
||||
import { isBatchNode, isInvocationNode } from 'features/nodes/types/invocation';
|
||||
import { buildNodesGraph } from 'features/nodes/util/graph/buildNodesGraph';
|
||||
import { resolveBatchValue } from 'features/nodes/util/node/resolveBatchValue';
|
||||
import { buildWorkflowWithValidation } from 'features/nodes/util/workflow/buildWorkflow';
|
||||
import { resolveBatchValue } from 'features/queue/store/readiness';
|
||||
import { groupBy } from 'lodash-es';
|
||||
import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue';
|
||||
import type { Batch, BatchConfig } from 'services/api/types';
|
||||
@@ -15,12 +16,13 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) =
|
||||
enqueueRequested.match(action) && action.payload.tabName === 'workflows',
|
||||
effect: async (action, { getState, dispatch }) => {
|
||||
const state = getState();
|
||||
const nodes = selectNodesSlice(state);
|
||||
const nodesState = selectNodesSlice(state);
|
||||
const workflow = state.workflow;
|
||||
const graph = buildNodesGraph(nodes);
|
||||
const templates = $templates.get();
|
||||
const graph = buildNodesGraph(state, templates);
|
||||
const builtWorkflow = buildWorkflowWithValidation({
|
||||
nodes: nodes.nodes,
|
||||
edges: nodes.edges,
|
||||
nodes: nodesState.nodes,
|
||||
edges: nodesState.edges,
|
||||
workflow,
|
||||
});
|
||||
|
||||
@@ -31,7 +33,7 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) =
|
||||
|
||||
const data: Batch['data'] = [];
|
||||
|
||||
const invocationNodes = nodes.nodes.filter(isInvocationNode);
|
||||
const invocationNodes = nodesState.nodes.filter(isInvocationNode);
|
||||
const batchNodes = invocationNodes.filter(isBatchNode);
|
||||
|
||||
// Handle zipping batch nodes. First group the batch nodes by their batch_group_id
|
||||
@@ -42,9 +44,11 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) =
|
||||
const zippedBatchDataCollectionItems: NonNullable<Batch['data']>[number] = [];
|
||||
|
||||
for (const node of batchNodes) {
|
||||
const value = resolveBatchValue(node, invocationNodes, nodes.edges);
|
||||
const value = await resolveBatchValue({ nodesState, node, dispatch });
|
||||
const sourceHandle = node.data.type === 'image_batch' ? 'image' : 'value';
|
||||
const edgesFromBatch = nodes.edges.filter((e) => e.source === node.id && e.sourceHandle === sourceHandle);
|
||||
const edgesFromBatch = nodesState.edges.filter(
|
||||
(e) => e.source === node.id && e.sourceHandle === sourceHandle
|
||||
);
|
||||
if (batchGroupId !== 'None') {
|
||||
// If this batch node has a batch_group_id, we will zip the data collection items
|
||||
for (const edge of edgesFromBatch) {
|
||||
|
||||
@@ -6,7 +6,18 @@ import { memo } from 'react';
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<Flex position="relative" width="100dvw" height="100dvh" alignItems="center" justifyContent="center" bg="#151519">
|
||||
<Flex
|
||||
position="absolute"
|
||||
width="100dvw"
|
||||
height="100dvh"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="#151519"
|
||||
top={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
left={0}
|
||||
>
|
||||
<Image src={InvokeLogoWhite} w="8rem" h="8rem" />
|
||||
<Spinner
|
||||
label="Loading"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useClipboard } from 'common/hooks/useClipboard';
|
||||
import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob';
|
||||
import { imageCopiedToClipboard } from 'features/gallery/store/actions';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -7,6 +9,7 @@ import { useTranslation } from 'react-i18next';
|
||||
export const useCopyImageToClipboard = () => {
|
||||
const { t } = useTranslation();
|
||||
const clipboard = useClipboard();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const copyImageToClipboard = useCallback(
|
||||
async (image_url: string) => {
|
||||
@@ -23,6 +26,7 @@ export const useCopyImageToClipboard = () => {
|
||||
title: t('toast.imageCopied'),
|
||||
status: 'success',
|
||||
});
|
||||
dispatch(imageCopiedToClipboard());
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
@@ -33,7 +37,7 @@ export const useCopyImageToClipboard = () => {
|
||||
});
|
||||
}
|
||||
},
|
||||
[clipboard, t]
|
||||
[clipboard, t, dispatch]
|
||||
);
|
||||
|
||||
return copyImageToClipboard;
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { CanvasState } from 'features/controlLayers/store/types';
|
||||
import { selectDeleteImageModalSlice } from 'features/deleteImageModal/store/slice';
|
||||
import { selectNodesSlice } from 'features/nodes/store/selectors';
|
||||
import type { NodesState } from 'features/nodes/store/types';
|
||||
import { isImageFieldInputInstance } from 'features/nodes/types/field';
|
||||
import { isImageFieldCollectionInputInstance, isImageFieldInputInstance } from 'features/nodes/types/field';
|
||||
import { isInvocationNode } from 'features/nodes/types/invocation';
|
||||
import type { UpscaleState } from 'features/parameters/store/upscaleSlice';
|
||||
import { selectUpscaleSlice } from 'features/parameters/store/upscaleSlice';
|
||||
@@ -13,11 +13,23 @@ import { some } from 'lodash-es';
|
||||
import type { ImageUsage } from './types';
|
||||
// TODO(psyche): handle image deletion (canvas staging area?)
|
||||
export const getImageUsage = (nodes: NodesState, canvas: CanvasState, upscale: UpscaleState, image_name: string) => {
|
||||
const isNodesImage = nodes.nodes
|
||||
.filter(isInvocationNode)
|
||||
.some((node) =>
|
||||
some(node.data.inputs, (input) => isImageFieldInputInstance(input) && input.value?.image_name === image_name)
|
||||
);
|
||||
const isNodesImage = nodes.nodes.filter(isInvocationNode).some((node) =>
|
||||
some(node.data.inputs, (input) => {
|
||||
if (isImageFieldInputInstance(input)) {
|
||||
if (input.value?.image_name === image_name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isImageFieldCollectionInputInstance(input)) {
|
||||
if (input.value?.some((value) => value?.image_name === image_name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
);
|
||||
|
||||
const isUpscaleImage = upscale.upscaleInitialImage?.image_name === image_name;
|
||||
|
||||
|
||||
@@ -36,7 +36,13 @@ const DeleteBoardModal = () => {
|
||||
const boardToDelete = useStore($boardToDelete);
|
||||
const { t } = useTranslation();
|
||||
const { currentData: boardImageNames, isFetching: isFetchingBoardNames } = useListAllImageNamesForBoardQuery(
|
||||
boardToDelete?.board_id ?? skipToken
|
||||
boardToDelete?.board_id
|
||||
? {
|
||||
board_id: boardToDelete?.board_id,
|
||||
categories: undefined,
|
||||
is_intermediate: undefined,
|
||||
}
|
||||
: skipToken
|
||||
);
|
||||
|
||||
const selectImageUsageSummary = useMemo(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { IconMenuItem } from 'common/components/IconMenuItem';
|
||||
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
|
||||
import { imageOpenedInNewTab } from 'features/gallery/store/actions';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiArrowSquareOutBold } from 'react-icons/pi';
|
||||
@@ -7,9 +9,11 @@ import { PiArrowSquareOutBold } from 'react-icons/pi';
|
||||
export const ImageMenuItemOpenInNewTab = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const imageDTO = useImageDTOContext();
|
||||
const dispatch = useAppDispatch();
|
||||
const onClick = useCallback(() => {
|
||||
window.open(imageDTO.image_url, '_blank');
|
||||
}, [imageDTO.image_url]);
|
||||
dispatch(imageOpenedInNewTab());
|
||||
}, [imageDTO.image_url, dispatch]);
|
||||
|
||||
return (
|
||||
<IconMenuItem
|
||||
|
||||
@@ -18,7 +18,6 @@ import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid
|
||||
import { SizedSkeletonLoader } from 'features/gallery/components/ImageGrid/SizedSkeletonLoader';
|
||||
import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
|
||||
import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice';
|
||||
import { atom } from 'nanostores';
|
||||
import type { MouseEventHandler } from 'react';
|
||||
import { memo, useCallback, useEffect, useId, useMemo, useState } from 'react';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
@@ -178,20 +177,15 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
|
||||
);
|
||||
}, [imageDTO, element, store, dndId]);
|
||||
|
||||
// Perf optimization:
|
||||
// The gallery image component can be heavy and re-render often. We want to track hovering state without causing
|
||||
// unnecessary re-renders. To do this, we use a local atom - which has a stable reference - in the image component -
|
||||
// and then pass the atom to the hover icons component, which subscribes to the atom and re-renders when the atom
|
||||
// changes.
|
||||
const $isHovered = useMemo(() => atom(false), []);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const onMouseOver = useCallback(() => {
|
||||
$isHovered.set(true);
|
||||
}, [$isHovered]);
|
||||
setIsHovered(true);
|
||||
}, []);
|
||||
|
||||
const onMouseOut = useCallback(() => {
|
||||
$isHovered.set(false);
|
||||
}, [$isHovered]);
|
||||
setIsHovered(false);
|
||||
}, []);
|
||||
|
||||
const onClick = useCallback<MouseEventHandler<HTMLDivElement>>(
|
||||
(e) => {
|
||||
@@ -247,7 +241,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
|
||||
maxH="full"
|
||||
borderRadius="base"
|
||||
/>
|
||||
<GalleryImageHoverIcons imageDTO={imageDTO} $isHovered={$isHovered} />
|
||||
<GalleryImageHoverIcons imageDTO={imageDTO} isHovered={isHovered} />
|
||||
</Flex>
|
||||
</Box>
|
||||
{dragPreviewState?.type === 'multiple-image' ? createMultipleImageDragPreview(dragPreviewState) : null}
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { GalleryImageDeleteIconButton } from 'features/gallery/components/ImageGrid/GalleryImageDeleteIconButton';
|
||||
import { GalleryImageOpenInViewerIconButton } from 'features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton';
|
||||
import { GalleryImageSizeBadge } from 'features/gallery/components/ImageGrid/GalleryImageSizeBadge';
|
||||
import { GalleryImageStarIconButton } from 'features/gallery/components/ImageGrid/GalleryImageStarIconButton';
|
||||
import { selectAlwaysShouldImageSizeBadge } from 'features/gallery/store/gallerySelectors';
|
||||
import type { Atom } from 'nanostores';
|
||||
import { memo } from 'react';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
type Props = {
|
||||
imageDTO: ImageDTO;
|
||||
$isHovered: Atom<boolean>;
|
||||
isHovered: boolean;
|
||||
};
|
||||
|
||||
export const GalleryImageHoverIcons = memo(({ imageDTO, $isHovered }: Props) => {
|
||||
export const GalleryImageHoverIcons = memo(({ imageDTO, isHovered }: Props) => {
|
||||
const alwaysShowImageSizeBadge = useAppSelector(selectAlwaysShouldImageSizeBadge);
|
||||
const isHovered = useStore($isHovered);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -3,3 +3,7 @@ import { createAction } from '@reduxjs/toolkit';
|
||||
export const sentImageToCanvas = createAction('gallery/sentImageToCanvas');
|
||||
|
||||
export const imageDownloaded = createAction('gallery/imageDownloaded');
|
||||
|
||||
export const imageCopiedToClipboard = createAction('gallery/imageCopiedToClipboard');
|
||||
|
||||
export const imageOpenedInNewTab = createAction('gallery/imageOpenedInNewTab');
|
||||
|
||||
@@ -31,6 +31,9 @@ const sx: SystemStyleObject = {
|
||||
'&[data-with-footer="true"]': {
|
||||
borderBottomRadius: 0,
|
||||
},
|
||||
'&[data-with-footer="false"]': {
|
||||
pb: 4,
|
||||
},
|
||||
};
|
||||
|
||||
const InvocationNode = ({ nodeId, isOpen }: Props) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ChakraProps } from '@invoke-ai/ui-library';
|
||||
import { Flex, FormControlGroup } from '@invoke-ai/ui-library';
|
||||
import { useIsExecutableNode } from 'features/nodes/hooks/useIsBatchNode';
|
||||
import { useNodeHasImageOutput } from 'features/nodes/hooks/useNodeHasImageOutput';
|
||||
import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants';
|
||||
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
@@ -16,6 +17,7 @@ const props: ChakraProps = { w: 'unset' };
|
||||
|
||||
const InvocationNodeFooter = ({ nodeId }: Props) => {
|
||||
const hasImageOutput = useNodeHasImageOutput(nodeId);
|
||||
const isExecutableNode = useIsExecutableNode(nodeId);
|
||||
const isCacheEnabled = useFeatureStatus('invocationCache');
|
||||
return (
|
||||
<Flex
|
||||
@@ -30,8 +32,8 @@ const InvocationNodeFooter = ({ nodeId }: Props) => {
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<FormControlGroup formControlProps={props} formLabelProps={props}>
|
||||
{isCacheEnabled && <UseCacheCheckbox nodeId={nodeId} />}
|
||||
{hasImageOutput && <SaveToGalleryCheckbox nodeId={nodeId} />}
|
||||
{isExecutableNode && isCacheEnabled && <UseCacheCheckbox nodeId={nodeId} />}
|
||||
{isExecutableNode && hasImageOutput && <SaveToGalleryCheckbox nodeId={nodeId} />}
|
||||
</FormControlGroup>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -8,8 +8,9 @@ import { useNodeFieldDnd } from 'features/nodes/components/sidePanel/builder/dnd
|
||||
import { useInputFieldIsConnected } from 'features/nodes/hooks/useInputFieldIsConnected';
|
||||
import { useInputFieldIsInvalid } from 'features/nodes/hooks/useInputFieldIsInvalid';
|
||||
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
|
||||
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
|
||||
import type { FieldInputTemplate } from 'features/nodes/types/field';
|
||||
import { memo, useCallback, useRef, useState } from 'react';
|
||||
import { memo, useRef } from 'react';
|
||||
|
||||
import { InputFieldRenderer } from './InputFieldRenderer';
|
||||
import { InputFieldTitle } from './InputFieldTitle';
|
||||
@@ -84,45 +85,36 @@ const directFieldSx: SystemStyleObject = {
|
||||
'&[data-is-connected="true"]': {
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
// The action buttons are hidden by default and shown on hover
|
||||
'& .direct-field-action-buttons': {
|
||||
display: 'none',
|
||||
},
|
||||
_hover: {
|
||||
'& .direct-field-action-buttons': {
|
||||
display: 'inline-flex',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const DirectField = memo(({ nodeId, fieldName, isInvalid, isConnected, fieldTemplate }: CommonProps) => {
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const dragHandleRef = useRef<HTMLDivElement>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const onMouseEnter = useCallback(() => {
|
||||
setIsHovered(true);
|
||||
}, []);
|
||||
|
||||
const onMouseLeave = useCallback(() => {
|
||||
setIsHovered(false);
|
||||
}, []);
|
||||
|
||||
const isDragging = useNodeFieldDnd({ nodeId, fieldName }, fieldTemplate, draggableRef, dragHandleRef);
|
||||
|
||||
return (
|
||||
<InputFieldWrapper>
|
||||
<Flex
|
||||
ref={draggableRef}
|
||||
sx={directFieldSx}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
data-is-connected={isConnected}
|
||||
data-is-dragging={isDragging}
|
||||
>
|
||||
<Flex ref={draggableRef} sx={directFieldSx} data-is-connected={isConnected} data-is-dragging={isDragging}>
|
||||
<Flex gap={1}>
|
||||
<Flex ref={dragHandleRef}>
|
||||
<Flex className={NO_DRAG_CLASS} ref={dragHandleRef}>
|
||||
<InputFieldTitle nodeId={nodeId} fieldName={fieldName} isInvalid={isInvalid} isDragging={isDragging} />
|
||||
</Flex>
|
||||
<Spacer />
|
||||
{isHovered && (
|
||||
<>
|
||||
<InputFieldDescriptionPopover nodeId={nodeId} fieldName={fieldName} />
|
||||
<InputFieldResetToDefaultValueIconButton nodeId={nodeId} fieldName={fieldName} />
|
||||
<InputFieldAddToFormRoot nodeId={nodeId} fieldName={fieldName} />
|
||||
</>
|
||||
)}
|
||||
<Flex className="direct-field-action-buttons">
|
||||
<InputFieldDescriptionPopover nodeId={nodeId} fieldName={fieldName} />
|
||||
<InputFieldResetToDefaultValueIconButton nodeId={nodeId} fieldName={fieldName} />
|
||||
<InputFieldAddToFormRoot nodeId={nodeId} fieldName={fieldName} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
|
||||
</Flex>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FloatFieldSlider } from 'features/nodes/components/flow/nodes/Invocatio
|
||||
import { FloatFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/FloatFieldCollectionInputComponent';
|
||||
import { FloatGeneratorFieldInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/FloatGeneratorFieldComponent';
|
||||
import { ImageFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldCollectionInputComponent';
|
||||
import { ImageGeneratorFieldInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageGeneratorFieldComponent';
|
||||
import { IntegerFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/IntegerFieldCollectionInputComponent';
|
||||
import { IntegerGeneratorFieldInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/IntegerGeneratorFieldComponent';
|
||||
import ModelIdentifierFieldInputComponent from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ModelIdentifierFieldInputComponent';
|
||||
@@ -49,6 +50,8 @@ import {
|
||||
isImageFieldCollectionInputTemplate,
|
||||
isImageFieldInputInstance,
|
||||
isImageFieldInputTemplate,
|
||||
isImageGeneratorFieldInputInstance,
|
||||
isImageGeneratorFieldInputTemplate,
|
||||
isIntegerFieldCollectionInputInstance,
|
||||
isIntegerFieldCollectionInputTemplate,
|
||||
isIntegerFieldInputInstance,
|
||||
@@ -392,6 +395,13 @@ export const InputFieldRenderer = memo(({ nodeId, fieldName, settings }: Props)
|
||||
return <StringGeneratorFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
if (isImageGeneratorFieldInputTemplate(template)) {
|
||||
if (!isImageGeneratorFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
return <ImageGeneratorFieldInputComponent nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useInputFieldIsConnected } from 'features/nodes/hooks/useInputFieldIsCo
|
||||
import { useInputFieldLabel } from 'features/nodes/hooks/useInputFieldLabel';
|
||||
import { useInputFieldTemplateTitle } from 'features/nodes/hooks/useInputFieldTemplateTitle';
|
||||
import { fieldLabelChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { HANDLE_TOOLTIP_OPEN_DELAY, NO_DRAG_CLASS, NO_FIT_ON_DOUBLE_CLICK_CLASS } from 'features/nodes/types/constants';
|
||||
import { HANDLE_TOOLTIP_OPEN_DELAY, NO_FIT_ON_DOUBLE_CLICK_CLASS } from 'features/nodes/types/constants';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -88,7 +88,7 @@ export const InputFieldTitle = memo((props: Props) => {
|
||||
isDisabled={isDragging}
|
||||
>
|
||||
<Text
|
||||
className={`${NO_DRAG_CLASS} ${NO_FIT_ON_DOUBLE_CLICK_CLASS}`}
|
||||
className={NO_FIT_ON_DOUBLE_CLICK_CLASS}
|
||||
sx={labelSx}
|
||||
noOfLines={1}
|
||||
data-is-invalid={isInvalid}
|
||||
@@ -104,7 +104,6 @@ export const InputFieldTitle = memo((props: Props) => {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className={NO_DRAG_CLASS}
|
||||
variant="outline"
|
||||
{...editable.inputProps}
|
||||
_focusVisible={{ borderRadius: 'base', h: 'unset', px: 2 }}
|
||||
|
||||
@@ -10,50 +10,99 @@ import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
|
||||
|
||||
import type { FieldComponentProps } from './types';
|
||||
|
||||
/**
|
||||
* The board field values in the UI do not map 1-to-1 to the values the graph expects.
|
||||
*
|
||||
* The graph value is either an object in the shape of `{board_id: string}` or undefined.
|
||||
*
|
||||
* But in the UI, we have the following options:
|
||||
* - auto: Use the "auto add" board. During graph building, we pull the auto add board ID from the state and use it.
|
||||
* - none: Do not assign a board. In the graph, this is represented as undefined.
|
||||
* - board_id: Assign the specified board. In the graph, this is represented as `{board_id: string}`.
|
||||
*
|
||||
* It's also possible that the UI value is undefined, which may be the case for some older workflows. In this case, we
|
||||
* map it to the "auto" option.
|
||||
*
|
||||
* So there is some translation that needs to happen in both directions - when the user selects a board in the UI, and
|
||||
* when we build the graph. The former is handled in this component, the latter in the `buildNodesGraph` function.
|
||||
*/
|
||||
|
||||
const listAllBoardsQueryArg = { include_archived: true };
|
||||
|
||||
const getBoardValue = (val: string) => {
|
||||
if (val === 'auto' || val === 'none') {
|
||||
return val;
|
||||
}
|
||||
|
||||
return {
|
||||
board_id: val,
|
||||
};
|
||||
};
|
||||
|
||||
const BoardFieldInputComponent = (props: FieldComponentProps<BoardFieldInputInstance, BoardFieldInputTemplate>) => {
|
||||
const { nodeId, field } = props;
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation();
|
||||
const { options, hasBoards } = useListAllBoardsQuery(
|
||||
{ include_archived: true },
|
||||
{
|
||||
selectFromResult: ({ data }) => {
|
||||
const options: ComboboxOption[] = [
|
||||
{
|
||||
label: 'None',
|
||||
value: 'none',
|
||||
},
|
||||
].concat(
|
||||
(data ?? []).map(({ board_id, board_name }) => ({
|
||||
label: board_name,
|
||||
value: board_id,
|
||||
}))
|
||||
);
|
||||
return {
|
||||
options,
|
||||
hasBoards: options.length > 1,
|
||||
};
|
||||
},
|
||||
const listAllBoardsQuery = useListAllBoardsQuery(listAllBoardsQueryArg);
|
||||
|
||||
const autoOption = useMemo<ComboboxOption>(() => {
|
||||
return {
|
||||
label: t('common.auto'),
|
||||
value: 'auto',
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const noneOption = useMemo<ComboboxOption>(() => {
|
||||
return {
|
||||
label: `${t('common.none')} (${t('boards.uncategorized')})`,
|
||||
value: 'none',
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const options = useMemo<ComboboxOption[]>(() => {
|
||||
const _options: ComboboxOption[] = [autoOption, noneOption];
|
||||
if (listAllBoardsQuery.data) {
|
||||
for (const board of listAllBoardsQuery.data) {
|
||||
_options.push({
|
||||
label: board.board_name,
|
||||
value: board.board_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
return _options;
|
||||
}, [autoOption, listAllBoardsQuery.data, noneOption]);
|
||||
|
||||
const onChange = useCallback<ComboboxOnChange>(
|
||||
(v) => {
|
||||
if (!v) {
|
||||
// This should never happen
|
||||
return;
|
||||
}
|
||||
|
||||
const value = getBoardValue(v.value);
|
||||
|
||||
dispatch(
|
||||
fieldBoardValueChanged({
|
||||
nodeId,
|
||||
fieldName: field.name,
|
||||
value: v.value !== 'none' ? { board_id: v.value } : undefined,
|
||||
value,
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, field.name, nodeId]
|
||||
);
|
||||
|
||||
const value = useMemo(() => options.find((o) => o.value === field.value?.board_id), [options, field.value]);
|
||||
const value = useMemo(() => {
|
||||
const _value = field.value;
|
||||
if (!_value || _value === 'auto') {
|
||||
return autoOption;
|
||||
}
|
||||
if (_value === 'none') {
|
||||
return noneOption;
|
||||
}
|
||||
const boardOption = options.find((o) => o.value === _value.board_id);
|
||||
return boardOption ?? autoOption;
|
||||
}, [field.value, options, autoOption, noneOption]);
|
||||
|
||||
const noOptionsMessage = useCallback(() => t('boards.noMatching'), [t]);
|
||||
|
||||
@@ -65,7 +114,6 @@ const BoardFieldInputComponent = (props: FieldComponentProps<BoardFieldInputInst
|
||||
onChange={onChange}
|
||||
placeholder={t('boards.selectBoard')}
|
||||
noOptionsMessage={noOptionsMessage}
|
||||
isDisabled={!hasBoards}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Switch } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { fieldBooleanValueChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
|
||||
import { NO_DRAG_CLASS, NO_FIT_ON_DOUBLE_CLICK_CLASS } from 'features/nodes/types/constants';
|
||||
import type { BooleanFieldInputInstance, BooleanFieldInputTemplate } from 'features/nodes/types/field';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
@@ -28,7 +28,13 @@ const BooleanFieldInputComponent = (
|
||||
[dispatch, field.name, nodeId]
|
||||
);
|
||||
|
||||
return <Switch className={NO_DRAG_CLASS} onChange={handleValueChanged} isChecked={field.value} />;
|
||||
return (
|
||||
<Switch
|
||||
className={`${NO_DRAG_CLASS} ${NO_FIT_ON_DOUBLE_CLICK_CLASS}`}
|
||||
onChange={handleValueChanged}
|
||||
isChecked={field.value}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(BooleanFieldInputComponent);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Flex, Select, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { getOverlayScrollbarsParams, overlayScrollbarsStyles } from 'common/components/OverlayScrollbars/constants';
|
||||
import { ImageGeneratorImagesFromBoardSettings } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageGeneratorImagesFromBoardSettings';
|
||||
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
|
||||
import { fieldImageGeneratorValueChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
|
||||
import type { ImageGeneratorFieldInputInstance, ImageGeneratorFieldInputTemplate } from 'features/nodes/types/field';
|
||||
import {
|
||||
getImageGeneratorDefaults,
|
||||
ImageGeneratorImagesFromBoardType,
|
||||
resolveImageGeneratorField,
|
||||
} from 'features/nodes/types/field';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const overlayscrollbarsOptions = getOverlayScrollbarsParams().options;
|
||||
|
||||
export const ImageGeneratorFieldInputComponent = memo(
|
||||
(props: FieldComponentProps<ImageGeneratorFieldInputInstance, ImageGeneratorFieldInputTemplate>) => {
|
||||
const { nodeId, field } = props;
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onChange = useCallback(
|
||||
(value: ImageGeneratorFieldInputInstance['value']) => {
|
||||
dispatch(
|
||||
fieldImageGeneratorValueChanged({
|
||||
nodeId,
|
||||
fieldName: field.name,
|
||||
value,
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, field.name, nodeId]
|
||||
);
|
||||
|
||||
const onChangeGeneratorType = useCallback(
|
||||
(e: ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = getImageGeneratorDefaults(e.target.value as ImageGeneratorFieldInputInstance['value']['type']);
|
||||
dispatch(
|
||||
fieldImageGeneratorValueChanged({
|
||||
nodeId,
|
||||
fieldName: field.name,
|
||||
value,
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, field.name, nodeId]
|
||||
);
|
||||
|
||||
const [resolvedValuesAsString, setResolvedValuesAsString] = useState<string | null>(null);
|
||||
const resolveAndSetValuesAsString = useMemo(
|
||||
() =>
|
||||
debounce(async (field: ImageGeneratorFieldInputInstance) => {
|
||||
const resolvedValues = await resolveImageGeneratorField(field, dispatch);
|
||||
if (resolvedValues.length === 0) {
|
||||
setResolvedValuesAsString(`<${t('nodes.generatorNoValues')}>`);
|
||||
} else {
|
||||
setResolvedValuesAsString(`<${t('nodes.generatorImages', { count: resolvedValues.length })}>`);
|
||||
}
|
||||
}, 300),
|
||||
[dispatch, t]
|
||||
);
|
||||
useEffect(() => {
|
||||
resolveAndSetValuesAsString(field);
|
||||
}, [field, resolveAndSetValuesAsString]);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" gap={2}>
|
||||
<Select
|
||||
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
|
||||
onChange={onChangeGeneratorType}
|
||||
value={field.value.type}
|
||||
size="sm"
|
||||
>
|
||||
<option value={ImageGeneratorImagesFromBoardType}>{t('nodes.generatorImagesFromBoard')}</option>
|
||||
</Select>
|
||||
{field.value.type === ImageGeneratorImagesFromBoardType && (
|
||||
<ImageGeneratorImagesFromBoardSettings state={field.value} onChange={onChange} />
|
||||
)}
|
||||
<Flex w="full" h="full" p={2} borderWidth={1} borderRadius="base" maxH={128}>
|
||||
<Flex w="full" h="auto">
|
||||
<OverlayScrollbarsComponent
|
||||
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
|
||||
defer
|
||||
style={overlayScrollbarsStyles}
|
||||
options={overlayscrollbarsOptions}
|
||||
>
|
||||
<Text
|
||||
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
|
||||
fontFamily="monospace"
|
||||
userSelect="text"
|
||||
cursor="text"
|
||||
>
|
||||
{resolvedValuesAsString}
|
||||
</Text>
|
||||
</OverlayScrollbarsComponent>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ImageGeneratorFieldInputComponent.displayName = 'ImageGeneratorFieldInputComponent';
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
|
||||
import { Combobox, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library';
|
||||
import { EMPTY_ARRAY } from 'app/store/constants';
|
||||
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
|
||||
import type { ImageGeneratorImagesFromBoard } from 'features/nodes/types/field';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
|
||||
|
||||
type ImageGeneratorImagesFromBoardSettingsProps = {
|
||||
state: ImageGeneratorImagesFromBoard;
|
||||
onChange: (state: ImageGeneratorImagesFromBoard) => void;
|
||||
};
|
||||
export const ImageGeneratorImagesFromBoardSettings = memo(
|
||||
({ state, onChange }: ImageGeneratorImagesFromBoardSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onChangeCategory = useCallback(
|
||||
(category: 'images' | 'assets') => {
|
||||
onChange({ ...state, category });
|
||||
},
|
||||
[onChange, state]
|
||||
);
|
||||
const onChangeBoardId = useCallback(
|
||||
(board_id: string) => {
|
||||
onChange({ ...state, board_id });
|
||||
},
|
||||
[onChange, state]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex gap={2} flexDir="column">
|
||||
<FormControl orientation="vertical">
|
||||
<FormLabel>{t('common.board')}</FormLabel>
|
||||
<BoardCombobox board_id={state.board_id} onChange={onChangeBoardId} />
|
||||
</FormControl>
|
||||
<FormControl orientation="vertical">
|
||||
<FormLabel>{t('nodes.generatorImagesCategory')}</FormLabel>
|
||||
<CategoryCombobox category={state.category} onChange={onChangeCategory} />
|
||||
</FormControl>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
);
|
||||
ImageGeneratorImagesFromBoardSettings.displayName = 'ImageGeneratorImagesFromBoardSettings';
|
||||
|
||||
const listAllBoardsQueryArg = { include_archived: false };
|
||||
|
||||
const BoardCombobox = ({
|
||||
board_id,
|
||||
onChange: _onChange,
|
||||
}: {
|
||||
board_id: string | undefined;
|
||||
onChange: (board_id: string) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const listAllBoardsQuery = useListAllBoardsQuery(listAllBoardsQueryArg);
|
||||
|
||||
const options = useMemo<ComboboxOption[]>(() => {
|
||||
if (!listAllBoardsQuery.data) {
|
||||
return EMPTY_ARRAY;
|
||||
}
|
||||
return listAllBoardsQuery.data.map((board) => ({
|
||||
label: board.board_name,
|
||||
value: board.board_id,
|
||||
}));
|
||||
}, [listAllBoardsQuery.data]);
|
||||
|
||||
const onChange = useCallback<ComboboxOnChange>(
|
||||
(v) => {
|
||||
if (!v) {
|
||||
// This should never happen
|
||||
return;
|
||||
}
|
||||
|
||||
_onChange(v.value);
|
||||
},
|
||||
[_onChange]
|
||||
);
|
||||
|
||||
const value = useMemo(() => options.find((o) => o.value === board_id) ?? null, [board_id, options]);
|
||||
|
||||
const noOptionsMessage = useCallback(() => t('boards.noMatching'), [t]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
|
||||
value={value}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
placeholder={t('boards.selectBoard')}
|
||||
noOptionsMessage={noOptionsMessage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CategoryCombobox = ({
|
||||
category,
|
||||
onChange: _onChange,
|
||||
}: {
|
||||
category: 'images' | 'assets';
|
||||
onChange: (category: 'images' | 'assets') => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const options = useMemo<ComboboxOption[]>(
|
||||
() => [
|
||||
{ label: t('gallery.images'), value: 'images' },
|
||||
{ label: t('gallery.assets'), value: 'assets' },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const onChange = useCallback<ComboboxOnChange>(
|
||||
(v) => {
|
||||
if (!v || (v.value !== 'images' && v.value !== 'assets')) {
|
||||
// This should never happen
|
||||
return;
|
||||
}
|
||||
|
||||
_onChange(v.value);
|
||||
},
|
||||
[_onChange]
|
||||
);
|
||||
|
||||
const value = useMemo(() => options.find((o) => o.value === category), [options, category]);
|
||||
|
||||
const noOptionsMessage = useCallback(() => t('boards.noMatching'), [t]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
className={`${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`}
|
||||
value={value}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
placeholder={t('boards.selectBoard')}
|
||||
noOptionsMessage={noOptionsMessage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,8 @@
|
||||
import { CompositeNumberInput, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library';
|
||||
import { GeneratorTextareaWithFileUpload } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/GeneratorTextareaWithFileUpload';
|
||||
import type { StringGeneratorDynamicPromptsCombinatorial } from 'features/nodes/types/field';
|
||||
import { memo, useCallback, useEffect, useMemo } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDynamicPromptsQuery } from 'services/api/endpoints/utilities';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
|
||||
type StringGeneratorDynamicPromptsCombinatorialSettingsProps = {
|
||||
state: StringGeneratorDynamicPromptsCombinatorial;
|
||||
@@ -13,41 +11,20 @@ type StringGeneratorDynamicPromptsCombinatorialSettingsProps = {
|
||||
export const StringGeneratorDynamicPromptsCombinatorialSettings = memo(
|
||||
({ state, onChange }: StringGeneratorDynamicPromptsCombinatorialSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const loadingValues = useMemo(() => [`<${t('nodes.generatorLoading')}>`], [t]);
|
||||
|
||||
const onChangeInput = useCallback(
|
||||
(input: string) => {
|
||||
onChange({ ...state, input, values: loadingValues });
|
||||
onChange({ ...state, input });
|
||||
},
|
||||
[onChange, state, loadingValues]
|
||||
[onChange, state]
|
||||
);
|
||||
const onChangeMaxPrompts = useCallback(
|
||||
(v: number) => {
|
||||
onChange({ ...state, maxPrompts: v, values: loadingValues });
|
||||
onChange({ ...state, maxPrompts: v });
|
||||
},
|
||||
[onChange, state, loadingValues]
|
||||
[onChange, state]
|
||||
);
|
||||
|
||||
const arg = useMemo(() => {
|
||||
return { prompt: state.input, max_prompts: state.maxPrompts, combinatorial: true };
|
||||
}, [state]);
|
||||
const [debouncedArg] = useDebounce(arg, 300);
|
||||
|
||||
const { data, isLoading } = useDynamicPromptsQuery(debouncedArg);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
onChange({ ...state, values: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
onChange({ ...state, values: data.prompts });
|
||||
}, [data, isLoading, onChange, state]);
|
||||
|
||||
return (
|
||||
<Flex gap={2} flexDir="column">
|
||||
<FormControl orientation="vertical">
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Checkbox, CompositeNumberInput, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library';
|
||||
import { GeneratorTextareaWithFileUpload } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/GeneratorTextareaWithFileUpload';
|
||||
import type { StringGeneratorDynamicPromptsRandom } from 'features/nodes/types/field';
|
||||
import { isNil, random } from 'lodash-es';
|
||||
import { memo, useCallback, useEffect, useMemo } from 'react';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDynamicPromptsQuery } from 'services/api/endpoints/utilities';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
|
||||
type StringGeneratorDynamicPromptsRandomSettingsProps = {
|
||||
state: StringGeneratorDynamicPromptsRandom;
|
||||
@@ -14,51 +12,29 @@ type StringGeneratorDynamicPromptsRandomSettingsProps = {
|
||||
export const StringGeneratorDynamicPromptsRandomSettings = memo(
|
||||
({ state, onChange }: StringGeneratorDynamicPromptsRandomSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const loadingValues = useMemo(() => [`<${t('nodes.generatorLoading')}>`], [t]);
|
||||
|
||||
const onChangeInput = useCallback(
|
||||
(input: string) => {
|
||||
onChange({ ...state, input, values: loadingValues });
|
||||
onChange({ ...state, input });
|
||||
},
|
||||
[onChange, state, loadingValues]
|
||||
[onChange, state]
|
||||
);
|
||||
const onChangeCount = useCallback(
|
||||
(v: number) => {
|
||||
onChange({ ...state, count: v, values: loadingValues });
|
||||
onChange({ ...state, count: v });
|
||||
},
|
||||
[onChange, state, loadingValues]
|
||||
[onChange, state]
|
||||
);
|
||||
const onToggleSeed = useCallback(() => {
|
||||
onChange({ ...state, seed: isNil(state.seed) ? 0 : null, values: loadingValues });
|
||||
}, [onChange, state, loadingValues]);
|
||||
onChange({ ...state, seed: isNil(state.seed) ? 0 : null });
|
||||
}, [onChange, state]);
|
||||
const onChangeSeed = useCallback(
|
||||
(seed?: number | null) => {
|
||||
onChange({ ...state, seed, values: loadingValues });
|
||||
onChange({ ...state, seed });
|
||||
},
|
||||
[onChange, state, loadingValues]
|
||||
[onChange, state]
|
||||
);
|
||||
|
||||
const arg = useMemo(() => {
|
||||
return { prompt: state.input, max_prompts: state.count, combinatorial: false, seed: state.seed ?? random() };
|
||||
}, [state.count, state.input, state.seed]);
|
||||
|
||||
const [debouncedArg] = useDebounce(arg, 300);
|
||||
|
||||
const { data, isLoading } = useDynamicPromptsQuery(debouncedArg);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
onChange({ ...state, values: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
onChange({ ...state, values: data.prompts });
|
||||
}, [data, isLoading, onChange, state]);
|
||||
|
||||
return (
|
||||
<Flex gap={2} flexDir="column">
|
||||
<Flex gap={2}>
|
||||
|
||||
@@ -15,12 +15,11 @@ import {
|
||||
StringGeneratorDynamicPromptsRandomType,
|
||||
StringGeneratorParseStringType,
|
||||
} from 'features/nodes/types/field';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
|
||||
const overlayscrollbarsOptions = getOverlayScrollbarsParams().options;
|
||||
|
||||
@@ -57,20 +56,22 @@ export const StringGeneratorFieldInputComponent = memo(
|
||||
[dispatch, field.name, nodeId]
|
||||
);
|
||||
|
||||
const [debouncedField] = useDebounce(field, 300);
|
||||
const resolvedValuesAsString = useMemo(() => {
|
||||
if (debouncedField.value.type === StringGeneratorDynamicPromptsRandomType && isNil(debouncedField.value.seed)) {
|
||||
const { count } = debouncedField.value;
|
||||
return `<${t('nodes.generatorNRandomValues', { count })}>`;
|
||||
}
|
||||
|
||||
const resolvedValues = resolveStringGeneratorField(debouncedField);
|
||||
if (resolvedValues.length === 0) {
|
||||
return `<${t('nodes.generatorNoValues')}>`;
|
||||
} else {
|
||||
return resolvedValues.join(', ');
|
||||
}
|
||||
}, [debouncedField, t]);
|
||||
const [resolvedValuesAsString, setResolvedValuesAsString] = useState<string | null>(null);
|
||||
const resolveAndSetValuesAsString = useMemo(
|
||||
() =>
|
||||
debounce(async (field: StringGeneratorFieldInputInstance) => {
|
||||
const resolvedValues = await resolveStringGeneratorField(field, dispatch);
|
||||
if (resolvedValues.length === 0) {
|
||||
setResolvedValuesAsString(`<${t('nodes.generatorNoValues')}>`);
|
||||
} else {
|
||||
setResolvedValuesAsString(resolvedValues.join(', '));
|
||||
}
|
||||
}, 300),
|
||||
[dispatch, t]
|
||||
);
|
||||
useEffect(() => {
|
||||
resolveAndSetValuesAsString(field);
|
||||
}, [field, resolveAndSetValuesAsString]);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" gap={2}>
|
||||
@@ -81,10 +82,10 @@ export const StringGeneratorFieldInputComponent = memo(
|
||||
size="sm"
|
||||
>
|
||||
<option value={StringGeneratorParseStringType}>{t('nodes.parseString')}</option>
|
||||
{/* <option value={StringGeneratorDynamicPromptsRandomType}>{t('nodes.dynamicPromptsRandom')}</option>
|
||||
<option value={StringGeneratorDynamicPromptsRandomType}>{t('nodes.dynamicPromptsRandom')}</option>
|
||||
<option value={StringGeneratorDynamicPromptsCombinatorialType}>
|
||||
{t('nodes.dynamicPromptsCombinatorial')}
|
||||
</option> */}
|
||||
</option>
|
||||
</Select>
|
||||
{field.value.type === StringGeneratorParseStringType && (
|
||||
<StringGeneratorParseStringSettings state={field.value} onChange={onChange} />
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Box, Flex, Textarea } from '@invoke-ai/ui-library';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { Node, NodeProps } from '@xyflow/react';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import NodeCollapseButton from 'features/nodes/components/flow/nodes/common/NodeCollapseButton';
|
||||
import NodeTitle from 'features/nodes/components/flow/nodes/common/NodeTitle';
|
||||
import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper';
|
||||
import { notesNodeValueChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { selectNodes } from 'features/nodes/store/selectors';
|
||||
import { NO_DRAG_CLASS, NO_PAN_CLASS } from 'features/nodes/types/constants';
|
||||
import type { NotesNodeData } from 'features/nodes/types/invocation';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
|
||||
const NotesNode = (props: NodeProps<Node<NotesNodeData>>) => {
|
||||
const { id: nodeId, data, selected } = props;
|
||||
@@ -21,6 +23,16 @@ const NotesNode = (props: NodeProps<Node<NotesNodeData>>) => {
|
||||
[dispatch, nodeId]
|
||||
);
|
||||
|
||||
const selectNodeExists = useMemo(
|
||||
() => createSelector(selectNodes, (nodes) => Boolean(nodes.find((n) => n.id === nodeId))),
|
||||
[nodeId]
|
||||
);
|
||||
const nodeExists = useAppSelector(selectNodeExists);
|
||||
|
||||
if (!nodeExists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeWrapper nodeId={nodeId} selected={selected}>
|
||||
<Flex
|
||||
|
||||
@@ -1,7 +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 { useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
|
||||
import { useMouseOverFormField, useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
|
||||
import { useNodeExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
|
||||
import { useZoomToNode } from 'features/nodes/hooks/useZoomToNode';
|
||||
import { selectNodeOpacity } from 'features/nodes/store/workflowSettingsSlice';
|
||||
@@ -16,8 +16,8 @@ type NodeWrapperProps = PropsWithChildren & {
|
||||
width?: ChakraProps['w'];
|
||||
};
|
||||
|
||||
// Animations are disabled as a performance optimization - they can cause massive slowdowns in large workflows - even
|
||||
// when the animations are GPU-accelerated CSS.
|
||||
// Certain CSS transitions are disabled as a performance optimization - they can cause massive slowdowns in large
|
||||
// workflows even when the animations are GPU-accelerated CSS.
|
||||
|
||||
const containerSx: SystemStyleObject = {
|
||||
h: 'full',
|
||||
@@ -25,6 +25,43 @@ const containerSx: SystemStyleObject = {
|
||||
borderRadius: 'base',
|
||||
transitionProperty: 'none',
|
||||
cursor: 'grab',
|
||||
// The action buttons are hidden by default and shown on hover
|
||||
'& .node-selection-overlay': {
|
||||
display: 'none',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
insetInlineEnd: 0,
|
||||
bottom: 0,
|
||||
insetInlineStart: 0,
|
||||
borderRadius: 'base',
|
||||
transitionProperty: 'none',
|
||||
pointerEvents: 'none',
|
||||
opacity: 0.5,
|
||||
},
|
||||
'&[data-is-mouse-over-node="true"] .node-selection-overlay': {
|
||||
opacity: 1,
|
||||
display: 'block',
|
||||
},
|
||||
'&[data-is-mouse-over-form-field="true"] .node-selection-overlay': {
|
||||
opacity: 1,
|
||||
display: 'block',
|
||||
bg: 'invokeBlueAlpha.100',
|
||||
},
|
||||
_hover: {
|
||||
'& .node-selection-overlay': {
|
||||
display: 'block',
|
||||
shadow: '0 0 0 2px var(--invoke-colors-blue-300)',
|
||||
},
|
||||
'&[data-is-selected="true"] .node-selection-overlay': {
|
||||
display: 'block',
|
||||
opacity: 1,
|
||||
shadow: '0 0 0 3px var(--invoke-colors-blue-300)',
|
||||
},
|
||||
},
|
||||
'&[data-is-selected="true"] .node-selection-overlay': {
|
||||
display: 'block',
|
||||
shadow: '0 0 0 3px var(--invoke-colors-blue-300)',
|
||||
},
|
||||
};
|
||||
|
||||
const shadowsSx: SystemStyleObject = {
|
||||
@@ -50,36 +87,17 @@ const inProgressSx: SystemStyleObject = {
|
||||
transitionProperty: 'none',
|
||||
opacity: 0.7,
|
||||
zIndex: -1,
|
||||
visibility: 'hidden',
|
||||
display: 'none',
|
||||
shadow: '0 0 0 2px var(--invoke-colors-yellow-400), 0 0 20px 2px var(--invoke-colors-orange-700)',
|
||||
'&[data-is-in-progress="true"]': {
|
||||
visibility: 'visible',
|
||||
},
|
||||
};
|
||||
|
||||
const selectionOverlaySx: SystemStyleObject = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
insetInlineEnd: 0,
|
||||
bottom: 0,
|
||||
insetInlineStart: 0,
|
||||
borderRadius: 'base',
|
||||
transitionProperty: 'none',
|
||||
pointerEvents: 'none',
|
||||
visibility: 'hidden',
|
||||
opacity: 0.5,
|
||||
'&[data-is-selected="true"], &[data-is-hovered="true"]': { visibility: 'visible' },
|
||||
'&[data-is-selected="true"]': { shadow: '0 0 0 3px var(--invoke-colors-blue-300)' },
|
||||
'&[data-is-hovered="true"]': { shadow: '0 0 0 2px var(--invoke-colors-blue-300)' },
|
||||
'&[data-is-selected="true"][data-is-hovered="true"]': {
|
||||
opacity: 1,
|
||||
shadow: '0 0 0 3px var(--invoke-colors-blue-300)',
|
||||
display: 'block',
|
||||
},
|
||||
};
|
||||
|
||||
const NodeWrapper = (props: NodeWrapperProps) => {
|
||||
const { nodeId, width, children, selected } = props;
|
||||
const { isMouseOverNode, handleMouseOut, handleMouseOver } = useMouseOverNode(nodeId);
|
||||
const mouseOverNode = useMouseOverNode(nodeId);
|
||||
const mouseOverFormField = useMouseOverFormField(nodeId);
|
||||
const zoomToNode = useZoomToNode();
|
||||
|
||||
const executionState = useNodeExecutionState(nodeId);
|
||||
@@ -117,17 +135,19 @@ const NodeWrapper = (props: NodeWrapperProps) => {
|
||||
<Box
|
||||
onClick={globalMenu.onCloseGlobal}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onMouseEnter={handleMouseOver}
|
||||
onMouseLeave={handleMouseOut}
|
||||
onMouseOver={mouseOverNode.handleMouseOver}
|
||||
onMouseOut={mouseOverNode.handleMouseOut}
|
||||
className={DRAG_HANDLE_CLASSNAME}
|
||||
sx={containerSx}
|
||||
width={width || NODE_WIDTH}
|
||||
opacity={opacity}
|
||||
data-is-selected={selected}
|
||||
data-is-mouse-over-form-field={mouseOverFormField.isMouseOverFormField}
|
||||
>
|
||||
<Box sx={shadowsSx} />
|
||||
<Box sx={inProgressSx} data-is-in-progress={isInProgress} />
|
||||
{children}
|
||||
<Box sx={selectionOverlaySx} data-is-selected={selected} data-is-hovered={isMouseOverNode} />
|
||||
<Box className="node-selection-overlay" />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export const ActiveWorkflowNameAndActions = memo(() => {
|
||||
const mode = useAppSelector(selectWorkflowMode);
|
||||
|
||||
return (
|
||||
<Flex w="full" alignItems="center" gap={2} minW={0}>
|
||||
<Flex w="full" alignItems="center" gap={1} minW={0}>
|
||||
<WorkflowListMenuTrigger />
|
||||
<Spacer />
|
||||
{mode === 'edit' && <SaveWorkflowButton />}
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { Box, Button, Flex, Popover, PopoverBody, PopoverContent, PopoverTrigger, Portal } from '@invoke-ai/ui-library';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Popover,
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Portal,
|
||||
Text,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $workflowCategories } from 'app/store/nanostores/workflowCategories';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
@@ -32,8 +42,17 @@ export const WorkflowListMenuTrigger = () => {
|
||||
initialFocusRef={searchInputRef}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Button variant="ghost" rightIcon={<PiFolderOpenFill />}>
|
||||
{workflowName || t('workflows.chooseWorkflowFromLibrary')}
|
||||
<Button variant="ghost" rightIcon={<PiFolderOpenFill />} size="sm">
|
||||
<Text
|
||||
display="auto"
|
||||
noOfLines={1}
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
wordBreak="break-all"
|
||||
>
|
||||
{workflowName || t('workflows.chooseWorkflowFromLibrary')}
|
||||
</Text>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Box, Flex, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import {
|
||||
ContainerContextProvider,
|
||||
DepthContextProvider,
|
||||
useContainerContext,
|
||||
useDepthContext,
|
||||
} from 'features/nodes/components/sidePanel/builder/contexts';
|
||||
import { DividerElement } from 'features/nodes/components/sidePanel/builder/DividerElement';
|
||||
import { useFormElementDnd, useRootElementDropTarget } from 'features/nodes/components/sidePanel/builder/dnd-hooks';
|
||||
import { DndListDropIndicator } from 'features/nodes/components/sidePanel/builder/DndListDropIndicator';
|
||||
import { FormElementEditModeContent } from 'features/nodes/components/sidePanel/builder/FormElementEditModeContent';
|
||||
import { FormElementEditModeHeader } from 'features/nodes/components/sidePanel/builder/FormElementEditModeHeader';
|
||||
import { HeadingElement } from 'features/nodes/components/sidePanel/builder/HeadingElement';
|
||||
import { NodeFieldElement } from 'features/nodes/components/sidePanel/builder/NodeFieldElement';
|
||||
import { TextElement } from 'features/nodes/components/sidePanel/builder/TextElement';
|
||||
import { selectFormRootElement, selectWorkflowMode, useElement } from 'features/nodes/store/workflowSlice';
|
||||
import type { ContainerElement } from 'features/nodes/types/workflow';
|
||||
import {
|
||||
CONTAINER_CLASS_NAME,
|
||||
isContainerElement,
|
||||
isDividerElement,
|
||||
isHeadingElement,
|
||||
isNodeFieldElement,
|
||||
isTextElement,
|
||||
ROOT_CONTAINER_CLASS_NAME,
|
||||
} from 'features/nodes/types/workflow';
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Equals } from 'tsafe';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
const ContainerElement = memo(({ id }: { id: string }) => {
|
||||
const el = useElement(id);
|
||||
const mode = useAppSelector(selectWorkflowMode);
|
||||
|
||||
if (!el || !isContainerElement(el)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mode === 'view') {
|
||||
return <ContainerElementComponentViewMode el={el} />;
|
||||
}
|
||||
|
||||
// mode === 'edit'
|
||||
return <ContainerElementComponentEditMode el={el} />;
|
||||
});
|
||||
ContainerElement.displayName = 'ContainerElementComponent';
|
||||
|
||||
const containerViewModeSx: SystemStyleObject = {
|
||||
gap: 4,
|
||||
'&[data-self-layout="column"]': {
|
||||
flexDir: 'column',
|
||||
alignItems: 'stretch',
|
||||
},
|
||||
'&[data-self-layout="row"]': {
|
||||
flexDir: 'row',
|
||||
alignItems: 'flex-start',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'visible',
|
||||
h: 'min-content',
|
||||
},
|
||||
'&[data-parent-layout="column"]': {
|
||||
w: 'full',
|
||||
h: 'min-content',
|
||||
},
|
||||
'&[data-parent-layout="row"]': {
|
||||
flex: '1 1 0',
|
||||
minW: 32,
|
||||
},
|
||||
};
|
||||
|
||||
const ContainerElementComponentViewMode = memo(({ el }: { el: ContainerElement }) => {
|
||||
const { t } = useTranslation();
|
||||
const depth = useDepthContext();
|
||||
const containerCtx = useContainerContext();
|
||||
const { id, data } = el;
|
||||
const { children, layout } = data;
|
||||
|
||||
return (
|
||||
<DepthContextProvider depth={depth + 1}>
|
||||
<ContainerContextProvider id={id} layout={layout}>
|
||||
<Flex
|
||||
id={id}
|
||||
className={CONTAINER_CLASS_NAME}
|
||||
sx={containerViewModeSx}
|
||||
data-self-layout={layout}
|
||||
data-depth={depth}
|
||||
data-parent-layout={containerCtx.layout}
|
||||
>
|
||||
{children.map((childId) => (
|
||||
<FormElementComponent key={childId} id={childId} />
|
||||
))}
|
||||
{children.length === 0 && (
|
||||
<Flex p={8} w="full" h="full" alignItems="center" justifyContent="center">
|
||||
<Text variant="subtext">{t('workflows.builder.containerPlaceholder')}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</ContainerContextProvider>
|
||||
</DepthContextProvider>
|
||||
);
|
||||
});
|
||||
ContainerElementComponentViewMode.displayName = 'ContainerElementComponentViewMode';
|
||||
|
||||
const containerEditModeSx: SystemStyleObject = {
|
||||
borderRadius: 'base',
|
||||
position: 'relative',
|
||||
'&[data-active-drop-region="center"]': {
|
||||
opacity: 1,
|
||||
bg: 'base.850',
|
||||
},
|
||||
flexDir: 'column',
|
||||
'&[data-parent-layout="column"]': {
|
||||
w: 'full',
|
||||
h: 'min-content',
|
||||
},
|
||||
'&[data-parent-layout="row"]': {
|
||||
flex: '1 1 0',
|
||||
h: 'min-content',
|
||||
},
|
||||
};
|
||||
|
||||
const containerEditModeContentSx: SystemStyleObject = {
|
||||
gap: 4,
|
||||
p: 4,
|
||||
flex: '1 1 0',
|
||||
'&[data-self-layout="column"]': {
|
||||
flexDir: 'column',
|
||||
},
|
||||
'&[data-self-layout="row"]': {
|
||||
flexDir: 'row',
|
||||
overflowX: 'auto',
|
||||
},
|
||||
};
|
||||
|
||||
const ContainerElementComponentEditMode = memo(({ el }: { el: ContainerElement }) => {
|
||||
const depth = useDepthContext();
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const dragHandleRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef<HTMLDivElement>(null);
|
||||
const [activeDropRegion, isDragging] = useFormElementDnd(el.id, draggableRef, dragHandleRef);
|
||||
const { id, data } = el;
|
||||
const { children, layout } = data;
|
||||
const containerCtx = useContainerContext();
|
||||
|
||||
useEffect(() => {
|
||||
const element = autoScrollRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (layout === 'column') {
|
||||
// No need to auto-scroll for column layout
|
||||
return;
|
||||
}
|
||||
|
||||
return autoScrollForElements({
|
||||
element,
|
||||
});
|
||||
}, [layout]);
|
||||
|
||||
return (
|
||||
<DepthContextProvider depth={depth + 1}>
|
||||
<ContainerContextProvider id={id} layout={layout}>
|
||||
<Flex
|
||||
id={id}
|
||||
ref={draggableRef}
|
||||
className={CONTAINER_CLASS_NAME}
|
||||
sx={containerEditModeSx}
|
||||
data-depth={depth}
|
||||
data-parent-layout={containerCtx.layout}
|
||||
data-active-drop-region={activeDropRegion}
|
||||
>
|
||||
<FormElementEditModeHeader dragHandleRef={dragHandleRef} element={el} data-is-dragging={isDragging} />
|
||||
<FormElementEditModeContent data-is-dragging={isDragging}>
|
||||
<Flex ref={autoScrollRef} sx={containerEditModeContentSx} data-self-layout={layout}>
|
||||
{children.map((childId) => (
|
||||
<FormElementComponent key={childId} id={childId} />
|
||||
))}
|
||||
{children.length === 0 && <NonRootPlaceholder />}
|
||||
</Flex>
|
||||
</FormElementEditModeContent>
|
||||
<DndListDropIndicator activeDropRegion={activeDropRegion} gap="var(--invoke-space-4)" />
|
||||
</Flex>
|
||||
</ContainerContextProvider>
|
||||
</DepthContextProvider>
|
||||
);
|
||||
});
|
||||
ContainerElementComponentEditMode.displayName = 'ContainerElementComponentEditMode';
|
||||
|
||||
const rootViewModeSx: SystemStyleObject = {
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
borderRadius: 'base',
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
gap: 4,
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
maxW: '768px',
|
||||
'&[data-self-layout="column"]': {
|
||||
flexDir: 'column',
|
||||
alignItems: 'stretch',
|
||||
},
|
||||
'&[data-self-layout="row"]': {
|
||||
flexDir: 'row',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
};
|
||||
|
||||
export const RootContainerElementViewMode = memo(() => {
|
||||
const el = useAppSelector(selectFormRootElement);
|
||||
const { id, data } = el;
|
||||
const { children, layout } = data;
|
||||
|
||||
return (
|
||||
<DepthContextProvider depth={0}>
|
||||
<ContainerContextProvider id={id} layout={layout}>
|
||||
<Box id={id} className={ROOT_CONTAINER_CLASS_NAME} sx={rootViewModeSx} data-self-layout={layout} data-depth={0}>
|
||||
{children.map((childId) => (
|
||||
<FormElementComponent key={childId} id={childId} />
|
||||
))}
|
||||
</Box>
|
||||
</ContainerContextProvider>
|
||||
</DepthContextProvider>
|
||||
);
|
||||
});
|
||||
RootContainerElementViewMode.displayName = 'RootContainerElementViewMode';
|
||||
|
||||
const rootEditModeSx: SystemStyleObject = {
|
||||
...rootViewModeSx,
|
||||
'&[data-is-dragging-over="true"]': {
|
||||
opacity: 1,
|
||||
bg: 'base.850',
|
||||
},
|
||||
};
|
||||
|
||||
export const RootContainerElementEditMode = memo(() => {
|
||||
const el = useAppSelector(selectFormRootElement);
|
||||
const { id, data } = el;
|
||||
const { children, layout } = data;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isDraggingOver = useRootElementDropTarget(ref);
|
||||
|
||||
return (
|
||||
<DepthContextProvider depth={0}>
|
||||
<ContainerContextProvider id={id} layout={layout}>
|
||||
<Flex
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={ROOT_CONTAINER_CLASS_NAME}
|
||||
sx={rootEditModeSx}
|
||||
data-self-layout={layout}
|
||||
data-depth={0}
|
||||
data-is-dragging-over={isDraggingOver}
|
||||
>
|
||||
{children.map((childId) => (
|
||||
<FormElementComponent key={childId} id={childId} />
|
||||
))}
|
||||
{children.length === 0 && <RootPlaceholder />}
|
||||
</Flex>
|
||||
</ContainerContextProvider>
|
||||
</DepthContextProvider>
|
||||
);
|
||||
});
|
||||
RootContainerElementEditMode.displayName = 'RootContainerElementEditMode';
|
||||
|
||||
const RootPlaceholder = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Flex p={8} w="full" h="full" alignItems="center" justifyContent="center">
|
||||
<Text variant="subtext">{t('workflows.builder.emptyRootPlaceholderEditMode')}</Text>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
RootPlaceholder.displayName = 'RootPlaceholder';
|
||||
|
||||
const NonRootPlaceholder = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Flex p={8} w="full" h="full" alignItems="center" justifyContent="center">
|
||||
<Text variant="subtext">{t('workflows.builder.containerPlaceholder')}</Text>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
NonRootPlaceholder.displayName = 'NonRootPlaceholder';
|
||||
|
||||
// TODO(psyche): Can we move this into a separate file and avoid circular dependencies between it and ContainerElementComponent?
|
||||
const FormElementComponent = memo(({ id }: { id: string }) => {
|
||||
const el = useElement(id);
|
||||
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isContainerElement(el)) {
|
||||
return <ContainerElement key={id} id={id} />;
|
||||
}
|
||||
|
||||
if (isNodeFieldElement(el)) {
|
||||
return <NodeFieldElement key={id} id={id} />;
|
||||
}
|
||||
|
||||
if (isDividerElement(el)) {
|
||||
return <DividerElement key={id} id={id} />;
|
||||
}
|
||||
|
||||
if (isHeadingElement(el)) {
|
||||
return <HeadingElement key={id} id={id} />;
|
||||
}
|
||||
|
||||
if (isTextElement(el)) {
|
||||
return <TextElement key={id} id={id} />;
|
||||
}
|
||||
|
||||
assert<Equals<typeof el, never>>(false, `Unhandled type for element with id ${id}`);
|
||||
});
|
||||
FormElementComponent.displayName = 'FormElementComponent';
|
||||
@@ -1,160 +0,0 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import {
|
||||
ContainerContextProvider,
|
||||
DepthContextProvider,
|
||||
useDepthContext,
|
||||
} from 'features/nodes/components/sidePanel/builder/contexts';
|
||||
import { DividerElementComponent } from 'features/nodes/components/sidePanel/builder/DividerElementComponent';
|
||||
import { useIsRootElement } from 'features/nodes/components/sidePanel/builder/dnd-hooks';
|
||||
import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper';
|
||||
import { HeadingElementComponent } from 'features/nodes/components/sidePanel/builder/HeadingElementComponent';
|
||||
import { NodeFieldElementComponent } from 'features/nodes/components/sidePanel/builder/NodeFieldElementComponent';
|
||||
import { TextElementComponent } from 'features/nodes/components/sidePanel/builder/TextElementComponent';
|
||||
import { selectWorkflowMode, useElement } from 'features/nodes/store/workflowSlice';
|
||||
import type { ContainerElement } from 'features/nodes/types/workflow';
|
||||
import {
|
||||
CONTAINER_CLASS_NAME,
|
||||
isContainerElement,
|
||||
isDividerElement,
|
||||
isHeadingElement,
|
||||
isNodeFieldElement,
|
||||
isTextElement,
|
||||
} from 'features/nodes/types/workflow';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Equals } from 'tsafe';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
const sx: SystemStyleObject = {
|
||||
gap: 4,
|
||||
flex: '1 1 0',
|
||||
'&[data-depth="0"]': {
|
||||
flex: 1,
|
||||
},
|
||||
'&[data-container-layout="column"]': {
|
||||
flexDir: 'column',
|
||||
},
|
||||
'&[data-container-layout="row"]': {
|
||||
flexDir: 'row',
|
||||
},
|
||||
};
|
||||
|
||||
const ContainerElementComponent = memo(({ id }: { id: string }) => {
|
||||
const el = useElement(id);
|
||||
const mode = useAppSelector(selectWorkflowMode);
|
||||
|
||||
if (!el || !isContainerElement(el)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mode === 'view') {
|
||||
return <ContainerElementComponentViewMode el={el} />;
|
||||
}
|
||||
|
||||
// mode === 'edit'
|
||||
return <ContainerElementComponentEditMode el={el} />;
|
||||
});
|
||||
ContainerElementComponent.displayName = 'ContainerElementComponent';
|
||||
|
||||
const ContainerElementComponentViewMode = memo(({ el }: { el: ContainerElement }) => {
|
||||
const { t } = useTranslation();
|
||||
const depth = useDepthContext();
|
||||
const { id, data } = el;
|
||||
const { children, layout } = data;
|
||||
|
||||
return (
|
||||
<DepthContextProvider depth={depth + 1}>
|
||||
<ContainerContextProvider id={id} layout={layout}>
|
||||
<Flex id={id} className={CONTAINER_CLASS_NAME} sx={sx} data-container-layout={layout} data-depth={depth}>
|
||||
{children.map((childId) => (
|
||||
<FormElementComponent key={childId} id={childId} />
|
||||
))}
|
||||
{children.length === 0 && (
|
||||
<Flex p={8} w="full" h="full" alignItems="center" justifyContent="center">
|
||||
<Text variant="subtext">{t('workflows.builder.containerPlaceholder')}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</ContainerContextProvider>
|
||||
</DepthContextProvider>
|
||||
);
|
||||
});
|
||||
ContainerElementComponentViewMode.displayName = 'ContainerElementComponentViewMode';
|
||||
|
||||
const ContainerElementComponentEditMode = memo(({ el }: { el: ContainerElement }) => {
|
||||
const depth = useDepthContext();
|
||||
const { id, data } = el;
|
||||
const { children, layout } = data;
|
||||
const isRootElement = useIsRootElement(id);
|
||||
|
||||
return (
|
||||
<FormElementEditModeWrapper element={el}>
|
||||
<DepthContextProvider depth={depth + 1}>
|
||||
<ContainerContextProvider id={id} layout={layout}>
|
||||
<Flex id={id} className={CONTAINER_CLASS_NAME} sx={sx} data-container-layout={layout} data-depth={depth}>
|
||||
{children.map((childId) => (
|
||||
<FormElementComponent key={childId} id={childId} />
|
||||
))}
|
||||
{children.length === 0 && isRootElement && <RootPlaceholder />}
|
||||
{children.length === 0 && !isRootElement && <NonRootPlaceholder />}
|
||||
</Flex>
|
||||
</ContainerContextProvider>
|
||||
</DepthContextProvider>
|
||||
</FormElementEditModeWrapper>
|
||||
);
|
||||
});
|
||||
ContainerElementComponentEditMode.displayName = 'ContainerElementComponentEditMode';
|
||||
|
||||
const RootPlaceholder = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Flex p={8} w="full" h="full" alignItems="center" justifyContent="center">
|
||||
<Text variant="subtext">{t('workflows.builder.emptyRootPlaceholderEditMode')}</Text>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
RootPlaceholder.displayName = 'RootPlaceholder';
|
||||
|
||||
const NonRootPlaceholder = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Flex p={8} w="full" h="full" alignItems="center" justifyContent="center">
|
||||
<Text variant="subtext">{t('workflows.builder.containerPlaceholder')}</Text>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
NonRootPlaceholder.displayName = 'NonRootPlaceholder';
|
||||
|
||||
// TODO(psyche): Can we move this into a separate file and avoid circular dependencies between it and ContainerElementComponent?
|
||||
export const FormElementComponent = memo(({ id }: { id: string }) => {
|
||||
const el = useElement(id);
|
||||
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isContainerElement(el)) {
|
||||
return <ContainerElementComponent key={id} id={id} />;
|
||||
}
|
||||
|
||||
if (isNodeFieldElement(el)) {
|
||||
return <NodeFieldElementComponent key={id} id={id} />;
|
||||
}
|
||||
|
||||
if (isDividerElement(el)) {
|
||||
return <DividerElementComponent key={id} id={id} />;
|
||||
}
|
||||
|
||||
if (isHeadingElement(el)) {
|
||||
return <HeadingElementComponent key={id} id={id} />;
|
||||
}
|
||||
|
||||
if (isTextElement(el)) {
|
||||
return <TextElementComponent key={id} id={id} />;
|
||||
}
|
||||
|
||||
assert<Equals<typeof el, never>>(false, `Unhandled type for element with id ${id}`);
|
||||
});
|
||||
FormElementComponent.displayName = 'FormElementComponent';
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Portal,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { formElementContainerDataChanged } from 'features/nodes/store/workflowSlice';
|
||||
@@ -32,26 +33,28 @@ export const ContainerElementSettings = memo(({ element }: { element: ContainerE
|
||||
}, [dispatch, id]);
|
||||
|
||||
return (
|
||||
<Popover isLazy lazyBehavior="unmount">
|
||||
<Popover placement="top" isLazy lazyBehavior="unmount">
|
||||
<PopoverTrigger>
|
||||
<IconButton aria-label="settings" icon={<PiWrenchFill />} variant="link" size="sm" alignSelf="stretch" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<FormControl>
|
||||
<FormLabel m={0}>{t('workflows.builder.layout')}</FormLabel>
|
||||
<ButtonGroup variant="outline" size="sm">
|
||||
<Button onClick={setLayoutToRow} colorScheme={layout === 'row' ? 'invokeBlue' : 'base'}>
|
||||
{t('workflows.builder.row')}
|
||||
</Button>
|
||||
<Button onClick={setLayoutToColumn} colorScheme={layout === 'column' ? 'invokeBlue' : 'base'}>
|
||||
{t('workflows.builder.column')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</FormControl>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
<Portal>
|
||||
<PopoverContent>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<FormControl>
|
||||
<FormLabel m={0}>{t('workflows.builder.layout')}</FormLabel>
|
||||
<ButtonGroup variant="outline" size="sm">
|
||||
<Button onClick={setLayoutToRow} colorScheme={layout === 'row' ? 'invokeBlue' : 'base'}>
|
||||
{t('workflows.builder.row')}
|
||||
</Button>
|
||||
<Button onClick={setLayoutToColumn} colorScheme={layout === 'column' ? 'invokeBlue' : 'base'}>
|
||||
{t('workflows.builder.column')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</FormControl>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { DividerElementEditMode } from 'features/nodes/components/sidePanel/builder/DividerElementEditMode';
|
||||
import { DividerElementViewMode } from 'features/nodes/components/sidePanel/builder/DividerElementViewMode';
|
||||
import { selectWorkflowMode, useElement } from 'features/nodes/store/workflowSlice';
|
||||
import { isDividerElement } from 'features/nodes/types/workflow';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const DividerElement = memo(({ id }: { id: string }) => {
|
||||
const el = useElement(id);
|
||||
const mode = useAppSelector(selectWorkflowMode);
|
||||
|
||||
if (!el || !isDividerElement(el)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'view') {
|
||||
return <DividerElementViewMode el={el} />;
|
||||
}
|
||||
|
||||
// mode === 'edit'
|
||||
return <DividerElementEditMode el={el} />;
|
||||
});
|
||||
|
||||
DividerElement.displayName = 'DividerElement';
|
||||
@@ -1,81 +1,25 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useContainerContext } from 'features/nodes/components/sidePanel/builder/contexts';
|
||||
import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper';
|
||||
import { selectWorkflowMode, useElement } from 'features/nodes/store/workflowSlice';
|
||||
import type { DividerElement } from 'features/nodes/types/workflow';
|
||||
import { DIVIDER_CLASS_NAME, isDividerElement } from 'features/nodes/types/workflow';
|
||||
import { memo } from 'react';
|
||||
|
||||
const sx: SystemStyleObject = {
|
||||
bg: 'base.700',
|
||||
flexShrink: 0,
|
||||
'&[data-layout="column"]': {
|
||||
'&[data-parent-layout="column"]': {
|
||||
width: '100%',
|
||||
height: '1px',
|
||||
},
|
||||
'&[data-layout="row"]': {
|
||||
'&[data-parent-layout="row"]': {
|
||||
height: '100%',
|
||||
width: '1px',
|
||||
minH: 32,
|
||||
},
|
||||
};
|
||||
|
||||
export const DividerElementComponent = memo(({ id }: { id: string }) => {
|
||||
const el = useElement(id);
|
||||
const mode = useAppSelector(selectWorkflowMode);
|
||||
export const DividerElementComponent = memo(() => {
|
||||
const containerCtx = useContainerContext();
|
||||
|
||||
if (!el || !isDividerElement(el)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'view') {
|
||||
return <DividerElementComponentViewMode el={el} />;
|
||||
}
|
||||
|
||||
// mode === 'edit'
|
||||
return <DividerElementComponentEditMode el={el} />;
|
||||
return <Flex sx={sx} data-parent-layout={containerCtx.layout} />;
|
||||
});
|
||||
|
||||
DividerElementComponent.displayName = 'DividerElementComponent';
|
||||
|
||||
const DividerElementComponentViewMode = memo(({ el }: { el: DividerElement }) => {
|
||||
const container = useContainerContext();
|
||||
const { id } = el;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
id={id}
|
||||
className={DIVIDER_CLASS_NAME}
|
||||
sx={sx}
|
||||
data-layout={
|
||||
// When there is no container, the layout is column by default
|
||||
container?.layout || 'column'
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DividerElementComponentViewMode.displayName = 'DividerElementComponentViewMode';
|
||||
|
||||
const DividerElementComponentEditMode = memo(({ el }: { el: DividerElement }) => {
|
||||
const container = useContainerContext();
|
||||
const { id } = el;
|
||||
|
||||
return (
|
||||
<FormElementEditModeWrapper element={el}>
|
||||
<Flex
|
||||
id={id}
|
||||
className={DIVIDER_CLASS_NAME}
|
||||
sx={sx}
|
||||
data-layout={
|
||||
// When there is no container, the layout is column by default
|
||||
container?.layout || 'column'
|
||||
}
|
||||
/>
|
||||
</FormElementEditModeWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
DividerElementComponentEditMode.displayName = 'DividerElementComponentEditMode';
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { useContainerContext } from 'features/nodes/components/sidePanel/builder/contexts';
|
||||
import { DividerElementComponent } from 'features/nodes/components/sidePanel/builder/DividerElementComponent';
|
||||
import { useFormElementDnd } from 'features/nodes/components/sidePanel/builder/dnd-hooks';
|
||||
import { DndListDropIndicator } from 'features/nodes/components/sidePanel/builder/DndListDropIndicator';
|
||||
import { FormElementEditModeContent } from 'features/nodes/components/sidePanel/builder/FormElementEditModeContent';
|
||||
import { FormElementEditModeHeader } from 'features/nodes/components/sidePanel/builder/FormElementEditModeHeader';
|
||||
import type { DividerElement } from 'features/nodes/types/workflow';
|
||||
import { DIVIDER_CLASS_NAME } from 'features/nodes/types/workflow';
|
||||
import { memo, useRef } from 'react';
|
||||
|
||||
const sx: SystemStyleObject = {
|
||||
position: 'relative',
|
||||
borderRadius: 'base',
|
||||
'&[data-parent-layout="column"]': {
|
||||
w: 'full',
|
||||
h: 'min-content',
|
||||
},
|
||||
'&[data-parent-layout="row"]': {
|
||||
w: 'min-content',
|
||||
h: 'full',
|
||||
},
|
||||
flexDir: 'column',
|
||||
};
|
||||
|
||||
export const DividerElementEditMode = memo(({ el }: { el: DividerElement }) => {
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const dragHandleRef = useRef<HTMLDivElement>(null);
|
||||
const [activeDropRegion, isDragging] = useFormElementDnd(el.id, draggableRef, dragHandleRef);
|
||||
const containerCtx = useContainerContext();
|
||||
const { id } = el;
|
||||
|
||||
return (
|
||||
<Flex ref={draggableRef} id={id} className={DIVIDER_CLASS_NAME} sx={sx} data-parent-layout={containerCtx.layout}>
|
||||
<FormElementEditModeHeader dragHandleRef={dragHandleRef} element={el} data-is-dragging={isDragging} />
|
||||
<FormElementEditModeContent data-is-dragging={isDragging} p={4}>
|
||||
<DividerElementComponent />
|
||||
</FormElementEditModeContent>
|
||||
<DndListDropIndicator activeDropRegion={activeDropRegion} gap="var(--invoke-space-4)" />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
DividerElementEditMode.displayName = 'DividerElementEditMode';
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { useContainerContext } from 'features/nodes/components/sidePanel/builder/contexts';
|
||||
import type { DividerElement } from 'features/nodes/types/workflow';
|
||||
import { DIVIDER_CLASS_NAME } from 'features/nodes/types/workflow';
|
||||
import { memo } from 'react';
|
||||
|
||||
const sx: SystemStyleObject = {
|
||||
bg: 'base.700',
|
||||
flexShrink: 0,
|
||||
'&[data-layout="column"]': {
|
||||
width: '100%',
|
||||
height: '1px',
|
||||
},
|
||||
'&[data-layout="row"]': {
|
||||
height: '100%',
|
||||
width: '1px',
|
||||
},
|
||||
};
|
||||
|
||||
export const DividerElementViewMode = memo(({ el }: { el: DividerElement }) => {
|
||||
const container = useContainerContext();
|
||||
const { id } = el;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
id={id}
|
||||
className={DIVIDER_CLASS_NAME}
|
||||
sx={sx}
|
||||
data-layout={
|
||||
// When there is no container, the layout is column by default
|
||||
container?.layout || 'column'
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DividerElementViewMode.displayName = 'DividerElementViewMode';
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { FlexProps, SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { useDepthContext } from 'features/nodes/components/sidePanel/builder/contexts';
|
||||
import { memo } from 'react';
|
||||
|
||||
const contentWrapperSx: SystemStyleObject = {
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
borderWidth: 1,
|
||||
borderRadius: 'base',
|
||||
borderTopRadius: 'unset',
|
||||
borderTop: 'unset',
|
||||
borderColor: 'baseAlpha.250',
|
||||
'&[data-depth="0"]': { borderColor: 'baseAlpha.100' },
|
||||
'&[data-depth="1"]': { borderColor: 'baseAlpha.150' },
|
||||
'&[data-depth="2"]': { borderColor: 'baseAlpha.200' },
|
||||
'&[data-is-dragging="true"]': {
|
||||
opacity: 0.3,
|
||||
},
|
||||
};
|
||||
|
||||
export const FormElementEditModeContent = memo(({ children, ...rest }: FlexProps) => {
|
||||
const depth = useDepthContext();
|
||||
return (
|
||||
<Flex sx={contentWrapperSx} data-depth={depth} {...rest}>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
FormElementEditModeContent.displayName = 'FormElementEditModeContent';
|
||||
@@ -1,17 +1,19 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex, forwardRef, IconButton, Spacer, Text } from '@invoke-ai/ui-library';
|
||||
import type { FlexProps, SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex, IconButton, Spacer, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { ContainerElementSettings } from 'features/nodes/components/sidePanel/builder/ContainerElementSettings';
|
||||
import { useDepthContext } from 'features/nodes/components/sidePanel/builder/contexts';
|
||||
import { NodeFieldElementSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementSettings';
|
||||
import { useMouseOverFormField } from 'features/nodes/hooks/useMouseOverNode';
|
||||
import { useZoomToNode } from 'features/nodes/hooks/useZoomToNode';
|
||||
import { formElementRemoved } from 'features/nodes/store/workflowSlice';
|
||||
import { type FormElement, isContainerElement, isNodeFieldElement } from 'features/nodes/types/workflow';
|
||||
import type { FormElement, NodeFieldElement } from 'features/nodes/types/workflow';
|
||||
import { isContainerElement, isNodeFieldElement } from 'features/nodes/types/workflow';
|
||||
import { startCase } from 'lodash-es';
|
||||
import type { RefObject } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiXBold } from 'react-icons/pi';
|
||||
|
||||
import { useIsRootElement } from './dnd-hooks';
|
||||
import { PiGpsFixBold, PiXBold } from 'react-icons/pi';
|
||||
|
||||
const sx: SystemStyleObject = {
|
||||
w: 'full',
|
||||
@@ -21,61 +23,100 @@ const sx: SystemStyleObject = {
|
||||
maxH: 8,
|
||||
borderTopRadius: 'base',
|
||||
alignItems: 'center',
|
||||
color: 'base.300',
|
||||
color: 'base.500',
|
||||
bg: 'baseAlpha.250',
|
||||
cursor: 'grab',
|
||||
'&[data-depth="0"]': { bg: 'baseAlpha.100' },
|
||||
'&[data-depth="1"]': { bg: 'baseAlpha.150' },
|
||||
'&[data-depth="2"]': { bg: 'baseAlpha.200' },
|
||||
'&[data-is-root="false"]': { cursor: 'grab' },
|
||||
'&[data-is-dragging="true"]': {
|
||||
opacity: 0.3,
|
||||
},
|
||||
_hover: {
|
||||
bg: 'baseAlpha.300',
|
||||
},
|
||||
};
|
||||
|
||||
export const FormElementEditModeHeader = memo(
|
||||
forwardRef(({ element }: { element: FormElement }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const depth = useDepthContext();
|
||||
const dispatch = useAppDispatch();
|
||||
const isRootElement = useIsRootElement(element.id);
|
||||
const removeElement = useCallback(() => {
|
||||
if (isRootElement) {
|
||||
return;
|
||||
}
|
||||
dispatch(formElementRemoved({ id: element.id }));
|
||||
}, [dispatch, element.id, isRootElement]);
|
||||
const label = useMemo(() => {
|
||||
if (isRootElement) {
|
||||
return 'Root Container';
|
||||
}
|
||||
if (isContainerElement(element) && element.data.layout === 'column') {
|
||||
return `Container (column layout)`;
|
||||
}
|
||||
if (isContainerElement(element) && element.data.layout === 'row') {
|
||||
return `Container (row layout)`;
|
||||
}
|
||||
return startCase(element.type);
|
||||
}, [element, isRootElement]);
|
||||
type Props = Omit<FlexProps, 'sx'> & { element: FormElement; dragHandleRef: RefObject<HTMLDivElement> };
|
||||
|
||||
return (
|
||||
<Flex ref={ref} sx={sx} data-depth={depth} data-is-root={isRootElement}>
|
||||
<Text fontWeight="semibold" noOfLines={1} wordBreak="break-all">
|
||||
{label}
|
||||
</Text>
|
||||
<Spacer />
|
||||
{isContainerElement(element) && !isRootElement && <ContainerElementSettings element={element} />}
|
||||
{isNodeFieldElement(element) && <NodeFieldElementSettings element={element} />}
|
||||
{!isRootElement && (
|
||||
<IconButton
|
||||
tooltip={t('common.delete')}
|
||||
aria-label={t('common.delete')}
|
||||
onClick={removeElement}
|
||||
icon={<PiXBold />}
|
||||
variant="link"
|
||||
size="sm"
|
||||
alignSelf="stretch"
|
||||
colorScheme="error"
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
})
|
||||
);
|
||||
export const FormElementEditModeHeader = memo(({ element, dragHandleRef, ...rest }: Props) => {
|
||||
const depth = useDepthContext();
|
||||
|
||||
return (
|
||||
<Flex ref={dragHandleRef} sx={sx} data-depth={depth} {...rest}>
|
||||
<Label element={element} />
|
||||
<Spacer />
|
||||
{isContainerElement(element) && <ContainerElementSettings element={element} />}
|
||||
{isNodeFieldElement(element) && <ZoomToNodeButton element={element} />}
|
||||
{isNodeFieldElement(element) && <NodeFieldElementSettings element={element} />}
|
||||
<RemoveElementButton element={element} />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
FormElementEditModeHeader.displayName = 'FormElementEditModeHeader';
|
||||
|
||||
const ZoomToNodeButton = memo(({ element }: { element: NodeFieldElement }) => {
|
||||
const { t } = useTranslation();
|
||||
const { nodeId } = element.data.fieldIdentifier;
|
||||
const zoomToNode = useZoomToNode();
|
||||
const mouseOverFormField = useMouseOverFormField(nodeId);
|
||||
const onClick = useCallback(() => {
|
||||
zoomToNode(nodeId);
|
||||
}, [nodeId, zoomToNode]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
onMouseOver={mouseOverFormField.handleMouseOver}
|
||||
onMouseOut={mouseOverFormField.handleMouseOut}
|
||||
tooltip={t('workflows.builder.zoomToNode')}
|
||||
aria-label={t('workflows.builder.zoomToNode')}
|
||||
onClick={onClick}
|
||||
icon={<PiGpsFixBold />}
|
||||
variant="link"
|
||||
size="sm"
|
||||
alignSelf="stretch"
|
||||
/>
|
||||
);
|
||||
});
|
||||
ZoomToNodeButton.displayName = 'ZoomToNodeButton';
|
||||
|
||||
const RemoveElementButton = memo(({ element }: { element: FormElement }) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const removeElement = useCallback(() => {
|
||||
dispatch(formElementRemoved({ id: element.id }));
|
||||
}, [dispatch, element.id]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
tooltip={t('common.delete')}
|
||||
aria-label={t('common.delete')}
|
||||
onClick={removeElement}
|
||||
icon={<PiXBold />}
|
||||
variant="link"
|
||||
size="sm"
|
||||
alignSelf="stretch"
|
||||
colorScheme="error"
|
||||
/>
|
||||
);
|
||||
});
|
||||
RemoveElementButton.displayName = 'RemoveElementButton';
|
||||
|
||||
const Label = memo(({ element }: { element: FormElement }) => {
|
||||
const label = useMemo(() => {
|
||||
if (isContainerElement(element) && element.data.layout === 'column') {
|
||||
return `Container (column layout)`;
|
||||
}
|
||||
if (isContainerElement(element) && element.data.layout === 'row') {
|
||||
return `Container (row layout)`;
|
||||
}
|
||||
return startCase(element.type);
|
||||
}, [element]);
|
||||
|
||||
return (
|
||||
<Text fontWeight="semibold" noOfLines={1} wordBreak="break-all" userSelect="none">
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
Label.displayName = 'Label';
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import { useContainerContext, useDepthContext } from 'features/nodes/components/sidePanel/builder/contexts';
|
||||
import { useFormElementDnd } from 'features/nodes/components/sidePanel/builder/dnd-hooks';
|
||||
import { DndListDropIndicator } from 'features/nodes/components/sidePanel/builder/DndListDropIndicator';
|
||||
import { FormElementEditModeHeader } from 'features/nodes/components/sidePanel/builder/FormElementEditModeHeader';
|
||||
import { getEditModeWrapperId } from 'features/nodes/components/sidePanel/builder/shared';
|
||||
import type { FormElement } from 'features/nodes/types/workflow';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { memo, useRef } from 'react';
|
||||
|
||||
import { useIsRootElement } from './dnd-hooks';
|
||||
|
||||
const EDIT_MODE_WRAPPER_CLASS_NAME = getPrefixedId('edit-mode-wrapper', '-');
|
||||
|
||||
const wrapperSx: SystemStyleObject = {
|
||||
position: 'relative',
|
||||
flex: '1 1 0',
|
||||
'&[data-element-type="divider"]&[data-layout="row"]': {
|
||||
flex: '0 1 0',
|
||||
},
|
||||
'&[data-is-root="true"]': {
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
},
|
||||
borderRadius: 'base',
|
||||
};
|
||||
|
||||
const innerSx: SystemStyleObject = {
|
||||
position: 'relative',
|
||||
flexDir: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
borderRadius: 'base',
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
'&[data-is-dragging="true"]': {
|
||||
opacity: 0.3,
|
||||
},
|
||||
'&[data-active-drop-region="center"]': {
|
||||
opacity: 1,
|
||||
bg: 'base.850',
|
||||
},
|
||||
'&[data-element-type="divider"]&[data-layout="row"]': {
|
||||
w: 'min-content',
|
||||
},
|
||||
'&[data-element-type="divider"]&[data-layout="column"]': {
|
||||
h: 'min-content',
|
||||
},
|
||||
};
|
||||
|
||||
const contentWrapperSx: SystemStyleObject = {
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
p: 4,
|
||||
gap: 4,
|
||||
borderWidth: 1,
|
||||
borderRadius: 'base',
|
||||
borderTopRadius: 'unset',
|
||||
borderTop: 'unset',
|
||||
borderColor: 'baseAlpha.250',
|
||||
'&[data-depth="0"]': { borderColor: 'baseAlpha.100' },
|
||||
'&[data-depth="1"]': { borderColor: 'baseAlpha.150' },
|
||||
'&[data-depth="2"]': { borderColor: 'baseAlpha.200' },
|
||||
};
|
||||
|
||||
export const FormElementEditModeWrapper = memo(({ element, children }: PropsWithChildren<{ element: FormElement }>) => {
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const dragHandleRef = useRef<HTMLDivElement>(null);
|
||||
const [activeDropRegion, isDragging] = useFormElementDnd(element.id, draggableRef, dragHandleRef);
|
||||
const containerCtx = useContainerContext();
|
||||
const depth = useDepthContext();
|
||||
const isRootElement = useIsRootElement(element.id);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
id={getEditModeWrapperId(element.id)}
|
||||
ref={draggableRef}
|
||||
className={EDIT_MODE_WRAPPER_CLASS_NAME}
|
||||
sx={wrapperSx}
|
||||
data-is-root={isRootElement}
|
||||
data-element-type={element.type}
|
||||
data-layout={containerCtx?.layout}
|
||||
>
|
||||
<Flex
|
||||
sx={innerSx}
|
||||
data-is-dragging={isDragging}
|
||||
data-active-drop-region={activeDropRegion}
|
||||
data-element-type={element.type}
|
||||
data-layout={containerCtx?.layout}
|
||||
>
|
||||
{!isRootElement && (
|
||||
// Non-root elements get the header and content wrapper
|
||||
<>
|
||||
<FormElementEditModeHeader ref={dragHandleRef} element={element} />
|
||||
<Flex sx={contentWrapperSx} data-depth={depth}>
|
||||
{children}
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
{isRootElement && (
|
||||
// But the root does not - helps the builder to look less busy
|
||||
<Flex ref={dragHandleRef} w="full" h="full">
|
||||
{children}
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
<DndListDropIndicator activeDropRegion={activeDropRegion} gap="var(--invoke-space-4)" />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
FormElementEditModeWrapper.displayName = 'FormElementEditModeWrapper';
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { HeadingElementEditMode } from 'features/nodes/components/sidePanel/builder/HeadingElementEditMode';
|
||||
import { HeadingElementViewMode } from 'features/nodes/components/sidePanel/builder/HeadingElementViewMode';
|
||||
import { selectWorkflowMode, useElement } from 'features/nodes/store/workflowSlice';
|
||||
import { isHeadingElement } from 'features/nodes/types/workflow';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const HeadingElement = memo(({ id }: { id: string }) => {
|
||||
const el = useElement(id);
|
||||
const mode = useAppSelector(selectWorkflowMode);
|
||||
|
||||
if (!el || !isHeadingElement(el)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mode === 'view') {
|
||||
return <HeadingElementViewMode el={el} />;
|
||||
}
|
||||
|
||||
// mode === 'edit'
|
||||
return <HeadingElementEditMode el={el} />;
|
||||
});
|
||||
|
||||
HeadingElement.displayName = 'HeadingElement';
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { HeadingProps, SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useEditable } from 'common/hooks/useEditable';
|
||||
import { AutosizeTextarea } from 'features/nodes/components/sidePanel/builder/AutosizeTextarea';
|
||||
import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper';
|
||||
import { formElementHeadingDataChanged, selectWorkflowMode, useElement } from 'features/nodes/store/workflowSlice';
|
||||
import type { HeadingElement } from 'features/nodes/types/workflow';
|
||||
import { HEADING_CLASS_NAME, isHeadingElement } from 'features/nodes/types/workflow';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const HeadingElementComponent = memo(({ id }: { id: string }) => {
|
||||
const el = useElement(id);
|
||||
const mode = useAppSelector(selectWorkflowMode);
|
||||
|
||||
if (!el || !isHeadingElement(el)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mode === 'view') {
|
||||
return <HeadingElementComponentViewMode el={el} />;
|
||||
}
|
||||
|
||||
// mode === 'edit'
|
||||
return <HeadingElementComponentEditMode el={el} />;
|
||||
});
|
||||
|
||||
HeadingElementComponent.displayName = 'HeadingElementComponent';
|
||||
|
||||
const HeadingElementComponentViewMode = memo(({ el }: { el: HeadingElement }) => {
|
||||
const { id, data } = el;
|
||||
const { content } = data;
|
||||
|
||||
return (
|
||||
<Flex id={id} className={HEADING_CLASS_NAME}>
|
||||
<HeadingContentDisplay content={content} />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
HeadingElementComponentViewMode.displayName = 'HeadingElementComponentViewMode';
|
||||
|
||||
const HeadingElementComponentEditMode = memo(({ el }: { el: HeadingElement }) => {
|
||||
const { id } = el;
|
||||
|
||||
return (
|
||||
<FormElementEditModeWrapper element={el}>
|
||||
<Flex id={id} className={HEADING_CLASS_NAME} w="full">
|
||||
<EditableHeading el={el} />
|
||||
</Flex>
|
||||
</FormElementEditModeWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
const FONT_SIZE = '2xl';
|
||||
|
||||
const headingSx: SystemStyleObject = {
|
||||
fontWeight: 'bold',
|
||||
fontSize: FONT_SIZE,
|
||||
'&[data-is-empty="true"]': {
|
||||
opacity: 0.3,
|
||||
},
|
||||
};
|
||||
|
||||
const HeadingContentDisplay = memo(({ content, ...rest }: { content: string } & HeadingProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Text sx={headingSx} data-is-empty={content === ''} {...rest}>
|
||||
{content || t('workflows.builder.headingPlaceholder')}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
HeadingContentDisplay.displayName = 'HeadingContentDisplay';
|
||||
|
||||
HeadingElementComponentEditMode.displayName = 'HeadingElementComponentEditMode';
|
||||
|
||||
const EditableHeading = memo(({ el }: { el: HeadingElement }) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const { id, data } = el;
|
||||
const { content } = data;
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const onChange = useCallback(
|
||||
(content: string) => {
|
||||
dispatch(formElementHeadingDataChanged({ id, changes: { content } }));
|
||||
},
|
||||
[dispatch, id]
|
||||
);
|
||||
|
||||
const editable = useEditable({
|
||||
value: content,
|
||||
defaultValue: '',
|
||||
onChange,
|
||||
inputRef: ref,
|
||||
});
|
||||
|
||||
if (!editable.isEditing) {
|
||||
return <HeadingContentDisplay content={editable.value} onDoubleClick={editable.startEditing} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AutosizeTextarea
|
||||
ref={ref}
|
||||
placeholder={t('workflows.builder.headingPlaceholder')}
|
||||
{...editable.inputProps}
|
||||
variant="outline"
|
||||
overflowWrap="anywhere"
|
||||
w="full"
|
||||
minRows={1}
|
||||
maxRows={10}
|
||||
resize="none"
|
||||
p={2}
|
||||
fontWeight="bold"
|
||||
fontSize={FONT_SIZE}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
EditableHeading.displayName = 'EditableHeading';
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { HeadingProps, SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Text } from '@invoke-ai/ui-library';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const headingSx: SystemStyleObject = {
|
||||
fontWeight: 'bold',
|
||||
fontSize: '2xl',
|
||||
'&[data-is-empty="true"]': {
|
||||
opacity: 0.3,
|
||||
},
|
||||
};
|
||||
|
||||
export const HeadingElementContent = memo(({ content, ...rest }: { content: string } & HeadingProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Text sx={headingSx} data-is-empty={content === ''} {...rest}>
|
||||
{content || t('workflows.builder.headingPlaceholder')}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
|
||||
HeadingElementContent.displayName = 'HeadingElementContent';
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useEditable } from 'common/hooks/useEditable';
|
||||
import { AutosizeTextarea } from 'features/nodes/components/sidePanel/builder/AutosizeTextarea';
|
||||
import { HeadingElementContent } from 'features/nodes/components/sidePanel/builder/HeadingElementContent';
|
||||
import { formElementHeadingDataChanged } from 'features/nodes/store/workflowSlice';
|
||||
import type { HeadingElement } from 'features/nodes/types/workflow';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const HeadingElementContentEditable = memo(({ el }: { el: HeadingElement }) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const { id, data } = el;
|
||||
const { content } = data;
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const onChange = useCallback(
|
||||
(content: string) => {
|
||||
dispatch(formElementHeadingDataChanged({ id, changes: { content } }));
|
||||
},
|
||||
[dispatch, id]
|
||||
);
|
||||
|
||||
const editable = useEditable({
|
||||
value: content,
|
||||
defaultValue: '',
|
||||
onChange,
|
||||
inputRef: ref,
|
||||
});
|
||||
|
||||
if (!editable.isEditing) {
|
||||
return <HeadingElementContent content={editable.value} onDoubleClick={editable.startEditing} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AutosizeTextarea
|
||||
ref={ref}
|
||||
placeholder={t('workflows.builder.headingPlaceholder')}
|
||||
{...editable.inputProps}
|
||||
variant="outline"
|
||||
overflowWrap="anywhere"
|
||||
w="full"
|
||||
minRows={1}
|
||||
maxRows={10}
|
||||
resize="none"
|
||||
p={1}
|
||||
px={2}
|
||||
fontWeight="bold"
|
||||
fontSize="2xl"
|
||||
_focusVisible={{ borderRadius: 'base', h: 'unset' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
HeadingElementContentEditable.displayName = 'HeadingElementContentEditable';
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { useContainerContext } from 'features/nodes/components/sidePanel/builder/contexts';
|
||||
import { useFormElementDnd } from 'features/nodes/components/sidePanel/builder/dnd-hooks';
|
||||
import { DndListDropIndicator } from 'features/nodes/components/sidePanel/builder/DndListDropIndicator';
|
||||
import { FormElementEditModeContent } from 'features/nodes/components/sidePanel/builder/FormElementEditModeContent';
|
||||
import { FormElementEditModeHeader } from 'features/nodes/components/sidePanel/builder/FormElementEditModeHeader';
|
||||
import { HeadingElementContentEditable } from 'features/nodes/components/sidePanel/builder/HeadingElementContentEditable';
|
||||
import type { HeadingElement } from 'features/nodes/types/workflow';
|
||||
import { HEADING_CLASS_NAME } from 'features/nodes/types/workflow';
|
||||
import { memo, useRef } from 'react';
|
||||
|
||||
const sx: SystemStyleObject = {
|
||||
position: 'relative',
|
||||
borderRadius: 'base',
|
||||
minW: 32,
|
||||
'&[data-parent-layout="column"]': {
|
||||
w: 'full',
|
||||
h: 'min-content',
|
||||
},
|
||||
'&[data-parent-layout="row"]': {
|
||||
flex: '1 0 0',
|
||||
},
|
||||
flexDir: 'column',
|
||||
};
|
||||
|
||||
export const HeadingElementEditMode = memo(({ el }: { el: HeadingElement }) => {
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const dragHandleRef = useRef<HTMLDivElement>(null);
|
||||
const [activeDropRegion, isDragging] = useFormElementDnd(el.id, draggableRef, dragHandleRef);
|
||||
const containerCtx = useContainerContext();
|
||||
const { id } = el;
|
||||
|
||||
return (
|
||||
<Flex ref={draggableRef} id={id} className={HEADING_CLASS_NAME} sx={sx} data-parent-layout={containerCtx.layout}>
|
||||
<FormElementEditModeHeader dragHandleRef={dragHandleRef} element={el} data-is-dragging={isDragging} />
|
||||
<FormElementEditModeContent data-is-dragging={isDragging} p={4}>
|
||||
<HeadingElementContentEditable el={el} />
|
||||
</FormElementEditModeContent>
|
||||
<DndListDropIndicator activeDropRegion={activeDropRegion} gap="var(--invoke-space-4)" />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
HeadingElementEditMode.displayName = 'HeadingElementEditMode';
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { useContainerContext } from 'features/nodes/components/sidePanel/builder/contexts';
|
||||
import { HeadingElementContent } from 'features/nodes/components/sidePanel/builder/HeadingElementContent';
|
||||
import type { HeadingElement } from 'features/nodes/types/workflow';
|
||||
import { HEADING_CLASS_NAME } from 'features/nodes/types/workflow';
|
||||
import { memo } from 'react';
|
||||
|
||||
const sx: SystemStyleObject = {
|
||||
'&[data-parent-layout="column"]': {
|
||||
w: 'full',
|
||||
h: 'min-content',
|
||||
},
|
||||
'&[data-parent-layout="row"]': {
|
||||
flex: '1 1 0',
|
||||
minW: 32,
|
||||
},
|
||||
};
|
||||
|
||||
export const HeadingElementViewMode = memo(({ el }: { el: HeadingElement }) => {
|
||||
const { id, data } = el;
|
||||
const { content } = data;
|
||||
const containerCtx = useContainerContext();
|
||||
|
||||
return (
|
||||
<Flex id={id} className={HEADING_CLASS_NAME} sx={sx} data-parent-layout={containerCtx.layout}>
|
||||
<HeadingElementContent content={content} />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
HeadingElementViewMode.displayName = 'HeadingElementViewMode';
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
|
||||
import { NodeFieldElementEditMode } from 'features/nodes/components/sidePanel/builder/NodeFieldElementEditMode';
|
||||
import { NodeFieldElementViewMode } from 'features/nodes/components/sidePanel/builder/NodeFieldElementViewMode';
|
||||
import { selectWorkflowMode, useElement } from 'features/nodes/store/workflowSlice';
|
||||
import { isNodeFieldElement } from 'features/nodes/types/workflow';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const NodeFieldElement = memo(({ id }: { id: string }) => {
|
||||
const el = useElement(id);
|
||||
const mode = useAppSelector(selectWorkflowMode);
|
||||
|
||||
if (!el || !isNodeFieldElement(el)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mode === 'view') {
|
||||
return (
|
||||
<InputFieldGate nodeId={el.data.fieldIdentifier.nodeId} fieldName={el.data.fieldIdentifier.fieldName}>
|
||||
<NodeFieldElementViewMode el={el} />
|
||||
</InputFieldGate>
|
||||
);
|
||||
}
|
||||
|
||||
// mode === 'edit'
|
||||
return (
|
||||
<InputFieldGate nodeId={el.data.fieldIdentifier.nodeId} fieldName={el.data.fieldIdentifier.fieldName}>
|
||||
<NodeFieldElementEditMode el={el} />
|
||||
</InputFieldGate>
|
||||
);
|
||||
});
|
||||
|
||||
NodeFieldElement.displayName = 'NodeFieldElement';
|
||||
@@ -1,176 +0,0 @@
|
||||
import { Flex, FormControl, FormHelperText, FormLabel, Input, Spacer, Textarea } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useEditable } from 'common/hooks/useEditable';
|
||||
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
|
||||
import { InputFieldRenderer } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer';
|
||||
import { NodeFieldElementResetToInitialValueIconButton } from 'features/nodes/components/flow/nodes/Invocation/fields/NodeFieldElementResetToInitialValueIconButton';
|
||||
import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper';
|
||||
import { useInputFieldDescription } from 'features/nodes/hooks/useInputFieldDescription';
|
||||
import { useInputFieldLabel } from 'features/nodes/hooks/useInputFieldLabel';
|
||||
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
|
||||
import { fieldDescriptionChanged, fieldLabelChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { selectWorkflowMode, useElement } from 'features/nodes/store/workflowSlice';
|
||||
import type { NodeFieldElement } from 'features/nodes/types/workflow';
|
||||
import { isNodeFieldElement, NODE_FIELD_CLASS_NAME } from 'features/nodes/types/workflow';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
|
||||
export const NodeFieldElementComponent = memo(({ id }: { id: string }) => {
|
||||
const el = useElement(id);
|
||||
const mode = useAppSelector(selectWorkflowMode);
|
||||
|
||||
if (!el || !isNodeFieldElement(el)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mode === 'view') {
|
||||
return (
|
||||
<InputFieldGate nodeId={el.data.fieldIdentifier.nodeId} fieldName={el.data.fieldIdentifier.fieldName}>
|
||||
<NodeFieldElementComponentViewMode el={el} />
|
||||
</InputFieldGate>
|
||||
);
|
||||
}
|
||||
|
||||
// mode === 'edit'
|
||||
return (
|
||||
<InputFieldGate nodeId={el.data.fieldIdentifier.nodeId} fieldName={el.data.fieldIdentifier.fieldName}>
|
||||
<NodeFieldElementComponentEditMode el={el} />{' '}
|
||||
</InputFieldGate>
|
||||
);
|
||||
});
|
||||
|
||||
NodeFieldElementComponent.displayName = 'NodeFieldElementComponent';
|
||||
|
||||
const NodeFieldElementComponentViewMode = memo(({ el }: { el: NodeFieldElement }) => {
|
||||
const { id, data } = el;
|
||||
const { fieldIdentifier, showDescription } = data;
|
||||
const label = useInputFieldLabel(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
|
||||
const description = useInputFieldDescription(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
|
||||
const fieldTemplate = useInputFieldTemplate(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
|
||||
|
||||
const _label = useMemo(() => label || fieldTemplate.title, [label, fieldTemplate.title]);
|
||||
const _description = useMemo(
|
||||
() => description || fieldTemplate.description,
|
||||
[description, fieldTemplate.description]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex id={id} className={NODE_FIELD_CLASS_NAME} flex={1}>
|
||||
<FormControl flex="1 1 0" orientation="vertical">
|
||||
<Flex w="full" gap={4}>
|
||||
<FormLabel>{_label}</FormLabel>
|
||||
<Spacer />
|
||||
<NodeFieldElementResetToInitialValueIconButton element={el} />
|
||||
</Flex>
|
||||
<Flex w="full" gap={4}>
|
||||
<InputFieldRenderer
|
||||
nodeId={fieldIdentifier.nodeId}
|
||||
fieldName={fieldIdentifier.fieldName}
|
||||
settings={data.settings}
|
||||
/>
|
||||
</Flex>
|
||||
{showDescription && _description && <FormHelperText>{_description}</FormHelperText>}
|
||||
</FormControl>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
NodeFieldElementComponentViewMode.displayName = 'NodeFieldElementComponentViewMode';
|
||||
|
||||
const NodeFieldElementComponentEditMode = memo(({ el }: { el: NodeFieldElement }) => {
|
||||
const { id, data } = el;
|
||||
const { fieldIdentifier, showDescription } = data;
|
||||
|
||||
return (
|
||||
<FormElementEditModeWrapper element={el}>
|
||||
<Flex id={id} className={NODE_FIELD_CLASS_NAME} flex="1 1 0">
|
||||
<FormControl flex="1 1 0" orientation="vertical">
|
||||
<NodeFieldEditableLabel el={el} />
|
||||
<Flex w="full" gap={4}>
|
||||
<InputFieldRenderer
|
||||
nodeId={fieldIdentifier.nodeId}
|
||||
fieldName={fieldIdentifier.fieldName}
|
||||
settings={data.settings}
|
||||
/>
|
||||
</Flex>
|
||||
{showDescription && <NodeFieldEditableDescription el={el} />}
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</FormElementEditModeWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
NodeFieldElementComponentEditMode.displayName = 'NodeFieldElementComponentEditMode';
|
||||
|
||||
const NodeFieldEditableLabel = memo(({ el }: { el: NodeFieldElement }) => {
|
||||
const { data } = el;
|
||||
const { fieldIdentifier } = data;
|
||||
const dispatch = useAppDispatch();
|
||||
const label = useInputFieldLabel(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
|
||||
const fieldTemplate = useInputFieldTemplate(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onChange = useCallback(
|
||||
(label: string) => {
|
||||
dispatch(fieldLabelChanged({ nodeId: fieldIdentifier.nodeId, fieldName: fieldIdentifier.fieldName, label }));
|
||||
},
|
||||
[dispatch, fieldIdentifier.fieldName, fieldIdentifier.nodeId]
|
||||
);
|
||||
|
||||
const editable = useEditable({
|
||||
value: label || fieldTemplate.title,
|
||||
defaultValue: fieldTemplate.title,
|
||||
inputRef,
|
||||
onChange,
|
||||
});
|
||||
|
||||
if (!editable.isEditing) {
|
||||
return (
|
||||
<Flex w="full" gap={4}>
|
||||
<FormLabel onDoubleClick={editable.startEditing} cursor="text">
|
||||
{editable.value}
|
||||
</FormLabel>
|
||||
<Spacer />
|
||||
<NodeFieldElementResetToInitialValueIconButton element={el} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return <Input ref={inputRef} variant="outline" {...editable.inputProps} />;
|
||||
});
|
||||
NodeFieldEditableLabel.displayName = 'NodeFieldEditableLabel';
|
||||
|
||||
const NodeFieldEditableDescription = memo(({ el }: { el: NodeFieldElement }) => {
|
||||
const { data } = el;
|
||||
const { fieldIdentifier } = data;
|
||||
const dispatch = useAppDispatch();
|
||||
const description = useInputFieldDescription(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
|
||||
const fieldTemplate = useInputFieldTemplate(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const onChange = useCallback(
|
||||
(description: string) => {
|
||||
dispatch(
|
||||
fieldDescriptionChanged({
|
||||
nodeId: fieldIdentifier.nodeId,
|
||||
fieldName: fieldIdentifier.fieldName,
|
||||
val: description,
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, fieldIdentifier.fieldName, fieldIdentifier.nodeId]
|
||||
);
|
||||
|
||||
const editable = useEditable({
|
||||
value: description || fieldTemplate.description,
|
||||
defaultValue: fieldTemplate.description,
|
||||
inputRef,
|
||||
onChange,
|
||||
});
|
||||
|
||||
if (!editable.isEditing) {
|
||||
return <FormHelperText onDoubleClick={editable.startEditing}>{editable.value}</FormHelperText>;
|
||||
}
|
||||
|
||||
return <Textarea ref={inputRef} variant="outline" {...editable.inputProps} />;
|
||||
});
|
||||
NodeFieldEditableDescription.displayName = 'NodeFieldEditableDescription';
|
||||
@@ -0,0 +1,54 @@
|
||||
import { FormHelperText, Textarea } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useEditable } from 'common/hooks/useEditable';
|
||||
import { useInputFieldDescription } from 'features/nodes/hooks/useInputFieldDescription';
|
||||
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
|
||||
import { fieldDescriptionChanged } from 'features/nodes/store/nodesSlice';
|
||||
import type { NodeFieldElement } from 'features/nodes/types/workflow';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
|
||||
export const NodeFieldElementDescriptionEditable = memo(({ el }: { el: NodeFieldElement }) => {
|
||||
const { data } = el;
|
||||
const { fieldIdentifier } = data;
|
||||
const dispatch = useAppDispatch();
|
||||
const description = useInputFieldDescription(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
|
||||
const fieldTemplate = useInputFieldTemplate(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const onChange = useCallback(
|
||||
(description: string) => {
|
||||
dispatch(
|
||||
fieldDescriptionChanged({
|
||||
nodeId: fieldIdentifier.nodeId,
|
||||
fieldName: fieldIdentifier.fieldName,
|
||||
val: description,
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, fieldIdentifier.fieldName, fieldIdentifier.nodeId]
|
||||
);
|
||||
|
||||
const editable = useEditable({
|
||||
value: description || fieldTemplate.description,
|
||||
defaultValue: fieldTemplate.description,
|
||||
inputRef,
|
||||
onChange,
|
||||
});
|
||||
|
||||
if (!editable.isEditing) {
|
||||
return <FormHelperText onDoubleClick={editable.startEditing}>{editable.value}</FormHelperText>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
variant="outline"
|
||||
fontSize="sm"
|
||||
p={1}
|
||||
px={2}
|
||||
_focusVisible={{ borderRadius: 'base', h: 'unset' }}
|
||||
{...editable.inputProps}
|
||||
/>
|
||||
);
|
||||
});
|
||||
NodeFieldElementDescriptionEditable.displayName = 'NodeFieldElementDescriptionEditable';
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Box, Flex, FormControl } from '@invoke-ai/ui-library';
|
||||
import { InputFieldRenderer } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer';
|
||||
import { useContainerContext } from 'features/nodes/components/sidePanel/builder/contexts';
|
||||
import { useFormElementDnd } from 'features/nodes/components/sidePanel/builder/dnd-hooks';
|
||||
import { DndListDropIndicator } from 'features/nodes/components/sidePanel/builder/DndListDropIndicator';
|
||||
import { FormElementEditModeContent } from 'features/nodes/components/sidePanel/builder/FormElementEditModeContent';
|
||||
import { FormElementEditModeHeader } from 'features/nodes/components/sidePanel/builder/FormElementEditModeHeader';
|
||||
import { NodeFieldElementDescriptionEditable } from 'features/nodes/components/sidePanel/builder/NodeFieldElementDescriptionEditable';
|
||||
import { NodeFieldElementLabelEditable } from 'features/nodes/components/sidePanel/builder/NodeFieldElementLabelEditable';
|
||||
import { useMouseOverFormField, useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
|
||||
import type { NodeFieldElement } from 'features/nodes/types/workflow';
|
||||
import { NODE_FIELD_CLASS_NAME } from 'features/nodes/types/workflow';
|
||||
import { memo, useRef } from 'react';
|
||||
|
||||
const sx: SystemStyleObject = {
|
||||
position: 'relative',
|
||||
borderRadius: 'base',
|
||||
'&[data-parent-layout="column"]': {
|
||||
w: 'full',
|
||||
h: 'min-content',
|
||||
},
|
||||
'&[data-parent-layout="row"]': {
|
||||
flex: '1 1 0',
|
||||
},
|
||||
flexDir: 'column',
|
||||
};
|
||||
|
||||
export const NodeFieldElementEditMode = memo(({ el }: { el: NodeFieldElement }) => {
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const dragHandleRef = useRef<HTMLDivElement>(null);
|
||||
const [activeDropRegion, isDragging] = useFormElementDnd(el.id, draggableRef, dragHandleRef);
|
||||
const containerCtx = useContainerContext();
|
||||
const { id, data } = el;
|
||||
const { fieldIdentifier, showDescription } = data;
|
||||
|
||||
return (
|
||||
<Flex ref={draggableRef} id={id} className={NODE_FIELD_CLASS_NAME} sx={sx} data-parent-layout={containerCtx.layout}>
|
||||
<FormElementEditModeHeader dragHandleRef={dragHandleRef} element={el} data-is-dragging={isDragging} />
|
||||
<FormElementEditModeContent data-is-dragging={isDragging} p={4}>
|
||||
<FormControl flex="1 1 0" orientation="vertical">
|
||||
<NodeFieldElementLabelEditable el={el} />
|
||||
<Flex w="full" gap={4}>
|
||||
<InputFieldRenderer
|
||||
nodeId={fieldIdentifier.nodeId}
|
||||
fieldName={fieldIdentifier.fieldName}
|
||||
settings={data.settings}
|
||||
/>
|
||||
</Flex>
|
||||
{showDescription && <NodeFieldElementDescriptionEditable el={el} />}
|
||||
</FormControl>
|
||||
</FormElementEditModeContent>
|
||||
<NodeFieldElementOverlay element={el} />
|
||||
<DndListDropIndicator activeDropRegion={activeDropRegion} gap="var(--invoke-space-4)" />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
NodeFieldElementEditMode.displayName = 'NodeFieldElementEditMode';
|
||||
|
||||
const nodeFieldOverlaySx: SystemStyleObject = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
insetInlineEnd: 0,
|
||||
bottom: 0,
|
||||
insetInlineStart: 0,
|
||||
borderRadius: 'base',
|
||||
transitionProperty: 'none',
|
||||
pointerEvents: 'none',
|
||||
display: 'none',
|
||||
'&[data-is-mouse-over-node-or-form-field="true"]': {
|
||||
display: 'block',
|
||||
bg: 'invokeBlueAlpha.100',
|
||||
},
|
||||
};
|
||||
|
||||
const NodeFieldElementOverlay = memo(({ element }: { element: NodeFieldElement }) => {
|
||||
const mouseOverNode = useMouseOverNode(element.data.fieldIdentifier.nodeId);
|
||||
const mouseOverFormField = useMouseOverFormField(element.data.fieldIdentifier.nodeId);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={nodeFieldOverlaySx}
|
||||
data-is-mouse-over-node-or-form-field={mouseOverNode.isMouseOverNode || mouseOverFormField.isMouseOverFormField}
|
||||
/>
|
||||
);
|
||||
});
|
||||
NodeFieldElementOverlay.displayName = 'NodeFieldElementOverlay';
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Flex, FormLabel, Spacer } from '@invoke-ai/ui-library';
|
||||
import { NodeFieldElementResetToInitialValueIconButton } from 'features/nodes/components/flow/nodes/Invocation/fields/NodeFieldElementResetToInitialValueIconButton';
|
||||
import { useInputFieldLabel } from 'features/nodes/hooks/useInputFieldLabel';
|
||||
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
|
||||
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 = useInputFieldLabel(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
|
||||
const fieldTemplate = useInputFieldTemplate(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
|
||||
|
||||
const _label = useMemo(() => label || fieldTemplate.title, [label, fieldTemplate.title]);
|
||||
|
||||
return (
|
||||
<Flex w="full" gap={4}>
|
||||
<FormLabel>{_label}</FormLabel>
|
||||
<Spacer />
|
||||
<NodeFieldElementResetToInitialValueIconButton element={el} />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
NodeFieldElementLabel.displayName = 'NodeFieldElementLabel';
|
||||
@@ -0,0 +1,56 @@
|
||||
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 { useInputFieldLabel } from 'features/nodes/hooks/useInputFieldLabel';
|
||||
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
|
||||
import { fieldLabelChanged } from 'features/nodes/store/nodesSlice';
|
||||
import type { NodeFieldElement } from 'features/nodes/types/workflow';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
|
||||
export const NodeFieldElementLabelEditable = memo(({ el }: { el: NodeFieldElement }) => {
|
||||
const { data } = el;
|
||||
const { fieldIdentifier } = data;
|
||||
const dispatch = useAppDispatch();
|
||||
const label = useInputFieldLabel(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
|
||||
const fieldTemplate = useInputFieldTemplate(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onChange = useCallback(
|
||||
(label: string) => {
|
||||
dispatch(fieldLabelChanged({ nodeId: fieldIdentifier.nodeId, fieldName: fieldIdentifier.fieldName, label }));
|
||||
},
|
||||
[dispatch, fieldIdentifier.fieldName, fieldIdentifier.nodeId]
|
||||
);
|
||||
|
||||
const editable = useEditable({
|
||||
value: label || fieldTemplate.title,
|
||||
defaultValue: fieldTemplate.title,
|
||||
inputRef,
|
||||
onChange,
|
||||
});
|
||||
|
||||
if (!editable.isEditing) {
|
||||
return (
|
||||
<Flex w="full" gap={4}>
|
||||
<FormLabel onDoubleClick={editable.startEditing} cursor="text">
|
||||
{editable.value}
|
||||
</FormLabel>
|
||||
<Spacer />
|
||||
<NodeFieldElementResetToInitialValueIconButton element={el} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
variant="outline"
|
||||
p={1}
|
||||
px={2}
|
||||
_focusVisible={{ borderRadius: 'base', h: 'unset' }}
|
||||
{...editable.inputProps}
|
||||
/>
|
||||
);
|
||||
});
|
||||
NodeFieldElementLabelEditable.displayName = 'NodeFieldElementLabelEditable';
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Portal,
|
||||
Switch,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
@@ -65,18 +66,20 @@ export const NodeFieldElementSettings = memo(({ element }: { element: NodeFieldE
|
||||
<PopoverTrigger>
|
||||
<IconButton aria-label="settings" icon={<PiWrenchFill />} variant="link" size="sm" alignSelf="stretch" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverArrow />
|
||||
<PopoverBody minW={48}>
|
||||
<FormControl>
|
||||
<FormLabel flex={1}>{t('workflows.builder.description')}</FormLabel>
|
||||
<Switch size="sm" isChecked={showDescription} onChange={toggleShowDescription} />
|
||||
</FormControl>
|
||||
{settings?.type === 'integer-field-config' && <NodeFieldElementIntegerConfig id={id} config={settings} />}
|
||||
{settings?.type === 'float-field-config' && <NodeFieldElementFloatSettings id={id} config={settings} />}
|
||||
{settings?.type === 'string-field-config' && <NodeFieldElementStringSettings id={id} config={settings} />}
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
<Portal>
|
||||
<PopoverContent>
|
||||
<PopoverArrow />
|
||||
<PopoverBody minW={48}>
|
||||
<FormControl>
|
||||
<FormLabel flex={1}>{t('workflows.builder.showDescription')}</FormLabel>
|
||||
<Switch size="sm" isChecked={showDescription} onChange={toggleShowDescription} />
|
||||
</FormControl>
|
||||
{settings?.type === 'integer-field-config' && <NodeFieldElementIntegerConfig id={id} config={settings} />}
|
||||
{settings?.type === 'float-field-config' && <NodeFieldElementFloatSettings id={id} config={settings} />}
|
||||
{settings?.type === 'string-field-config' && <NodeFieldElementStringSettings id={id} config={settings} />}
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -26,8 +26,8 @@ export const NodeFieldElementStringSettings = memo(
|
||||
<FormControl>
|
||||
<FormLabel flex={1}>{t('workflows.builder.component')}</FormLabel>
|
||||
<Select value={config.component} onChange={onChangeComponent} size="sm">
|
||||
<option value="input">{t('workflows.builder.input')}</option>
|
||||
<option value="textarea">{t('workflows.builder.textarea')}</option>
|
||||
<option value="input">{t('workflows.builder.singleLine')}</option>
|
||||
<option value="textarea">{t('workflows.builder.multiLine')}</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex, FormControl, FormHelperText } from '@invoke-ai/ui-library';
|
||||
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 { useInputFieldDescription } from 'features/nodes/hooks/useInputFieldDescription';
|
||||
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
|
||||
import type { NodeFieldElement } from 'features/nodes/types/workflow';
|
||||
import { NODE_FIELD_CLASS_NAME } from 'features/nodes/types/workflow';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
const sx: SystemStyleObject = {
|
||||
'&[data-parent-layout="column"]': {
|
||||
w: 'full',
|
||||
h: 'min-content',
|
||||
},
|
||||
'&[data-parent-layout="row"]': {
|
||||
flex: '1 1 0',
|
||||
minW: 32,
|
||||
},
|
||||
};
|
||||
|
||||
export const NodeFieldElementViewMode = memo(({ el }: { el: NodeFieldElement }) => {
|
||||
const { id, data } = el;
|
||||
const { fieldIdentifier, showDescription } = data;
|
||||
const description = useInputFieldDescription(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
|
||||
const fieldTemplate = useInputFieldTemplate(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
|
||||
const containerCtx = useContainerContext();
|
||||
|
||||
const _description = useMemo(
|
||||
() => description || fieldTemplate.description,
|
||||
[description, fieldTemplate.description]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex id={id} className={NODE_FIELD_CLASS_NAME} sx={sx} data-parent-layout={containerCtx.layout}>
|
||||
<FormControl flex="1 1 0" orientation="vertical">
|
||||
<NodeFieldElementLabel el={el} />
|
||||
<Flex w="full" gap={4}>
|
||||
<InputFieldRenderer
|
||||
nodeId={fieldIdentifier.nodeId}
|
||||
fieldName={fieldIdentifier.fieldName}
|
||||
settings={data.settings}
|
||||
/>
|
||||
</Flex>
|
||||
{showDescription && _description && <FormHelperText>{_description}</FormHelperText>}
|
||||
</FormControl>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
NodeFieldElementViewMode.displayName = 'NodeFieldElementViewMode';
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { TextElementEditMode } from 'features/nodes/components/sidePanel/builder/TextElementEditMode';
|
||||
import { TextElementViewMode } from 'features/nodes/components/sidePanel/builder/TextElementViewMode';
|
||||
import { selectWorkflowMode, useElement } from 'features/nodes/store/workflowSlice';
|
||||
import { isTextElement } from 'features/nodes/types/workflow';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const TextElement = memo(({ id }: { id: string }) => {
|
||||
const el = useElement(id);
|
||||
const mode = useAppSelector(selectWorkflowMode);
|
||||
|
||||
if (!el || !isTextElement(el)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mode === 'view') {
|
||||
return <TextElementViewMode el={el} />;
|
||||
}
|
||||
|
||||
// mode === 'edit'
|
||||
return <TextElementEditMode el={el} />;
|
||||
});
|
||||
TextElement.displayName = 'TextElement';
|
||||
@@ -1,115 +0,0 @@
|
||||
import type { SystemStyleObject, TextProps } from '@invoke-ai/ui-library';
|
||||
import { Flex, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useEditable } from 'common/hooks/useEditable';
|
||||
import { AutosizeTextarea } from 'features/nodes/components/sidePanel/builder/AutosizeTextarea';
|
||||
import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper';
|
||||
import { formElementTextDataChanged, selectWorkflowMode, useElement } from 'features/nodes/store/workflowSlice';
|
||||
import type { TextElement } from 'features/nodes/types/workflow';
|
||||
import { isTextElement, TEXT_CLASS_NAME } from 'features/nodes/types/workflow';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const TextElementComponent = memo(({ id }: { id: string }) => {
|
||||
const el = useElement(id);
|
||||
const mode = useAppSelector(selectWorkflowMode);
|
||||
|
||||
if (!el || !isTextElement(el)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mode === 'view') {
|
||||
return <TextElementComponentViewMode el={el} />;
|
||||
}
|
||||
|
||||
// mode === 'edit'
|
||||
return <TextElementComponentEditMode el={el} />;
|
||||
});
|
||||
TextElementComponent.displayName = 'TextElementComponent';
|
||||
|
||||
const TextElementComponentViewMode = memo(({ el }: { el: TextElement }) => {
|
||||
const { id, data } = el;
|
||||
const { content } = data;
|
||||
|
||||
return (
|
||||
<Flex id={id} className={TEXT_CLASS_NAME} w="full">
|
||||
<TextContentDisplay content={content} />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
TextElementComponentViewMode.displayName = 'TextElementComponentViewMode';
|
||||
|
||||
const textSx: SystemStyleObject = {
|
||||
fontSize: 'md',
|
||||
overflowWrap: 'anywhere',
|
||||
'&[data-is-empty="true"]': {
|
||||
opacity: 0.3,
|
||||
},
|
||||
};
|
||||
|
||||
const TextContentDisplay = memo(({ content, ...rest }: { content: string } & TextProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Text sx={textSx} data-is-empty={content === ''} {...rest}>
|
||||
{content || t('workflows.builder.textPlaceholder')}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
TextContentDisplay.displayName = 'TextContentDisplay';
|
||||
|
||||
const TextElementComponentEditMode = memo(({ el }: { el: TextElement }) => {
|
||||
const { id } = el;
|
||||
|
||||
return (
|
||||
<FormElementEditModeWrapper element={el}>
|
||||
<Flex id={id} className={TEXT_CLASS_NAME} w="full">
|
||||
<EditableText el={el} />
|
||||
</Flex>
|
||||
</FormElementEditModeWrapper>
|
||||
);
|
||||
});
|
||||
TextElementComponentEditMode.displayName = 'TextElementComponentEditMode';
|
||||
|
||||
const EditableText = memo(({ el }: { el: TextElement }) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const { id, data } = el;
|
||||
const { content } = data;
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const onChange = useCallback(
|
||||
(content: string) => {
|
||||
dispatch(formElementTextDataChanged({ id, changes: { content } }));
|
||||
},
|
||||
[dispatch, id]
|
||||
);
|
||||
|
||||
const editable = useEditable({
|
||||
value: content,
|
||||
defaultValue: '',
|
||||
onChange,
|
||||
inputRef: ref,
|
||||
});
|
||||
|
||||
if (!editable.isEditing) {
|
||||
return <TextContentDisplay content={editable.value} onDoubleClick={editable.startEditing} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AutosizeTextarea
|
||||
ref={ref}
|
||||
placeholder={t('workflows.builder.textPlaceholder')}
|
||||
{...editable.inputProps}
|
||||
fontSize="md"
|
||||
variant="outline"
|
||||
overflowWrap="anywhere"
|
||||
w="full"
|
||||
minRows={1}
|
||||
maxRows={10}
|
||||
resize="none"
|
||||
p={2}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
EditableText.displayName = 'EditableText';
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { SystemStyleObject, TextProps } from '@invoke-ai/ui-library';
|
||||
import { Text } from '@invoke-ai/ui-library';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const textSx: SystemStyleObject = {
|
||||
fontSize: 'md',
|
||||
overflowWrap: 'anywhere',
|
||||
'&[data-is-empty="true"]': {
|
||||
opacity: 0.3,
|
||||
},
|
||||
};
|
||||
|
||||
export const TextElementContent = memo(({ content, ...rest }: { content: string } & TextProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Text sx={textSx} data-is-empty={content === ''} {...rest}>
|
||||
{content || t('workflows.builder.textPlaceholder')}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
|
||||
TextElementContent.displayName = 'TextElementContent';
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useEditable } from 'common/hooks/useEditable';
|
||||
import { AutosizeTextarea } from 'features/nodes/components/sidePanel/builder/AutosizeTextarea';
|
||||
import { TextElementContent } from 'features/nodes/components/sidePanel/builder/TextElementContent';
|
||||
import { formElementTextDataChanged } from 'features/nodes/store/workflowSlice';
|
||||
import type { TextElement } from 'features/nodes/types/workflow';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const TextElementContentEditable = memo(({ el }: { el: TextElement }) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const { id, data } = el;
|
||||
const { content } = data;
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const onChange = useCallback(
|
||||
(content: string) => {
|
||||
dispatch(formElementTextDataChanged({ id, changes: { content } }));
|
||||
},
|
||||
[dispatch, id]
|
||||
);
|
||||
|
||||
const editable = useEditable({
|
||||
value: content,
|
||||
defaultValue: '',
|
||||
onChange,
|
||||
inputRef: ref,
|
||||
});
|
||||
|
||||
if (!editable.isEditing) {
|
||||
return <TextElementContent content={editable.value} onDoubleClick={editable.startEditing} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AutosizeTextarea
|
||||
ref={ref}
|
||||
placeholder={t('workflows.builder.textPlaceholder')}
|
||||
{...editable.inputProps}
|
||||
fontSize="md"
|
||||
variant="outline"
|
||||
overflowWrap="anywhere"
|
||||
w="full"
|
||||
minRows={1}
|
||||
maxRows={10}
|
||||
resize="none"
|
||||
p={2}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TextElementContentEditable.displayName = 'TextElementContentEditable';
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { useContainerContext } from 'features/nodes/components/sidePanel/builder/contexts';
|
||||
import { useFormElementDnd } from 'features/nodes/components/sidePanel/builder/dnd-hooks';
|
||||
import { DndListDropIndicator } from 'features/nodes/components/sidePanel/builder/DndListDropIndicator';
|
||||
import { FormElementEditModeContent } from 'features/nodes/components/sidePanel/builder/FormElementEditModeContent';
|
||||
import { FormElementEditModeHeader } from 'features/nodes/components/sidePanel/builder/FormElementEditModeHeader';
|
||||
import { TEXT_CLASS_NAME, type TextElement } from 'features/nodes/types/workflow';
|
||||
import { memo, useRef } from 'react';
|
||||
|
||||
import { TextElementContentEditable } from './TextElementContentEditable';
|
||||
|
||||
const sx: SystemStyleObject = {
|
||||
position: 'relative',
|
||||
borderRadius: 'base',
|
||||
minW: 32,
|
||||
'&[data-parent-layout="column"]': {
|
||||
w: 'full',
|
||||
h: 'min-content',
|
||||
},
|
||||
'&[data-parent-layout="row"]': {
|
||||
flex: '1 0 0',
|
||||
},
|
||||
flexDir: 'column',
|
||||
};
|
||||
|
||||
export const TextElementEditMode = memo(({ el }: { el: TextElement }) => {
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const dragHandleRef = useRef<HTMLDivElement>(null);
|
||||
const [activeDropRegion, isDragging] = useFormElementDnd(el.id, draggableRef, dragHandleRef);
|
||||
const containerCtx = useContainerContext();
|
||||
const { id } = el;
|
||||
|
||||
return (
|
||||
<Flex ref={draggableRef} id={id} className={TEXT_CLASS_NAME} sx={sx} data-parent-layout={containerCtx.layout}>
|
||||
<FormElementEditModeHeader dragHandleRef={dragHandleRef} element={el} data-is-dragging={isDragging} />
|
||||
<FormElementEditModeContent data-is-dragging={isDragging} p={4}>
|
||||
<TextElementContentEditable el={el} />
|
||||
</FormElementEditModeContent>
|
||||
<DndListDropIndicator activeDropRegion={activeDropRegion} gap="var(--invoke-space-4)" />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
TextElementEditMode.displayName = 'TextElementEditMode';
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { useContainerContext } from 'features/nodes/components/sidePanel/builder/contexts';
|
||||
import { TextElementContent } from 'features/nodes/components/sidePanel/builder/TextElementContent';
|
||||
import { TEXT_CLASS_NAME, type TextElement } from 'features/nodes/types/workflow';
|
||||
import { memo } from 'react';
|
||||
|
||||
const sx: SystemStyleObject = {
|
||||
'&[data-parent-layout="column"]': {
|
||||
w: 'full',
|
||||
h: 'min-content',
|
||||
},
|
||||
'&[data-parent-layout="row"]': {
|
||||
flex: '1 1 0',
|
||||
minW: 32,
|
||||
},
|
||||
};
|
||||
|
||||
export const TextElementViewMode = memo(({ el }: { el: TextElement }) => {
|
||||
const { id, data } = el;
|
||||
const { content } = data;
|
||||
const containerCtx = useContainerContext();
|
||||
|
||||
return (
|
||||
<Flex id={id} className={TEXT_CLASS_NAME} sx={sx} data-parent-layout={containerCtx.layout}>
|
||||
<TextElementContent content={content} />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
TextElementViewMode.displayName = 'TextElementViewMode';
|
||||
@@ -7,11 +7,11 @@ import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
|
||||
import { firefoxDndFix } from 'features/dnd/util';
|
||||
import { FormElementComponent } from 'features/nodes/components/sidePanel/builder/ContainerElementComponent';
|
||||
import { RootContainerElementEditMode } from 'features/nodes/components/sidePanel/builder/ContainerElement';
|
||||
import { buildFormElementDndData, useBuilderDndMonitor } from 'features/nodes/components/sidePanel/builder/dnd-hooks';
|
||||
import { WorkflowBuilderEditMenu } from 'features/nodes/components/sidePanel/builder/WorkflowBuilderMenu';
|
||||
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
|
||||
import { selectFormRootElementId, selectIsFormEmpty } from 'features/nodes/store/workflowSlice';
|
||||
import { selectIsFormEmpty } from 'features/nodes/store/workflowSlice';
|
||||
import type { FormElement } from 'features/nodes/types/workflow';
|
||||
import { buildContainer, buildDivider, buildHeading, buildText } from 'features/nodes/types/workflow';
|
||||
import { startCase } from 'lodash-es';
|
||||
@@ -23,9 +23,9 @@ import { assert } from 'tsafe';
|
||||
|
||||
const sx: SystemStyleObject = {
|
||||
pt: 3,
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
'&[data-is-empty="true"]': {
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
pt: 0,
|
||||
},
|
||||
};
|
||||
@@ -60,7 +60,6 @@ WorkflowBuilder.displayName = 'WorkflowBuilder';
|
||||
|
||||
const WorkflowBuilderContent = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const rootElementId = useAppSelector(selectFormRootElementId);
|
||||
const isFormEmpty = useAppSelector(selectIsFormEmpty);
|
||||
const openApiSchemaQuery = useGetOpenAPISchemaQuery();
|
||||
const loadedTemplates = useStore($hasTemplates);
|
||||
@@ -71,7 +70,7 @@ const WorkflowBuilderContent = memo(() => {
|
||||
|
||||
return (
|
||||
<Flex sx={sx} data-is-empty={isFormEmpty}>
|
||||
<FormElementComponent id={rootElementId} />
|
||||
<RootContainerElementEditMode />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
@@ -124,23 +123,20 @@ const useAddFormElementDnd = (
|
||||
return isDragging;
|
||||
};
|
||||
|
||||
const addFormElementButtonSx: SystemStyleObject = {
|
||||
cursor: 'grab',
|
||||
borderStyle: 'dashed',
|
||||
_active: { borderStyle: 'dashed' },
|
||||
_disabled: { borderStyle: 'dashed', opacity: 0.5 },
|
||||
};
|
||||
|
||||
const AddFormElementDndButton = ({ type }: { type: Parameters<typeof useAddFormElementDnd>[0] }) => {
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const isDragging = useAddFormElementDnd(type, draggableRef);
|
||||
|
||||
return (
|
||||
// Must be as div for draggable to work correctly
|
||||
<Button
|
||||
as="div"
|
||||
ref={draggableRef}
|
||||
variant="outline"
|
||||
cursor="grab"
|
||||
borderStyle="dashed"
|
||||
isDisabled={isDragging}
|
||||
size="sm"
|
||||
_active={{ borderStyle: 'dashed' }}
|
||||
_disabled={{ borderStyle: 'dashed', opacity: 0.5 }}
|
||||
>
|
||||
<Button as="div" ref={draggableRef} size="sm" isDisabled={isDragging} variant="outline" sx={addFormElementButtonSx}>
|
||||
{startCase(type)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ContainerElement, ElementId } from 'features/nodes/types/workflow';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { createContext, memo, useContext, useMemo } from 'react';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
type ContainerContextValue = {
|
||||
id: ElementId;
|
||||
@@ -17,6 +18,10 @@ ContainerContextProvider.displayName = 'ContainerContextProvider';
|
||||
|
||||
export const useContainerContext = () => {
|
||||
const container = useContext(ContainerContext);
|
||||
assert(
|
||||
container !== null,
|
||||
'useContainerContext must be used inside a ContainerContextProvider and cannot be used by the root container'
|
||||
);
|
||||
return container;
|
||||
};
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
getElement,
|
||||
getInitialValue,
|
||||
} from 'features/nodes/components/sidePanel/builder/form-manipulation';
|
||||
import { getEditModeWrapperId } from 'features/nodes/components/sidePanel/builder/shared';
|
||||
import { selectNodesSlice } from 'features/nodes/store/selectors';
|
||||
import {
|
||||
formElementAdded,
|
||||
@@ -41,6 +40,7 @@ import type { RefObject } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import type { Param0 } from 'tsafe';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
const log = logger('dnd');
|
||||
|
||||
@@ -63,7 +63,7 @@ const isFormElementDndData = (data: Record<string | symbol, unknown>): data is F
|
||||
* @param elementId The id of the element to flash
|
||||
*/
|
||||
const flashElement = (elementId: ElementId) => {
|
||||
const element = document.querySelector(`#${getEditModeWrapperId(elementId)}`);
|
||||
const element = document.querySelector(`#${elementId}`);
|
||||
if (element instanceof HTMLElement) {
|
||||
triggerPostMoveFlash(element, colorTokenToCssVar('base.800'));
|
||||
}
|
||||
@@ -329,6 +329,9 @@ export const useFormElementDnd = (
|
||||
const getAllowedDropRegions = useGetAllowedDropRegions();
|
||||
|
||||
useEffect(() => {
|
||||
if (isRootElement) {
|
||||
assert(false, 'Root element should not be draggable');
|
||||
}
|
||||
const draggableElement = draggableRef.current;
|
||||
const dragHandleElement = dragHandleRef.current;
|
||||
|
||||
@@ -339,8 +342,6 @@ export const useFormElementDnd = (
|
||||
return combine(
|
||||
firefoxDndFix(draggableElement),
|
||||
draggable({
|
||||
// Don't allow dragging the root element
|
||||
canDrag: () => !isRootElement,
|
||||
element: draggableElement,
|
||||
dragHandle: dragHandleElement,
|
||||
getInitialData: () => {
|
||||
@@ -356,7 +357,8 @@ export const useFormElementDnd = (
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element: draggableElement,
|
||||
getIsSticky: () => !isRootElement,
|
||||
// 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,
|
||||
getData: ({ input }) => {
|
||||
@@ -404,6 +406,52 @@ export const useFormElementDnd = (
|
||||
return [activeDropRegion, isDragging] as const;
|
||||
};
|
||||
|
||||
export const useRootElementDropTarget = (droppableRef: RefObject<HTMLDivElement>) => {
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const getElement = useGetElement();
|
||||
const getAllowedDropRegions = useGetAllowedDropRegions();
|
||||
const rootElementId = useAppSelector(selectFormRootElementId);
|
||||
|
||||
useEffect(() => {
|
||||
const droppableElement = droppableRef.current;
|
||||
|
||||
if (!droppableElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
return combine(
|
||||
dropTargetForElements({
|
||||
element: droppableElement,
|
||||
getIsSticky: () => false,
|
||||
canDrop: ({ source }) =>
|
||||
getElement(rootElementId, isContainerElement).data.children.length === 0 && isFormElementDndData(source.data),
|
||||
getData: ({ input }) => {
|
||||
const element = getElement(rootElementId, isContainerElement);
|
||||
|
||||
const targetData = buildFormElementDndData(element);
|
||||
|
||||
return attachClosestCenterOrEdge(targetData, {
|
||||
element: droppableElement,
|
||||
input,
|
||||
allowedCenterOrEdge: ['center'],
|
||||
});
|
||||
},
|
||||
onDrag: () => {
|
||||
setIsDraggingOver(true);
|
||||
},
|
||||
onDragLeave: () => {
|
||||
setIsDraggingOver(false);
|
||||
},
|
||||
onDrop: () => {
|
||||
setIsDraggingOver(false);
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [droppableRef, getAllowedDropRegions, getElement, rootElementId]);
|
||||
|
||||
return isDraggingOver;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook that provides dnd functionality for node fields.
|
||||
*
|
||||
@@ -457,7 +505,7 @@ export const useNodeFieldDnd = (
|
||||
* @param elementId The id of the element
|
||||
* @returns Whether the element is the root element
|
||||
*/
|
||||
export const useIsRootElement = (elementId: string) => {
|
||||
const useIsRootElement = (elementId: string) => {
|
||||
const rootElementId = useAppSelector(selectFormRootElementId);
|
||||
const isRootElement = useMemo(() => rootElementId === elementId, [rootElementId, elementId]);
|
||||
return isRootElement;
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// This must be in this file to avoid circular dependencies
|
||||
export const getEditModeWrapperId = (id: string) => `${id}-edit-mode-wrapper`;
|
||||
@@ -3,10 +3,10 @@ import { useStore } from '@nanostores/react';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
|
||||
import { FormElementComponent } from 'features/nodes/components/sidePanel/builder/ContainerElementComponent';
|
||||
import { RootContainerElementViewMode } from 'features/nodes/components/sidePanel/builder/ContainerElement';
|
||||
import { EmptyState } from 'features/nodes/components/sidePanel/viewMode/EmptyState';
|
||||
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
|
||||
import { selectFormRootElementId, selectIsFormEmpty } from 'features/nodes/store/workflowSlice';
|
||||
import { selectIsFormEmpty } from 'features/nodes/store/workflowSlice';
|
||||
import { t } from 'i18next';
|
||||
import { memo } from 'react';
|
||||
import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo';
|
||||
@@ -25,7 +25,6 @@ ViewModeLeftPanelContent.displayName = 'ViewModeLeftPanelContent';
|
||||
const ViewModeLeftPanelContentInner = memo(() => {
|
||||
const { isLoading } = useGetOpenAPISchemaQuery();
|
||||
const loadedTemplates = useStore($hasTemplates);
|
||||
const rootElementId = useAppSelector(selectFormRootElementId);
|
||||
const isFormEmpty = useAppSelector(selectIsFormEmpty);
|
||||
|
||||
if (isLoading || !loadedTemplates) {
|
||||
@@ -37,8 +36,8 @@ const ViewModeLeftPanelContentInner = memo(() => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" w="full" maxW="768px">
|
||||
<FormElementComponent id={rootElementId} />
|
||||
<Flex w="full" h="full" justifyContent="center">
|
||||
<RootContainerElementViewMode />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
|
||||
import { isBatchNodeType, isGeneratorNodeType } from 'features/nodes/types/invocation';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const useIsExecutableNode = (nodeId: string) => {
|
||||
const template = useNodeTemplate(nodeId);
|
||||
const isExecutableNode = useMemo(
|
||||
() => !isBatchNodeType(template.type) && !isGeneratorNodeType(template.type),
|
||||
[template]
|
||||
);
|
||||
return isExecutableNode;
|
||||
};
|
||||
@@ -23,3 +23,26 @@ export const useMouseOverNode = (nodeId: string) => {
|
||||
|
||||
return { isMouseOverNode, handleMouseOver, handleMouseOut };
|
||||
};
|
||||
|
||||
const $mouseOverFormField = atom<string | null>(null);
|
||||
|
||||
export const useMouseOverFormField = (nodeId: string) => {
|
||||
const [isMouseOverFormField, setIsMouseOverFormField] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = $mouseOverFormField.subscribe((v) => {
|
||||
setIsMouseOverFormField(v === nodeId);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [isMouseOverFormField, nodeId]);
|
||||
|
||||
const handleMouseOver = useCallback(() => {
|
||||
$mouseOverFormField.set(nodeId);
|
||||
}, [nodeId]);
|
||||
|
||||
const handleMouseOut = useCallback(() => {
|
||||
$mouseOverFormField.set(null);
|
||||
}, []);
|
||||
|
||||
return { isMouseOverFormField, handleMouseOver, handleMouseOut };
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useIsExecutableNode } from 'features/nodes/hooks/useIsBatchNode';
|
||||
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
@@ -5,7 +6,11 @@ import { useNodeHasImageOutput } from './useNodeHasImageOutput';
|
||||
|
||||
export const useWithFooter = (nodeId: string) => {
|
||||
const hasImageOutput = useNodeHasImageOutput(nodeId);
|
||||
const isExecutableNode = useIsExecutableNode(nodeId);
|
||||
const isCacheEnabled = useFeatureStatus('invocationCache');
|
||||
const withFooter = useMemo(() => hasImageOutput || isCacheEnabled, [hasImageOutput, isCacheEnabled]);
|
||||
const withFooter = useMemo(
|
||||
() => isExecutableNode && (hasImageOutput || isCacheEnabled),
|
||||
[hasImageOutput, isCacheEnabled, isExecutableNode]
|
||||
);
|
||||
return withFooter;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useReactFlow } from '@xyflow/react';
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { $flow } from 'features/nodes/store/reactFlowInstance';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
const log = logger('workflows');
|
||||
|
||||
export const useZoomToNode = () => {
|
||||
const flow = useReactFlow();
|
||||
const zoomToNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
flow.fitView({ duration: 300, maxZoom: 1.5, nodes: [{ id: nodeId }] });
|
||||
},
|
||||
[flow]
|
||||
);
|
||||
const zoomToNode = useCallback((nodeId: string) => {
|
||||
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 }] });
|
||||
}, []);
|
||||
return zoomToNode;
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
FluxVAEModelFieldValue,
|
||||
ImageFieldCollectionValue,
|
||||
ImageFieldValue,
|
||||
ImageGeneratorFieldValue,
|
||||
IntegerFieldCollectionValue,
|
||||
IntegerFieldValue,
|
||||
IntegerGeneratorFieldValue,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
zFluxVAEModelFieldValue,
|
||||
zImageFieldCollectionValue,
|
||||
zImageFieldValue,
|
||||
zImageGeneratorFieldValue,
|
||||
zIntegerFieldCollectionValue,
|
||||
zIntegerFieldValue,
|
||||
zIntegerGeneratorFieldValue,
|
||||
@@ -422,6 +424,9 @@ export const nodesSlice = createSlice({
|
||||
fieldStringGeneratorValueChanged: (state, action: FieldValueAction<StringGeneratorFieldValue>) => {
|
||||
fieldValueReducer(state, action, zStringGeneratorFieldValue);
|
||||
},
|
||||
fieldImageGeneratorValueChanged: (state, action: FieldValueAction<ImageGeneratorFieldValue>) => {
|
||||
fieldValueReducer(state, action, zImageGeneratorFieldValue);
|
||||
},
|
||||
fieldDescriptionChanged: (state, action: PayloadAction<{ nodeId: string; fieldName: string; val?: string }>) => {
|
||||
const { nodeId, fieldName, val } = action.payload;
|
||||
const field = getField(nodeId, fieldName, state);
|
||||
@@ -514,6 +519,7 @@ export const {
|
||||
fieldFloatGeneratorValueChanged,
|
||||
fieldIntegerGeneratorValueChanged,
|
||||
fieldStringGeneratorValueChanged,
|
||||
fieldImageGeneratorValueChanged,
|
||||
fieldDescriptionChanged,
|
||||
nodeEditorReset,
|
||||
nodeIsIntermediateChanged,
|
||||
|
||||
@@ -36,14 +36,14 @@ const validateImageFieldCollectionValue = (
|
||||
// Image collections may have min or max items to validate
|
||||
if (minItems !== undefined && minItems > 0 && count === 0) {
|
||||
reasons.push(t('parameters.invoke.collectionEmpty'));
|
||||
}
|
||||
} else {
|
||||
if (minItems !== undefined && count < minItems) {
|
||||
reasons.push(t('parameters.invoke.collectionTooFewItems', { count, minItems }));
|
||||
}
|
||||
|
||||
if (minItems !== undefined && count < minItems) {
|
||||
reasons.push(t('parameters.invoke.collectionTooFewItems', { count, minItems }));
|
||||
}
|
||||
|
||||
if (maxItems !== undefined && count > maxItems) {
|
||||
reasons.push(t('parameters.invoke.collectionTooManyItems', { count, maxItems }));
|
||||
if (maxItems !== undefined && count > maxItems) {
|
||||
reasons.push(t('parameters.invoke.collectionTooManyItems', { count, maxItems }));
|
||||
}
|
||||
}
|
||||
|
||||
return reasons;
|
||||
@@ -60,14 +60,14 @@ const validateStringFieldCollectionValue = (
|
||||
// Image collections may have min or max items to validate
|
||||
if (minItems !== undefined && minItems > 0 && count === 0) {
|
||||
reasons.push(t('parameters.invoke.collectionEmpty'));
|
||||
}
|
||||
} else {
|
||||
if (minItems !== undefined && count < minItems) {
|
||||
reasons.push(t('parameters.invoke.collectionTooFewItems', { count, minItems }));
|
||||
}
|
||||
|
||||
if (minItems !== undefined && count < minItems) {
|
||||
reasons.push(t('parameters.invoke.collectionTooFewItems', { count, minItems }));
|
||||
}
|
||||
|
||||
if (maxItems !== undefined && count > maxItems) {
|
||||
reasons.push(t('parameters.invoke.collectionTooManyItems', { count, maxItems }));
|
||||
if (maxItems !== undefined && count > maxItems) {
|
||||
reasons.push(t('parameters.invoke.collectionTooManyItems', { count, maxItems }));
|
||||
}
|
||||
}
|
||||
|
||||
for (const str of value) {
|
||||
@@ -93,14 +93,14 @@ const validateNumberFieldCollectionValue = (
|
||||
// Image collections may have min or max items to validate
|
||||
if (minItems !== undefined && minItems > 0 && count === 0) {
|
||||
reasons.push(t('parameters.invoke.collectionEmpty'));
|
||||
}
|
||||
} else {
|
||||
if (minItems !== undefined && count < minItems) {
|
||||
reasons.push(t('parameters.invoke.collectionTooFewItems', { count, minItems }));
|
||||
}
|
||||
|
||||
if (minItems !== undefined && count < minItems) {
|
||||
reasons.push(t('parameters.invoke.collectionTooFewItems', { count, minItems }));
|
||||
}
|
||||
|
||||
if (maxItems !== undefined && count > maxItems) {
|
||||
reasons.push(t('parameters.invoke.collectionTooManyItems', { count, maxItems }));
|
||||
if (maxItems !== undefined && count > maxItems) {
|
||||
reasons.push(t('parameters.invoke.collectionTooManyItems', { count, maxItems }));
|
||||
}
|
||||
}
|
||||
|
||||
for (const num of value) {
|
||||
@@ -174,6 +174,8 @@ export const getFieldErrors = (
|
||||
prefix,
|
||||
issue: t('parameters.invoke.missingInputForField'),
|
||||
});
|
||||
} else if (isConnected) {
|
||||
// Connected fields have no value to validate - they are OK
|
||||
} else if (
|
||||
field.value &&
|
||||
isImageFieldCollectionInputTemplate(fieldTemplate) &&
|
||||
|
||||
@@ -319,6 +319,7 @@ export const img_resize: InvocationTemplate = {
|
||||
fieldKind: 'input',
|
||||
input: 'direct',
|
||||
ui_hidden: false,
|
||||
default: 'auto',
|
||||
type: {
|
||||
name: 'BoardField',
|
||||
cardinality: 'SINGLE',
|
||||
|
||||
@@ -5,15 +5,17 @@ import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { deepClone } from 'common/util/deepClone';
|
||||
import {
|
||||
addElement,
|
||||
getElement,
|
||||
removeElement,
|
||||
reparentElement,
|
||||
} from 'features/nodes/components/sidePanel/builder/form-manipulation';
|
||||
import { workflowLoaded } from 'features/nodes/store/actions';
|
||||
import { isAnyNodeOrEdgeMutation, nodeEditorReset, nodesChanged } from 'features/nodes/store/nodesSlice';
|
||||
import type { WorkflowMode, WorkflowsState as WorkflowState } from 'features/nodes/store/types';
|
||||
import type { NodesState, WorkflowMode, WorkflowsState as WorkflowState } from 'features/nodes/store/types';
|
||||
import type { FieldIdentifier, StatefulFieldValue } from 'features/nodes/types/field';
|
||||
import { isInvocationNode } from 'features/nodes/types/invocation';
|
||||
import type {
|
||||
BuilderForm,
|
||||
ContainerElement,
|
||||
ElementId,
|
||||
FormElement,
|
||||
@@ -188,35 +190,19 @@ export const workflowSlice = createSlice({
|
||||
formElementContainerDataChanged: (state, action: FormElementDataChangedAction<ContainerElement>) => {
|
||||
formElementDataChangedReducer(state, action, isContainerElement);
|
||||
},
|
||||
formFieldInitialValuesChanged: (
|
||||
state,
|
||||
action: PayloadAction<{ formFieldInitialValues: WorkflowState['formFieldInitialValues'] }>
|
||||
) => {
|
||||
const { formFieldInitialValues } = action.payload;
|
||||
state.formFieldInitialValues = formFieldInitialValues;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(workflowLoaded, (state, action): WorkflowState => {
|
||||
const { nodes, edges: _edges, ...workflowExtra } = action.payload;
|
||||
|
||||
const formFieldInitialValues: Record<string, StatefulFieldValue> = {};
|
||||
|
||||
if (workflowExtra.form) {
|
||||
for (const el of Object.values(workflowExtra.form.elements)) {
|
||||
if (!isNodeFieldElement(el)) {
|
||||
continue;
|
||||
}
|
||||
const { nodeId, fieldName } = el.data.fieldIdentifier;
|
||||
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
|
||||
if (!isInvocationNode(node)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field = node.data.inputs[fieldName];
|
||||
|
||||
if (!field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
formFieldInitialValues[el.id] = field.value;
|
||||
}
|
||||
}
|
||||
const formFieldInitialValues = getFormFieldInitialValues(workflowExtra.form, nodes);
|
||||
|
||||
return {
|
||||
...deepClone(initialWorkflowState),
|
||||
@@ -322,6 +308,7 @@ export const {
|
||||
formElementTextDataChanged,
|
||||
formElementNodeFieldDataChanged,
|
||||
formElementContainerDataChanged,
|
||||
formFieldInitialValuesChanged,
|
||||
} = workflowSlice.actions;
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
@@ -343,6 +330,34 @@ export const selectWorkflowSlice = (state: RootState) => state.workflow;
|
||||
const createWorkflowSelector = <T>(selector: Selector<WorkflowState, T>) =>
|
||||
createSelector(selectWorkflowSlice, selector);
|
||||
|
||||
// The form builder's initial values are based on the current values of the node fields in the workflow.
|
||||
export const getFormFieldInitialValues = (form: BuilderForm, nodes: NodesState['nodes']) => {
|
||||
const formFieldInitialValues: Record<string, StatefulFieldValue> = {};
|
||||
|
||||
for (const el of Object.values(form.elements)) {
|
||||
if (!isNodeFieldElement(el)) {
|
||||
continue;
|
||||
}
|
||||
const { nodeId, fieldName } = el.data.fieldIdentifier;
|
||||
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
|
||||
if (!isInvocationNode(node)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field = node.data.inputs[fieldName];
|
||||
|
||||
if (!field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
formFieldInitialValues[el.id] = field.value;
|
||||
}
|
||||
|
||||
return formFieldInitialValues;
|
||||
};
|
||||
|
||||
export const selectWorkflowName = createWorkflowSelector((workflow) => workflow.name);
|
||||
export const selectWorkflowId = createWorkflowSelector((workflow) => workflow.id);
|
||||
export const selectWorkflowMode = createWorkflowSelector((workflow) => workflow.mode);
|
||||
@@ -351,6 +366,7 @@ export const selectWorkflowSearchTerm = createWorkflowSelector((workflow) => wor
|
||||
export const selectWorkflowOrderBy = createWorkflowSelector((workflow) => workflow.orderBy);
|
||||
export const selectWorkflowOrderDirection = createWorkflowSelector((workflow) => workflow.orderDirection);
|
||||
export const selectWorkflowDescription = createWorkflowSelector((workflow) => workflow.description);
|
||||
export const selectWorkflowForm = createWorkflowSelector((workflow) => workflow.form);
|
||||
|
||||
export const selectCleanEditor = createSelector([selectNodesSlice, selectWorkflowSlice], (nodes, workflow) => {
|
||||
const noNodes = !nodes.nodes.length;
|
||||
@@ -362,6 +378,9 @@ export const selectCleanEditor = createSelector([selectNodesSlice, selectWorkflo
|
||||
export const selectFormRootElementId = createWorkflowSelector((workflow) => {
|
||||
return workflow.form.rootElementId;
|
||||
});
|
||||
export const selectFormRootElement = createWorkflowSelector((workflow) => {
|
||||
return getElement(workflow.form, workflow.form.rootElementId, isContainerElement);
|
||||
});
|
||||
export const selectIsFormEmpty = createWorkflowSelector((workflow) => {
|
||||
const rootElement = workflow.form.elements[workflow.form.rootElementId];
|
||||
if (!rootElement || !isContainerElement(rootElement)) {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { isNil, trim } from 'lodash-es';
|
||||
import { EMPTY_ARRAY } from 'app/store/constants';
|
||||
import type { AppDispatch } from 'app/store/store';
|
||||
import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types';
|
||||
import { isNil, random, trim } from 'lodash-es';
|
||||
import MersenneTwister from 'mtwist';
|
||||
import { boardsApi } from 'services/api/endpoints/boards';
|
||||
import { utilitiesApi } from 'services/api/endpoints/utilities';
|
||||
import { assert } from 'tsafe';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { ImageField } from './common';
|
||||
import { zBoardField, zColorField, zImageField, zModelIdentifierField, zSchedulerField } from './common';
|
||||
|
||||
/**
|
||||
@@ -239,6 +245,10 @@ const zStringGeneratorFieldType = zFieldTypeBase.extend({
|
||||
name: z.literal('StringGeneratorField'),
|
||||
originalType: zStatelessFieldType.optional(),
|
||||
});
|
||||
const zImageGeneratorFieldType = zFieldTypeBase.extend({
|
||||
name: z.literal('ImageGeneratorField'),
|
||||
originalType: zStatelessFieldType.optional(),
|
||||
});
|
||||
const zStatefulFieldType = z.union([
|
||||
zIntegerFieldType,
|
||||
zFloatFieldType,
|
||||
@@ -270,6 +280,7 @@ const zStatefulFieldType = z.union([
|
||||
zFloatGeneratorFieldType,
|
||||
zIntegerGeneratorFieldType,
|
||||
zStringGeneratorFieldType,
|
||||
zImageGeneratorFieldType,
|
||||
]);
|
||||
export type StatefulFieldType = z.infer<typeof zStatefulFieldType>;
|
||||
const statefulFieldTypeNames = zStatefulFieldType.options.map((o) => o.shape.name.value);
|
||||
@@ -642,7 +653,7 @@ export const isImageFieldCollectionInputTemplate = buildTemplateTypeGuard<ImageF
|
||||
// #endregion
|
||||
|
||||
// #region BoardField
|
||||
export const zBoardFieldValue = zBoardField.optional();
|
||||
export const zBoardFieldValue = z.union([zBoardField, z.enum(['none', 'auto'])]).optional();
|
||||
const zBoardFieldInputInstance = zFieldInputInstanceBase.extend({
|
||||
value: zBoardFieldValue,
|
||||
});
|
||||
@@ -1225,7 +1236,6 @@ const zIntegerGeneratorArithmeticSequence = z.object({
|
||||
start: z.number().int().default(0),
|
||||
step: z.number().int().default(1),
|
||||
count: z.number().int().default(10),
|
||||
values: z.array(z.number().int()).nullish(),
|
||||
});
|
||||
export type IntegerGeneratorArithmeticSequence = z.infer<typeof zIntegerGeneratorArithmeticSequence>;
|
||||
export const getIntegerGeneratorArithmeticSequenceDefaults = () => zIntegerGeneratorArithmeticSequence.parse({});
|
||||
@@ -1244,7 +1254,6 @@ const zIntegerGeneratorLinearDistribution = z.object({
|
||||
start: z.number().int().default(0),
|
||||
end: z.number().int().default(10),
|
||||
count: z.number().int().default(10),
|
||||
values: z.array(z.number().int()).nullish(),
|
||||
});
|
||||
export type IntegerGeneratorLinearDistribution = z.infer<typeof zIntegerGeneratorLinearDistribution>;
|
||||
const getIntegerGeneratorLinearDistributionDefaults = () => zIntegerGeneratorLinearDistribution.parse({});
|
||||
@@ -1264,7 +1273,6 @@ const zIntegerGeneratorUniformRandomDistribution = z.object({
|
||||
max: z.number().int().default(10),
|
||||
count: z.number().int().default(10),
|
||||
seed: z.number().int().nullish(),
|
||||
values: z.array(z.number().int()).nullish(),
|
||||
});
|
||||
export type IntegerGeneratorUniformRandomDistribution = z.infer<typeof zIntegerGeneratorUniformRandomDistribution>;
|
||||
const getIntegerGeneratorUniformRandomDistributionDefaults = () => zIntegerGeneratorUniformRandomDistribution.parse({});
|
||||
@@ -1280,7 +1288,6 @@ const zIntegerGeneratorParseString = z.object({
|
||||
type: z.literal(IntegerGeneratorParseStringType).default(IntegerGeneratorParseStringType),
|
||||
input: z.string().default('1,2,3,4,5,6,7,8,9,10'),
|
||||
splitOn: z.string().default(','),
|
||||
values: z.array(z.number().int()).nullish(),
|
||||
});
|
||||
export type IntegerGeneratorParseString = z.infer<typeof zIntegerGeneratorParseString>;
|
||||
const getIntegerGeneratorParseStringDefaults = () => zIntegerGeneratorParseString.parse({});
|
||||
@@ -1334,9 +1341,6 @@ export const isIntegerGeneratorFieldInputInstance = buildInstanceTypeGuard(zInte
|
||||
export const isIntegerGeneratorFieldInputTemplate =
|
||||
buildTemplateTypeGuard<IntegerGeneratorFieldInputTemplate>('IntegerGeneratorField');
|
||||
export const resolveIntegerGeneratorField = ({ value }: IntegerGeneratorFieldInputInstance) => {
|
||||
if (value.values) {
|
||||
return value.values;
|
||||
}
|
||||
if (value.type === IntegerGeneratorArithmeticSequenceType) {
|
||||
return getIntegerGeneratorArithmeticSequenceValues(value);
|
||||
}
|
||||
@@ -1374,7 +1378,6 @@ const zStringGeneratorParseString = z.object({
|
||||
type: z.literal(StringGeneratorParseStringType).default(StringGeneratorParseStringType),
|
||||
input: z.string().default('foo,bar,baz,qux'),
|
||||
splitOn: z.string().default(','),
|
||||
values: z.array(z.string()).nullish(),
|
||||
});
|
||||
export type StringGeneratorParseString = z.infer<typeof zStringGeneratorParseString>;
|
||||
export const getStringGeneratorParseStringDefaults = () => zStringGeneratorParseString.parse({});
|
||||
@@ -1404,14 +1407,36 @@ const zStringGeneratorDynamicPromptsCombinatorial = z.object({
|
||||
.default(StringGeneratorDynamicPromptsCombinatorialType),
|
||||
input: z.string().default('a super {cute|ferocious} {dog|cat}'),
|
||||
maxPrompts: z.number().int().gte(1).default(10),
|
||||
values: z.array(z.string()).nullish(),
|
||||
});
|
||||
export type StringGeneratorDynamicPromptsCombinatorial = z.infer<typeof zStringGeneratorDynamicPromptsCombinatorial>;
|
||||
const getStringGeneratorDynamicPromptsCombinatorialDefaults = () =>
|
||||
zStringGeneratorDynamicPromptsCombinatorial.parse({});
|
||||
const getStringGeneratorDynamicPromptsCombinatorialValues = (generator: StringGeneratorDynamicPromptsCombinatorial) => {
|
||||
const { values } = generator;
|
||||
return values ?? [];
|
||||
const getStringGeneratorDynamicPromptsCombinatorialValues = async (
|
||||
generator: StringGeneratorDynamicPromptsCombinatorial,
|
||||
dispatch: AppDispatch
|
||||
): Promise<string[]> => {
|
||||
const { input, maxPrompts } = generator;
|
||||
const req = dispatch(
|
||||
utilitiesApi.endpoints.dynamicPrompts.initiate(
|
||||
{
|
||||
prompt: input,
|
||||
max_prompts: maxPrompts,
|
||||
combinatorial: true,
|
||||
},
|
||||
{
|
||||
subscribe: false,
|
||||
}
|
||||
)
|
||||
);
|
||||
try {
|
||||
const { prompts, error } = await req.unwrap();
|
||||
if (error) {
|
||||
return EMPTY_ARRAY;
|
||||
}
|
||||
return prompts;
|
||||
} catch {
|
||||
return EMPTY_ARRAY;
|
||||
}
|
||||
};
|
||||
|
||||
export const StringGeneratorDynamicPromptsRandomType = 'string_generator_dynamic_prompts_random';
|
||||
@@ -1420,13 +1445,36 @@ const zStringGeneratorDynamicPromptsRandom = z.object({
|
||||
input: z.string().default('a super {cute|ferocious} {dog|cat}'),
|
||||
count: z.number().int().gte(1).default(10),
|
||||
seed: z.number().int().nullish(),
|
||||
values: z.array(z.string()).nullish(),
|
||||
});
|
||||
export type StringGeneratorDynamicPromptsRandom = z.infer<typeof zStringGeneratorDynamicPromptsRandom>;
|
||||
const getStringGeneratorDynamicPromptsRandomDefaults = () => zStringGeneratorDynamicPromptsRandom.parse({});
|
||||
const getStringGeneratorDynamicPromptsRandomValues = (generator: StringGeneratorDynamicPromptsRandom) => {
|
||||
const { values } = generator;
|
||||
return values ?? [];
|
||||
const getStringGeneratorDynamicPromptsRandomValues = async (
|
||||
generator: StringGeneratorDynamicPromptsRandom,
|
||||
dispatch: AppDispatch
|
||||
): Promise<string[]> => {
|
||||
const { input, seed, count } = generator;
|
||||
const req = dispatch(
|
||||
utilitiesApi.endpoints.dynamicPrompts.initiate(
|
||||
{
|
||||
prompt: input,
|
||||
max_prompts: count,
|
||||
combinatorial: false,
|
||||
seed: seed ?? random(),
|
||||
},
|
||||
{
|
||||
subscribe: false,
|
||||
}
|
||||
)
|
||||
);
|
||||
try {
|
||||
const { prompts, error } = await req.unwrap();
|
||||
if (error) {
|
||||
return EMPTY_ARRAY;
|
||||
}
|
||||
return prompts;
|
||||
} catch {
|
||||
return EMPTY_ARRAY;
|
||||
}
|
||||
};
|
||||
|
||||
export const zStringGeneratorFieldValue = z.union([
|
||||
@@ -1453,18 +1501,18 @@ export const isStringGeneratorFieldInputTemplate = buildTemplateTypeGuard<String
|
||||
zStringGeneratorFieldType.shape.name.value
|
||||
);
|
||||
|
||||
export const resolveStringGeneratorField = ({ value }: StringGeneratorFieldInputInstance) => {
|
||||
if (value.values) {
|
||||
return value.values;
|
||||
}
|
||||
export const resolveStringGeneratorField = async (
|
||||
{ value }: StringGeneratorFieldInputInstance,
|
||||
dispatch: AppDispatch
|
||||
) => {
|
||||
if (value.type === StringGeneratorParseStringType) {
|
||||
return getStringGeneratorParseStringValues(value);
|
||||
}
|
||||
if (value.type === StringGeneratorDynamicPromptsRandomType) {
|
||||
return getStringGeneratorDynamicPromptsRandomValues(value);
|
||||
return await getStringGeneratorDynamicPromptsRandomValues(value, dispatch);
|
||||
}
|
||||
if (value.type === StringGeneratorDynamicPromptsCombinatorialType) {
|
||||
return getStringGeneratorDynamicPromptsCombinatorialValues(value);
|
||||
return await getStringGeneratorDynamicPromptsCombinatorialValues(value, dispatch);
|
||||
}
|
||||
assert(false, 'Invalid string generator type');
|
||||
};
|
||||
@@ -1482,6 +1530,78 @@ export const getStringGeneratorDefaults = (type: StringGeneratorFieldValue['type
|
||||
};
|
||||
// #endregion
|
||||
|
||||
// #region ImageGeneratorField
|
||||
export const ImageGeneratorImagesFromBoardType = 'image_generator_images_from_board';
|
||||
const zImageGeneratorImagesFromBoard = z.object({
|
||||
type: z.literal(ImageGeneratorImagesFromBoardType).default(ImageGeneratorImagesFromBoardType),
|
||||
board_id: z.string().trim().min(1).optional(),
|
||||
category: z.union([z.literal('images'), z.literal('assets')]).default('images'),
|
||||
});
|
||||
export type ImageGeneratorImagesFromBoard = z.infer<typeof zImageGeneratorImagesFromBoard>;
|
||||
export const getImageGeneratorImagesFromBoardDefaults = () => zImageGeneratorImagesFromBoard.parse({});
|
||||
const getImageGeneratorImagesFromBoardValues = async (
|
||||
generator: ImageGeneratorImagesFromBoard,
|
||||
dispatch: AppDispatch
|
||||
) => {
|
||||
const { board_id, category } = generator;
|
||||
if (!board_id) {
|
||||
return EMPTY_ARRAY;
|
||||
}
|
||||
const req = dispatch(
|
||||
boardsApi.endpoints.listAllImageNamesForBoard.initiate(
|
||||
{
|
||||
board_id,
|
||||
categories: category === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES,
|
||||
is_intermediate: false,
|
||||
},
|
||||
{ subscribe: false }
|
||||
)
|
||||
);
|
||||
try {
|
||||
const imageNames = await req.unwrap();
|
||||
return imageNames.map((image_name) => ({ image_name }));
|
||||
} catch {
|
||||
return EMPTY_ARRAY;
|
||||
}
|
||||
};
|
||||
|
||||
export const zImageGeneratorFieldValue = zImageGeneratorImagesFromBoard;
|
||||
const zImageGeneratorFieldInputInstance = zFieldInputInstanceBase.extend({
|
||||
value: zImageGeneratorFieldValue,
|
||||
});
|
||||
const zImageGeneratorFieldInputTemplate = zFieldInputTemplateBase.extend({
|
||||
type: zImageGeneratorFieldType,
|
||||
originalType: zFieldType.optional(),
|
||||
default: zImageGeneratorFieldValue,
|
||||
});
|
||||
const zImageGeneratorFieldOutputTemplate = zFieldOutputTemplateBase.extend({
|
||||
type: zImageGeneratorFieldType,
|
||||
});
|
||||
export type ImageGeneratorFieldValue = z.infer<typeof zImageGeneratorFieldValue>;
|
||||
export type ImageGeneratorFieldInputInstance = z.infer<typeof zImageGeneratorFieldInputInstance>;
|
||||
export type ImageGeneratorFieldInputTemplate = z.infer<typeof zImageGeneratorFieldInputTemplate>;
|
||||
export const isImageGeneratorFieldInputInstance = buildInstanceTypeGuard(zImageGeneratorFieldInputInstance);
|
||||
export const isImageGeneratorFieldInputTemplate = buildTemplateTypeGuard<ImageGeneratorFieldInputTemplate>(
|
||||
zImageGeneratorFieldType.shape.name.value
|
||||
);
|
||||
|
||||
export const resolveImageGeneratorField = async (
|
||||
{ value }: ImageGeneratorFieldInputInstance,
|
||||
dispatch: AppDispatch
|
||||
): Promise<ImageField[]> => {
|
||||
if (value.type === ImageGeneratorImagesFromBoardType) {
|
||||
return await getImageGeneratorImagesFromBoardValues(value, dispatch);
|
||||
}
|
||||
assert(false, 'Invalid image generator type');
|
||||
};
|
||||
export const getImageGeneratorDefaults = (type: ImageGeneratorFieldValue['type']) => {
|
||||
if (type === ImageGeneratorImagesFromBoardType) {
|
||||
return getImageGeneratorImagesFromBoardDefaults();
|
||||
}
|
||||
assert(false, 'Invalid string generator type');
|
||||
};
|
||||
// #endregion
|
||||
|
||||
// #region StatelessField
|
||||
/**
|
||||
* StatelessField is a catchall for stateless fields with no UI input components. They do not
|
||||
@@ -1563,6 +1683,7 @@ export const zStatefulFieldValue = z.union([
|
||||
zFloatGeneratorFieldValue,
|
||||
zIntegerGeneratorFieldValue,
|
||||
zStringGeneratorFieldValue,
|
||||
zImageGeneratorFieldValue,
|
||||
]);
|
||||
export type StatefulFieldValue = z.infer<typeof zStatefulFieldValue>;
|
||||
|
||||
@@ -1603,6 +1724,7 @@ const zStatefulFieldInputInstance = z.union([
|
||||
zFloatGeneratorFieldInputInstance,
|
||||
zIntegerGeneratorFieldInputInstance,
|
||||
zStringGeneratorFieldInputInstance,
|
||||
zImageGeneratorFieldInputInstance,
|
||||
]);
|
||||
|
||||
export const zFieldInputInstance = z.union([zStatefulFieldInputInstance, zStatelessFieldInputInstance]);
|
||||
@@ -1646,6 +1768,7 @@ const zStatefulFieldInputTemplate = z.union([
|
||||
zFloatGeneratorFieldInputTemplate,
|
||||
zIntegerGeneratorFieldInputTemplate,
|
||||
zStringGeneratorFieldInputTemplate,
|
||||
zImageGeneratorFieldInputTemplate,
|
||||
]);
|
||||
|
||||
export const zFieldInputTemplate = z.union([zStatefulFieldInputTemplate, zStatelessFieldInputTemplate]);
|
||||
@@ -1682,6 +1805,7 @@ const zStatefulFieldOutputTemplate = z.union([
|
||||
zFloatGeneratorFieldOutputTemplate,
|
||||
zIntegerGeneratorFieldOutputTemplate,
|
||||
zStringGeneratorFieldOutputTemplate,
|
||||
zImageGeneratorFieldOutputTemplate,
|
||||
]);
|
||||
|
||||
export const zFieldOutputTemplate = z.union([zStatefulFieldOutputTemplate, zStatelessFieldOutputTemplate]);
|
||||
|
||||
@@ -92,28 +92,15 @@ export type CollapsedInvocationNodeEdge = Edge<InvocationNodeEdgeCollapsedData,
|
||||
export type AnyEdge = DefaultInvocationNodeEdge | CollapsedInvocationNodeEdge;
|
||||
// #endregion
|
||||
|
||||
export const isBatchNode = (node: InvocationNode) => {
|
||||
switch (node.data.type) {
|
||||
case 'image_batch':
|
||||
case 'string_batch':
|
||||
case 'integer_batch':
|
||||
case 'float_batch':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
export const isBatchNodeType = (type: string) =>
|
||||
['image_batch', 'string_batch', 'integer_batch', 'float_batch'].includes(type);
|
||||
|
||||
const isGeneratorNode = (node: InvocationNode) => {
|
||||
switch (node.data.type) {
|
||||
case 'float_generator':
|
||||
case 'integer_generator':
|
||||
case 'string_generator':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
export const isGeneratorNodeType = (type: string) =>
|
||||
['image_generator', 'string_generator', 'integer_generator', 'float_generator'].includes(type);
|
||||
|
||||
export const isBatchNode = (node: InvocationNode) => isBatchNodeType(node.data.type);
|
||||
|
||||
const isGeneratorNode = (node: InvocationNode) => isGeneratorNodeType(node.data.type);
|
||||
|
||||
export const isExecutableNode = (node: InvocationNode) => {
|
||||
return !isBatchNode(node) && !isGeneratorNode(node);
|
||||
|
||||
@@ -70,7 +70,7 @@ const zElementBase = z.object({
|
||||
export const zNumberComponent = z.enum(['number-input', 'slider', 'number-input-and-slider']);
|
||||
|
||||
const NODE_FIELD_TYPE = 'node-field';
|
||||
export const NODE_FIELD_CLASS_NAME = getPrefixedId(NODE_FIELD_TYPE, '-');
|
||||
export const NODE_FIELD_CLASS_NAME = `form-builder-${NODE_FIELD_TYPE}`;
|
||||
const FLOAT_FIELD_SETTINGS_TYPE = 'float-field-config';
|
||||
const zNodeFieldFloatSettings = z.object({
|
||||
type: z.literal(FLOAT_FIELD_SETTINGS_TYPE).default(FLOAT_FIELD_SETTINGS_TYPE),
|
||||
@@ -141,7 +141,7 @@ export const buildNodeFieldElement = (
|
||||
};
|
||||
|
||||
const HEADING_TYPE = 'heading';
|
||||
export const HEADING_CLASS_NAME = getPrefixedId(HEADING_TYPE, '-');
|
||||
export const HEADING_CLASS_NAME = `form-builder-${HEADING_TYPE}`;
|
||||
const zHeadingElement = zElementBase.extend({
|
||||
type: z.literal(HEADING_TYPE),
|
||||
data: z.object({ content: z.string() }),
|
||||
@@ -162,7 +162,7 @@ export const buildHeading = (
|
||||
};
|
||||
|
||||
const TEXT_TYPE = 'text';
|
||||
export const TEXT_CLASS_NAME = getPrefixedId(TEXT_TYPE, '-');
|
||||
export const TEXT_CLASS_NAME = `form-builder-${TEXT_TYPE}`;
|
||||
const zTextElement = zElementBase.extend({
|
||||
type: z.literal(TEXT_TYPE),
|
||||
data: z.object({ content: z.string() }),
|
||||
@@ -183,7 +183,7 @@ export const buildText = (
|
||||
};
|
||||
|
||||
const DIVIDER_TYPE = 'divider';
|
||||
export const DIVIDER_CLASS_NAME = getPrefixedId(DIVIDER_TYPE, '-');
|
||||
export const DIVIDER_CLASS_NAME = `form-builder-${DIVIDER_TYPE}`;
|
||||
const zDividerElement = zElementBase.extend({
|
||||
type: z.literal(DIVIDER_TYPE),
|
||||
});
|
||||
@@ -199,7 +199,8 @@ export const buildDivider = (parentId?: NodeFieldElement['parentId']): DividerEl
|
||||
};
|
||||
|
||||
const CONTAINER_TYPE = 'container';
|
||||
export const CONTAINER_CLASS_NAME = getPrefixedId(CONTAINER_TYPE, '-');
|
||||
export const CONTAINER_CLASS_NAME = `form-builder-${CONTAINER_TYPE}`;
|
||||
export const ROOT_CONTAINER_CLASS_NAME = `form-builder-root-${CONTAINER_TYPE}`;
|
||||
const zContainerElement = zElementBase.extend({
|
||||
type: z.literal(CONTAINER_TYPE),
|
||||
data: z.object({
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import type { NodesState } from 'features/nodes/store/types';
|
||||
import type { RootState } from 'app/store/store';
|
||||
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
|
||||
import { selectNodesSlice } from 'features/nodes/store/selectors';
|
||||
import type { Templates } from 'features/nodes/store/types';
|
||||
import type { BoardField } from 'features/nodes/types/common';
|
||||
import type { BoardFieldInputInstance } from 'features/nodes/types/field';
|
||||
import { isBoardFieldInputInstance, isBoardFieldInputTemplate } from 'features/nodes/types/field';
|
||||
import { isExecutableNode, isInvocationNode } from 'features/nodes/types/invocation';
|
||||
import { omit, reduce } from 'lodash-es';
|
||||
import type { AnyInvocation, Graph } from 'services/api/types';
|
||||
@@ -7,11 +13,32 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const log = logger('workflows');
|
||||
|
||||
const getBoardField = (field: BoardFieldInputInstance, state: RootState): BoardField | undefined => {
|
||||
// Translate the UI value to the graph value. See note in BoardFieldInputComponent for more info.
|
||||
const { value } = field;
|
||||
|
||||
if (value === 'auto' || !value) {
|
||||
const autoAddBoardId = selectAutoAddBoardId(state);
|
||||
if (autoAddBoardId === 'none') {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
board_id: autoAddBoardId,
|
||||
};
|
||||
}
|
||||
|
||||
if (value === 'none') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a graph from the node editor state.
|
||||
*/
|
||||
export const buildNodesGraph = (nodesState: NodesState): Graph => {
|
||||
const { nodes, edges } = nodesState;
|
||||
export const buildNodesGraph = (state: RootState, templates: Templates): Graph => {
|
||||
const { nodes, edges } = selectNodesSlice(state);
|
||||
|
||||
// Exclude all batch nodes - we will handle these in the batch setup in a diff function
|
||||
const filteredNodes = nodes.filter(isInvocationNode).filter(isExecutableNode);
|
||||
@@ -21,11 +48,26 @@ export const buildNodesGraph = (nodesState: NodesState): Graph => {
|
||||
const { id, data } = node;
|
||||
const { type, inputs, isIntermediate } = data;
|
||||
|
||||
const nodeTemplate = templates[type];
|
||||
if (!nodeTemplate) {
|
||||
log.warn({ id, type }, 'Node template not found!');
|
||||
return nodesAccumulator;
|
||||
}
|
||||
|
||||
// Transform each node's inputs to simple key-value pairs
|
||||
const transformedInputs = reduce(
|
||||
inputs,
|
||||
(inputsAccumulator, input, name) => {
|
||||
inputsAccumulator[name] = input.value;
|
||||
const fieldTemplate = nodeTemplate.inputs[name];
|
||||
if (!fieldTemplate) {
|
||||
log.warn({ id, name }, 'Field template not found!');
|
||||
return inputsAccumulator;
|
||||
}
|
||||
if (isBoardFieldInputTemplate(fieldTemplate) && isBoardFieldInputInstance(input)) {
|
||||
inputsAccumulator[name] = getBoardField(input, state);
|
||||
} else {
|
||||
inputsAccumulator[name] = input.value;
|
||||
}
|
||||
|
||||
return inputsAccumulator;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { AppDispatch } from 'app/store/store';
|
||||
import type { NodesState } from 'features/nodes/store/types';
|
||||
import type { ImageField } from 'features/nodes/types/common';
|
||||
import {
|
||||
isFloatFieldCollectionInputInstance,
|
||||
isFloatGeneratorFieldInputInstance,
|
||||
isImageFieldCollectionInputInstance,
|
||||
isImageGeneratorFieldInputInstance,
|
||||
isIntegerFieldCollectionInputInstance,
|
||||
isIntegerGeneratorFieldInputInstance,
|
||||
isStringFieldCollectionInputInstance,
|
||||
isStringGeneratorFieldInputInstance,
|
||||
resolveFloatGeneratorField,
|
||||
resolveImageGeneratorField,
|
||||
resolveIntegerGeneratorField,
|
||||
resolveStringGeneratorField,
|
||||
} from 'features/nodes/types/field';
|
||||
import type { InvocationNode } from 'features/nodes/types/invocation';
|
||||
import { isBatchNode, isInvocationNode } from 'features/nodes/types/invocation';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
export const resolveBatchValue = async (arg: {
|
||||
dispatch: AppDispatch;
|
||||
nodesState: NodesState;
|
||||
node: InvocationNode;
|
||||
}): Promise<number[] | string[] | ImageField[]> => {
|
||||
const { node, dispatch, nodesState } = arg;
|
||||
const { nodes, edges } = nodesState;
|
||||
const invocationNodes = nodes.filter(isInvocationNode);
|
||||
|
||||
if (node.data.type === 'image_batch') {
|
||||
assert(isImageFieldCollectionInputInstance(node.data.inputs.images));
|
||||
const ownValue = node.data.inputs.images.value ?? [];
|
||||
const incomers = edges.find((edge) => edge.target === node.id && edge.targetHandle === 'images');
|
||||
|
||||
if (!incomers) {
|
||||
return ownValue ?? [];
|
||||
}
|
||||
|
||||
const generatorNode = invocationNodes.find((node) => node.id === incomers.source);
|
||||
assert(generatorNode, 'Missing edge from image generator to image batch');
|
||||
|
||||
const generatorField = generatorNode.data.inputs['generator'];
|
||||
assert(isImageGeneratorFieldInputInstance(generatorField), 'Invalid image generator field');
|
||||
|
||||
const generatorValue = await resolveImageGeneratorField(generatorField, dispatch);
|
||||
return generatorValue;
|
||||
} else if (node.data.type === 'string_batch') {
|
||||
assert(isStringFieldCollectionInputInstance(node.data.inputs.strings));
|
||||
const ownValue = node.data.inputs.strings.value;
|
||||
const edgeToStrings = edges.find((edge) => edge.target === node.id && edge.targetHandle === 'strings');
|
||||
|
||||
if (!edgeToStrings) {
|
||||
return ownValue ?? [];
|
||||
}
|
||||
|
||||
const generatorNode = invocationNodes.find((node) => node.id === edgeToStrings.source);
|
||||
assert(generatorNode, 'Missing edge from string generator to string batch');
|
||||
|
||||
const generatorField = generatorNode.data.inputs['generator'];
|
||||
assert(isStringGeneratorFieldInputInstance(generatorField), 'Invalid string generator');
|
||||
|
||||
const generatorValue = await resolveStringGeneratorField(generatorField, dispatch);
|
||||
return generatorValue;
|
||||
} else if (node.data.type === 'float_batch') {
|
||||
assert(isFloatFieldCollectionInputInstance(node.data.inputs.floats));
|
||||
const ownValue = node.data.inputs.floats.value;
|
||||
const edgeToFloats = edges.find((edge) => edge.target === node.id && edge.targetHandle === 'floats');
|
||||
|
||||
if (!edgeToFloats) {
|
||||
return ownValue ?? [];
|
||||
}
|
||||
|
||||
const generatorNode = invocationNodes.find((node) => node.id === edgeToFloats.source);
|
||||
assert(generatorNode, 'Missing edge from float generator to float batch');
|
||||
|
||||
const generatorField = generatorNode.data.inputs['generator'];
|
||||
assert(isFloatGeneratorFieldInputInstance(generatorField), 'Invalid float generator');
|
||||
|
||||
const generatorValue = resolveFloatGeneratorField(generatorField);
|
||||
return generatorValue;
|
||||
} else if (node.data.type === 'integer_batch') {
|
||||
assert(isIntegerFieldCollectionInputInstance(node.data.inputs.integers));
|
||||
const ownValue = node.data.inputs.integers.value;
|
||||
const incomers = edges.find((edge) => edge.target === node.id && edge.targetHandle === 'integers');
|
||||
|
||||
if (!incomers) {
|
||||
return ownValue ?? [];
|
||||
}
|
||||
|
||||
const generatorNode = invocationNodes.find((node) => node.id === incomers.source);
|
||||
assert(generatorNode, 'Missing edge from integer generator to integer batch');
|
||||
|
||||
const generatorField = generatorNode.data.inputs['generator'];
|
||||
assert(isIntegerGeneratorFieldInputInstance(generatorField), 'Invalid integer generator field');
|
||||
|
||||
const generatorValue = resolveIntegerGeneratorField(generatorField);
|
||||
return generatorValue;
|
||||
}
|
||||
assert(false, 'Invalid batch node type');
|
||||
};
|
||||
|
||||
export type BatchSizeResult = number | 'EMPTY_BATCHES' | 'NO_BATCHES' | 'MISMATCHED_BATCH_GROUP';
|
||||
|
||||
export const getBatchSize = async (nodesState: NodesState, dispatch: AppDispatch): Promise<BatchSizeResult> => {
|
||||
const { nodes } = nodesState;
|
||||
const batchNodes = nodes.filter(isInvocationNode).filter(isBatchNode);
|
||||
const ungroupedBatchNodes = batchNodes.filter((node) => node.data.inputs['batch_group_id']?.value === 'None');
|
||||
const group1BatchNodes = batchNodes.filter((node) => node.data.inputs['batch_group_id']?.value === 'Group 1');
|
||||
const group2BatchNodes = batchNodes.filter((node) => node.data.inputs['batch_group_id']?.value === 'Group 2');
|
||||
const group3BatchNodes = batchNodes.filter((node) => node.data.inputs['batch_group_id']?.value === 'Group 3');
|
||||
const group4BatchNodes = batchNodes.filter((node) => node.data.inputs['batch_group_id']?.value === 'Group 4');
|
||||
const group5BatchNodes = batchNodes.filter((node) => node.data.inputs['batch_group_id']?.value === 'Group 5');
|
||||
|
||||
const ungroupedBatchSizes = await Promise.all(
|
||||
ungroupedBatchNodes.map(async (node) => (await resolveBatchValue({ nodesState, dispatch, node })).length)
|
||||
);
|
||||
const group1BatchSizes = await Promise.all(
|
||||
group1BatchNodes.map(async (node) => (await resolveBatchValue({ nodesState, dispatch, node })).length)
|
||||
);
|
||||
const group2BatchSizes = await Promise.all(
|
||||
group2BatchNodes.map(async (node) => (await resolveBatchValue({ nodesState, dispatch, node })).length)
|
||||
);
|
||||
const group3BatchSizes = await Promise.all(
|
||||
group3BatchNodes.map(async (node) => (await resolveBatchValue({ nodesState, dispatch, node })).length)
|
||||
);
|
||||
const group4BatchSizes = await Promise.all(
|
||||
group4BatchNodes.map(async (node) => (await resolveBatchValue({ nodesState, dispatch, node })).length)
|
||||
);
|
||||
const group5BatchSizes = await Promise.all(
|
||||
group5BatchNodes.map(async (node) => (await resolveBatchValue({ nodesState, dispatch, node })).length)
|
||||
);
|
||||
|
||||
// All batch nodes _must_ have a populated collection
|
||||
|
||||
const allBatchSizes = [
|
||||
...ungroupedBatchSizes,
|
||||
...group1BatchSizes,
|
||||
...group2BatchSizes,
|
||||
...group3BatchSizes,
|
||||
...group4BatchSizes,
|
||||
...group5BatchSizes,
|
||||
];
|
||||
|
||||
// There are no batch nodes
|
||||
if (allBatchSizes.length === 0) {
|
||||
return 'NO_BATCHES';
|
||||
}
|
||||
|
||||
// All batch nodes must have a populated collection
|
||||
if (allBatchSizes.some((size) => size === 0)) {
|
||||
return 'EMPTY_BATCHES';
|
||||
}
|
||||
|
||||
for (const group of [group1BatchSizes, group2BatchSizes, group3BatchSizes, group4BatchSizes, group5BatchSizes]) {
|
||||
// Ignore groups with no batch nodes
|
||||
if (group.length === 0) {
|
||||
continue;
|
||||
}
|
||||
// Grouped batch nodes must have the same collection size
|
||||
if (group.some((size) => size !== group[0])) {
|
||||
return 'MISMATCHED_BATCH_GROUP';
|
||||
}
|
||||
}
|
||||
|
||||
// Total batch size = product of all ungrouped batches and each grouped batch
|
||||
const totalBatchSize = [
|
||||
...ungroupedBatchSizes,
|
||||
// In case of no batch nodes in a group, fall back to 1 for the product calculation
|
||||
group1BatchSizes[0] ?? 1,
|
||||
group2BatchSizes[0] ?? 1,
|
||||
group3BatchSizes[0] ?? 1,
|
||||
group4BatchSizes[0] ?? 1,
|
||||
group5BatchSizes[0] ?? 1,
|
||||
].reduce((acc, size) => acc * size, 1);
|
||||
|
||||
return totalBatchSize;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user