From 9843fec3931ba791b09a3805e44fbe6636191996 Mon Sep 17 00:00:00 2001 From: huangzhuo1949 <167434202+huangzhuo1949@users.noreply.github.com> Date: Mon, 10 Nov 2025 12:17:13 +0800 Subject: [PATCH 01/23] fix: elasticsearch_vector version (#28028) Co-authored-by: huangzhuo Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../rag/datasource/vdb/elasticsearch/elasticsearch_vector.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/core/rag/datasource/vdb/elasticsearch/elasticsearch_vector.py b/api/core/rag/datasource/vdb/elasticsearch/elasticsearch_vector.py index 0ff8c915e6..1470713b88 100644 --- a/api/core/rag/datasource/vdb/elasticsearch/elasticsearch_vector.py +++ b/api/core/rag/datasource/vdb/elasticsearch/elasticsearch_vector.py @@ -147,7 +147,8 @@ class ElasticSearchVector(BaseVector): def _get_version(self) -> str: info = self._client.info() - return cast(str, info["version"]["number"]) + # remove any suffix like "-SNAPSHOT" from the version string + return cast(str, info["version"]["number"]).split("-")[0] def _check_version(self): if parse_version(self._version) < parse_version("8.0.0"): From ed234e311b5728074483e165ad5f41a72535c116 Mon Sep 17 00:00:00 2001 From: Will Date: Mon, 10 Nov 2025 17:20:38 +0800 Subject: [PATCH 02/23] fix workflow default updated_at (#28047) --- api/models/workflow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/models/workflow.py b/api/models/workflow.py index 18757c64ae..ed30821bc0 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -140,6 +140,7 @@ class Workflow(Base): updated_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, + default=func.current_timestamp(), server_default=func.current_timestamp(), onupdate=func.current_timestamp(), ) From b9bc48d8dd4251ee8782d4c9f5b37555f2665c01 Mon Sep 17 00:00:00 2001 From: QuantumGhost Date: Mon, 10 Nov 2025 17:23:21 +0800 Subject: [PATCH 03/23] feat(api): Introduce Broadcast Channel (#27835) This PR introduces a `BroadcastChannel` abstraction with broadcasting and at-most once delivery semantics, serving as the communication component between celery worker and API server. It also includes a reference implementation backed by Redis PubSub. Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/libs/broadcast_channel/channel.py | 134 +++++ api/libs/broadcast_channel/exc.py | 12 + api/libs/broadcast_channel/redis/__init__.py | 3 + api/libs/broadcast_channel/redis/channel.py | 200 +++++++ .../broadcast_channel/redis/test_channel.py | 311 +++++++++++ .../redis/test_channel_unit_tests.py | 514 ++++++++++++++++++ 6 files changed, 1174 insertions(+) create mode 100644 api/libs/broadcast_channel/channel.py create mode 100644 api/libs/broadcast_channel/exc.py create mode 100644 api/libs/broadcast_channel/redis/__init__.py create mode 100644 api/libs/broadcast_channel/redis/channel.py create mode 100644 api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_channel.py create mode 100644 api/tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py diff --git a/api/libs/broadcast_channel/channel.py b/api/libs/broadcast_channel/channel.py new file mode 100644 index 0000000000..5bbf0c79a3 --- /dev/null +++ b/api/libs/broadcast_channel/channel.py @@ -0,0 +1,134 @@ +""" +Broadcast channel for Pub/Sub messaging. +""" + +import types +from abc import abstractmethod +from collections.abc import Iterator +from contextlib import AbstractContextManager +from typing import Protocol, Self + + +class Subscription(AbstractContextManager["Subscription"], Protocol): + """A subscription to a topic that provides an iterator over received messages. + The subscription can be used as a context manager and will automatically + close when exiting the context. + + Note: `Subscription` instances are not thread-safe. Each thread should create its own + subscription. + """ + + @abstractmethod + def __iter__(self) -> Iterator[bytes]: + """`__iter__` returns an iterator used to consume the message from this subscription. + + If the caller did not enter the context, `__iter__` may lazily perform the setup before + yielding messages; otherwise `__enter__` handles it.” + + If the subscription is closed, then the returned iterator exits without + raising any error. + """ + ... + + @abstractmethod + def close(self) -> None: + """close closes the subscription, releases any resources associated with it.""" + ... + + def __enter__(self) -> Self: + """`__enter__` does the setup logic of the subscription (if any), and return itself.""" + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: types.TracebackType | None, + ) -> bool | None: + self.close() + return None + + @abstractmethod + def receive(self, timeout: float | None = 0.1) -> bytes | None: + """Receive the next message from the broadcast channel. + + If `timeout` is specified, this method returns `None` if no message is + received within the given period. If `timeout` is `None`, the call blocks + until a message is received. + + Calling receive with `timeout=None` is highly discouraged, as it is impossible to + cancel a blocking subscription. + + :param timeout: timeout for receive message, in seconds. + + Returns: + bytes: The received message as a byte string, or + None: If the timeout expires before a message is received. + + Raises: + SubscriptionClosed: If the subscription has already been closed. + """ + ... + + +class Producer(Protocol): + """Producer is an interface for message publishing. It is already bound to a specific topic. + + `Producer` implementations must be thread-safe and support concurrent use by multiple threads. + """ + + @abstractmethod + def publish(self, payload: bytes) -> None: + """Publish a message to the bounded topic.""" + ... + + +class Subscriber(Protocol): + """Subscriber is an interface for subscription creation. It is already bound to a specific topic. + + `Subscriber` implementations must be thread-safe and support concurrent use by multiple threads. + """ + + @abstractmethod + def subscribe(self) -> Subscription: + pass + + +class Topic(Producer, Subscriber, Protocol): + """A named channel for publishing and subscribing to messages. + + Topics provide both read and write access. For restricted access, + use as_producer() for write-only view or as_subscriber() for read-only view. + + `Topic` implementations must be thread-safe and support concurrent use by multiple threads. + """ + + @abstractmethod + def as_producer(self) -> Producer: + """as_producer creates a write-only view for this topic.""" + ... + + @abstractmethod + def as_subscriber(self) -> Subscriber: + """as_subscriber create a read-only view for this topic.""" + ... + + +class BroadcastChannel(Protocol): + """A broadcasting channel is a channel supporting broadcasting semantics. + + Each channel is identified by a topic, different topics are isolated and do not affect each other. + + There can be multiple subscriptions to a specific topic. When a publisher publishes a message to + a specific topic, all subscription should receive the published message. + + There are no restriction for the persistence of messages. Once a subscription is created, it + should receive all subsequent messages published. + + `BroadcastChannel` implementations must be thread-safe and support concurrent use by multiple threads. + """ + + @abstractmethod + def topic(self, topic: str) -> "Topic": + """topic returns a `Topic` instance for the given topic name.""" + ... diff --git a/api/libs/broadcast_channel/exc.py b/api/libs/broadcast_channel/exc.py new file mode 100644 index 0000000000..ab958c94ed --- /dev/null +++ b/api/libs/broadcast_channel/exc.py @@ -0,0 +1,12 @@ +class BroadcastChannelError(Exception): + """`BroadcastChannelError` is the base class for all exceptions related + to `BroadcastChannel`.""" + + pass + + +class SubscriptionClosedError(BroadcastChannelError): + """SubscriptionClosedError means that the subscription has been closed and + methods for consuming messages should not be called.""" + + pass diff --git a/api/libs/broadcast_channel/redis/__init__.py b/api/libs/broadcast_channel/redis/__init__.py new file mode 100644 index 0000000000..138fef5c5f --- /dev/null +++ b/api/libs/broadcast_channel/redis/__init__.py @@ -0,0 +1,3 @@ +from .channel import BroadcastChannel + +__all__ = ["BroadcastChannel"] diff --git a/api/libs/broadcast_channel/redis/channel.py b/api/libs/broadcast_channel/redis/channel.py new file mode 100644 index 0000000000..e6b32345be --- /dev/null +++ b/api/libs/broadcast_channel/redis/channel.py @@ -0,0 +1,200 @@ +import logging +import queue +import threading +import types +from collections.abc import Generator, Iterator +from typing import Self + +from libs.broadcast_channel.channel import Producer, Subscriber, Subscription +from libs.broadcast_channel.exc import SubscriptionClosedError +from redis import Redis +from redis.client import PubSub + +_logger = logging.getLogger(__name__) + + +class BroadcastChannel: + """ + Redis Pub/Sub based broadcast channel implementation. + + Provides "at most once" delivery semantics for messages published to channels. + Uses Redis PUBLISH/SUBSCRIBE commands for real-time message delivery. + + The `redis_client` used to construct BroadcastChannel should have `decode_responses` set to `False`. + """ + + def __init__( + self, + redis_client: Redis, + ): + self._client = redis_client + + def topic(self, topic: str) -> "Topic": + return Topic(self._client, topic) + + +class Topic: + def __init__(self, redis_client: Redis, topic: str): + self._client = redis_client + self._topic = topic + + def as_producer(self) -> Producer: + return self + + def publish(self, payload: bytes) -> None: + self._client.publish(self._topic, payload) + + def as_subscriber(self) -> Subscriber: + return self + + def subscribe(self) -> Subscription: + return _RedisSubscription( + pubsub=self._client.pubsub(), + topic=self._topic, + ) + + +class _RedisSubscription(Subscription): + def __init__( + self, + pubsub: PubSub, + topic: str, + ): + # The _pubsub is None only if the subscription is closed. + self._pubsub: PubSub | None = pubsub + self._topic = topic + self._closed = threading.Event() + self._queue: queue.Queue[bytes] = queue.Queue(maxsize=1024) + self._dropped_count = 0 + self._listener_thread: threading.Thread | None = None + self._start_lock = threading.Lock() + self._started = False + + def _start_if_needed(self) -> None: + with self._start_lock: + if self._started: + return + if self._closed.is_set(): + raise SubscriptionClosedError("The Redis subscription is closed") + if self._pubsub is None: + raise SubscriptionClosedError("The Redis subscription has been cleaned up") + + self._pubsub.subscribe(self._topic) + _logger.debug("Subscribed to channel %s", self._topic) + + self._listener_thread = threading.Thread( + target=self._listen, + name=f"redis-broadcast-{self._topic}", + daemon=True, + ) + self._listener_thread.start() + self._started = True + + def _listen(self) -> None: + pubsub = self._pubsub + assert pubsub is not None, "PubSub should not be None while starting listening." + while not self._closed.is_set(): + raw_message = pubsub.get_message(ignore_subscribe_messages=True, timeout=0.1) + + if raw_message is None: + continue + + if raw_message.get("type") != "message": + continue + + channel_field = raw_message.get("channel") + if isinstance(channel_field, bytes): + channel_name = channel_field.decode("utf-8") + elif isinstance(channel_field, str): + channel_name = channel_field + else: + channel_name = str(channel_field) + + if channel_name != self._topic: + _logger.warning("Ignoring message from unexpected channel %s", channel_name) + continue + + payload_bytes: bytes | None = raw_message.get("data") + if not isinstance(payload_bytes, bytes): + _logger.error("Received invalid data from channel %s, type=%s", self._topic, type(payload_bytes)) + continue + + self._enqueue_message(payload_bytes) + + _logger.debug("Listener thread stopped for channel %s", self._topic) + pubsub.unsubscribe(self._topic) + pubsub.close() + _logger.debug("PubSub closed for topic %s", self._topic) + self._pubsub = None + + def _enqueue_message(self, payload: bytes) -> None: + while not self._closed.is_set(): + try: + self._queue.put_nowait(payload) + return + except queue.Full: + try: + self._queue.get_nowait() + self._dropped_count += 1 + _logger.debug( + "Dropped message from Redis subscription, topic=%s, total_dropped=%d", + self._topic, + self._dropped_count, + ) + except queue.Empty: + continue + return + + def _message_iterator(self) -> Generator[bytes, None, None]: + while not self._closed.is_set(): + try: + item = self._queue.get(timeout=0.1) + except queue.Empty: + continue + + yield item + + def __iter__(self) -> Iterator[bytes]: + if self._closed.is_set(): + raise SubscriptionClosedError("The Redis subscription is closed") + self._start_if_needed() + return iter(self._message_iterator()) + + def receive(self, timeout: float | None = None) -> bytes | None: + if self._closed.is_set(): + raise SubscriptionClosedError("The Redis subscription is closed") + self._start_if_needed() + + try: + item = self._queue.get(timeout=timeout) + except queue.Empty: + return None + + return item + + def __enter__(self) -> Self: + self._start_if_needed() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: types.TracebackType | None, + ) -> bool | None: + self.close() + return None + + def close(self) -> None: + if self._closed.is_set(): + return + + self._closed.set() + # NOTE: PubSub is not thread-safe. More specifically, the `PubSub.close` method and the `PubSub.get_message` + # method should NOT be called concurrently. + # + # Due to the restriction above, the PubSub cleanup logic happens inside the consumer thread. + listener = self._listener_thread + if listener is not None: + listener.join(timeout=1.0) + self._listener_thread = None diff --git a/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_channel.py b/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_channel.py new file mode 100644 index 0000000000..c2e17328d6 --- /dev/null +++ b/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_channel.py @@ -0,0 +1,311 @@ +""" +Integration tests for Redis broadcast channel implementation using TestContainers. + +This test suite covers real Redis interactions including: +- Multiple producer/consumer scenarios +- Network failure scenarios +- Performance under load +- Real-world usage patterns +""" + +import threading +import time +import uuid +from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor, as_completed + +import pytest +import redis +from testcontainers.redis import RedisContainer + +from libs.broadcast_channel.channel import BroadcastChannel, Subscription, Topic +from libs.broadcast_channel.exc import SubscriptionClosedError +from libs.broadcast_channel.redis.channel import BroadcastChannel as RedisBroadcastChannel + + +class TestRedisBroadcastChannelIntegration: + """Integration tests for Redis broadcast channel with real Redis instance.""" + + @pytest.fixture(scope="class") + def redis_container(self) -> Iterator[RedisContainer]: + """Create a Redis container for integration testing.""" + with RedisContainer(image="redis:6-alpine") as container: + yield container + + @pytest.fixture(scope="class") + def redis_client(self, redis_container: RedisContainer) -> redis.Redis: + """Create a Redis client connected to the test container.""" + host = redis_container.get_container_host_ip() + port = redis_container.get_exposed_port(6379) + return redis.Redis(host=host, port=port, decode_responses=False) + + @pytest.fixture + def broadcast_channel(self, redis_client: redis.Redis) -> BroadcastChannel: + """Create a BroadcastChannel instance with real Redis client.""" + return RedisBroadcastChannel(redis_client) + + @classmethod + def _get_test_topic_name(cls): + return f"test_topic_{uuid.uuid4()}" + + # ==================== Basic Functionality Tests ====================' + + def test_close_an_active_subscription_should_stop_iteration(self, broadcast_channel): + topic_name = self._get_test_topic_name() + topic = broadcast_channel.topic(topic_name) + subscription = topic.subscribe() + consuming_event = threading.Event() + + def consume(): + msgs = [] + consuming_event.set() + for msg in subscription: + msgs.append(msg) + return msgs + + with ThreadPoolExecutor(max_workers=1) as executor: + producer_future = executor.submit(consume) + consuming_event.wait() + subscription.close() + msgs = producer_future.result(timeout=1) + assert msgs == [] + + def test_end_to_end_messaging(self, broadcast_channel: BroadcastChannel): + """Test complete end-to-end messaging flow.""" + topic_name = "test-topic" + message = b"hello world" + + # Create producer and subscriber + topic = broadcast_channel.topic(topic_name) + producer = topic.as_producer() + subscription = topic.subscribe() + + # Publish and receive message + + def producer_thread(): + time.sleep(0.1) # Small delay to ensure subscriber is ready + producer.publish(message) + time.sleep(0.1) + subscription.close() + + def consumer_thread() -> list[bytes]: + received_messages = [] + for msg in subscription: + received_messages.append(msg) + return received_messages + + # Run producer and consumer + with ThreadPoolExecutor(max_workers=2) as executor: + producer_future = executor.submit(producer_thread) + consumer_future = executor.submit(consumer_thread) + + # Wait for completion + producer_future.result(timeout=5.0) + received_messages = consumer_future.result(timeout=5.0) + + assert len(received_messages) == 1 + assert received_messages[0] == message + + def test_multiple_subscribers_same_topic(self, broadcast_channel: BroadcastChannel): + """Test message broadcasting to multiple subscribers.""" + topic_name = "broadcast-topic" + message = b"broadcast message" + subscriber_count = 5 + + # Create producer and multiple subscribers + topic = broadcast_channel.topic(topic_name) + producer = topic.as_producer() + subscriptions = [topic.subscribe() for _ in range(subscriber_count)] + + def producer_thread(): + time.sleep(0.2) # Allow all subscribers to connect + producer.publish(message) + time.sleep(0.2) + for sub in subscriptions: + sub.close() + + def consumer_thread(subscription: Subscription) -> list[bytes]: + received_msgs = [] + while True: + try: + msg = subscription.receive(0.1) + except SubscriptionClosedError: + break + if msg is None: + continue + received_msgs.append(msg) + if len(received_msgs) >= 1: + break + return received_msgs + + # Run producer and consumers + with ThreadPoolExecutor(max_workers=subscriber_count + 1) as executor: + producer_future = executor.submit(producer_thread) + consumer_futures = [executor.submit(consumer_thread, subscription) for subscription in subscriptions] + + # Wait for completion + producer_future.result(timeout=10.0) + msgs_by_consumers = [] + for future in as_completed(consumer_futures, timeout=10.0): + msgs_by_consumers.append(future.result()) + + # Close all subscriptions + for subscription in subscriptions: + subscription.close() + + # Verify all subscribers received the message + for msgs in msgs_by_consumers: + assert len(msgs) == 1 + assert msgs[0] == message + + def test_topic_isolation(self, broadcast_channel: BroadcastChannel): + """Test that different topics are isolated from each other.""" + topic1_name = "topic1" + topic2_name = "topic2" + message1 = b"message for topic1" + message2 = b"message for topic2" + + # Create producers and subscribers for different topics + topic1 = broadcast_channel.topic(topic1_name) + topic2 = broadcast_channel.topic(topic2_name) + + def producer_thread(): + time.sleep(0.1) + topic1.publish(message1) + topic2.publish(message2) + + def consumer_by_thread(topic: Topic) -> list[bytes]: + subscription = topic.subscribe() + received = [] + with subscription: + for msg in subscription: + received.append(msg) + if len(received) >= 1: + break + return received + + # Run all threads + with ThreadPoolExecutor(max_workers=3) as executor: + producer_future = executor.submit(producer_thread) + consumer1_future = executor.submit(consumer_by_thread, topic1) + consumer2_future = executor.submit(consumer_by_thread, topic2) + + # Wait for completion + producer_future.result(timeout=5.0) + received_by_topic1 = consumer1_future.result(timeout=5.0) + received_by_topic2 = consumer2_future.result(timeout=5.0) + + # Verify topic isolation + assert len(received_by_topic1) == 1 + assert len(received_by_topic2) == 1 + assert received_by_topic1[0] == message1 + assert received_by_topic2[0] == message2 + + # ==================== Performance Tests ==================== + + def test_concurrent_producers(self, broadcast_channel: BroadcastChannel): + """Test multiple producers publishing to the same topic.""" + topic_name = "concurrent-producers-topic" + producer_count = 5 + messages_per_producer = 5 + + topic = broadcast_channel.topic(topic_name) + subscription = topic.subscribe() + + expected_total = producer_count * messages_per_producer + consumer_ready = threading.Event() + + def producer_thread(producer_idx: int) -> set[bytes]: + producer = topic.as_producer() + produced = set() + for i in range(messages_per_producer): + message = f"producer_{producer_idx}_msg_{i}".encode() + produced.add(message) + producer.publish(message) + time.sleep(0.001) # Small delay to avoid overwhelming + return produced + + def consumer_thread() -> set[bytes]: + received_msgs: set[bytes] = set() + with subscription: + consumer_ready.set() + while True: + try: + msg = subscription.receive(timeout=0.1) + except SubscriptionClosedError: + break + if msg is None: + if len(received_msgs) >= expected_total: + break + else: + continue + + received_msgs.add(msg) + return received_msgs + + # Run producers and consumer + with ThreadPoolExecutor(max_workers=producer_count + 1) as executor: + consumer_future = executor.submit(consumer_thread) + consumer_ready.wait() + producer_futures = [executor.submit(producer_thread, i) for i in range(producer_count)] + + sent_msgs: set[bytes] = set() + # Wait for completion + for future in as_completed(producer_futures, timeout=30.0): + sent_msgs.update(future.result()) + + subscription.close() + consumer_received_msgs = consumer_future.result(timeout=30.0) + + # Verify message content + assert sent_msgs == consumer_received_msgs + + # ==================== Resource Management Tests ==================== + + def test_subscription_cleanup(self, broadcast_channel: BroadcastChannel, redis_client: redis.Redis): + """Test proper cleanup of subscription resources.""" + topic_name = "cleanup-test-topic" + + # Create multiple subscriptions + topic = broadcast_channel.topic(topic_name) + + def _consume(sub: Subscription): + for i in sub: + pass + + subscriptions = [] + for i in range(5): + subscription = topic.subscribe() + subscriptions.append(subscription) + + # Start all subscriptions + thread = threading.Thread(target=_consume, args=(subscription,)) + thread.start() + time.sleep(0.01) + + # Verify subscriptions are active + pubsub_info = redis_client.pubsub_numsub(topic_name) + # pubsub_numsub returns list of tuples, find our topic + topic_subscribers = 0 + for channel, count in pubsub_info: + # the channel name returned by redis is bytes. + if channel == topic_name.encode(): + topic_subscribers = count + break + assert topic_subscribers >= 5 + + # Close all subscriptions + for subscription in subscriptions: + subscription.close() + + # Wait a bit for cleanup + time.sleep(1) + + # Verify subscriptions are cleaned up + pubsub_info_after = redis_client.pubsub_numsub(topic_name) + topic_subscribers_after = 0 + for channel, count in pubsub_info_after: + if channel == topic_name.encode(): + topic_subscribers_after = count + break + assert topic_subscribers_after == 0 diff --git a/api/tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py b/api/tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py new file mode 100644 index 0000000000..dffad4142c --- /dev/null +++ b/api/tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py @@ -0,0 +1,514 @@ +""" +Comprehensive unit tests for Redis broadcast channel implementation. + +This test suite covers all aspects of the Redis broadcast channel including: +- Basic functionality and contract compliance +- Error handling and edge cases +- Thread safety and concurrency +- Resource management and cleanup +- Performance and reliability scenarios +""" + +import dataclasses +import threading +import time +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from libs.broadcast_channel.exc import BroadcastChannelError, SubscriptionClosedError +from libs.broadcast_channel.redis.channel import ( + BroadcastChannel as RedisBroadcastChannel, +) +from libs.broadcast_channel.redis.channel import ( + Topic, + _RedisSubscription, +) + + +class TestBroadcastChannel: + """Test cases for the main BroadcastChannel class.""" + + @pytest.fixture + def mock_redis_client(self) -> MagicMock: + """Create a mock Redis client for testing.""" + client = MagicMock() + client.pubsub.return_value = MagicMock() + return client + + @pytest.fixture + def broadcast_channel(self, mock_redis_client: MagicMock) -> RedisBroadcastChannel: + """Create a BroadcastChannel instance with mock Redis client.""" + return RedisBroadcastChannel(mock_redis_client) + + def test_topic_creation(self, broadcast_channel: RedisBroadcastChannel, mock_redis_client: MagicMock): + """Test that topic() method returns a Topic instance with correct parameters.""" + topic_name = "test-topic" + topic = broadcast_channel.topic(topic_name) + + assert isinstance(topic, Topic) + assert topic._client == mock_redis_client + assert topic._topic == topic_name + + def test_topic_isolation(self, broadcast_channel: RedisBroadcastChannel): + """Test that different topic names create isolated Topic instances.""" + topic1 = broadcast_channel.topic("topic1") + topic2 = broadcast_channel.topic("topic2") + + assert topic1 is not topic2 + assert topic1._topic == "topic1" + assert topic2._topic == "topic2" + + +class TestTopic: + """Test cases for the Topic class.""" + + @pytest.fixture + def mock_redis_client(self) -> MagicMock: + """Create a mock Redis client for testing.""" + client = MagicMock() + client.pubsub.return_value = MagicMock() + return client + + @pytest.fixture + def topic(self, mock_redis_client: MagicMock) -> Topic: + """Create a Topic instance for testing.""" + return Topic(mock_redis_client, "test-topic") + + def test_as_producer_returns_self(self, topic: Topic): + """Test that as_producer() returns self as Producer interface.""" + producer = topic.as_producer() + assert producer is topic + # Producer is a Protocol, check duck typing instead + assert hasattr(producer, "publish") + + def test_as_subscriber_returns_self(self, topic: Topic): + """Test that as_subscriber() returns self as Subscriber interface.""" + subscriber = topic.as_subscriber() + assert subscriber is topic + # Subscriber is a Protocol, check duck typing instead + assert hasattr(subscriber, "subscribe") + + def test_publish_calls_redis_publish(self, topic: Topic, mock_redis_client: MagicMock): + """Test that publish() calls Redis PUBLISH with correct parameters.""" + payload = b"test message" + topic.publish(payload) + + mock_redis_client.publish.assert_called_once_with("test-topic", payload) + + +@dataclasses.dataclass(frozen=True) +class SubscriptionTestCase: + """Test case data for subscription tests.""" + + name: str + buffer_size: int + payload: bytes + expected_messages: list[bytes] + should_drop: bool = False + description: str = "" + + +class TestRedisSubscription: + """Test cases for the _RedisSubscription class.""" + + @pytest.fixture + def mock_pubsub(self) -> MagicMock: + """Create a mock PubSub instance for testing.""" + pubsub = MagicMock() + pubsub.subscribe = MagicMock() + pubsub.unsubscribe = MagicMock() + pubsub.close = MagicMock() + pubsub.get_message = MagicMock() + return pubsub + + @pytest.fixture + def subscription(self, mock_pubsub: MagicMock) -> Generator[_RedisSubscription, None, None]: + """Create a _RedisSubscription instance for testing.""" + subscription = _RedisSubscription( + pubsub=mock_pubsub, + topic="test-topic", + ) + yield subscription + subscription.close() + + @pytest.fixture + def started_subscription(self, subscription: _RedisSubscription) -> _RedisSubscription: + """Create a subscription that has been started.""" + subscription._start_if_needed() + return subscription + + # ==================== Lifecycle Tests ==================== + + def test_subscription_initialization(self, mock_pubsub: MagicMock): + """Test that subscription is properly initialized.""" + subscription = _RedisSubscription( + pubsub=mock_pubsub, + topic="test-topic", + ) + + assert subscription._pubsub is mock_pubsub + assert subscription._topic == "test-topic" + assert not subscription._closed.is_set() + assert subscription._dropped_count == 0 + assert subscription._listener_thread is None + assert not subscription._started + + def test_start_if_needed_first_call(self, subscription: _RedisSubscription, mock_pubsub: MagicMock): + """Test that _start_if_needed() properly starts subscription on first call.""" + subscription._start_if_needed() + + mock_pubsub.subscribe.assert_called_once_with("test-topic") + assert subscription._started is True + assert subscription._listener_thread is not None + + def test_start_if_needed_subsequent_calls(self, started_subscription: _RedisSubscription): + """Test that _start_if_needed() doesn't start subscription on subsequent calls.""" + original_thread = started_subscription._listener_thread + started_subscription._start_if_needed() + + # Should not create new thread or generator + assert started_subscription._listener_thread is original_thread + + def test_start_if_needed_when_closed(self, subscription: _RedisSubscription): + """Test that _start_if_needed() raises error when subscription is closed.""" + subscription.close() + + with pytest.raises(SubscriptionClosedError, match="The Redis subscription is closed"): + subscription._start_if_needed() + + def test_start_if_needed_when_cleaned_up(self, subscription: _RedisSubscription): + """Test that _start_if_needed() raises error when pubsub is None.""" + subscription._pubsub = None + + with pytest.raises(SubscriptionClosedError, match="The Redis subscription has been cleaned up"): + subscription._start_if_needed() + + def test_context_manager_usage(self, subscription: _RedisSubscription, mock_pubsub: MagicMock): + """Test that subscription works as context manager.""" + with subscription as sub: + assert sub is subscription + assert subscription._started is True + mock_pubsub.subscribe.assert_called_once_with("test-topic") + + def test_close_idempotent(self, subscription: _RedisSubscription, mock_pubsub: MagicMock): + """Test that close() is idempotent and can be called multiple times.""" + subscription._start_if_needed() + + # Close multiple times + subscription.close() + subscription.close() + subscription.close() + + # Should only cleanup once + mock_pubsub.unsubscribe.assert_called_once_with("test-topic") + mock_pubsub.close.assert_called_once() + assert subscription._pubsub is None + assert subscription._closed.is_set() + + def test_close_cleanup(self, subscription: _RedisSubscription, mock_pubsub: MagicMock): + """Test that close() properly cleans up all resources.""" + subscription._start_if_needed() + thread = subscription._listener_thread + + subscription.close() + + # Verify cleanup + mock_pubsub.unsubscribe.assert_called_once_with("test-topic") + mock_pubsub.close.assert_called_once() + assert subscription._pubsub is None + assert subscription._listener_thread is None + + # Wait for thread to finish (with timeout) + if thread and thread.is_alive(): + thread.join(timeout=1.0) + assert not thread.is_alive() + + # ==================== Message Processing Tests ==================== + + def test_message_iterator_with_messages(self, started_subscription: _RedisSubscription): + """Test message iterator behavior with messages in queue.""" + test_messages = [b"msg1", b"msg2", b"msg3"] + + # Add messages to queue + for msg in test_messages: + started_subscription._queue.put_nowait(msg) + + # Iterate through messages + iterator = iter(started_subscription) + received_messages = [] + + for msg in iterator: + received_messages.append(msg) + if len(received_messages) >= len(test_messages): + break + + assert received_messages == test_messages + + def test_message_iterator_when_closed(self, subscription: _RedisSubscription): + """Test that iterator raises error when subscription is closed.""" + subscription.close() + + with pytest.raises(BroadcastChannelError, match="The Redis subscription is closed"): + iter(subscription) + + # ==================== Message Enqueue Tests ==================== + + def test_enqueue_message_success(self, started_subscription: _RedisSubscription): + """Test successful message enqueue.""" + payload = b"test message" + + started_subscription._enqueue_message(payload) + + assert started_subscription._queue.qsize() == 1 + assert started_subscription._queue.get_nowait() == payload + + def test_enqueue_message_when_closed(self, subscription: _RedisSubscription): + """Test message enqueue when subscription is closed.""" + subscription.close() + payload = b"test message" + + # Should not raise exception, but should not enqueue + subscription._enqueue_message(payload) + + assert subscription._queue.empty() + + def test_enqueue_message_with_full_queue(self, started_subscription: _RedisSubscription): + """Test message enqueue with full queue (dropping behavior).""" + # Fill the queue + for i in range(started_subscription._queue.maxsize): + started_subscription._queue.put_nowait(f"old_msg_{i}".encode()) + + # Try to enqueue new message (should drop oldest) + new_message = b"new_message" + started_subscription._enqueue_message(new_message) + + # Should have dropped one message and added new one + assert started_subscription._dropped_count == 1 + + # New message should be in queue + messages = [] + while not started_subscription._queue.empty(): + messages.append(started_subscription._queue.get_nowait()) + + assert new_message in messages + + # ==================== Listener Thread Tests ==================== + + @patch("time.sleep", side_effect=lambda x: None) # Speed up test + def test_listener_thread_normal_operation( + self, mock_sleep, subscription: _RedisSubscription, mock_pubsub: MagicMock + ): + """Test listener thread normal operation.""" + # Mock message from Redis + mock_message = {"type": "message", "channel": "test-topic", "data": b"test payload"} + mock_pubsub.get_message.return_value = mock_message + + # Start listener + subscription._start_if_needed() + + # Wait a bit for processing + time.sleep(0.1) + + # Verify message was processed + assert not subscription._queue.empty() + assert subscription._queue.get_nowait() == b"test payload" + + def test_listener_thread_ignores_subscribe_messages(self, subscription: _RedisSubscription, mock_pubsub: MagicMock): + """Test that listener thread ignores subscribe/unsubscribe messages.""" + mock_message = {"type": "subscribe", "channel": "test-topic", "data": 1} + mock_pubsub.get_message.return_value = mock_message + + subscription._start_if_needed() + time.sleep(0.1) + + # Should not enqueue subscribe messages + assert subscription._queue.empty() + + def test_listener_thread_ignores_wrong_channel(self, subscription: _RedisSubscription, mock_pubsub: MagicMock): + """Test that listener thread ignores messages from wrong channels.""" + mock_message = {"type": "message", "channel": "wrong-topic", "data": b"test payload"} + mock_pubsub.get_message.return_value = mock_message + + subscription._start_if_needed() + time.sleep(0.1) + + # Should not enqueue messages from wrong channels + assert subscription._queue.empty() + + def test_listener_thread_handles_redis_exceptions(self, subscription: _RedisSubscription, mock_pubsub: MagicMock): + """Test that listener thread handles Redis exceptions gracefully.""" + mock_pubsub.get_message.side_effect = Exception("Redis error") + + subscription._start_if_needed() + + # Wait for thread to handle exception + time.sleep(0.2) + + # Thread should still be alive but not processing + assert subscription._listener_thread is not None + assert not subscription._listener_thread.is_alive() + + def test_listener_thread_stops_when_closed(self, subscription: _RedisSubscription, mock_pubsub: MagicMock): + """Test that listener thread stops when subscription is closed.""" + subscription._start_if_needed() + thread = subscription._listener_thread + + # Close subscription + subscription.close() + + # Wait for thread to finish + if thread is not None and thread.is_alive(): + thread.join(timeout=1.0) + + assert thread is None or not thread.is_alive() + + # ==================== Table-driven Tests ==================== + + @pytest.mark.parametrize( + "test_case", + [ + SubscriptionTestCase( + name="basic_message", + buffer_size=5, + payload=b"hello world", + expected_messages=[b"hello world"], + description="Basic message publishing and receiving", + ), + SubscriptionTestCase( + name="empty_message", + buffer_size=5, + payload=b"", + expected_messages=[b""], + description="Empty message handling", + ), + SubscriptionTestCase( + name="large_message", + buffer_size=5, + payload=b"x" * 10000, + expected_messages=[b"x" * 10000], + description="Large message handling", + ), + SubscriptionTestCase( + name="unicode_message", + buffer_size=5, + payload="你好世界".encode(), + expected_messages=["你好世界".encode()], + description="Unicode message handling", + ), + ], + ) + def test_subscription_scenarios(self, test_case: SubscriptionTestCase, mock_pubsub: MagicMock): + """Test various subscription scenarios using table-driven approach.""" + subscription = _RedisSubscription( + pubsub=mock_pubsub, + topic="test-topic", + ) + + # Simulate receiving message + mock_message = {"type": "message", "channel": "test-topic", "data": test_case.payload} + mock_pubsub.get_message.return_value = mock_message + + try: + with subscription: + # Wait for message processing + time.sleep(0.1) + + # Collect received messages + received = [] + for msg in subscription: + received.append(msg) + if len(received) >= len(test_case.expected_messages): + break + + assert received == test_case.expected_messages, f"Failed: {test_case.description}" + finally: + subscription.close() + + def test_concurrent_close_and_enqueue(self, started_subscription: _RedisSubscription): + """Test concurrent close and enqueue operations.""" + errors = [] + + def close_subscription(): + try: + time.sleep(0.05) # Small delay + started_subscription.close() + except Exception as e: + errors.append(e) + + def enqueue_messages(): + try: + for i in range(50): + started_subscription._enqueue_message(f"msg_{i}".encode()) + time.sleep(0.001) + except Exception as e: + errors.append(e) + + # Start threads + close_thread = threading.Thread(target=close_subscription) + enqueue_thread = threading.Thread(target=enqueue_messages) + + close_thread.start() + enqueue_thread.start() + + # Wait for completion + close_thread.join(timeout=2.0) + enqueue_thread.join(timeout=2.0) + + # Should not have any errors (operations should be safe) + assert len(errors) == 0 + + # ==================== Error Handling Tests ==================== + + def test_iterator_after_close(self, subscription: _RedisSubscription): + """Test iterator behavior after close.""" + subscription.close() + + with pytest.raises(SubscriptionClosedError, match="The Redis subscription is closed"): + iter(subscription) + + def test_start_after_close(self, subscription: _RedisSubscription): + """Test start attempts after close.""" + subscription.close() + + with pytest.raises(SubscriptionClosedError, match="The Redis subscription is closed"): + subscription._start_if_needed() + + def test_pubsub_none_operations(self, subscription: _RedisSubscription): + """Test operations when pubsub is None.""" + subscription._pubsub = None + + with pytest.raises(SubscriptionClosedError, match="The Redis subscription has been cleaned up"): + subscription._start_if_needed() + + # Close should still work + subscription.close() # Should not raise + + def test_channel_name_variations(self, mock_pubsub: MagicMock): + """Test various channel name formats.""" + channel_names = [ + "simple", + "with-dashes", + "with_underscores", + "with.numbers", + "WITH.UPPERCASE", + "mixed-CASE_name", + "very.long.channel.name.with.multiple.parts", + ] + + for channel_name in channel_names: + subscription = _RedisSubscription( + pubsub=mock_pubsub, + topic=channel_name, + ) + + subscription._start_if_needed() + mock_pubsub.subscribe.assert_called_with(channel_name) + subscription.close() + + def test_received_on_closed_subscription(self, subscription: _RedisSubscription): + subscription.close() + + with pytest.raises(SubscriptionClosedError): + subscription.receive() From 2c62a77cf47f79c1d72a6bdc63b125da5d7d0dea Mon Sep 17 00:00:00 2001 From: Joel Date: Mon, 10 Nov 2025 18:39:12 +0800 Subject: [PATCH 04/23] Chore: change query log time range (#28052) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../[appId]/overview/chart-view.tsx | 45 +++------- .../time-range-picker/date-picker.tsx | 80 +++++++++++++++++ .../overview/time-range-picker/index.tsx | 86 +++++++++++++++++++ .../time-range-picker/range-selector.tsx | 81 +++++++++++++++++ web/app/components/app/overview/app-chart.tsx | 15 +++- .../date-and-time-picker/calendar/index.tsx | 4 +- .../date-and-time-picker/calendar/item.tsx | 4 +- .../date-picker/index.tsx | 27 ++++-- .../base/date-and-time-picker/types.ts | 4 + .../assets/vender/other/hourglass-shape.svg | 3 + .../src/vender/other/HourglassShape.json | 27 ++++++ .../icons/src/vender/other/HourglassShape.tsx | 20 +++++ .../base/icons/src/vender/other/index.ts | 1 + web/app/components/base/select/index.tsx | 4 +- web/i18n/en-US/app-log.ts | 2 + web/i18n/ja-JP/app-log.ts | 2 + web/i18n/zh-Hans/app-log.ts | 2 + web/utils/format.ts | 51 +++++++++++ 18 files changed, 410 insertions(+), 48 deletions(-) create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/index.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx create mode 100644 web/app/components/base/icons/assets/vender/other/hourglass-shape.svg create mode 100644 web/app/components/base/icons/src/vender/other/HourglassShape.json create mode 100644 web/app/components/base/icons/src/vender/other/HourglassShape.tsx diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx index 847de19165..d5430b03ad 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx @@ -5,15 +5,19 @@ import quarterOfYear from 'dayjs/plugin/quarterOfYear' import { useTranslation } from 'react-i18next' import type { PeriodParams } from '@/app/components/app/overview/app-chart' import { AvgResponseTime, AvgSessionInteractions, AvgUserInteractions, ConversationsChart, CostChart, EndUsersChart, MessagesChart, TokenPerSecond, UserSatisfactionRate, WorkflowCostChart, WorkflowDailyTerminalsChart, WorkflowMessagesChart } from '@/app/components/app/overview/app-chart' -import type { Item } from '@/app/components/base/select' -import { SimpleSelect } from '@/app/components/base/select' -import { TIME_PERIOD_MAPPING } from '@/app/components/app/log/filter' import { useStore as useAppStore } from '@/app/components/app/store' +import TimeRangePicker from './time-range-picker' dayjs.extend(quarterOfYear) const today = dayjs() +const TIME_PERIOD_MAPPING = [ + { value: 0, name: 'today' }, + { value: 7, name: 'last7days' }, + { value: 30, name: 'last30days' }, +] + const queryDateFormat = 'YYYY-MM-DD HH:mm' export type IChartViewProps = { @@ -26,21 +30,7 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) { const appDetail = useAppStore(state => state.appDetail) const isChatApp = appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow' const isWorkflow = appDetail?.mode === 'workflow' - const [period, setPeriod] = useState({ name: t('appLog.filter.period.last7days'), query: { start: today.subtract(7, 'day').startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } }) - - const onSelect = (item: Item) => { - if (item.value === -1) { - setPeriod({ name: item.name, query: undefined }) - } - else if (item.value === 0) { - const startOfToday = today.startOf('day').format(queryDateFormat) - const endOfToday = today.endOf('day').format(queryDateFormat) - setPeriod({ name: item.name, query: { start: startOfToday, end: endOfToday } }) - } - else { - setPeriod({ name: item.name, query: { start: today.subtract(item.value as number, 'day').startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } }) - } - } + const [period, setPeriod] = useState({ name: t('appLog.filter.period.today'), query: { start: today.startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } }) if (!appDetail) return null @@ -50,20 +40,11 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) {
{t('common.appMenus.overview')}
-
- ({ value: k, name: t(`appLog.filter.period.${v.name}`) }))} - className='mt-0 !w-40' - notClearable={true} - onSelect={(item) => { - const id = item.value - const value = TIME_PERIOD_MAPPING[id]?.value ?? '-1' - const name = item.name || t('appLog.filter.period.allTime') - onSelect({ value, name }) - }} - defaultValue={'2'} - /> -
+ {headerRight}
diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx new file mode 100644 index 0000000000..2bfdece433 --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx @@ -0,0 +1,80 @@ +'use client' +import { RiCalendarLine } from '@remixicon/react' +import type { Dayjs } from 'dayjs' +import type { FC } from 'react' +import React, { useCallback } from 'react' +import cn from '@/utils/classnames' +import { formatToLocalTime } from '@/utils/format' +import { useI18N } from '@/context/i18n' +import Picker from '@/app/components/base/date-and-time-picker/date-picker' +import type { TriggerProps } from '@/app/components/base/date-and-time-picker/types' +import { noop } from 'lodash-es' +import dayjs from 'dayjs' + +type Props = { + start: Dayjs + end: Dayjs + onStartChange: (date?: Dayjs) => void + onEndChange: (date?: Dayjs) => void +} + +const today = dayjs() +const DatePicker: FC = ({ + start, + end, + onStartChange, + onEndChange, +}) => { + const { locale } = useI18N() + + const renderDate = useCallback(({ value, handleClickTrigger, isOpen }: TriggerProps) => { + return ( +
+ {value ? formatToLocalTime(value, locale, 'MMM D') : ''} +
+ ) + }, [locale]) + + const availableStartDate = end.subtract(30, 'day') + const startDateDisabled = useCallback((date: Dayjs) => { + if (date.isAfter(today, 'date')) + return true + return !((date.isAfter(availableStartDate, 'date') || date.isSame(availableStartDate, 'date')) && (date.isBefore(end, 'date') || date.isSame(end, 'date'))) + }, [availableStartDate, end]) + + const availableEndDate = start.add(30, 'day') + const endDateDisabled = useCallback((date: Dayjs) => { + if (date.isAfter(today, 'date')) + return true + return !((date.isAfter(start, 'date') || date.isSame(start, 'date')) && (date.isBefore(availableEndDate, 'date') || date.isSame(availableEndDate, 'date'))) + }, [availableEndDate, start]) + + return ( +
+
+ +
+ + - + +
+ + ) +} +export default React.memo(DatePicker) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/index.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/index.tsx new file mode 100644 index 0000000000..4738bdeebf --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/index.tsx @@ -0,0 +1,86 @@ +'use client' +import type { PeriodParams, PeriodParamsWithTimeRange } from '@/app/components/app/overview/app-chart' +import type { FC } from 'react' +import React, { useCallback, useState } from 'react' +import type { Dayjs } from 'dayjs' +import { HourglassShape } from '@/app/components/base/icons/src/vender/other' +import RangeSelector from './range-selector' +import DatePicker from './date-picker' +import dayjs from 'dayjs' +import { useI18N } from '@/context/i18n' +import { formatToLocalTime } from '@/utils/format' + +const today = dayjs() + +type Props = { + ranges: { value: number; name: string }[] + onSelect: (payload: PeriodParams) => void + queryDateFormat: string +} + +const TimeRangePicker: FC = ({ + ranges, + onSelect, + queryDateFormat, +}) => { + const { locale } = useI18N() + + const [isCustomRange, setIsCustomRange] = useState(false) + const [start, setStart] = useState(today) + const [end, setEnd] = useState(today) + + const handleRangeChange = useCallback((payload: PeriodParamsWithTimeRange) => { + setIsCustomRange(false) + setStart(payload.query!.start) + setEnd(payload.query!.end) + onSelect({ + name: payload.name, + query: { + start: payload.query!.start.format(queryDateFormat), + end: payload.query!.end.format(queryDateFormat), + }, + }) + }, [onSelect, queryDateFormat]) + + const handleDateChange = useCallback((type: 'start' | 'end') => { + return (date?: Dayjs) => { + if (!date) return + if (type === 'start' && date.isSame(start)) return + if (type === 'end' && date.isSame(end)) return + if (type === 'start') + setStart(date) + else + setEnd(date) + + const currStart = type === 'start' ? date : start + const currEnd = type === 'end' ? date : end + onSelect({ + name: `${formatToLocalTime(currStart, locale, 'MMM D')} - ${formatToLocalTime(currEnd, locale, 'MMM D')}`, + query: { + start: currStart.format(queryDateFormat), + end: currEnd.format(queryDateFormat), + }, + }) + + setIsCustomRange(true) + } + }, [start, end, onSelect, locale, queryDateFormat]) + + return ( +
+ + + +
+ ) +} +export default React.memo(TimeRangePicker) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx new file mode 100644 index 0000000000..f99ea52492 --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx @@ -0,0 +1,81 @@ +'use client' +import type { PeriodParamsWithTimeRange, TimeRange } from '@/app/components/app/overview/app-chart' +import type { FC } from 'react' +import React, { useCallback } from 'react' +import { SimpleSelect } from '@/app/components/base/select' +import type { Item } from '@/app/components/base/select' +import dayjs from 'dayjs' +import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react' +import cn from '@/utils/classnames' +import { useTranslation } from 'react-i18next' + +const today = dayjs() + +type Props = { + isCustomRange: boolean + ranges: { value: number; name: string }[] + onSelect: (payload: PeriodParamsWithTimeRange) => void +} + +const RangeSelector: FC = ({ + isCustomRange, + ranges, + onSelect, +}) => { + const { t } = useTranslation() + + const handleSelectRange = useCallback((item: Item) => { + const { name, value } = item + let period: TimeRange | null = null + if (value === 0) { + const startOfToday = today.startOf('day') + const endOfToday = today.endOf('day') + period = { start: startOfToday, end: endOfToday } + } + else { + period = { start: today.subtract(item.value as number, 'day').startOf('day'), end: today.endOf('day') } + } + onSelect({ query: period!, name }) + }, [onSelect]) + + const renderTrigger = useCallback((item: Item | null, isOpen: boolean) => { + return ( +
+
{isCustomRange ? t('appLog.filter.period.custom') : item?.name}
+ +
+ ) + }, [isCustomRange]) + + const renderOption = useCallback(({ item, selected }: { item: Item; selected: boolean }) => { + return ( + <> + {selected && ( + + + )} + {item.name} + + ) + }, []) + return ( + ({ ...v, name: t(`appLog.filter.period.${v.name}`) }))} + className='mt-0 !w-40' + notClearable={true} + onSelect={handleSelectRange} + defaultValue={0} + wrapperClassName='h-8' + optionWrapClassName='w-[200px] translate-x-[-24px]' + renderTrigger={renderTrigger} + optionClassName='flex items-center py-0 pl-7 pr-2 h-8' + renderOption={renderOption} + /> + ) +} +export default React.memo(RangeSelector) diff --git a/web/app/components/app/overview/app-chart.tsx b/web/app/components/app/overview/app-chart.tsx index c550f0b23f..8f28e16402 100644 --- a/web/app/components/app/overview/app-chart.tsx +++ b/web/app/components/app/overview/app-chart.tsx @@ -4,6 +4,7 @@ import React from 'react' import ReactECharts from 'echarts-for-react' import type { EChartsOption } from 'echarts' import useSWR from 'swr' +import type { Dayjs } from 'dayjs' import dayjs from 'dayjs' import { get } from 'lodash-es' import Decimal from 'decimal.js' @@ -78,6 +79,16 @@ export type PeriodParams = { } } +export type TimeRange = { + start: Dayjs + end: Dayjs +} + +export type PeriodParamsWithTimeRange = { + name: string + query?: TimeRange +} + export type IBizChartProps = { period: PeriodParams id: string @@ -215,9 +226,7 @@ const Chart: React.FC = ({ formatter(params) { return `
${params.name}
${valueFormatter((params.data as any)[yField])} - ${!CHART_TYPE_CONFIG[chartType].showTokens - ? '' - : ` + ${!CHART_TYPE_CONFIG[chartType].showTokens ? '' : ` ( ~$${get(params.data, 'total_price', 0)} ) diff --git a/web/app/components/base/date-and-time-picker/calendar/index.tsx b/web/app/components/base/date-and-time-picker/calendar/index.tsx index 00612fcb37..03dcb0eda3 100644 --- a/web/app/components/base/date-and-time-picker/calendar/index.tsx +++ b/web/app/components/base/date-and-time-picker/calendar/index.tsx @@ -8,9 +8,10 @@ const Calendar: FC = ({ selectedDate, onDateClick, wrapperClassName, + getIsDateDisabled, }) => { return
- +
{ days.map(day => = ({ day={day} selectedDate={selectedDate} onClick={onDateClick} + isDisabled={getIsDateDisabled ? getIsDateDisabled(day.date) : false} />) }
diff --git a/web/app/components/base/date-and-time-picker/calendar/item.tsx b/web/app/components/base/date-and-time-picker/calendar/item.tsx index 1da8b9b3b5..7132d7bdfb 100644 --- a/web/app/components/base/date-and-time-picker/calendar/item.tsx +++ b/web/app/components/base/date-and-time-picker/calendar/item.tsx @@ -7,6 +7,7 @@ const Item: FC = ({ day, selectedDate, onClick, + isDisabled, }) => { const { date, isCurrentMonth } = day const isSelected = selectedDate?.isSame(date, 'date') @@ -14,11 +15,12 @@ const Item: FC = ({ return (
{/* description */} @@ -333,7 +363,8 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx {/* operations */}
- -
- -
+ {/* Switch operation (if available) */} + {switchOperation && ( +
+ +
+ )} {showSwitchModal && ( void + id: string + title: string + icon: JSX.Element + onClick: () => void + type?: 'divider' } -const AppOperations = ({ operations, gap }: { - operations: Operation[] +type AppOperationsProps = { gap: number -}) => { + operations?: Operation[] + primaryOperations?: Operation[] + secondaryOperations?: Operation[] +} + +const EMPTY_OPERATIONS: Operation[] = [] + +const AppOperations = ({ + operations, + primaryOperations, + secondaryOperations, + gap, +}: AppOperationsProps) => { const { t } = useTranslation() const [visibleOpreations, setVisibleOperations] = useState([]) const [moreOperations, setMoreOperations] = useState([]) @@ -23,22 +37,59 @@ const AppOperations = ({ operations, gap }: { setShowMore(true) }, [setShowMore]) + const primaryOps = useMemo(() => { + if (operations) + return operations + if (primaryOperations) + return primaryOperations + return EMPTY_OPERATIONS + }, [operations, primaryOperations]) + + const secondaryOps = useMemo(() => { + if (operations) + return EMPTY_OPERATIONS + if (secondaryOperations) + return secondaryOperations + return EMPTY_OPERATIONS + }, [operations, secondaryOperations]) + const inlineOperations = primaryOps.filter(operation => operation.type !== 'divider') + useEffect(() => { - const moreElement = document.getElementById('more') - const navElement = document.getElementById('nav') + const applyState = (visible: Operation[], overflow: Operation[]) => { + const combinedMore = [...overflow, ...secondaryOps] + if (!overflow.length && combinedMore[0]?.type === 'divider') + combinedMore.shift() + setVisibleOperations(visible) + setMoreOperations(combinedMore) + } + + const inline = primaryOps.filter(operation => operation.type !== 'divider') + + if (!inline.length) { + applyState([], []) + return + } + + const navElement = navRef.current + const moreElement = document.getElementById('more-measure') + + if (!navElement || !moreElement) + return + let width = 0 - const containerWidth = navElement?.clientWidth ?? 0 - const moreWidth = moreElement?.clientWidth ?? 0 + const containerWidth = navElement.clientWidth + const moreWidth = moreElement.clientWidth - if (containerWidth === 0 || moreWidth === 0) return + if (containerWidth === 0 || moreWidth === 0) + return - const updatedEntries: Record = operations.reduce((pre, cur) => { + const updatedEntries: Record = inline.reduce((pre, cur) => { pre[cur.id] = false return pre }, {} as Record) - const childrens = Array.from(navRef.current!.children).slice(0, -1) + const childrens = Array.from(navElement.children).slice(0, -1) for (let i = 0; i < childrens.length; i++) { - const child: any = childrens[i] + const child = childrens[i] as HTMLElement const id = child.dataset.targetid if (!id) break const childWidth = child.clientWidth @@ -55,88 +106,106 @@ const AppOperations = ({ operations, gap }: { break } } - setVisibleOperations(operations.filter(item => updatedEntries[item.id])) - setMoreOperations(operations.filter(item => !updatedEntries[item.id])) - }, [operations, gap]) + + const visible = inline.filter(item => updatedEntries[item.id]) + const overflow = inline.filter(item => !updatedEntries[item.id]) + + applyState(visible, overflow) + }, [gap, primaryOps, secondaryOps]) + + const shouldShowMoreButton = moreOperations.length > 0 return ( <> - {!visibleOpreations.length && } -
- {visibleOpreations.map(operation => + {inlineOperations.map(operation => ( , - )} - {visibleOpreations.length < operations.length && - - - - -
- {moreOperations.map(item =>
+ ))} + +
+
+ {visibleOpreations.map(operation => ( + + ))} + {shouldShowMoreButton && ( + + +
)} -
-
-
} + + + {t('common.operation.more')} + + + + +
+ {moreOperations.map(item => item.type === 'divider' + ? ( +
+ ) + : ( +
+ {cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })} + {item.title} +
+ ))} +
+ + + )}
) diff --git a/web/app/components/app-sidebar/app-sidebar-dropdown.tsx b/web/app/components/app-sidebar/app-sidebar-dropdown.tsx index b1da43ae14..3c5d38dd82 100644 --- a/web/app/components/app-sidebar/app-sidebar-dropdown.tsx +++ b/web/app/components/app-sidebar/app-sidebar-dropdown.tsx @@ -17,6 +17,7 @@ import NavLink from './navLink' import { useStore as useAppStore } from '@/app/components/app/store' import type { NavIcon } from './navLink' import cn from '@/utils/classnames' +import { AppModeEnum } from '@/types/app' type Props = { navigation: Array<{ @@ -97,7 +98,7 @@ const AppSidebarDropdown = ({ navigation }: Props) => {
{appDetail.name}
-
{appDetail.mode === 'advanced-chat' ? t('app.types.advanced') : appDetail.mode === 'agent-chat' ? t('app.types.agent') : appDetail.mode === 'chat' ? t('app.types.chatbot') : appDetail.mode === 'completion' ? t('app.types.completion') : t('app.types.workflow')}
+
{appDetail.mode === AppModeEnum.ADVANCED_CHAT ? t('app.types.advanced') : appDetail.mode === AppModeEnum.AGENT_CHAT ? t('app.types.agent') : appDetail.mode === AppModeEnum.CHAT ? t('app.types.chatbot') : appDetail.mode === AppModeEnum.COMPLETION ? t('app.types.completion') : t('app.types.workflow')}
diff --git a/web/app/components/app-sidebar/basic.tsx b/web/app/components/app-sidebar/basic.tsx index 77a965c03e..da85fb154b 100644 --- a/web/app/components/app-sidebar/basic.tsx +++ b/web/app/components/app-sidebar/basic.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import AppIcon from '../base/app-icon' import Tooltip from '@/app/components/base/tooltip' import { - Code, + ApiAggregate, WindowCursor, } from '@/app/components/base/icons/src/vender/workflow' @@ -40,8 +40,8 @@ const NotionSvg = , - api:
- + api:
+
, dataset: , webapp:
@@ -56,12 +56,12 @@ export default function AppBasic({ icon, icon_background, name, isExternal, type return (
{icon && icon_background && iconType === 'app' && ( -
+
)} {iconType !== 'app' - &&
+ &&
{ICON_MAP[iconType]}
diff --git a/web/app/components/app/annotation/index.tsx b/web/app/components/app/annotation/index.tsx index bc63b85f6d..8718890e35 100644 --- a/web/app/components/app/annotation/index.tsx +++ b/web/app/components/app/annotation/index.tsx @@ -24,7 +24,7 @@ import type { AnnotationReplyConfig } from '@/models/debug' import { sleep } from '@/utils' import { useProviderContext } from '@/context/provider-context' import AnnotationFullModal from '@/app/components/billing/annotation-full/modal' -import type { App } from '@/types/app' +import { type App, AppModeEnum } from '@/types/app' import cn from '@/utils/classnames' import { delAnnotations } from '@/service/annotation' @@ -37,7 +37,7 @@ const Annotation: FC = (props) => { const { t } = useTranslation() const [isShowEdit, setIsShowEdit] = useState(false) const [annotationConfig, setAnnotationConfig] = useState(null) - const [isChatApp] = useState(appDetail.mode !== 'completion') + const [isChatApp] = useState(appDetail.mode !== AppModeEnum.COMPLETION) const [controlRefreshSwitch, setControlRefreshSwitch] = useState(() => Date.now()) const { plan, enableBilling } = useProviderContext() const isAnnotationFull = enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse diff --git a/web/app/components/app/app-publisher/features-wrapper.tsx b/web/app/components/app/app-publisher/features-wrapper.tsx index 409c390f4b..4b64558016 100644 --- a/web/app/components/app/app-publisher/features-wrapper.tsx +++ b/web/app/components/app/app-publisher/features-wrapper.tsx @@ -22,37 +22,39 @@ const FeaturesWrappedAppPublisher = (props: Props) => { const features = useFeatures(s => s.features) const featuresStore = useFeaturesStore() const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false) + const { more_like_this, opening_statement, suggested_questions, sensitive_word_avoidance, speech_to_text, text_to_speech, suggested_questions_after_answer, retriever_resource, annotation_reply, file_upload, resetAppConfig } = props.publishedConfig.modelConfig + const handleConfirm = useCallback(() => { - props.resetAppConfig?.() + resetAppConfig?.() const { features, setFeatures, } = featuresStore!.getState() const newFeatures = produce(features, (draft) => { - draft.moreLikeThis = props.publishedConfig.modelConfig.more_like_this || { enabled: false } + draft.moreLikeThis = more_like_this || { enabled: false } draft.opening = { - enabled: !!props.publishedConfig.modelConfig.opening_statement, - opening_statement: props.publishedConfig.modelConfig.opening_statement || '', - suggested_questions: props.publishedConfig.modelConfig.suggested_questions || [], + enabled: !!opening_statement, + opening_statement: opening_statement || '', + suggested_questions: suggested_questions || [], } - draft.moderation = props.publishedConfig.modelConfig.sensitive_word_avoidance || { enabled: false } - draft.speech2text = props.publishedConfig.modelConfig.speech_to_text || { enabled: false } - draft.text2speech = props.publishedConfig.modelConfig.text_to_speech || { enabled: false } - draft.suggested = props.publishedConfig.modelConfig.suggested_questions_after_answer || { enabled: false } - draft.citation = props.publishedConfig.modelConfig.retriever_resource || { enabled: false } - draft.annotationReply = props.publishedConfig.modelConfig.annotation_reply || { enabled: false } + draft.moderation = sensitive_word_avoidance || { enabled: false } + draft.speech2text = speech_to_text || { enabled: false } + draft.text2speech = text_to_speech || { enabled: false } + draft.suggested = suggested_questions_after_answer || { enabled: false } + draft.citation = retriever_resource || { enabled: false } + draft.annotationReply = annotation_reply || { enabled: false } draft.file = { image: { - detail: props.publishedConfig.modelConfig.file_upload?.image?.detail || Resolution.high, - enabled: !!props.publishedConfig.modelConfig.file_upload?.image?.enabled, - number_limits: props.publishedConfig.modelConfig.file_upload?.image?.number_limits || 3, - transfer_methods: props.publishedConfig.modelConfig.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], + detail: file_upload?.image?.detail || Resolution.high, + enabled: !!file_upload?.image?.enabled, + number_limits: file_upload?.image?.number_limits || 3, + transfer_methods: file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], }, - enabled: !!(props.publishedConfig.modelConfig.file_upload?.enabled || props.publishedConfig.modelConfig.file_upload?.image?.enabled), - allowed_file_types: props.publishedConfig.modelConfig.file_upload?.allowed_file_types || [SupportUploadFileTypes.image], - allowed_file_extensions: props.publishedConfig.modelConfig.file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`), - allowed_file_upload_methods: props.publishedConfig.modelConfig.file_upload?.allowed_file_upload_methods || props.publishedConfig.modelConfig.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], - number_limits: props.publishedConfig.modelConfig.file_upload?.number_limits || props.publishedConfig.modelConfig.file_upload?.image?.number_limits || 3, + enabled: !!(file_upload?.enabled || file_upload?.image?.enabled), + allowed_file_types: file_upload?.allowed_file_types || [SupportUploadFileTypes.image], + allowed_file_extensions: file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`), + allowed_file_upload_methods: file_upload?.allowed_file_upload_methods || file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], + number_limits: file_upload?.number_limits || file_upload?.image?.number_limits || 3, } as FileUpload }) setFeatures(newFeatures) @@ -69,7 +71,7 @@ const FeaturesWrappedAppPublisher = (props: Props) => { ...props, onPublish: handlePublish, onRestore: () => setRestoreConfirmOpen(true), - }}/> + }} /> {restoreConfirmOpen && ( = { + [AccessMode.ORGANIZATION]: { + label: 'organization', + icon: RiBuildingLine, + }, + [AccessMode.SPECIFIC_GROUPS_MEMBERS]: { + label: 'specific', + icon: RiLockLine, + }, + [AccessMode.PUBLIC]: { + label: 'anyone', + icon: RiGlobalLine, + }, + [AccessMode.EXTERNAL_MEMBERS]: { + label: 'external', + icon: RiVerifiedBadgeLine, + }, +} + +const AccessModeDisplay: React.FC<{ mode?: AccessMode }> = ({ mode }) => { + const { t } = useTranslation() + + if (!mode || !ACCESS_MODE_MAP[mode]) + return null + + const { icon: Icon, label } = ACCESS_MODE_MAP[mode] + + return ( + <> + +
+ {t(`app.accessControlDialog.accessItems.${label}`)} +
+ + ) +} export type AppPublisherProps = { disabled?: boolean @@ -64,6 +103,9 @@ export type AppPublisherProps = { toolPublished?: boolean inputs?: InputVar[] onRefreshData?: () => void + workflowToolAvailable?: boolean + missingStartNode?: boolean + hasTriggerNode?: boolean // Whether workflow currently contains any trigger nodes (used to hide missing-start CTA when triggers exist). } const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P'] @@ -82,28 +124,48 @@ const AppPublisher = ({ toolPublished, inputs, onRefreshData, + workflowToolAvailable = true, + missingStartNode = false, + hasTriggerNode = false, }: AppPublisherProps) => { const { t } = useTranslation() + const [published, setPublished] = useState(false) const [open, setOpen] = useState(false) + const [showAppAccessControl, setShowAppAccessControl] = useState(false) + const [isAppAccessSet, setIsAppAccessSet] = useState(true) + const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) + const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(s => s.setAppDetail) const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { formatTimeFromNow } = useFormatTimeFromNow() const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {} - const appMode = (appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow') ? 'chat' : appDetail.mode + + const appMode = (appDetail?.mode !== AppModeEnum.COMPLETION && appDetail?.mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : appDetail.mode const appURL = `${appBaseURL}${basePath}/${appMode}/${accessToken}` - const isChatApp = ['chat', 'agent-chat', 'completion'].includes(appDetail?.mode || '') + const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT) + const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false }) const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS) + const noAccessPermission = useMemo(() => systemFeatures.webapp_auth.enabled && appDetail && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result, [systemFeatures, appDetail, userCanAccessApp]) + const disabledFunctionButton = useMemo(() => (!publishedAt || missingStartNode || noAccessPermission), [publishedAt, missingStartNode, noAccessPermission]) + + const disabledFunctionTooltip = useMemo(() => { + if (!publishedAt) + return t('app.notPublishedYet') + if (missingStartNode) + return t('app.noUserInputNode') + if (noAccessPermission) + return t('app.noAccessPermission') + }, [missingStartNode, noAccessPermission, publishedAt]) + useEffect(() => { if (systemFeatures.webapp_auth.enabled && open && appDetail) refetch() }, [open, appDetail, refetch, systemFeatures]) - const [showAppAccessControl, setShowAppAccessControl] = useState(false) - const [isAppAccessSet, setIsAppAccessSet] = useState(true) useEffect(() => { if (appDetail && appAccessSubjects) { if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0) @@ -174,8 +236,6 @@ const AppPublisher = ({ } }, [appDetail, setAppDetail]) - const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) - useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => { e.preventDefault() if (publishDisabled || published) @@ -183,6 +243,10 @@ const AppPublisher = ({ handlePublish() }, { exactMatch: true, useCapture: true }) + const hasPublishedVersion = !!publishedAt + const workflowToolDisabled = !hasPublishedVersion || !workflowToolAvailable + const workflowToolMessage = workflowToolDisabled ? t('workflow.common.workflowAsToolDisabledHint') : undefined + return ( <>
} -
- - } - > - {t('workflow.common.runApp')} - - - {appDetail?.mode === 'workflow' || appDetail?.mode === 'completion' - ? ( - + { + // Hide run/batch run app buttons when there is a trigger node. + !hasTriggerNode && ( +
+ } + disabled={disabledFunctionButton} + link={appURL} + icon={} > - {t('workflow.common.batchRunApp')} + {t('workflow.common.runApp')} - ) - : ( - { - setEmbeddingModalOpen(true) - handleTrigger() - }} - disabled={!publishedAt} - icon={} - > - {t('workflow.common.embedIntoSite')} - - )} - - { - if (publishedAt) - handleOpenInExplore() - }} - disabled={!publishedAt || (systemFeatures.webapp_auth.enabled && !userCanAccessApp?.result)} - icon={} - > - {t('workflow.common.openInExplore')} - - - } - > - {t('workflow.common.accessAPIReference')} - - {appDetail?.mode === 'workflow' && ( - + {appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION + ? ( + + } + > + {t('workflow.common.batchRunApp')} + + + ) + : ( + { + setEmbeddingModalOpen(true) + handleTrigger() + }} + disabled={!publishedAt} + icon={} + > + {t('workflow.common.embedIntoSite')} + + )} + + { + if (publishedAt) + handleOpenInExplore() + }} + disabled={disabledFunctionButton} + icon={} + > + {t('workflow.common.openInExplore')} + + + + } + > + {t('workflow.common.accessAPIReference')} + + + {appDetail?.mode === AppModeEnum.WORKFLOW && ( + + )} +
)} -
}
diff --git a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx index aa8d0f65ca..5bf2f177ff 100644 --- a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx +++ b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx @@ -25,7 +25,7 @@ import Tooltip from '@/app/components/base/tooltip' import PromptEditor from '@/app/components/base/prompt-editor' import ConfigContext from '@/context/debug-configuration' import { getNewVar, getVars } from '@/utils/var' -import { AppType } from '@/types/app' +import { AppModeEnum } from '@/types/app' import { useModalContext } from '@/context/modal-context' import type { ExternalDataTool } from '@/models/common' import { useToastContext } from '@/app/components/base/toast' @@ -102,7 +102,7 @@ const AdvancedPromptInput: FC = ({ }, }) } - const isChatApp = mode !== AppType.completion + const isChatApp = mode !== AppModeEnum.COMPLETION const [isCopied, setIsCopied] = React.useState(false) const promptVariablesObj = (() => { diff --git a/web/app/components/app/configuration/config-prompt/index.tsx b/web/app/components/app/configuration/config-prompt/index.tsx index ec34588e41..416f87e135 100644 --- a/web/app/components/app/configuration/config-prompt/index.tsx +++ b/web/app/components/app/configuration/config-prompt/index.tsx @@ -12,11 +12,13 @@ import Button from '@/app/components/base/button' import AdvancedMessageInput from '@/app/components/app/configuration/config-prompt/advanced-prompt-input' import { PromptRole } from '@/models/debug' import type { PromptItem, PromptVariable } from '@/models/debug' -import { type AppType, ModelModeType } from '@/types/app' +import type { AppModeEnum } from '@/types/app' +import { ModelModeType } from '@/types/app' import ConfigContext from '@/context/debug-configuration' import { MAX_PROMPT_MESSAGE_LENGTH } from '@/config' + export type IPromptProps = { - mode: AppType + mode: AppModeEnum promptTemplate: string promptVariables: PromptVariable[] readonly?: boolean diff --git a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx index 8634232b2b..68bf6dd7c2 100644 --- a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx +++ b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx @@ -10,7 +10,7 @@ import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap' import cn from '@/utils/classnames' import type { PromptVariable } from '@/models/debug' import Tooltip from '@/app/components/base/tooltip' -import { AppType } from '@/types/app' +import { AppModeEnum } from '@/types/app' import { getNewVar, getVars } from '@/utils/var' import AutomaticBtn from '@/app/components/app/configuration/config/automatic/automatic-btn' import type { GenRes } from '@/service/debug' @@ -29,7 +29,7 @@ import { useFeaturesStore } from '@/app/components/base/features/hooks' import { noop } from 'lodash-es' export type ISimplePromptInput = { - mode: AppType + mode: AppModeEnum promptTemplate: string promptVariables: PromptVariable[] readonly?: boolean @@ -155,7 +155,7 @@ const Prompt: FC = ({ setModelConfig(newModelConfig) setPrevPromptConfig(modelConfig.configs) - if (mode !== AppType.completion) { + if (mode !== AppModeEnum.COMPLETION) { setIntroduction(res.opening_statement || '') const newFeatures = produce(features, (draft) => { draft.opening = { @@ -177,7 +177,7 @@ const Prompt: FC = ({ {!noTitle && (
-
{mode !== AppType.completion ? t('appDebug.chatSubTitle') : t('appDebug.completionSubTitle')}
+
{mode !== AppModeEnum.COMPLETION ? t('appDebug.chatSubTitle') : t('appDebug.completionSubTitle')}
{!readonly && ( = ({ {showAutomatic && ( = ({ const { type, label, variable, options, max_length } = tempPayload const modalRef = useRef(null) const appDetail = useAppStore(state => state.appDetail) - const isBasicApp = appDetail?.mode !== 'advanced-chat' && appDetail?.mode !== 'workflow' + const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW const isSupportJSON = false const jsonSchemaStr = useMemo(() => { const isJsonObject = type === InputVarType.jsonObject diff --git a/web/app/components/app/configuration/config-var/index.tsx b/web/app/components/app/configuration/config-var/index.tsx index 0e453d5171..4090b39a3b 100644 --- a/web/app/components/app/configuration/config-var/index.tsx +++ b/web/app/components/app/configuration/config-var/index.tsx @@ -17,7 +17,7 @@ import { getNewVar, hasDuplicateStr } from '@/utils/var' import Toast from '@/app/components/base/toast' import Confirm from '@/app/components/base/confirm' import ConfigContext from '@/context/debug-configuration' -import { AppType } from '@/types/app' +import { AppModeEnum } from '@/types/app' import type { ExternalDataTool } from '@/models/common' import { useModalContext } from '@/context/modal-context' import { useEventEmitterContextContext } from '@/context/event-emitter' @@ -201,7 +201,7 @@ const ConfigVar: FC = ({ promptVariables, readonly, onPromptVar const handleRemoveVar = (index: number) => { const removeVar = promptVariables[index] - if (mode === AppType.completion && dataSets.length > 0 && removeVar.is_context_var) { + if (mode === AppModeEnum.COMPLETION && dataSets.length > 0 && removeVar.is_context_var) { showDeleteContextVarModal() setRemoveIndex(index) return diff --git a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx index 604b5532b0..ef28dd222c 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx @@ -28,6 +28,7 @@ import { AuthCategory, PluginAuthInAgent, } from '@/app/components/plugins/plugin-auth' +import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' type Props = { showBackButton?: boolean @@ -193,7 +194,7 @@ const SettingBuiltInTool: FC = ({ onClick={onHide} > - BACK + {t('plugin.detailPanel.operation.back')}
)}
@@ -215,6 +216,7 @@ const SettingBuiltInTool: FC = ({ provider: collection.name, category: AuthCategory.tool, providerType: collection.type, + detail: collection as any, }} credentialId={credentialId} onAuthorizationItemClick={onAuthorizationItemClick} @@ -244,13 +246,14 @@ const SettingBuiltInTool: FC = ({ )}
{isInfoActive ? infoUI : settingUI} + {!readonly && !isInfoActive && ( +
+ + +
+ )}
- {!readonly && !isInfoActive && ( -
- - -
- )} +
diff --git a/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx b/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx index 5c87eea3ca..dfcaabf017 100644 --- a/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx +++ b/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx @@ -19,8 +19,7 @@ import Modal from '@/app/components/base/modal' import Button from '@/app/components/base/button' import Toast from '@/app/components/base/toast' import { generateBasicAppFirstTimeRule, generateRule } from '@/service/debug' -import type { CompletionParams, Model } from '@/types/app' -import type { AppType } from '@/types/app' +import type { AppModeEnum, CompletionParams, Model } from '@/types/app' import Loading from '@/app/components/base/loading' import Confirm from '@/app/components/base/confirm' @@ -44,7 +43,7 @@ import { useGenerateRuleTemplate } from '@/service/use-apps' const i18nPrefix = 'appDebug.generate' export type IGetAutomaticResProps = { - mode: AppType + mode: AppModeEnum isShow: boolean onClose: () => void onFinished: (res: GenRes) => void @@ -299,7 +298,6 @@ const GetAutomaticRes: FC = ({ portalToFollowElemContentClassName='z-[1000]' isAdvancedMode={true} provider={model.provider} - mode={model.mode} completionParams={model.completion_params} modelId={model.name} setModel={handleModelChange} diff --git a/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx b/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx index b581da979f..3612f89b02 100644 --- a/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx +++ b/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx @@ -5,8 +5,8 @@ import { useTranslation } from 'react-i18next' import { languageMap } from '../../../../workflow/nodes/_base/components/editor/code-editor/index' import { generateRule } from '@/service/debug' import type { GenRes } from '@/service/debug' -import type { ModelModeType } from '@/types/app' -import type { AppType, CompletionParams, Model } from '@/types/app' +import type { AppModeEnum, ModelModeType } from '@/types/app' +import type { CompletionParams, Model } from '@/types/app' import Modal from '@/app/components/base/modal' import Button from '@/app/components/base/button' import { Generator } from '@/app/components/base/icons/src/vender/other' @@ -33,7 +33,7 @@ export type IGetCodeGeneratorResProps = { flowId: string nodeId: string currentCode?: string - mode: AppType + mode: AppModeEnum isShow: boolean codeLanguages: CodeLanguage onClose: () => void @@ -142,7 +142,7 @@ export const GetCodeGeneratorResModal: FC = ( ideal_output: ideaOutput, language: languageMap[codeLanguages] || 'javascript', }) - if((res as any).code) // not current or current is the same as the template would return a code field + if ((res as any).code) // not current or current is the same as the template would return a code field res.modified = (res as any).code if (error) { @@ -214,7 +214,6 @@ export const GetCodeGeneratorResModal: FC = ( portalToFollowElemContentClassName='z-[1000]' isAdvancedMode={true} provider={model.provider} - mode={model.mode} completionParams={model.completion_params} modelId={model.name} setModel={handleModelChange} diff --git a/web/app/components/app/configuration/config/index.tsx b/web/app/components/app/configuration/config/index.tsx index 7e130a4e95..4e67d1bd32 100644 --- a/web/app/components/app/configuration/config/index.tsx +++ b/web/app/components/app/configuration/config/index.tsx @@ -14,8 +14,7 @@ import ConfigContext from '@/context/debug-configuration' import ConfigPrompt from '@/app/components/app/configuration/config-prompt' import ConfigVar from '@/app/components/app/configuration/config-var' import type { ModelConfig, PromptVariable } from '@/models/debug' -import type { AppType } from '@/types/app' -import { ModelModeType } from '@/types/app' +import { AppModeEnum, ModelModeType } from '@/types/app' const Config: FC = () => { const { @@ -29,7 +28,7 @@ const Config: FC = () => { setModelConfig, setPrevPromptConfig, } = useContext(ConfigContext) - const isChatApp = ['advanced-chat', 'agent-chat', 'chat'].includes(mode) + const isChatApp = [AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.CHAT].includes(mode) const formattingChangedDispatcher = useFormattingChangedDispatcher() const promptTemplate = modelConfig.configs.prompt_template @@ -62,7 +61,7 @@ const Config: FC = () => { > {/* Template */} { draft.metadata_model_config = { provider: model.provider, name: model.modelId, - mode: model.mode || 'chat', + mode: model.mode || AppModeEnum.CHAT, completion_params: draft.metadata_model_config?.completion_params || { temperature: 0.7 }, } }) @@ -302,7 +302,7 @@ const DatasetConfig: FC = () => { />
- {mode === AppType.completion && dataSet.length > 0 && ( + {mode === AppModeEnum.COMPLETION && dataSet.length > 0 && ( = ({ popupClassName='!w-[387px]' portalToFollowElemContentClassName='!z-[1002]' isAdvancedMode={true} - mode={model?.mode} provider={model?.provider} completionParams={model?.completion_params} modelId={model?.name} diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx index 62f1010b54..93d0384aee 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx @@ -16,6 +16,7 @@ import { useToastContext } from '@/app/components/base/toast' import { updateDatasetSetting } from '@/service/datasets' import { useAppContext } from '@/context/app-context' import { useModalContext } from '@/context/modal-context' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import type { RetrievalConfig } from '@/types/app' import RetrievalSettings from '@/app/components/datasets/external-knowledge-base/create/RetrievalSettings' import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config' @@ -277,7 +278,7 @@ const SettingsModal: FC = ({
{t('datasetSettings.form.embeddingModelTip')} - setShowAccountSettingModal({ payload: 'provider' })}>{t('datasetSettings.form.embeddingModelTipLink')} + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })}>{t('datasetSettings.form.embeddingModelTipLink')}
diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx index 95c43f5101..6148e2e808 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx @@ -11,6 +11,7 @@ import Dropdown from '@/app/components/base/dropdown' import type { Item } from '@/app/components/base/dropdown' import { useProviderContext } from '@/context/provider-context' import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { AppModeEnum } from '@/types/app' type DebugItemProps = { modelAndParameter: ModelAndParameter @@ -112,13 +113,13 @@ const DebugItem: FC = ({
{ - (mode === 'chat' || mode === 'agent-chat') && currentProvider && currentModel && currentModel.status === ModelStatusEnum.active && ( + (mode === AppModeEnum.CHAT || mode === AppModeEnum.AGENT_CHAT) && currentProvider && currentModel && currentModel.status === ModelStatusEnum.active && ( ) } { - mode === 'completion' && currentProvider && currentModel && currentModel.status === ModelStatusEnum.active && ( - + mode === AppModeEnum.COMPLETION && currentProvider && currentModel && currentModel.status === ModelStatusEnum.active && ( + ) }
diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/index.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/index.tsx index b876adfa3d..6c388f5afa 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/index.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/index.tsx @@ -18,6 +18,7 @@ import { useFeatures } from '@/app/components/base/features/hooks' import { useStore as useAppStore } from '@/app/components/app/store' import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { InputForm } from '@/app/components/base/chat/chat/type' +import { AppModeEnum } from '@/types/app' const DebugWithMultipleModel = () => { const { @@ -33,7 +34,7 @@ const DebugWithMultipleModel = () => { } = useDebugWithMultipleModelContext() const { eventEmitter } = useEventEmitterContextContext() - const isChatMode = mode === 'chat' || mode === 'agent-chat' + const isChatMode = mode === AppModeEnum.CHAT || mode === AppModeEnum.AGENT_CHAT const handleSend = useCallback((message: string, files?: FileEntity[]) => { if (checkCanSend && !checkCanSend()) diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.tsx index 17d04acdc7..e7c4d98733 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.tsx @@ -26,7 +26,6 @@ const ModelParameterTrigger: FC = ({ }) => { const { t } = useTranslation() const { - mode, isAdvancedMode, } = useDebugConfigurationContext() const { @@ -57,7 +56,6 @@ const ModelParameterTrigger: FC = ({ return ( = ({ const [completionFiles, setCompletionFiles] = useState([]) const checkCanSend = useCallback(() => { - if (isAdvancedMode && mode !== AppType.completion) { + if (isAdvancedMode && mode !== AppModeEnum.COMPLETION) { if (modelModeType === ModelModeType.completion) { if (!hasSetBlockStatus.history) { notify({ type: 'error', message: t('appDebug.otherError.historyNoBeEmpty') }) @@ -410,7 +410,7 @@ const Debug: FC = ({ ) : null } - {mode !== AppType.completion && ( + {mode !== AppModeEnum.COMPLETION && ( <> = ({ )} - {mode !== AppType.completion && expanded && ( + {mode !== AppModeEnum.COMPLETION && expanded && (
)} - {mode === AppType.completion && ( + {mode === AppModeEnum.COMPLETION && ( = ({ !debugWithMultipleModel && (
{/* Chat */} - {mode !== AppType.completion && ( + {mode !== AppModeEnum.COMPLETION && (
= ({
)} {/* Text Generation */} - {mode === AppType.completion && ( + {mode === AppModeEnum.COMPLETION && ( <> {(completionRes || isResponding) && ( <> @@ -528,7 +528,7 @@ const Debug: FC = ({ )} )} - {mode === AppType.completion && showPromptLogModal && ( + {mode === AppModeEnum.COMPLETION && showPromptLogModal && ( { const mode = modelModeType const toReplacePrePrompt = prePrompt || '' + if (!appMode) + return + if (!isAdvancedPrompt) { const { chat_prompt_config, completion_prompt_config, stop } = await fetchPromptTemplate({ appMode, @@ -122,7 +125,6 @@ const useAdvancedPromptConfig = ({ }) setChatPromptConfig(newPromptConfig) } - else { const newPromptConfig = produce(completion_prompt_config, (draft) => { draft.prompt.text = draft.prompt.text.replace(PRE_PROMPT_PLACEHOLDER_TEXT, toReplacePrePrompt) @@ -152,7 +154,7 @@ const useAdvancedPromptConfig = ({ else draft.prompt.text = completionPromptConfig.prompt?.text.replace(PRE_PROMPT_PLACEHOLDER_TEXT, toReplacePrePrompt) - if (['advanced-chat', 'agent-chat', 'chat'].includes(appMode) && completionPromptConfig.conversation_histories_role.assistant_prefix && completionPromptConfig.conversation_histories_role.user_prefix) + if ([AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.CHAT].includes(appMode) && completionPromptConfig.conversation_histories_role.assistant_prefix && completionPromptConfig.conversation_histories_role.user_prefix) draft.conversation_histories_role = completionPromptConfig.conversation_histories_role }) setCompletionPromptConfig(newPromptConfig) diff --git a/web/app/components/app/configuration/index.tsx b/web/app/components/app/configuration/index.tsx index 4f47bfd883..afe640278e 100644 --- a/web/app/components/app/configuration/index.tsx +++ b/web/app/components/app/configuration/index.tsx @@ -47,11 +47,12 @@ import { fetchAppDetailDirect, updateAppModelConfig } from '@/service/apps' import { promptVariablesToUserInputsForm, userInputsFormToPromptVariables } from '@/utils/model-config' import { fetchDatasets } from '@/service/datasets' import { useProviderContext } from '@/context/provider-context' -import { AgentStrategy, AppType, ModelModeType, RETRIEVE_TYPE, Resolution, TransferMethod } from '@/types/app' +import { AgentStrategy, AppModeEnum, ModelModeType, RETRIEVE_TYPE, Resolution, TransferMethod } from '@/types/app' import { PromptMode } from '@/models/debug' import { ANNOTATION_DEFAULT, DATASET_DEFAULT, DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' import SelectDataSet from '@/app/components/app/configuration/dataset-config/select-dataset' import { useModalContext } from '@/context/modal-context' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import Drawer from '@/app/components/base/drawer' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' @@ -110,7 +111,7 @@ const Configuration: FC = () => { const pathname = usePathname() const matched = pathname.match(/\/app\/([^/]+)/) const appId = (matched?.length && matched[1]) ? matched[1] : '' - const [mode, setMode] = useState('') + const [mode, setMode] = useState(AppModeEnum.CHAT) const [publishedConfig, setPublishedConfig] = useState(null) const [conversationId, setConversationId] = useState('') @@ -209,7 +210,7 @@ const Configuration: FC = () => { dataSets: [], agentConfig: DEFAULT_AGENT_SETTING, }) - const isAgent = mode === 'agent-chat' + const isAgent = mode === AppModeEnum.AGENT_CHAT const isOpenAI = modelConfig.provider === 'langgenius/openai/openai' @@ -451,7 +452,7 @@ const Configuration: FC = () => { const appMode = mode if (modeMode === ModelModeType.completion) { - if (appMode !== AppType.completion) { + if (appMode !== AppModeEnum.COMPLETION) { if (!completionPromptConfig.prompt?.text || !completionPromptConfig.conversation_histories_role.assistant_prefix || !completionPromptConfig.conversation_histories_role.user_prefix) await migrateToDefaultPrompt(true, ModelModeType.completion) } @@ -554,7 +555,7 @@ const Configuration: FC = () => { } setCollectionList(collectionList) const res = await fetchAppDetailDirect({ url: '/apps', id: appId }) - setMode(res.mode) + setMode(res.mode as AppModeEnum) const modelConfig = res.model_config as BackendModelConfig const promptMode = modelConfig.prompt_type === PromptMode.advanced ? PromptMode.advanced : PromptMode.simple doSetPromptMode(promptMode) @@ -665,10 +666,10 @@ const Configuration: FC = () => { external_data_tools: modelConfig.external_data_tools ?? [], system_parameters: modelConfig.system_parameters, dataSets: datasets || [], - agentConfig: res.mode === 'agent-chat' ? { + agentConfig: res.mode === AppModeEnum.AGENT_CHAT ? { max_iteration: DEFAULT_AGENT_SETTING.max_iteration, ...modelConfig.agent_mode, - // remove dataset + // remove dataset enabled: true, // modelConfig.agent_mode?.enabled is not correct. old app: the value of app with dataset's is always true tools: (modelConfig.agent_mode?.tools ?? []).filter((tool: any) => { return !tool.dataset @@ -705,7 +706,7 @@ const Configuration: FC = () => { provider: currentRerankProvider?.provider, model: currentRerankModel?.model, }) - setDatasetConfigs({ + const datasetConfigsToSet = { ...modelConfig.dataset_configs, ...retrievalConfig, ...(retrievalConfig.reranking_model ? { @@ -714,13 +715,15 @@ const Configuration: FC = () => { reranking_provider_name: correctModelProvider(retrievalConfig.reranking_model.provider), }, } : {}), - } as DatasetConfigs) + } as DatasetConfigs + datasetConfigsToSet.retrieval_model = datasetConfigsToSet.retrieval_model ?? RETRIEVE_TYPE.multiWay + setDatasetConfigs(datasetConfigsToSet) setHasFetchedDetail(true) })() }, [appId]) const promptEmpty = (() => { - if (mode !== AppType.completion) + if (mode !== AppModeEnum.COMPLETION) return false if (isAdvancedMode) { @@ -734,7 +737,7 @@ const Configuration: FC = () => { else { return !modelConfig.configs.prompt_template } })() const cannotPublish = (() => { - if (mode !== AppType.completion) { + if (mode !== AppModeEnum.COMPLETION) { if (!isAdvancedMode) return false @@ -749,7 +752,7 @@ const Configuration: FC = () => { } else { return promptEmpty } })() - const contextVarEmpty = mode === AppType.completion && dataSets.length > 0 && !hasSetContextVar + const contextVarEmpty = mode === AppModeEnum.COMPLETION && dataSets.length > 0 && !hasSetContextVar const onPublish = async (modelAndParameter?: ModelAndParameter, features?: FeaturesData) => { const modelId = modelAndParameter?.model || modelConfig.model_id const promptTemplate = modelConfig.configs.prompt_template @@ -759,7 +762,7 @@ const Configuration: FC = () => { notify({ type: 'error', message: t('appDebug.otherError.promptNoBeEmpty') }) return } - if (isAdvancedMode && mode !== AppType.completion) { + if (isAdvancedMode && mode !== AppModeEnum.COMPLETION) { if (modelModeType === ModelModeType.completion) { if (!hasSetBlockStatus.history) { notify({ type: 'error', message: t('appDebug.otherError.historyNoBeEmpty') }) @@ -981,7 +984,6 @@ const Configuration: FC = () => { <> {
setShowAccountSettingModal({ payload: 'provider' })} + onSetting={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })} inputs={inputs} modelParameterParams={{ setModel: setModel as any, @@ -1040,7 +1042,7 @@ const Configuration: FC = () => { content={t('appDebug.trailUseGPT4Info.description')} isShow={showUseGPT4Confirm} onConfirm={() => { - setShowAccountSettingModal({ payload: 'provider' }) + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER }) setShowUseGPT4Confirm(false) }} onCancel={() => setShowUseGPT4Confirm(false)} @@ -1072,7 +1074,7 @@ const Configuration: FC = () => { setShowAccountSettingModal({ payload: 'provider' })} + onSetting={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })} inputs={inputs} modelParameterParams={{ setModel: setModel as any, @@ -1089,7 +1091,7 @@ const Configuration: FC = () => { show inWorkflow={false} showFileUpload={false} - isChatMode={mode !== 'completion'} + isChatMode={mode !== AppModeEnum.COMPLETION} disabled={false} onChange={handleFeaturesChange} onClose={() => setShowAppConfigureFeaturesModal(false)} diff --git a/web/app/components/app/configuration/prompt-value-panel/index.tsx b/web/app/components/app/configuration/prompt-value-panel/index.tsx index 43c836132f..e8b988767c 100644 --- a/web/app/components/app/configuration/prompt-value-panel/index.tsx +++ b/web/app/components/app/configuration/prompt-value-panel/index.tsx @@ -10,7 +10,7 @@ import { } from '@remixicon/react' import ConfigContext from '@/context/debug-configuration' import type { Inputs } from '@/models/debug' -import { AppType, ModelModeType } from '@/types/app' +import { AppModeEnum, ModelModeType } from '@/types/app' import Select from '@/app/components/base/select' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' @@ -25,7 +25,7 @@ import cn from '@/utils/classnames' import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input' export type IPromptValuePanelProps = { - appType: AppType + appType: AppModeEnum onSend?: () => void inputs: Inputs visionConfig: VisionSettings @@ -55,7 +55,7 @@ const PromptValuePanel: FC = ({ }, [promptVariables]) const canNotRun = useMemo(() => { - if (mode !== AppType.completion) + if (mode !== AppModeEnum.COMPLETION) return true if (isAdvancedMode) { @@ -215,7 +215,7 @@ const PromptValuePanel: FC = ({
diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index 0b0b325d9a..8b19f43034 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -25,7 +25,7 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' import { getRedirection } from '@/utils/app-redirection' import Input from '@/app/components/base/input' -import type { AppMode } from '@/types/app' +import { AppModeEnum } from '@/types/app' import { DSLImportMode } from '@/models/app' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' @@ -61,7 +61,7 @@ const Apps = ({ handleSearch() } - const [currentType, setCurrentType] = useState([]) + const [currentType, setCurrentType] = useState([]) const [currCategory, setCurrCategory] = useTabSearchParams({ defaultTab: allCategoriesEn, disableSearchParams: true, @@ -93,15 +93,15 @@ const Apps = ({ if (currentType.length === 0) return filteredByCategory return filteredByCategory.filter((item) => { - if (currentType.includes('chat') && item.app.mode === 'chat') + if (currentType.includes(AppModeEnum.CHAT) && item.app.mode === AppModeEnum.CHAT) return true - if (currentType.includes('advanced-chat') && item.app.mode === 'advanced-chat') + if (currentType.includes(AppModeEnum.ADVANCED_CHAT) && item.app.mode === AppModeEnum.ADVANCED_CHAT) return true - if (currentType.includes('agent-chat') && item.app.mode === 'agent-chat') + if (currentType.includes(AppModeEnum.AGENT_CHAT) && item.app.mode === AppModeEnum.AGENT_CHAT) return true - if (currentType.includes('completion') && item.app.mode === 'completion') + if (currentType.includes(AppModeEnum.COMPLETION) && item.app.mode === AppModeEnum.COMPLETION) return true - if (currentType.includes('workflow') && item.app.mode === 'workflow') + if (currentType.includes(AppModeEnum.WORKFLOW) && item.app.mode === AppModeEnum.WORKFLOW) return true return false }) diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index 3a07e6e0a1..10fc099f9f 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -18,7 +18,7 @@ import { basePath } from '@/utils/var' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import { ToastContext } from '@/app/components/base/toast' -import type { AppMode } from '@/types/app' +import { AppModeEnum } from '@/types/app' import { createApp } from '@/service/apps' import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' @@ -35,7 +35,7 @@ type CreateAppProps = { onSuccess: () => void onClose: () => void onCreateFromTemplate?: () => void - defaultAppMode?: AppMode + defaultAppMode?: AppModeEnum } function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: CreateAppProps) { @@ -43,7 +43,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: const { push } = useRouter() const { notify } = useContext(ToastContext) - const [appMode, setAppMode] = useState(defaultAppMode || 'advanced-chat') + const [appMode, setAppMode] = useState(defaultAppMode || AppModeEnum.ADVANCED_CHAT) const [appIcon, setAppIcon] = useState({ type: 'emoji', icon: '🤖', background: '#FFEAD5' }) const [showAppIconPicker, setShowAppIconPicker] = useState(false) const [name, setName] = useState('') @@ -57,7 +57,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: const isCreatingRef = useRef(false) useEffect(() => { - if (appMode === 'chat' || appMode === 'agent-chat' || appMode === 'completion') + if (appMode === AppModeEnum.CHAT || appMode === AppModeEnum.AGENT_CHAT || appMode === AppModeEnum.COMPLETION) setIsAppTypeExpanded(true) }, [appMode]) @@ -118,24 +118,24 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
} onClick={() => { - setAppMode('workflow') + setAppMode(AppModeEnum.WORKFLOW) }} />
} onClick={() => { - setAppMode('advanced-chat') + setAppMode(AppModeEnum.ADVANCED_CHAT) }} />
@@ -152,34 +152,34 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: {isAppTypeExpanded && (
} onClick={() => { - setAppMode('chat') + setAppMode(AppModeEnum.CHAT) }} /> } onClick={() => { - setAppMode('agent-chat') + setAppMode(AppModeEnum.AGENT_CHAT) }} /> } onClick={() => { - setAppMode('completion') + setAppMode(AppModeEnum.COMPLETION) }} /> )} @@ -255,11 +255,11 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
- - - - - + + + + +
@@ -309,16 +309,16 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP } -function AppPreview({ mode }: { mode: AppMode }) { +function AppPreview({ mode }: { mode: AppModeEnum }) { const { t } = useTranslation() const docLink = useDocLink() const modeToPreviewInfoMap = { - 'chat': { + [AppModeEnum.CHAT]: { title: t('app.types.chatbot'), description: t('app.newApp.chatbotUserDescription'), link: docLink('/guides/application-orchestrate/chatbot-application'), }, - 'advanced-chat': { + [AppModeEnum.ADVANCED_CHAT]: { title: t('app.types.advanced'), description: t('app.newApp.advancedUserDescription'), link: docLink('/guides/workflow/README', { @@ -326,12 +326,12 @@ function AppPreview({ mode }: { mode: AppMode }) { 'ja-JP': '/guides/workflow/concepts', }), }, - 'agent-chat': { + [AppModeEnum.AGENT_CHAT]: { title: t('app.types.agent'), description: t('app.newApp.agentUserDescription'), link: docLink('/guides/application-orchestrate/agent'), }, - 'completion': { + [AppModeEnum.COMPLETION]: { title: t('app.newApp.completeApp'), description: t('app.newApp.completionUserDescription'), link: docLink('/guides/application-orchestrate/text-generator', { @@ -339,7 +339,7 @@ function AppPreview({ mode }: { mode: AppMode }) { 'ja-JP': '/guides/application-orchestrate/README', }), }, - 'workflow': { + [AppModeEnum.WORKFLOW]: { title: t('app.types.workflow'), description: t('app.newApp.workflowUserDescription'), link: docLink('/guides/workflow/README', { @@ -358,14 +358,14 @@ function AppPreview({ mode }: { mode: AppMode }) { } -function AppScreenShot({ mode, show }: { mode: AppMode; show: boolean }) { +function AppScreenShot({ mode, show }: { mode: AppModeEnum; show: boolean }) { const { theme } = useTheme() const modeToImageMap = { - 'chat': 'Chatbot', - 'advanced-chat': 'Chatflow', - 'agent-chat': 'Agent', - 'completion': 'TextGenerator', - 'workflow': 'Workflow', + [AppModeEnum.CHAT]: 'Chatbot', + [AppModeEnum.ADVANCED_CHAT]: 'Chatflow', + [AppModeEnum.AGENT_CHAT]: 'Agent', + [AppModeEnum.COMPLETION]: 'TextGenerator', + [AppModeEnum.WORKFLOW]: 'Workflow', } return diff --git a/web/app/components/app/log-annotation/index.tsx b/web/app/components/app/log-annotation/index.tsx index 12a611eea8..c0b0854b29 100644 --- a/web/app/components/app/log-annotation/index.tsx +++ b/web/app/components/app/log-annotation/index.tsx @@ -11,6 +11,7 @@ import Loading from '@/app/components/base/loading' import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type' import TabSlider from '@/app/components/base/tab-slider-plain' import { useStore as useAppStore } from '@/app/components/app/store' +import { AppModeEnum } from '@/types/app' type Props = { pageType: PageType @@ -24,7 +25,7 @@ const LogAnnotation: FC = ({ const appDetail = useAppStore(state => state.appDetail) const options = useMemo(() => { - if (appDetail?.mode === 'completion') + if (appDetail?.mode === AppModeEnum.COMPLETION) return [{ value: PageType.log, text: t('appLog.title') }] return [ { value: PageType.log, text: t('appLog.title') }, @@ -42,7 +43,7 @@ const LogAnnotation: FC = ({ return (
- {appDetail.mode !== 'workflow' && ( + {appDetail.mode !== AppModeEnum.WORKFLOW && ( = ({ options={options} /> )} -
- {pageType === PageType.log && appDetail.mode !== 'workflow' && ()} +
+ {pageType === PageType.log && appDetail.mode !== AppModeEnum.WORKFLOW && ()} {pageType === PageType.annotation && ()} - {pageType === PageType.log && appDetail.mode === 'workflow' && ()} + {pageType === PageType.log && appDetail.mode === AppModeEnum.WORKFLOW && ()}
) diff --git a/web/app/components/app/log/empty-element.tsx b/web/app/components/app/log/empty-element.tsx index 78f32bf922..ddddacd873 100644 --- a/web/app/components/app/log/empty-element.tsx +++ b/web/app/components/app/log/empty-element.tsx @@ -5,7 +5,8 @@ import Link from 'next/link' import { Trans, useTranslation } from 'react-i18next' import { basePath } from '@/utils/var' import { getRedirectionPath } from '@/utils/app-redirection' -import type { App, AppMode } from '@/types/app' +import type { App } from '@/types/app' +import { AppModeEnum } from '@/types/app' const ThreeDotsIcon = ({ className }: SVGProps) => { return @@ -16,9 +17,9 @@ const ThreeDotsIcon = ({ className }: SVGProps) => { const EmptyElement: FC<{ appDetail: App }> = ({ appDetail }) => { const { t } = useTranslation() - const getWebAppType = (appType: AppMode) => { - if (appType !== 'completion' && appType !== 'workflow') - return 'chat' + const getWebAppType = (appType: AppModeEnum) => { + if (appType !== AppModeEnum.COMPLETION && appType !== AppModeEnum.WORKFLOW) + return AppModeEnum.CHAT return appType } diff --git a/web/app/components/app/log/index.tsx b/web/app/components/app/log/index.tsx index e556748494..55a3f7d12d 100644 --- a/web/app/components/app/log/index.tsx +++ b/web/app/components/app/log/index.tsx @@ -14,6 +14,7 @@ import Loading from '@/app/components/base/loading' import { fetchChatConversations, fetchCompletionConversations } from '@/service/log' import { APP_PAGE_LIMIT } from '@/config' import type { App } from '@/types/app' +import { AppModeEnum } from '@/types/app' export type ILogsProps = { appDetail: App } @@ -37,7 +38,7 @@ const Logs: FC = ({ appDetail }) => { const debouncedQueryParams = useDebounce(queryParams, { wait: 500 }) // Get the app type first - const isChatMode = appDetail.mode !== 'completion' + const isChatMode = appDetail.mode !== AppModeEnum.COMPLETION const query = { page: currPage + 1, diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index d295784083..5de86be7b9 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -20,7 +20,7 @@ import Indicator from '../../header/indicator' import VarPanel from './var-panel' import type { FeedbackFunc, FeedbackType, IChatItem, SubmitAnnotationFunc } from '@/app/components/base/chat/chat/type' import type { Annotation, ChatConversationGeneralDetail, ChatConversationsResponse, ChatMessage, ChatMessagesRequest, CompletionConversationGeneralDetail, CompletionConversationsResponse, LogAnnotation } from '@/models/log' -import type { App } from '@/types/app' +import { type App, AppModeEnum } from '@/types/app' import ActionButton from '@/app/components/base/action-button' import Loading from '@/app/components/base/loading' import Drawer from '@/app/components/base/drawer' @@ -374,7 +374,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { // Only load initial messages, don't auto-load more useEffect(() => { - if (appDetail?.id && detail.id && appDetail?.mode !== 'completion' && !fetchInitiated.current) { + if (appDetail?.id && detail.id && appDetail?.mode !== AppModeEnum.COMPLETION && !fetchInitiated.current) { // Mark as initialized, but don't auto-load more messages fetchInitiated.current = true // Still call fetchData to get initial messages @@ -583,8 +583,8 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { } }, [hasMore, isLoading, loadMoreMessages]) - const isChatMode = appDetail?.mode !== 'completion' - const isAdvanced = appDetail?.mode === 'advanced-chat' + const isChatMode = appDetail?.mode !== AppModeEnum.COMPLETION + const isAdvanced = appDetail?.mode === AppModeEnum.ADVANCED_CHAT const varList = (detail.model_config as any).user_input_form?.map((item: any) => { const itemContent = item[Object.keys(item)[0]] @@ -911,8 +911,8 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) const closingConversationIdRef = useRef(null) const pendingConversationIdRef = useRef(null) const pendingConversationCacheRef = useRef(undefined) - const isChatMode = appDetail.mode !== 'completion' // Whether the app is a chat app - const isChatflow = appDetail.mode === 'advanced-chat' // Whether the app is a chatflow app + const isChatMode = appDetail.mode !== AppModeEnum.COMPLETION // Whether the app is a chat app + const isChatflow = appDetail.mode === AppModeEnum.ADVANCED_CHAT // Whether the app is a chatflow app const { setShowPromptLogModal, setShowAgentLogModal, setShowMessageLogModal } = useAppStore(useShallow((state: AppStoreState) => ({ setShowPromptLogModal: state.setShowPromptLogModal, setShowAgentLogModal: state.setShowAgentLogModal, diff --git a/web/app/components/app/overview/__tests__/toggle-logic.test.ts b/web/app/components/app/overview/__tests__/toggle-logic.test.ts new file mode 100644 index 0000000000..0c1e1ea0d3 --- /dev/null +++ b/web/app/components/app/overview/__tests__/toggle-logic.test.ts @@ -0,0 +1,228 @@ +import { getWorkflowEntryNode } from '@/app/components/workflow/utils/workflow-entry' + +// Mock the getWorkflowEntryNode function +jest.mock('@/app/components/workflow/utils/workflow-entry', () => ({ + getWorkflowEntryNode: jest.fn(), +})) + +const mockGetWorkflowEntryNode = getWorkflowEntryNode as jest.MockedFunction + +describe('App Card Toggle Logic', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // Helper function that mirrors the actual logic from app-card.tsx + const calculateToggleState = ( + appMode: string, + currentWorkflow: any, + isCurrentWorkspaceEditor: boolean, + isCurrentWorkspaceManager: boolean, + cardType: 'webapp' | 'api', + ) => { + const isWorkflowApp = appMode === 'workflow' + const appUnpublished = isWorkflowApp && !currentWorkflow?.graph + const hasEntryNode = mockGetWorkflowEntryNode(currentWorkflow?.graph?.nodes || []) + const missingEntryNode = isWorkflowApp && !hasEntryNode + const hasInsufficientPermissions = cardType === 'webapp' ? !isCurrentWorkspaceEditor : !isCurrentWorkspaceManager + const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingEntryNode + const isMinimalState = appUnpublished || missingEntryNode + + return { + toggleDisabled, + isMinimalState, + appUnpublished, + missingEntryNode, + hasInsufficientPermissions, + } + } + + describe('Entry Node Detection Logic', () => { + it('should disable toggle when workflow missing entry node', () => { + mockGetWorkflowEntryNode.mockReturnValue(false) + + const result = calculateToggleState( + 'workflow', + { graph: { nodes: [] } }, + true, + true, + 'webapp', + ) + + expect(result.toggleDisabled).toBe(true) + expect(result.missingEntryNode).toBe(true) + expect(result.isMinimalState).toBe(true) + }) + + it('should enable toggle when workflow has entry node', () => { + mockGetWorkflowEntryNode.mockReturnValue(true) + + const result = calculateToggleState( + 'workflow', + { graph: { nodes: [{ data: { type: 'start' } }] } }, + true, + true, + 'webapp', + ) + + expect(result.toggleDisabled).toBe(false) + expect(result.missingEntryNode).toBe(false) + expect(result.isMinimalState).toBe(false) + }) + }) + + describe('Published State Logic', () => { + it('should disable toggle when workflow unpublished (no graph)', () => { + const result = calculateToggleState( + 'workflow', + null, // No workflow data = unpublished + true, + true, + 'webapp', + ) + + expect(result.toggleDisabled).toBe(true) + expect(result.appUnpublished).toBe(true) + expect(result.isMinimalState).toBe(true) + }) + + it('should disable toggle when workflow unpublished (empty graph)', () => { + const result = calculateToggleState( + 'workflow', + {}, // No graph property = unpublished + true, + true, + 'webapp', + ) + + expect(result.toggleDisabled).toBe(true) + expect(result.appUnpublished).toBe(true) + expect(result.isMinimalState).toBe(true) + }) + + it('should consider published state when workflow has graph', () => { + mockGetWorkflowEntryNode.mockReturnValue(true) + + const result = calculateToggleState( + 'workflow', + { graph: { nodes: [] } }, + true, + true, + 'webapp', + ) + + expect(result.appUnpublished).toBe(false) + }) + }) + + describe('Permissions Logic', () => { + it('should disable webapp toggle when user lacks editor permissions', () => { + mockGetWorkflowEntryNode.mockReturnValue(true) + + const result = calculateToggleState( + 'workflow', + { graph: { nodes: [] } }, + false, // No editor permission + true, + 'webapp', + ) + + expect(result.toggleDisabled).toBe(true) + expect(result.hasInsufficientPermissions).toBe(true) + }) + + it('should disable api toggle when user lacks manager permissions', () => { + mockGetWorkflowEntryNode.mockReturnValue(true) + + const result = calculateToggleState( + 'workflow', + { graph: { nodes: [] } }, + true, + false, // No manager permission + 'api', + ) + + expect(result.toggleDisabled).toBe(true) + expect(result.hasInsufficientPermissions).toBe(true) + }) + + it('should enable toggle when user has proper permissions', () => { + mockGetWorkflowEntryNode.mockReturnValue(true) + + const webappResult = calculateToggleState( + 'workflow', + { graph: { nodes: [] } }, + true, // Has editor permission + false, + 'webapp', + ) + + const apiResult = calculateToggleState( + 'workflow', + { graph: { nodes: [] } }, + false, + true, // Has manager permission + 'api', + ) + + expect(webappResult.toggleDisabled).toBe(false) + expect(apiResult.toggleDisabled).toBe(false) + }) + }) + + describe('Combined Conditions Logic', () => { + it('should handle multiple disable conditions correctly', () => { + mockGetWorkflowEntryNode.mockReturnValue(false) + + const result = calculateToggleState( + 'workflow', + null, // Unpublished + false, // No permissions + false, + 'webapp', + ) + + // All three conditions should be true + expect(result.appUnpublished).toBe(true) + expect(result.missingEntryNode).toBe(true) + expect(result.hasInsufficientPermissions).toBe(true) + expect(result.toggleDisabled).toBe(true) + expect(result.isMinimalState).toBe(true) + }) + + it('should enable when all conditions are satisfied', () => { + mockGetWorkflowEntryNode.mockReturnValue(true) + + const result = calculateToggleState( + 'workflow', + { graph: { nodes: [{ data: { type: 'start' } }] } }, // Published + true, // Has permissions + true, + 'webapp', + ) + + expect(result.appUnpublished).toBe(false) + expect(result.missingEntryNode).toBe(false) + expect(result.hasInsufficientPermissions).toBe(false) + expect(result.toggleDisabled).toBe(false) + expect(result.isMinimalState).toBe(false) + }) + }) + + describe('Non-Workflow Apps', () => { + it('should not check workflow-specific conditions for non-workflow apps', () => { + const result = calculateToggleState( + 'chat', // Non-workflow mode + null, + true, + true, + 'webapp', + ) + + expect(result.appUnpublished).toBe(false) // isWorkflowApp is false + expect(result.missingEntryNode).toBe(false) // isWorkflowApp is false + expect(result.toggleDisabled).toBe(false) + expect(result.isMinimalState).toBe(false) + }) + }) +}) diff --git a/web/app/components/app/overview/apikey-info-panel/index.tsx b/web/app/components/app/overview/apikey-info-panel/index.tsx index 7654d49e99..b50b0077cb 100644 --- a/web/app/components/app/overview/apikey-info-panel/index.tsx +++ b/web/app/components/app/overview/apikey-info-panel/index.tsx @@ -9,6 +9,7 @@ import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/gene import { IS_CE_EDITION } from '@/config' import { useProviderContext } from '@/context/provider-context' import { useModalContext } from '@/context/modal-context' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' const APIKeyInfoPanel: FC = () => { const isCloud = !IS_CE_EDITION @@ -47,7 +48,7 @@ const APIKeyInfoPanel: FC = () => {
{t('appOverview.apiKeyInfo.setAPIBtn')}
diff --git a/web/app/components/app/overview/app-card.tsx b/web/app/components/app/overview/app-card.tsx index c6df0ebfd9..dcb6ae6b4d 100644 --- a/web/app/components/app/overview/app-card.tsx +++ b/web/app/components/app/overview/app-card.tsx @@ -39,7 +39,11 @@ import { fetchAppDetailDirect } from '@/service/apps' import { AccessMode } from '@/models/access-control' import AccessControl from '../app-access-control' import { useAppWhiteListSubjects } from '@/service/access-control' +import { useAppWorkflow } from '@/service/use-workflow' import { useGlobalPublicStore } from '@/context/global-public-context' +import { BlockEnum } from '@/app/components/workflow/types' +import { useDocLink } from '@/context/i18n' +import { AppModeEnum } from '@/types/app' export type IAppCardProps = { className?: string @@ -65,6 +69,8 @@ function AppCard({ const router = useRouter() const pathname = usePathname() const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext() + const { data: currentWorkflow } = useAppWorkflow(appInfo.mode === AppModeEnum.WORKFLOW ? appInfo.id : '') + const docLink = useDocLink() const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(state => state.setAppDetail) const [showSettingsModal, setShowSettingsModal] = useState(false) @@ -85,7 +91,7 @@ function AppCard({ api: [{ opName: t('appOverview.overview.apiInfo.doc'), opIcon: RiBookOpenLine }], app: [], } - if (appInfo.mode !== 'completion' && appInfo.mode !== 'workflow') + if (appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW) operationsMap.webapp.push({ opName: t('appOverview.overview.appInfo.embedded.entry'), opIcon: RiWindowLine }) operationsMap.webapp.push({ opName: t('appOverview.overview.appInfo.customize.entry'), opIcon: RiPaintBrushLine }) @@ -98,12 +104,18 @@ function AppCard({ const isApp = cardType === 'webapp' const basicName = isApp - ? appInfo?.site?.title + ? t('appOverview.overview.appInfo.title') : t('appOverview.overview.apiInfo.title') - const toggleDisabled = isApp ? !isCurrentWorkspaceEditor : !isCurrentWorkspaceManager - const runningStatus = isApp ? appInfo.enable_site : appInfo.enable_api + const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW + const appUnpublished = isWorkflowApp && !currentWorkflow?.graph + const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start) + const missingStartNode = isWorkflowApp && !hasStartNode + const hasInsufficientPermissions = isApp ? !isCurrentWorkspaceEditor : !isCurrentWorkspaceManager + const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode + const runningStatus = (appUnpublished || missingStartNode) ? false : (isApp ? appInfo.enable_site : appInfo.enable_api) + const isMinimalState = appUnpublished || missingStartNode const { app_base_url, access_token } = appInfo.site ?? {} - const appMode = (appInfo.mode !== 'completion' && appInfo.mode !== 'workflow') ? 'chat' : appInfo.mode + const appMode = (appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : appInfo.mode const appUrl = `${app_base_url}${basePath}/${appMode}/${access_token}` const apiUrl = appInfo?.api_base_url @@ -175,10 +187,10 @@ function AppCard({ return (
-
+
- -
-
-
- {isApp - ? t('appOverview.overview.appInfo.accessibleAddress') - : t('appOverview.overview.apiInfo.accessibleAddress')} -
-
-
-
- {isApp ? appUrl : apiUrl} -
+ +
+ {t('appOverview.overview.appInfo.enableTooltip.description')} +
+
window.open(docLink('/guides/workflow/node/user-input'), '_blank')} + > + {t('appOverview.overview.appInfo.enableTooltip.learnMore')} +
+ + ) : '' + } + position="right" + popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg" + offset={24} + > +
+
- - {isApp && } - {isApp && } - {/* button copy link/ button regenerate */} - {showConfirmDelete && ( - { - onGenCode() - setShowConfirmDelete(false) - }} - onCancel={() => setShowConfirmDelete(false)} +
+
+ {!isMinimalState && ( +
+
+ {isApp + ? t('appOverview.overview.appInfo.accessibleAddress') + : t('appOverview.overview.apiInfo.accessibleAddress')} +
+
+
+
+ {isApp ? appUrl : apiUrl} +
+
+ - )} - {isApp && isCurrentWorkspaceManager && ( - -
setShowConfirmDelete(true)} + {isApp && } + {isApp && } + {/* button copy link/ button regenerate */} + {showConfirmDelete && ( + { + onGenCode() + setShowConfirmDelete(false) + }} + onCancel={() => setShowConfirmDelete(false)} + /> + )} + {isApp && isCurrentWorkspaceManager && ( +
-
-
- )} + className="h-6 w-6 cursor-pointer rounded-md hover:bg-state-base-hover" + onClick={() => setShowConfirmDelete(true)} + > +
+
+ + )} +
-
- {isApp && systemFeatures.webapp_auth.enabled && appDetail &&
+ )} + {!isMinimalState && isApp && systemFeatures.webapp_auth.enabled && appDetail &&
{t('app.publishApp.title')}
@@ -287,43 +324,45 @@ function AppCard({
}
-
- {!isApp && } - {OPERATIONS_MAP[cardType].map((op) => { - const disabled - = op.opName === t('appOverview.overview.appInfo.settings.entry') - ? false - : !runningStatus - return ( - - ) - })} -
+ +
+ +
{op.opName}
+
+
+ + ) + })} +
+ )}
{isApp ? ( <> setShowSettingsModal(false)} diff --git a/web/app/components/app/overview/customize/index.tsx b/web/app/components/app/overview/customize/index.tsx index 11d29bb0c8..e440a8cf26 100644 --- a/web/app/components/app/overview/customize/index.tsx +++ b/web/app/components/app/overview/customize/index.tsx @@ -4,7 +4,7 @@ import React from 'react' import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline' import { useTranslation } from 'react-i18next' import { useDocLink } from '@/context/i18n' -import type { AppMode } from '@/types/app' +import { AppModeEnum } from '@/types/app' import Button from '@/app/components/base/button' import Modal from '@/app/components/base/modal' import Tag from '@/app/components/base/tag' @@ -15,7 +15,7 @@ type IShareLinkProps = { linkUrl: string api_base_url: string appId: string - mode: AppMode + mode: AppModeEnum } const StepNum: FC<{ children: React.ReactNode }> = ({ children }) => @@ -42,7 +42,7 @@ const CustomizeModal: FC = ({ }) => { const { t } = useTranslation() const docLink = useDocLink() - const isChatApp = mode === 'chat' || mode === 'advanced-chat' + const isChatApp = mode === AppModeEnum.CHAT || mode === AppModeEnum.ADVANCED_CHAT return = ({ if (isFreePlan) setShowPricingModal() else - setShowAccountSettingModal({ payload: 'billing' }) + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING }) }, [isFreePlan, setShowAccountSettingModal, setShowPricingModal]) useEffect(() => { @@ -328,7 +329,7 @@ const SettingsModal: FC = ({
{t(`${prefixSettings}.workflow.subTitle`)}
setInputInfo({ ...inputInfo, show_workflow_steps: v })} /> diff --git a/web/app/components/app/overview/trigger-card.tsx b/web/app/components/app/overview/trigger-card.tsx new file mode 100644 index 0000000000..5a0e387ba2 --- /dev/null +++ b/web/app/components/app/overview/trigger-card.tsx @@ -0,0 +1,224 @@ +'use client' +import React from 'react' +import { useTranslation } from 'react-i18next' +import Link from 'next/link' +import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow' +import Switch from '@/app/components/base/switch' +import type { AppDetailResponse } from '@/models/app' +import type { AppSSO } from '@/types/app' +import { useAppContext } from '@/context/app-context' +import { + type AppTrigger, + useAppTriggers, + useInvalidateAppTriggers, + useUpdateTriggerStatus, +} from '@/service/use-tools' +import { useAllTriggerPlugins } from '@/service/use-triggers' +import { canFindTool } from '@/utils' +import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status' +import BlockIcon from '@/app/components/workflow/block-icon' +import { BlockEnum } from '@/app/components/workflow/types' +import { useDocLink } from '@/context/i18n' + +export type ITriggerCardProps = { + appInfo: AppDetailResponse & Partial + onToggleResult?: (err: Error | null, message?: string) => void +} + +const getTriggerIcon = (trigger: AppTrigger, triggerPlugins: any[]) => { + const { trigger_type, status, provider_name } = trigger + + // Status dot styling based on trigger status + const getStatusDot = () => { + if (status === 'enabled') { + return ( +
+ ) + } + else { + return ( +
+ ) + } + } + + // Get BlockEnum type from trigger_type + let blockType: BlockEnum + switch (trigger_type) { + case 'trigger-webhook': + blockType = BlockEnum.TriggerWebhook + break + case 'trigger-schedule': + blockType = BlockEnum.TriggerSchedule + break + case 'trigger-plugin': + blockType = BlockEnum.TriggerPlugin + break + default: + blockType = BlockEnum.TriggerWebhook + } + + let triggerIcon: string | undefined + if (trigger_type === 'trigger-plugin' && provider_name) { + const targetTriggers = triggerPlugins || [] + const foundTrigger = targetTriggers.find(triggerWithProvider => + canFindTool(triggerWithProvider.id, provider_name) + || triggerWithProvider.id.includes(provider_name) + || triggerWithProvider.name === provider_name, + ) + triggerIcon = foundTrigger?.icon + } + + return ( +
+ + {getStatusDot()} +
+ ) +} + +function TriggerCard({ appInfo, onToggleResult }: ITriggerCardProps) { + const { t } = useTranslation() + const docLink = useDocLink() + const appId = appInfo.id + const { isCurrentWorkspaceEditor } = useAppContext() + const { data: triggersResponse, isLoading } = useAppTriggers(appId) + const { mutateAsync: updateTriggerStatus } = useUpdateTriggerStatus() + const invalidateAppTriggers = useInvalidateAppTriggers() + const { data: triggerPlugins } = useAllTriggerPlugins() + + // Zustand store for trigger status sync + const { setTriggerStatus, setTriggerStatuses } = useTriggerStatusStore() + + const triggers = triggersResponse?.data || [] + const triggerCount = triggers.length + + // Sync trigger statuses to Zustand store when data loads initially or after API calls + React.useEffect(() => { + if (triggers.length > 0) { + const statusMap = triggers.reduce((acc, trigger) => { + // Map API status to EntryNodeStatus: only 'enabled' shows green, others show gray + acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled' + return acc + }, {} as Record) + + // Only update if there are actual changes to prevent overriding optimistic updates + setTriggerStatuses(statusMap) + } + }, [triggers, setTriggerStatuses]) + + const onToggleTrigger = async (trigger: AppTrigger, enabled: boolean) => { + try { + // Immediately update Zustand store for real-time UI sync + const newStatus = enabled ? 'enabled' : 'disabled' + setTriggerStatus(trigger.node_id, newStatus) + + await updateTriggerStatus({ + appId, + triggerId: trigger.id, + enableTrigger: enabled, + }) + invalidateAppTriggers(appId) + + // Success toast notification + onToggleResult?.(null) + } + catch (error) { + // Rollback Zustand store state on error + const rollbackStatus = enabled ? 'disabled' : 'enabled' + setTriggerStatus(trigger.node_id, rollbackStatus) + + // Error toast notification + onToggleResult?.(error as Error) + } + } + + if (isLoading) { + return ( +
+
+
+
+
+
+
+ ) + } + + return ( +
+
+
+
+
+
+ +
+
+
+ {triggerCount > 0 + ? t('appOverview.overview.triggerInfo.triggersAdded', { count: triggerCount }) + : t('appOverview.overview.triggerInfo.noTriggerAdded') + } +
+
+
+
+
+ + {triggerCount > 0 && ( +
+ {triggers.map(trigger => ( +
+
+
+ {getTriggerIcon(trigger, triggerPlugins || [])} +
+
+ {trigger.title} +
+
+
+
+ {trigger.status === 'enabled' + ? t('appOverview.overview.status.running') + : t('appOverview.overview.status.disable')} +
+
+
+ onToggleTrigger(trigger, enabled)} + disabled={!isCurrentWorkspaceEditor} + /> +
+
+ ))} +
+ )} + + {triggerCount === 0 && ( +
+
+ {t('appOverview.overview.triggerInfo.triggerStatusDescription')}{' '} + + {t('appOverview.overview.triggerInfo.learnAboutTriggers')} + +
+
+ )} +
+
+ ) +} + +export default TriggerCard diff --git a/web/app/components/app/switch-app-modal/index.tsx b/web/app/components/app/switch-app-modal/index.tsx index f1654eb65e..a7e1cea429 100644 --- a/web/app/components/app/switch-app-modal/index.tsx +++ b/web/app/components/app/switch-app-modal/index.tsx @@ -24,6 +24,7 @@ import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/aler import AppIcon from '@/app/components/base/app-icon' import { useStore as useAppStore } from '@/app/components/app/store' import { noop } from 'lodash-es' +import { AppModeEnum } from '@/types/app' type SwitchAppModalProps = { show: boolean @@ -77,7 +78,7 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo isCurrentWorkspaceEditor, { id: newAppID, - mode: appDetail.mode === 'completion' ? 'workflow' : 'advanced-chat', + mode: appDetail.mode === AppModeEnum.COMPLETION ? AppModeEnum.WORKFLOW : AppModeEnum.ADVANCED_CHAT, }, removeOriginal ? replace : push, ) diff --git a/web/app/components/app/type-selector/index.tsx b/web/app/components/app/type-selector/index.tsx index f8432ceab6..0f6f050953 100644 --- a/web/app/components/app/type-selector/index.tsx +++ b/web/app/components/app/type-selector/index.tsx @@ -9,13 +9,14 @@ import { PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication' -import type { AppMode } from '@/types/app' +import { AppModeEnum } from '@/types/app' + export type AppSelectorProps = { - value: Array + value: Array onChange: (value: AppSelectorProps['value']) => void } -const allTypes: AppMode[] = ['workflow', 'advanced-chat', 'chat', 'agent-chat', 'completion'] +const allTypes: AppModeEnum[] = [AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT, AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION] const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => { const [open, setOpen] = useState(false) @@ -66,7 +67,7 @@ const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => { export default AppTypeSelector type AppTypeIconProps = { - type: AppMode + type: AppModeEnum style?: React.CSSProperties className?: string wrapperClassName?: string @@ -75,27 +76,27 @@ type AppTypeIconProps = { export const AppTypeIcon = React.memo(({ type, className, wrapperClassName, style }: AppTypeIconProps) => { const wrapperClassNames = cn('inline-flex h-5 w-5 items-center justify-center rounded-md border border-divider-regular', wrapperClassName) const iconClassNames = cn('h-3.5 w-3.5 text-components-avatar-shape-fill-stop-100', className) - if (type === 'chat') { + if (type === AppModeEnum.CHAT) { return
} - if (type === 'agent-chat') { + if (type === AppModeEnum.AGENT_CHAT) { return
} - if (type === 'advanced-chat') { + if (type === AppModeEnum.ADVANCED_CHAT) { return
} - if (type === 'workflow') { + if (type === AppModeEnum.WORKFLOW) { return
} - if (type === 'completion') { + if (type === AppModeEnum.COMPLETION) { return
@@ -133,7 +134,7 @@ function AppTypeSelectTrigger({ values }: { readonly values: AppSelectorProps['v type AppTypeSelectorItemProps = { checked: boolean - type: AppMode + type: AppModeEnum onClick: () => void } function AppTypeSelectorItem({ checked, type, onClick }: AppTypeSelectorItemProps) { @@ -147,21 +148,21 @@ function AppTypeSelectorItem({ checked, type, onClick }: AppTypeSelectorItemProp } type AppTypeLabelProps = { - type: AppMode + type: AppModeEnum className?: string } export function AppTypeLabel({ type, className }: AppTypeLabelProps) { const { t } = useTranslation() let label = '' - if (type === 'chat') + if (type === AppModeEnum.CHAT) label = t('app.typeSelector.chatbot') - if (type === 'agent-chat') + if (type === AppModeEnum.AGENT_CHAT) label = t('app.typeSelector.agent') - if (type === 'completion') + if (type === AppModeEnum.COMPLETION) label = t('app.typeSelector.completion') - if (type === 'advanced-chat') + if (type === AppModeEnum.ADVANCED_CHAT) label = t('app.typeSelector.advanced') - if (type === 'workflow') + if (type === AppModeEnum.WORKFLOW) label = t('app.typeSelector.workflow') return {label} diff --git a/web/app/components/app/workflow-log/detail.tsx b/web/app/components/app/workflow-log/detail.tsx index 7ce701dd68..1c1ed75e80 100644 --- a/web/app/components/app/workflow-log/detail.tsx +++ b/web/app/components/app/workflow-log/detail.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react' import { useTranslation } from 'react-i18next' import { RiCloseLine, RiPlayLargeLine } from '@remixicon/react' import Run from '@/app/components/workflow/run' +import { WorkflowContextProvider } from '@/app/components/workflow/context' import { useStore } from '@/app/components/app/store' import TooltipPlus from '@/app/components/base/tooltip' import { useRouter } from 'next/navigation' @@ -10,9 +11,10 @@ import { useRouter } from 'next/navigation' type ILogDetail = { runID: string onClose: () => void + canReplay?: boolean } -const DetailPanel: FC = ({ runID, onClose }) => { +const DetailPanel: FC = ({ runID, onClose, canReplay = false }) => { const { t } = useTranslation() const appDetail = useStore(state => state.appDetail) const router = useRouter() @@ -29,24 +31,28 @@ const DetailPanel: FC = ({ runID, onClose }) => {

{t('appLog.runDetail.workflowTitle')}

- - - + + + )}
- + + +
) } diff --git a/web/app/components/app/workflow-log/index.tsx b/web/app/components/app/workflow-log/index.tsx index c6f9d985ae..30a1974347 100644 --- a/web/app/components/app/workflow-log/index.tsx +++ b/web/app/components/app/workflow-log/index.tsx @@ -41,6 +41,7 @@ const Logs: FC = ({ appDetail }) => { const query = { page: currPage + 1, + detail: true, limit, ...(debouncedQueryParams.status !== 'all' ? { status: debouncedQueryParams.status } : {}), ...(debouncedQueryParams.keyword ? { keyword: debouncedQueryParams.keyword } : {}), diff --git a/web/app/components/app/workflow-log/list.tsx b/web/app/components/app/workflow-log/list.tsx index 395df5da2b..0e9b5dd67f 100644 --- a/web/app/components/app/workflow-log/list.tsx +++ b/web/app/components/app/workflow-log/list.tsx @@ -1,16 +1,19 @@ 'use client' import type { FC } from 'react' -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import { ArrowDownIcon } from '@heroicons/react/24/outline' import DetailPanel from './detail' +import TriggerByDisplay from './trigger-by-display' import type { WorkflowAppLogDetail, WorkflowLogsResponse } from '@/models/log' -import type { App } from '@/types/app' +import { type App, AppModeEnum } from '@/types/app' import Loading from '@/app/components/base/loading' import Drawer from '@/app/components/base/drawer' import Indicator from '@/app/components/header/indicator' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useTimestamp from '@/hooks/use-timestamp' import cn from '@/utils/classnames' +import type { WorkflowRunTriggeredFrom } from '@/models/log' type ILogs = { logs?: WorkflowLogsResponse @@ -29,6 +32,28 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { const [showDrawer, setShowDrawer] = useState(false) const [currentLog, setCurrentLog] = useState() + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc') + const [localLogs, setLocalLogs] = useState(logs?.data || []) + + useEffect(() => { + if (!logs?.data) { + setLocalLogs([]) + return + } + + const sortedLogs = [...logs.data].sort((a, b) => { + const result = a.created_at - b.created_at + return sortOrder === 'asc' ? result : -result + }) + + setLocalLogs(sortedLogs) + }, [logs?.data, sortOrder]) + + const handleSort = () => { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc') + } + + const isWorkflow = appDetail?.mode === AppModeEnum.WORKFLOW const statusTdRender = (status: string) => { if (status === 'succeeded') { @@ -43,7 +68,7 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { return (
- Fail + Failure
) } @@ -88,15 +113,26 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { - {t('appLog.table.header.startTime')} + +
+ {t('appLog.table.header.startTime')} + +
+ {t('appLog.table.header.status')} {t('appLog.table.header.runtime')} {t('appLog.table.header.tokens')} - {t('appLog.table.header.user')} + {t('appLog.table.header.user')} + {isWorkflow && {t('appLog.table.header.triggered_from')}} - {logs.data.map((log: WorkflowAppLogDetail) => { + {localLogs.map((log: WorkflowAppLogDetail) => { const endUser = log.created_by_end_user ? log.created_by_end_user.session_id : log.created_by_account ? log.created_by_account.name : defaultValue return = ({ logs, appDetail, onRefresh }) => { {endUser}
+ {isWorkflow && ( + + + + )} })} @@ -136,7 +177,11 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { footer={null} panelClassName='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[600px] rounded-xl border border-components-panel-border' > - +
) diff --git a/web/app/components/app/workflow-log/trigger-by-display.tsx b/web/app/components/app/workflow-log/trigger-by-display.tsx new file mode 100644 index 0000000000..1411503cc2 --- /dev/null +++ b/web/app/components/app/workflow-log/trigger-by-display.tsx @@ -0,0 +1,134 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { + Code, + KnowledgeRetrieval, + Schedule, + WebhookLine, + WindowCursor, +} from '@/app/components/base/icons/src/vender/workflow' +import BlockIcon from '@/app/components/workflow/block-icon' +import { BlockEnum } from '@/app/components/workflow/types' +import useTheme from '@/hooks/use-theme' +import type { TriggerMetadata } from '@/models/log' +import { WorkflowRunTriggeredFrom } from '@/models/log' +import { Theme } from '@/types/app' + +type TriggerByDisplayProps = { + triggeredFrom: WorkflowRunTriggeredFrom + className?: string + showText?: boolean + triggerMetadata?: TriggerMetadata +} + +const getTriggerDisplayName = (triggeredFrom: WorkflowRunTriggeredFrom, t: any, metadata?: TriggerMetadata) => { + if (triggeredFrom === WorkflowRunTriggeredFrom.PLUGIN && metadata?.event_name) + return metadata.event_name + + const nameMap: Record = { + 'debugging': t('appLog.triggerBy.debugging'), + 'app-run': t('appLog.triggerBy.appRun'), + 'webhook': t('appLog.triggerBy.webhook'), + 'schedule': t('appLog.triggerBy.schedule'), + 'plugin': t('appLog.triggerBy.plugin'), + 'rag-pipeline-run': t('appLog.triggerBy.ragPipelineRun'), + 'rag-pipeline-debugging': t('appLog.triggerBy.ragPipelineDebugging'), + } + + return nameMap[triggeredFrom] || triggeredFrom +} + +const getPluginIcon = (metadata: TriggerMetadata | undefined, theme: Theme) => { + if (!metadata) + return null + + const icon = theme === Theme.dark + ? metadata.icon_dark || metadata.icon + : metadata.icon || metadata.icon_dark + + if (!icon) + return null + + return ( + + ) +} + +const getTriggerIcon = (triggeredFrom: WorkflowRunTriggeredFrom, metadata: TriggerMetadata | undefined, theme: Theme) => { + switch (triggeredFrom) { + case 'webhook': + return ( +
+ +
+ ) + case 'schedule': + return ( +
+ +
+ ) + case 'plugin': + return getPluginIcon(metadata, theme) || ( + + ) + case 'debugging': + return ( +
+ +
+ ) + case 'rag-pipeline-run': + case 'rag-pipeline-debugging': + return ( +
+ +
+ ) + case 'app-run': + default: + // For user input types (app-run, etc.), use webapp icon + return ( +
+ +
+ ) + } +} + +const TriggerByDisplay: FC = ({ + triggeredFrom, + className = '', + showText = true, + triggerMetadata, +}) => { + const { t } = useTranslation() + const { theme } = useTheme() + + const displayName = getTriggerDisplayName(triggeredFrom, t, triggerMetadata) + const icon = getTriggerIcon(triggeredFrom, triggerMetadata, theme) + + return ( +
+
+ {icon} +
+ {showText && ( + + {displayName} + + )} +
+ ) +} + +export default TriggerByDisplay diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index cd3495e3c6..564eb493e5 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation' import { useTranslation } from 'react-i18next' import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react' import cn from '@/utils/classnames' -import type { App } from '@/types/app' +import { type App, AppModeEnum } from '@/types/app' import Toast, { ToastContext } from '@/app/components/base/toast' import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps' import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' @@ -171,7 +171,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { } const exportCheck = async () => { - if (app.mode !== 'workflow' && app.mode !== 'advanced-chat') { + if (app.mode !== AppModeEnum.WORKFLOW && app.mode !== AppModeEnum.ADVANCED_CHAT) { onExport() return } @@ -269,7 +269,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { - {(app.mode === 'completion' || app.mode === 'chat') && ( + {(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) && ( <> +
: t('common.noData')} +
+ ) : ( + filteredOptions.map((option) => { + const selected = value.includes(option.value) + + return ( +
{ + if (!option.disabled && !disabled) + handleToggleOption(option.value) + }} + > + { + if (!option.disabled && !disabled) + handleToggleOption(option.value) + }} + disabled={option.disabled || disabled} + /> +
+ {option.label} +
+
+ ) + }) + )} + + + + ) +} + +export default CheckboxList diff --git a/web/app/components/base/checkbox/index.tsx b/web/app/components/base/checkbox/index.tsx index 2411d98966..9495292ea6 100644 --- a/web/app/components/base/checkbox/index.tsx +++ b/web/app/components/base/checkbox/index.tsx @@ -30,7 +30,7 @@ const Checkbox = ({
(null) + const titleRef = useRef(null) const [isVisible, setIsVisible] = useState(isShow) + const [isTitleTruncated, setIsTitleTruncated] = useState(false) const confirmTxt = confirmText || `${t('common.operation.confirm')}` const cancelTxt = cancelText || `${t('common.operation.cancel')}` @@ -80,6 +83,13 @@ function Confirm({ } }, [isShow]) + useEffect(() => { + if (titleRef.current) { + const isOverflowing = titleRef.current.scrollWidth > titleRef.current.clientWidth + setIsTitleTruncated(isOverflowing) + } + }, [title, isVisible]) + if (!isVisible) return null @@ -92,8 +102,18 @@ function Confirm({
-
{title}
-
{content}
+ +
+ {title} +
+
+
{content}
{showCancel && } diff --git a/web/app/components/base/date-and-time-picker/time-picker/footer.tsx b/web/app/components/base/date-and-time-picker/time-picker/footer.tsx index 47dd8b127c..dc35830250 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/footer.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/footer.tsx @@ -10,26 +10,25 @@ const Footer: FC = ({ const { t } = useTranslation() return ( -
-
- {/* Now */} - - {/* Confirm Button */} - -
+
+ {/* Now Button */} + + {/* Confirm Button */} +
) } diff --git a/web/app/components/base/date-and-time-picker/time-picker/index.spec.tsx b/web/app/components/base/date-and-time-picker/time-picker/index.spec.tsx index bd4468e82d..24c7fff52f 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/index.spec.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/index.spec.tsx @@ -29,6 +29,15 @@ jest.mock('@/app/components/base/portal-to-follow-elem', () => ({ jest.mock('./options', () => () =>
) jest.mock('./header', () => () =>
) +jest.mock('@/app/components/base/timezone-label', () => { + return function MockTimezoneLabel({ timezone, inline, className }: { timezone: string, inline?: boolean, className?: string }) { + return ( + + UTC+8 + + ) + } +}) describe('TimePicker', () => { const baseProps: Pick = { @@ -94,4 +103,86 @@ describe('TimePicker', () => { expect(isDayjsObject(emitted)).toBe(true) expect(emitted?.utcOffset()).toBe(dayjs().tz('America/New_York').utcOffset()) }) + + describe('Timezone Label Integration', () => { + test('should not display timezone label by default', () => { + render( + , + ) + + expect(screen.queryByTestId('timezone-label')).not.toBeInTheDocument() + }) + + test('should not display timezone label when showTimezone is false', () => { + render( + , + ) + + expect(screen.queryByTestId('timezone-label')).not.toBeInTheDocument() + }) + + test('should display timezone label when showTimezone is true', () => { + render( + , + ) + + const timezoneLabel = screen.getByTestId('timezone-label') + expect(timezoneLabel).toBeInTheDocument() + expect(timezoneLabel).toHaveAttribute('data-timezone', 'Asia/Shanghai') + }) + + test('should pass inline prop to timezone label', () => { + render( + , + ) + + const timezoneLabel = screen.getByTestId('timezone-label') + expect(timezoneLabel).toHaveAttribute('data-inline', 'true') + }) + + test('should not display timezone label when showTimezone is true but timezone is not provided', () => { + render( + , + ) + + expect(screen.queryByTestId('timezone-label')).not.toBeInTheDocument() + }) + + test('should apply shrink-0 and text-xs classes to timezone label', () => { + render( + , + ) + + const timezoneLabel = screen.getByTestId('timezone-label') + expect(timezoneLabel).toHaveClass('shrink-0', 'text-xs') + }) + }) }) diff --git a/web/app/components/base/date-and-time-picker/time-picker/index.tsx b/web/app/components/base/date-and-time-picker/time-picker/index.tsx index f23fcf8f4e..9577a107e5 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/index.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/index.tsx @@ -19,6 +19,7 @@ import Header from './header' import { useTranslation } from 'react-i18next' import { RiCloseCircleFill, RiTimeLine } from '@remixicon/react' import cn from '@/utils/classnames' +import TimezoneLabel from '@/app/components/base/timezone-label' const to24Hour = (hour12: string, period: Period) => { const normalized = Number.parseInt(hour12, 10) % 12 @@ -35,6 +36,10 @@ const TimePicker = ({ title, minuteFilter, popupClassName, + notClearable = false, + triggerFullWidth = false, + showTimezone = false, + placement = 'bottom-start', }: TimePickerProps) => { const { t } = useTranslation() const [isOpen, setIsOpen] = useState(false) @@ -189,7 +194,7 @@ const TimePicker = ({ const inputElem = ( - + {renderTrigger ? (renderTrigger({ inputElem, onClick: handleClickTrigger, isOpen, })) : (
{inputElem} + {showTimezone && timezone && ( + + )} string[] popupClassName?: string + notClearable?: boolean + triggerFullWidth?: boolean + showTimezone?: boolean + placement?: Placement } export type TimePickerFooterProps = { diff --git a/web/app/components/base/date-and-time-picker/utils/dayjs.spec.ts b/web/app/components/base/date-and-time-picker/utils/dayjs.spec.ts index 549ab01029..5c891126b5 100644 --- a/web/app/components/base/date-and-time-picker/utils/dayjs.spec.ts +++ b/web/app/components/base/date-and-time-picker/utils/dayjs.spec.ts @@ -1,5 +1,6 @@ import dayjs from './dayjs' import { + convertTimezoneToOffsetStr, getDateWithTimezone, isDayjsObject, toDayjs, @@ -65,3 +66,50 @@ describe('dayjs utilities', () => { expect(result?.minute()).toBe(0) }) }) + +describe('convertTimezoneToOffsetStr', () => { + test('should return default UTC+0 for undefined timezone', () => { + expect(convertTimezoneToOffsetStr(undefined)).toBe('UTC+0') + }) + + test('should return default UTC+0 for invalid timezone', () => { + expect(convertTimezoneToOffsetStr('Invalid/Timezone')).toBe('UTC+0') + }) + + test('should handle whole hour positive offsets without leading zeros', () => { + expect(convertTimezoneToOffsetStr('Asia/Shanghai')).toBe('UTC+8') + expect(convertTimezoneToOffsetStr('Pacific/Auckland')).toBe('UTC+12') + expect(convertTimezoneToOffsetStr('Pacific/Apia')).toBe('UTC+13') + }) + + test('should handle whole hour negative offsets without leading zeros', () => { + expect(convertTimezoneToOffsetStr('Pacific/Niue')).toBe('UTC-11') + expect(convertTimezoneToOffsetStr('Pacific/Honolulu')).toBe('UTC-10') + expect(convertTimezoneToOffsetStr('America/New_York')).toBe('UTC-5') + }) + + test('should handle zero offset', () => { + expect(convertTimezoneToOffsetStr('Europe/London')).toBe('UTC+0') + expect(convertTimezoneToOffsetStr('UTC')).toBe('UTC+0') + }) + + test('should handle half-hour offsets (30 minutes)', () => { + // India Standard Time: UTC+5:30 + expect(convertTimezoneToOffsetStr('Asia/Kolkata')).toBe('UTC+5:30') + // Australian Central Time: UTC+9:30 + expect(convertTimezoneToOffsetStr('Australia/Adelaide')).toBe('UTC+9:30') + expect(convertTimezoneToOffsetStr('Australia/Darwin')).toBe('UTC+9:30') + }) + + test('should handle 45-minute offsets', () => { + // Chatham Time: UTC+12:45 + expect(convertTimezoneToOffsetStr('Pacific/Chatham')).toBe('UTC+12:45') + }) + + test('should preserve leading zeros in minute part for non-zero minutes', () => { + // Ensure +05:30 is displayed as "UTC+5:30", not "UTC+5:3" + const result = convertTimezoneToOffsetStr('Asia/Kolkata') + expect(result).toMatch(/UTC[+-]\d+:30/) + expect(result).not.toMatch(/UTC[+-]\d+:3[^0]/) + }) +}) diff --git a/web/app/components/base/date-and-time-picker/utils/dayjs.ts b/web/app/components/base/date-and-time-picker/utils/dayjs.ts index 4f53c766ea..b05e725985 100644 --- a/web/app/components/base/date-and-time-picker/utils/dayjs.ts +++ b/web/app/components/base/date-and-time-picker/utils/dayjs.ts @@ -107,7 +107,18 @@ export const convertTimezoneToOffsetStr = (timezone?: string) => { const tzItem = tz.find(item => item.value === timezone) if (!tzItem) return DEFAULT_OFFSET_STR - return `UTC${tzItem.name.charAt(0)}${tzItem.name.charAt(2)}` + // Extract offset from name format like "-11:00 Niue Time" or "+05:30 India Time" + // Name format is always "{offset}:{minutes} {timezone name}" + const offsetMatch = tzItem.name.match(/^([+-]?\d{1,2}):(\d{2})/) + if (!offsetMatch) + return DEFAULT_OFFSET_STR + // Parse hours and minutes separately + const hours = Number.parseInt(offsetMatch[1], 10) + const minutes = Number.parseInt(offsetMatch[2], 10) + const sign = hours >= 0 ? '+' : '' + // If minutes are non-zero, include them in the output (e.g., "UTC+5:30") + // Otherwise, only show hours (e.g., "UTC+8") + return minutes !== 0 ? `UTC${sign}${hours}:${offsetMatch[2]}` : `UTC${sign}${hours}` } export const isDayjsObject = (value: unknown): value is Dayjs => dayjs.isDayjs(value) diff --git a/web/app/components/base/divider/index.tsx b/web/app/components/base/divider/index.tsx index 6fe16b95a2..387f24a5e9 100644 --- a/web/app/components/base/divider/index.tsx +++ b/web/app/components/base/divider/index.tsx @@ -29,7 +29,7 @@ export type DividerProps = { const Divider: FC = ({ type, bgStyle, className = '', style }) => { return ( -
+
) } diff --git a/web/app/components/base/drawer/index.tsx b/web/app/components/base/drawer/index.tsx index c35acbeac7..101ac22b6c 100644 --- a/web/app/components/base/drawer/index.tsx +++ b/web/app/components/base/drawer/index.tsx @@ -10,6 +10,7 @@ export type IDrawerProps = { description?: string dialogClassName?: string dialogBackdropClassName?: string + containerClassName?: string panelClassName?: string children: React.ReactNode footer?: React.ReactNode @@ -22,6 +23,7 @@ export type IDrawerProps = { onCancel?: () => void onOk?: () => void unmount?: boolean + noOverlay?: boolean } export default function Drawer({ @@ -29,6 +31,7 @@ export default function Drawer({ description = '', dialogClassName = '', dialogBackdropClassName = '', + containerClassName = '', panelClassName = '', children, footer, @@ -41,6 +44,7 @@ export default function Drawer({ onCancel, onOk, unmount = false, + noOverlay = false, }: IDrawerProps) { const { t } = useTranslation() return ( @@ -53,15 +57,15 @@ export default function Drawer({ }} className={cn('fixed inset-0 z-[30] overflow-y-auto', dialogClassName)} > -
+
{/* mask */} - { if (!clickOutsideNotOpen) onClose() }} - /> + />}
<>
diff --git a/web/app/components/base/encrypted-bottom/index.tsx b/web/app/components/base/encrypted-bottom/index.tsx new file mode 100644 index 0000000000..8416217517 --- /dev/null +++ b/web/app/components/base/encrypted-bottom/index.tsx @@ -0,0 +1,30 @@ +import cn from '@/utils/classnames' +import { RiLock2Fill } from '@remixicon/react' +import Link from 'next/link' +import { useTranslation } from 'react-i18next' + +type Props = { + className?: string + frontTextKey?: string + backTextKey?: string +} + +export const EncryptedBottom = (props: Props) => { + const { t } = useTranslation() + const { frontTextKey, backTextKey, className } = props + + return ( +
+ + {t(frontTextKey || 'common.provider.encrypted.front')} + + PKCS1_OAEP + + {t(backTextKey || 'common.provider.encrypted.back')} +
+ ) +} diff --git a/web/app/components/base/error-boundary/index.tsx b/web/app/components/base/error-boundary/index.tsx new file mode 100644 index 0000000000..e3df2c2ca8 --- /dev/null +++ b/web/app/components/base/error-boundary/index.tsx @@ -0,0 +1,273 @@ +'use client' +import type { ErrorInfo, ReactNode } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { RiAlertLine, RiBugLine } from '@remixicon/react' +import Button from '@/app/components/base/button' +import cn from '@/utils/classnames' + +type ErrorBoundaryState = { + hasError: boolean + error: Error | null + errorInfo: ErrorInfo | null + errorCount: number +} + +type ErrorBoundaryProps = { + children: ReactNode + fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode) + onError?: (error: Error, errorInfo: ErrorInfo) => void + onReset?: () => void + showDetails?: boolean + className?: string + resetKeys?: Array + resetOnPropsChange?: boolean + isolate?: boolean + enableRecovery?: boolean + customTitle?: string + customMessage?: string +} + +// Internal class component for error catching +class ErrorBoundaryInner extends React.Component< + ErrorBoundaryProps & { + resetErrorBoundary: () => void + onResetKeysChange: (prevResetKeys?: Array) => void + }, + ErrorBoundaryState +> { + constructor(props: any) { + super(props) + this.state = { + hasError: false, + error: null, + errorInfo: null, + errorCount: 0, + } + } + + static getDerivedStateFromError(error: Error): Partial { + return { + hasError: true, + error, + } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + if (process.env.NODE_ENV === 'development') { + console.error('ErrorBoundary caught an error:', error) + console.error('Error Info:', errorInfo) + } + + this.setState(prevState => ({ + errorInfo, + errorCount: prevState.errorCount + 1, + })) + + if (this.props.onError) + this.props.onError(error, errorInfo) + } + + componentDidUpdate(prevProps: any) { + const { resetKeys, resetOnPropsChange } = this.props + const { hasError } = this.state + + if (hasError && prevProps.resetKeys !== resetKeys) { + if (resetKeys?.some((key, idx) => key !== prevProps.resetKeys?.[idx])) + this.props.resetErrorBoundary() + } + + if (hasError && resetOnPropsChange && prevProps.children !== this.props.children) + this.props.resetErrorBoundary() + + if (prevProps.resetKeys !== resetKeys) + this.props.onResetKeysChange(prevProps.resetKeys) + } + + render() { + const { hasError, error, errorInfo, errorCount } = this.state + const { + fallback, + children, + showDetails = false, + className, + isolate = true, + enableRecovery = true, + customTitle, + customMessage, + resetErrorBoundary, + } = this.props + + if (hasError && error) { + if (fallback) { + if (typeof fallback === 'function') + return fallback(error, resetErrorBoundary) + + return fallback + } + + return ( +
+
+ +

+ {customTitle || 'Something went wrong'} +

+
+ +

+ {customMessage || 'An unexpected error occurred while rendering this component.'} +

+ + {showDetails && errorInfo && ( +
+ + + + Error Details (Development Only) + + +
+
+ Error: +
+                    {error.toString()}
+                  
+
+ {errorInfo && ( +
+ Component Stack: +
+                      {errorInfo.componentStack}
+                    
+
+ )} + {errorCount > 1 && ( +
+ This error has occurred {errorCount} times +
+ )} +
+
+ )} + + {enableRecovery && ( +
+ + +
+ )} +
+ ) + } + + return children + } +} + +// Main functional component wrapper +const ErrorBoundary: React.FC = (props) => { + const [errorBoundaryKey, setErrorBoundaryKey] = useState(0) + const resetKeysRef = useRef(props.resetKeys) + const prevResetKeysRef = useRef | undefined>(undefined) + + const resetErrorBoundary = useCallback(() => { + setErrorBoundaryKey(prev => prev + 1) + props.onReset?.() + }, [props]) + + const onResetKeysChange = useCallback((prevResetKeys?: Array) => { + prevResetKeysRef.current = prevResetKeys + }, []) + + useEffect(() => { + if (prevResetKeysRef.current !== props.resetKeys) + resetKeysRef.current = props.resetKeys + }, [props.resetKeys]) + + return ( + + ) +} + +// Hook for imperative error handling +export function useErrorHandler() { + const [error, setError] = useState(null) + + useEffect(() => { + if (error) + throw error + }, [error]) + + return setError +} + +// Hook for catching async errors +export function useAsyncError() { + const [, setError] = useState() + + return useCallback( + (error: Error) => { + setError(() => { + throw error + }) + }, + [setError], + ) +} + +// HOC for wrapping components with error boundary +export function withErrorBoundary

( + Component: React.ComponentType

, + errorBoundaryProps?: Omit, +): React.ComponentType

{ + const WrappedComponent = (props: P) => ( + + + + ) + + WrappedComponent.displayName = `withErrorBoundary(${Component.displayName || Component.name || 'Component'})` + + return WrappedComponent +} + +// Simple error fallback component +export const ErrorFallback: React.FC<{ + error: Error + resetErrorBoundary: () => void +}> = ({ error, resetErrorBoundary }) => { + return ( +

+

Oops! Something went wrong

+

{error.message}

+ +
+ ) +} + +export default ErrorBoundary diff --git a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx index 095137203b..ff45a7ea4c 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx @@ -26,6 +26,7 @@ import { CustomConfigurationStatusEnum } from '@/app/components/header/account-s import cn from '@/utils/classnames' import { noop } from 'lodash-es' import { useDocLink } from '@/context/i18n' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' const systemTypes = ['openai_moderation', 'keywords', 'api'] @@ -55,7 +56,7 @@ const ModerationSettingModal: FC = ({ const { setShowAccountSettingModal } = useModalContext() const handleOpenSettingsModal = () => { setShowAccountSettingModal({ - payload: 'provider', + payload: ACCOUNT_SETTING_TAB.PROVIDER, onCancelCallback: () => { mutate() }, diff --git a/web/app/components/base/form/components/base/base-field.tsx b/web/app/components/base/form/components/base/base-field.tsx index bf415e08a8..db57059b82 100644 --- a/web/app/components/base/form/components/base/base-field.tsx +++ b/web/app/components/base/form/components/base/base-field.tsx @@ -1,20 +1,71 @@ +import CheckboxList from '@/app/components/base/checkbox-list' +import type { FieldState, FormSchema, TypeWithI18N } from '@/app/components/base/form/types' +import { FormItemValidateStatusEnum, FormTypeEnum } from '@/app/components/base/form/types' +import Input from '@/app/components/base/input' +import Radio from '@/app/components/base/radio' +import RadioE from '@/app/components/base/radio/ui' +import PureSelect from '@/app/components/base/select/pure' +import Tooltip from '@/app/components/base/tooltip' +import { useRenderI18nObject } from '@/hooks/use-i18n' +import { useTriggerPluginDynamicOptions } from '@/service/use-triggers' +import cn from '@/utils/classnames' +import { RiExternalLinkLine } from '@remixicon/react' +import type { AnyFieldApi } from '@tanstack/react-form' +import { useStore } from '@tanstack/react-form' import { isValidElement, memo, useCallback, useMemo, } from 'react' -import { RiExternalLinkLine } from '@remixicon/react' -import type { AnyFieldApi } from '@tanstack/react-form' -import { useStore } from '@tanstack/react-form' -import cn from '@/utils/classnames' -import Input from '@/app/components/base/input' -import PureSelect from '@/app/components/base/select/pure' -import type { FormSchema } from '@/app/components/base/form/types' -import { FormTypeEnum } from '@/app/components/base/form/types' -import { useRenderI18nObject } from '@/hooks/use-i18n' -import Radio from '@/app/components/base/radio' -import RadioE from '@/app/components/base/radio/ui' +import { useTranslation } from 'react-i18next' + +const getExtraProps = (type: FormTypeEnum) => { + switch (type) { + case FormTypeEnum.secretInput: + return { type: 'password', autoComplete: 'new-password' } + case FormTypeEnum.textNumber: + return { type: 'number' } + default: + return { type: 'text' } + } +} + +const getTranslatedContent = ({ content, render }: { + content: React.ReactNode | string | null | undefined | TypeWithI18N | Record + render: (content: TypeWithI18N | Record) => string +}): string => { + if (isValidElement(content) || typeof content === 'string') + return content as string + + if (typeof content === 'object' && content !== null) + return render(content as TypeWithI18N) + + return '' +} + +const VALIDATE_STATUS_STYLE_MAP: Record = { + [FormItemValidateStatusEnum.Error]: { + componentClassName: 'border-components-input-border-destructive focus:border-components-input-border-destructive', + textClassName: 'text-text-destructive', + infoFieldName: 'errors', + }, + [FormItemValidateStatusEnum.Warning]: { + componentClassName: 'border-components-input-border-warning focus:border-components-input-border-warning', + textClassName: 'text-text-warning', + infoFieldName: 'warnings', + }, + [FormItemValidateStatusEnum.Success]: { + componentClassName: '', + textClassName: '', + infoFieldName: '', + }, + [FormItemValidateStatusEnum.Validating]: { + componentClassName: '', + textClassName: '', + infoFieldName: '', + }, +} export type BaseFieldProps = { fieldClassName?: string @@ -25,7 +76,9 @@ export type BaseFieldProps = { field: AnyFieldApi disabled?: boolean onChange?: (field: string, value: any) => void + fieldState?: FieldState } + const BaseField = ({ fieldClassName, labelClassName, @@ -35,204 +88,259 @@ const BaseField = ({ field, disabled: propsDisabled, onChange, + fieldState, }: BaseFieldProps) => { const renderI18nObject = useRenderI18nObject() + const { t } = useTranslation() const { + name, label, required, placeholder, options, labelClassName: formLabelClassName, disabled: formSchemaDisabled, + type: formItemType, + dynamicSelectParams, + multiple = false, + tooltip, + showCopy, + description, + url, + help, } = formSchema const disabled = propsDisabled || formSchemaDisabled - const memorizedLabel = useMemo(() => { - if (isValidElement(label)) - return label + const [translatedLabel, translatedPlaceholder, translatedTooltip, translatedDescription, translatedHelp] = useMemo(() => { + const results = [ + label, + placeholder, + tooltip, + description, + help, + ].map(v => getTranslatedContent({ content: v, render: renderI18nObject })) + if (!results[1]) results[1] = t('common.placeholder.input') + return results + }, [label, placeholder, tooltip, description, help, renderI18nObject]) - if (typeof label === 'string') - return label + const watchedVariables = useMemo(() => { + const variables = new Set() - if (typeof label === 'object' && label !== null) - return renderI18nObject(label as Record) - }, [label, renderI18nObject]) - const memorizedPlaceholder = useMemo(() => { - if (typeof placeholder === 'string') - return placeholder + for (const option of options || []) { + for (const condition of option.show_on || []) + variables.add(condition.variable) + } - if (typeof placeholder === 'object' && placeholder !== null) - return renderI18nObject(placeholder as Record) - }, [placeholder, renderI18nObject]) - const optionValues = useStore(field.form.store, (s) => { + return Array.from(variables) + }, [options]) + + const watchedValues = useStore(field.form.store, (s) => { const result: Record = {} - options?.forEach((option) => { - if (option.show_on?.length) { - option.show_on.forEach((condition) => { - result[condition.variable] = s.values[condition.variable] - }) - } - }) + for (const variable of watchedVariables) + result[variable] = s.values[variable] + return result }) + const memorizedOptions = useMemo(() => { return options?.filter((option) => { - if (!option.show_on || option.show_on.length === 0) + if (!option.show_on?.length) return true return option.show_on.every((condition) => { - const conditionValue = optionValues[condition.variable] - return conditionValue === condition.value + return watchedValues[condition.variable] === condition.value }) }).map((option) => { return { - label: typeof option.label === 'string' ? option.label : renderI18nObject(option.label), + label: getTranslatedContent({ content: option.label, render: renderI18nObject }), value: option.value, } }) || [] - }, [options, renderI18nObject, optionValues]) + }, [options, renderI18nObject, watchedValues]) + const value = useStore(field.form.store, s => s.values[field.name]) + const { data: dynamicOptionsData, isLoading: isDynamicOptionsLoading, error: dynamicOptionsError } = useTriggerPluginDynamicOptions( + dynamicSelectParams || { + plugin_id: '', + provider: '', + action: '', + parameter: '', + credential_id: '', + }, + formItemType === FormTypeEnum.dynamicSelect, + ) + + const dynamicOptions = useMemo(() => { + if (!dynamicOptionsData?.options) + return [] + return dynamicOptionsData.options.map(option => ({ + label: getTranslatedContent({ content: option.label, render: renderI18nObject }), + value: option.value, + })) + }, [dynamicOptionsData, renderI18nObject]) + const handleChange = useCallback((value: any) => { field.handleChange(value) onChange?.(field.name, value) }, [field, onChange]) return ( -
-
- {memorizedLabel} - { - required && !isValidElement(label) && ( - * - ) - } -
-
- { - formSchema.type === FormTypeEnum.textInput && ( - { - handleChange(e.target.value) - }} - onBlur={field.handleBlur} - disabled={disabled} - placeholder={memorizedPlaceholder} + <> +
+
+ {translatedLabel} + { + required && !isValidElement(label) && ( + * + ) + } + {tooltip && ( + {translatedTooltip}
} + triggerClassName='ml-0.5 w-4 h-4' /> - ) - } - { - formSchema.type === FormTypeEnum.secretInput && ( - handleChange(e.target.value)} - onBlur={field.handleBlur} - disabled={disabled} - placeholder={memorizedPlaceholder} - autoComplete={'new-password'} - /> - ) - } - { - formSchema.type === FormTypeEnum.textNumber && ( - handleChange(e.target.value)} - onBlur={field.handleBlur} - disabled={disabled} - placeholder={memorizedPlaceholder} - /> - ) - } - { - formSchema.type === FormTypeEnum.select && ( - handleChange(v)} - disabled={disabled} - placeholder={memorizedPlaceholder} - options={memorizedOptions} - triggerPopupSameWidth - popupProps={{ - className: 'max-h-[320px] overflow-y-auto', - }} - /> - ) - } - { - formSchema.type === FormTypeEnum.radio && ( + )} +
+
+ { + [FormTypeEnum.textInput, FormTypeEnum.secretInput, FormTypeEnum.textNumber].includes(formItemType) && ( + { + handleChange(e.target.value) + }} + onBlur={field.handleBlur} + disabled={disabled} + placeholder={translatedPlaceholder} + {...getExtraProps(formItemType)} + showCopyIcon={showCopy} + /> + ) + } + { + formItemType === FormTypeEnum.select && !multiple && ( + handleChange(v)} + disabled={disabled} + placeholder={translatedPlaceholder} + options={memorizedOptions} + triggerPopupSameWidth + popupProps={{ + className: 'max-h-[320px] overflow-y-auto', + }} + /> + ) + } + { + formItemType === FormTypeEnum.checkbox /* && multiple */ && ( + field.handleChange(v)} + options={memorizedOptions} + maxHeight='200px' + /> + ) + } + { + formItemType === FormTypeEnum.dynamicSelect && ( + + ) + } + { + formItemType === FormTypeEnum.radio && ( +
+ { + memorizedOptions.map(option => ( +
!disabled && handleChange(option.value)} + > + { + formSchema.showRadioUI && ( + + ) + } + {option.label} +
+ )) + } +
+ ) + } + { + formItemType === FormTypeEnum.boolean && ( + field.handleChange(v)} + > + True + False + + ) + } + {fieldState?.validateStatus && [FormItemValidateStatusEnum.Error, FormItemValidateStatusEnum.Warning].includes(fieldState?.validateStatus) && (
- { - memorizedOptions.map(option => ( -
!disabled && handleChange(option.value)} - > - { - formSchema.showRadioUI && ( - - ) - } - {option.label} -
- )) - } + {fieldState?.[VALIDATE_STATUS_STYLE_MAP[fieldState?.validateStatus].infoFieldName as keyof FieldState]}
- ) - } - { - formSchema.type === FormTypeEnum.boolean && ( - field.handleChange(v)} - > - True - False - - ) - } - { - formSchema.url && ( - - - {renderI18nObject(formSchema?.help as any)} - - { - - } - - ) - } + )} +
-
+ {description && ( +
+ {translatedDescription} +
+ )} + { + url && ( + + + {translatedHelp} + + + + ) + } + + ) } diff --git a/web/app/components/base/form/components/base/base-form.tsx b/web/app/components/base/form/components/base/base-form.tsx index 6b7e992510..0d35380523 100644 --- a/web/app/components/base/form/components/base/base-form.tsx +++ b/web/app/components/base/form/components/base/base-form.tsx @@ -3,6 +3,7 @@ import { useCallback, useImperativeHandle, useMemo, + useState, } from 'react' import type { AnyFieldApi, @@ -12,9 +13,12 @@ import { useForm, useStore, } from '@tanstack/react-form' -import type { - FormRef, - FormSchema, +import { + type FieldState, + FormItemValidateStatusEnum, + type FormRef, + type FormSchema, + type SetFieldsParam, } from '@/app/components/base/form/types' import { BaseField, @@ -36,6 +40,8 @@ export type BaseFormProps = { disabled?: boolean formFromProps?: AnyFormApi onChange?: (field: string, value: any) => void + onSubmit?: (e: React.FormEvent) => void + preventDefaultSubmit?: boolean } & Pick const BaseForm = ({ @@ -50,6 +56,8 @@ const BaseForm = ({ disabled, formFromProps, onChange, + onSubmit, + preventDefaultSubmit = false, }: BaseFormProps) => { const initialDefaultValues = useMemo(() => { if (defaultValues) @@ -68,6 +76,8 @@ const BaseForm = ({ const { getFormValues } = useGetFormValues(form, formSchemas) const { getValidators } = useGetValidators() + const [fieldStates, setFieldStates] = useState>({}) + const showOnValues = useStore(form.store, (s: any) => { const result: Record = {} formSchemas.forEach((schema) => { @@ -81,6 +91,34 @@ const BaseForm = ({ return result }) + const setFields = useCallback((fields: SetFieldsParam[]) => { + const newFieldStates: Record = { ...fieldStates } + + for (const field of fields) { + const { name, value, errors, warnings, validateStatus, help } = field + + if (value !== undefined) + form.setFieldValue(name, value) + + let finalValidateStatus = validateStatus + if (!finalValidateStatus) { + if (errors && errors.length > 0) + finalValidateStatus = FormItemValidateStatusEnum.Error + else if (warnings && warnings.length > 0) + finalValidateStatus = FormItemValidateStatusEnum.Warning + } + + newFieldStates[name] = { + validateStatus: finalValidateStatus, + help, + errors, + warnings, + } + } + + setFieldStates(newFieldStates) + }, [form, fieldStates]) + useImperativeHandle(ref, () => { return { getForm() { @@ -89,8 +127,9 @@ const BaseForm = ({ getFormValues: (option) => { return getFormValues(option) }, + setFields, } - }, [form, getFormValues]) + }, [form, getFormValues, setFields]) const renderField = useCallback((field: AnyFieldApi) => { const formSchema = formSchemas?.find(schema => schema.name === field.name) @@ -100,18 +139,19 @@ const BaseForm = ({ ) } return null - }, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled, onChange]) + }, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled, onChange, fieldStates]) const renderFieldWrapper = useCallback((formSchema: FormSchema) => { const validators = getValidators(formSchema) @@ -142,9 +182,18 @@ const BaseForm = ({ if (!formSchemas?.length) return null + const handleSubmit = (e: React.FormEvent) => { + if (preventDefaultSubmit) { + e.preventDefault() + e.stopPropagation() + } + onSubmit?.(e) + } + return (
{formSchemas.map(renderFieldWrapper)}
diff --git a/web/app/components/base/form/components/field/select.tsx b/web/app/components/base/form/components/field/select.tsx index dee047e2eb..8a36a49510 100644 --- a/web/app/components/base/form/components/field/select.tsx +++ b/web/app/components/base/form/components/field/select.tsx @@ -11,7 +11,9 @@ type SelectFieldProps = { options: Option[] onChange?: (value: string) => void className?: string -} & Omit +} & Omit & { + multiple?: false +} const SelectField = ({ label, diff --git a/web/app/components/base/form/components/field/variable-or-constant-input.tsx b/web/app/components/base/form/components/field/variable-or-constant-input.tsx index a07e356fa2..b8a96c5401 100644 --- a/web/app/components/base/form/components/field/variable-or-constant-input.tsx +++ b/web/app/components/base/form/components/field/variable-or-constant-input.tsx @@ -1,5 +1,5 @@ import type { ChangeEvent } from 'react' -import { useState } from 'react' +import { useCallback, useState } from 'react' import { RiEditLine } from '@remixicon/react' import cn from '@/utils/classnames' import SegmentedControl from '@/app/components/base/segmented-control' @@ -33,9 +33,9 @@ const VariableOrConstantInputField = ({ }, ] - const handleVariableOrConstantChange = (value: string) => { + const handleVariableOrConstantChange = useCallback((value: string) => { setVariableType(value) - } + }, [setVariableType]) const handleVariableValueChange = () => { console.log('Variable value changed') diff --git a/web/app/components/base/form/hooks/use-get-form-values.ts b/web/app/components/base/form/hooks/use-get-form-values.ts index 36100a724a..b7d08cc005 100644 --- a/web/app/components/base/form/hooks/use-get-form-values.ts +++ b/web/app/components/base/form/hooks/use-get-form-values.ts @@ -12,7 +12,7 @@ export const useGetFormValues = (form: AnyFormApi, formSchemas: FormSchema[]) => const getFormValues = useCallback(( { - needCheckValidatedValues, + needCheckValidatedValues = true, needTransformWhenSecretFieldIsPristine, }: GetValuesOptions, ) => { @@ -20,7 +20,7 @@ export const useGetFormValues = (form: AnyFormApi, formSchemas: FormSchema[]) => if (!needCheckValidatedValues) { return { values, - isCheckValidated: false, + isCheckValidated: true, } } diff --git a/web/app/components/base/form/index.stories.tsx b/web/app/components/base/form/index.stories.tsx index c1b9e894e0..f170cb4771 100644 --- a/web/app/components/base/form/index.stories.tsx +++ b/web/app/components/base/form/index.stories.tsx @@ -102,14 +102,14 @@ const FormPlayground = () => { options={{ ...demoFormOpts, validators: { - onSubmit: ({ value }) => { - const result = UserSchema.safeParse(value as typeof demoFormOpts.defaultValues) + onSubmit: ({ value: formValue }) => { + const result = UserSchema.safeParse(formValue as typeof demoFormOpts.defaultValues) if (!result.success) return result.error.issues[0].message return undefined }, }, - onSubmit: ({ value }) => { + onSubmit: () => { setStatus('Successfully saved profile.') }, }} diff --git a/web/app/components/base/form/types.ts b/web/app/components/base/form/types.ts index ce3b5ec965..268f9db89a 100644 --- a/web/app/components/base/form/types.ts +++ b/web/app/components/base/form/types.ts @@ -6,6 +6,7 @@ import type { AnyFormApi, FieldValidators, } from '@tanstack/react-form' +import type { Locale } from '@/i18n-config' export type TypeWithI18N = { en_US: T @@ -36,7 +37,7 @@ export enum FormTypeEnum { } export type FormOption = { - label: TypeWithI18N | string + label: string | TypeWithI18N | Record value: string show_on?: FormShowOnObject[] icon?: string @@ -44,23 +45,41 @@ export type FormOption = { export type AnyValidators = FieldValidators +export enum FormItemValidateStatusEnum { + Success = 'success', + Warning = 'warning', + Error = 'error', + Validating = 'validating', +} + export type FormSchema = { type: FormTypeEnum name: string - label: string | ReactNode | TypeWithI18N + label: string | ReactNode | TypeWithI18N | Record required: boolean + multiple?: boolean default?: any - tooltip?: string | TypeWithI18N + description?: string | TypeWithI18N | Record + tooltip?: string | TypeWithI18N | Record show_on?: FormShowOnObject[] url?: string scope?: string - help?: string | TypeWithI18N - placeholder?: string | TypeWithI18N + help?: string | TypeWithI18N | Record + placeholder?: string | TypeWithI18N | Record options?: FormOption[] labelClassName?: string + fieldClassName?: string validators?: AnyValidators showRadioUI?: boolean disabled?: boolean + showCopy?: boolean + dynamicSelectParams?: { + plugin_id: string + provider: string + action: string + parameter: string + credential_id: string + } } export type FormValues = Record @@ -69,11 +88,25 @@ export type GetValuesOptions = { needTransformWhenSecretFieldIsPristine?: boolean needCheckValidatedValues?: boolean } + +export type FieldState = { + validateStatus?: FormItemValidateStatusEnum + help?: string | ReactNode + errors?: string[] + warnings?: string[] +} + +export type SetFieldsParam = { + name: string + value?: any +} & FieldState + export type FormRefObject = { getForm: () => AnyFormApi getFormValues: (obj: GetValuesOptions) => { values: Record isCheckValidated: boolean } + setFields: (fields: SetFieldsParam[]) => void } export type FormRef = ForwardedRef diff --git a/web/app/components/base/icons/assets/vender/line/alertsAndFeedback/warning.svg b/web/app/components/base/icons/assets/vender/line/alertsAndFeedback/warning.svg new file mode 100644 index 0000000000..8174878acb --- /dev/null +++ b/web/app/components/base/icons/assets/vender/line/alertsAndFeedback/warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/assets/vender/line/arrows/IconR.svg b/web/app/components/base/icons/assets/vender/line/arrows/IconR.svg new file mode 100644 index 0000000000..7ff1df98e2 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/line/arrows/IconR.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/assets/vender/plugin/trigger.svg b/web/app/components/base/icons/assets/vender/plugin/trigger.svg new file mode 100644 index 0000000000..261fcd02b7 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/plugin/trigger.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/web/app/components/base/icons/assets/vender/solid/arrows/arrow-down-double-line.svg b/web/app/components/base/icons/assets/vender/solid/arrows/arrow-down-double-line.svg new file mode 100644 index 0000000000..56caa01c59 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/solid/arrows/arrow-down-double-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/assets/vender/solid/arrows/arrow-down-round-fill.svg b/web/app/components/base/icons/assets/vender/solid/arrows/arrow-down-round-fill.svg new file mode 100644 index 0000000000..48e70bcb51 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/solid/arrows/arrow-down-round-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/assets/vender/solid/arrows/arrow-up-double-line.svg b/web/app/components/base/icons/assets/vender/solid/arrows/arrow-up-double-line.svg new file mode 100644 index 0000000000..1f0b9858e1 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/solid/arrows/arrow-up-double-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/assets/vender/workflow/api-aggregate.svg b/web/app/components/base/icons/assets/vender/workflow/api-aggregate.svg new file mode 100644 index 0000000000..aaf2206d21 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/workflow/api-aggregate.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/assets/vender/workflow/asterisk.svg b/web/app/components/base/icons/assets/vender/workflow/asterisk.svg new file mode 100644 index 0000000000..d273c7e3d5 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/workflow/asterisk.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/assets/vender/workflow/calendar-check-line.svg b/web/app/components/base/icons/assets/vender/workflow/calendar-check-line.svg new file mode 100644 index 0000000000..2c7f148c71 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/workflow/calendar-check-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/assets/vender/workflow/schedule.svg b/web/app/components/base/icons/assets/vender/workflow/schedule.svg new file mode 100644 index 0000000000..69977c4c7f --- /dev/null +++ b/web/app/components/base/icons/assets/vender/workflow/schedule.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/app/components/base/icons/assets/vender/workflow/trigger-all.svg b/web/app/components/base/icons/assets/vender/workflow/trigger-all.svg new file mode 100644 index 0000000000..dedcc0ad3c --- /dev/null +++ b/web/app/components/base/icons/assets/vender/workflow/trigger-all.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/web/app/components/base/icons/assets/vender/workflow/webhook-line.svg b/web/app/components/base/icons/assets/vender/workflow/webhook-line.svg new file mode 100644 index 0000000000..16fd30a961 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/workflow/webhook-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/src/image/llm/BaichuanTextCn.module.css b/web/app/components/base/icons/src/image/llm/BaichuanTextCn.module.css new file mode 100644 index 0000000000..97ab9b22f9 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/BaichuanTextCn.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: inline-flex; + background: url(~@/app/components/base/icons/assets/image/llm/baichuan-text-cn.png) center center no-repeat; + background-size: contain; +} diff --git a/web/app/components/base/icons/src/image/llm/BaichuanTextCn.tsx b/web/app/components/base/icons/src/image/llm/BaichuanTextCn.tsx new file mode 100644 index 0000000000..be9a407eb2 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/BaichuanTextCn.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import cn from '@/utils/classnames' +import s from './BaichuanTextCn.module.css' + +const Icon = ( + { + ref, + className, + ...restProps + }: React.DetailedHTMLProps, HTMLSpanElement> & { + ref?: React.RefObject; + }, +) => + +Icon.displayName = 'BaichuanTextCn' + +export default Icon diff --git a/web/app/components/base/icons/src/image/llm/Minimax.module.css b/web/app/components/base/icons/src/image/llm/Minimax.module.css new file mode 100644 index 0000000000..551ecc3c62 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/Minimax.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: inline-flex; + background: url(~@/app/components/base/icons/assets/image/llm/minimax.png) center center no-repeat; + background-size: contain; +} diff --git a/web/app/components/base/icons/src/image/llm/Minimax.tsx b/web/app/components/base/icons/src/image/llm/Minimax.tsx new file mode 100644 index 0000000000..7df7e3fcbc --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/Minimax.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import cn from '@/utils/classnames' +import s from './Minimax.module.css' + +const Icon = ( + { + ref, + className, + ...restProps + }: React.DetailedHTMLProps, HTMLSpanElement> & { + ref?: React.RefObject; + }, +) => + +Icon.displayName = 'Minimax' + +export default Icon diff --git a/web/app/components/base/icons/src/image/llm/MinimaxText.module.css b/web/app/components/base/icons/src/image/llm/MinimaxText.module.css new file mode 100644 index 0000000000..a63be49e8b --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/MinimaxText.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: inline-flex; + background: url(~@/app/components/base/icons/assets/image/llm/minimax-text.png) center center no-repeat; + background-size: contain; +} diff --git a/web/app/components/base/icons/src/image/llm/MinimaxText.tsx b/web/app/components/base/icons/src/image/llm/MinimaxText.tsx new file mode 100644 index 0000000000..840e8cb439 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/MinimaxText.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import cn from '@/utils/classnames' +import s from './MinimaxText.module.css' + +const Icon = ( + { + ref, + className, + ...restProps + }: React.DetailedHTMLProps, HTMLSpanElement> & { + ref?: React.RefObject; + }, +) => + +Icon.displayName = 'MinimaxText' + +export default Icon diff --git a/web/app/components/base/icons/src/image/llm/Tongyi.module.css b/web/app/components/base/icons/src/image/llm/Tongyi.module.css new file mode 100644 index 0000000000..3ca440768c --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/Tongyi.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: inline-flex; + background: url(~@/app/components/base/icons/assets/image/llm/tongyi.png) center center no-repeat; + background-size: contain; +} diff --git a/web/app/components/base/icons/src/image/llm/Tongyi.tsx b/web/app/components/base/icons/src/image/llm/Tongyi.tsx new file mode 100644 index 0000000000..2f62f1a355 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/Tongyi.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import cn from '@/utils/classnames' +import s from './Tongyi.module.css' + +const Icon = ( + { + ref, + className, + ...restProps + }: React.DetailedHTMLProps, HTMLSpanElement> & { + ref?: React.RefObject; + }, +) => + +Icon.displayName = 'Tongyi' + +export default Icon diff --git a/web/app/components/base/icons/src/image/llm/TongyiText.module.css b/web/app/components/base/icons/src/image/llm/TongyiText.module.css new file mode 100644 index 0000000000..f713671808 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/TongyiText.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: inline-flex; + background: url(~@/app/components/base/icons/assets/image/llm/tongyi-text.png) center center no-repeat; + background-size: contain; +} diff --git a/web/app/components/base/icons/src/image/llm/TongyiText.tsx b/web/app/components/base/icons/src/image/llm/TongyiText.tsx new file mode 100644 index 0000000000..a52f63c248 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/TongyiText.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import cn from '@/utils/classnames' +import s from './TongyiText.module.css' + +const Icon = ( + { + ref, + className, + ...restProps + }: React.DetailedHTMLProps, HTMLSpanElement> & { + ref?: React.RefObject; + }, +) => + +Icon.displayName = 'TongyiText' + +export default Icon diff --git a/web/app/components/base/icons/src/image/llm/TongyiTextCn.module.css b/web/app/components/base/icons/src/image/llm/TongyiTextCn.module.css new file mode 100644 index 0000000000..d07e6e8bc4 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/TongyiTextCn.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: inline-flex; + background: url(~@/app/components/base/icons/assets/image/llm/tongyi-text-cn.png) center center no-repeat; + background-size: contain; +} diff --git a/web/app/components/base/icons/src/image/llm/TongyiTextCn.tsx b/web/app/components/base/icons/src/image/llm/TongyiTextCn.tsx new file mode 100644 index 0000000000..c982c73aed --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/TongyiTextCn.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import cn from '@/utils/classnames' +import s from './TongyiTextCn.module.css' + +const Icon = ( + { + ref, + className, + ...restProps + }: React.DetailedHTMLProps, HTMLSpanElement> & { + ref?: React.RefObject; + }, +) => + +Icon.displayName = 'TongyiTextCn' + +export default Icon diff --git a/web/app/components/base/icons/src/image/llm/Wxyy.module.css b/web/app/components/base/icons/src/image/llm/Wxyy.module.css new file mode 100644 index 0000000000..44344a495f --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/Wxyy.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: inline-flex; + background: url(~@/app/components/base/icons/assets/image/llm/wxyy.png) center center no-repeat; + background-size: contain; +} diff --git a/web/app/components/base/icons/src/image/llm/Wxyy.tsx b/web/app/components/base/icons/src/image/llm/Wxyy.tsx new file mode 100644 index 0000000000..a3c494811e --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/Wxyy.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import cn from '@/utils/classnames' +import s from './Wxyy.module.css' + +const Icon = ( + { + ref, + className, + ...restProps + }: React.DetailedHTMLProps, HTMLSpanElement> & { + ref?: React.RefObject; + }, +) => + +Icon.displayName = 'Wxyy' + +export default Icon diff --git a/web/app/components/base/icons/src/image/llm/WxyyText.module.css b/web/app/components/base/icons/src/image/llm/WxyyText.module.css new file mode 100644 index 0000000000..58a0c62047 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/WxyyText.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: inline-flex; + background: url(~@/app/components/base/icons/assets/image/llm/wxyy-text.png) center center no-repeat; + background-size: contain; +} diff --git a/web/app/components/base/icons/src/image/llm/WxyyText.tsx b/web/app/components/base/icons/src/image/llm/WxyyText.tsx new file mode 100644 index 0000000000..e5dd6e8803 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/WxyyText.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import cn from '@/utils/classnames' +import s from './WxyyText.module.css' + +const Icon = ( + { + ref, + className, + ...restProps + }: React.DetailedHTMLProps, HTMLSpanElement> & { + ref?: React.RefObject; + }, +) => + +Icon.displayName = 'WxyyText' + +export default Icon diff --git a/web/app/components/base/icons/src/image/llm/WxyyTextCn.module.css b/web/app/components/base/icons/src/image/llm/WxyyTextCn.module.css new file mode 100644 index 0000000000..fb5839ab07 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/WxyyTextCn.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: inline-flex; + background: url(~@/app/components/base/icons/assets/image/llm/wxyy-text-cn.png) center center no-repeat; + background-size: contain; +} diff --git a/web/app/components/base/icons/src/image/llm/WxyyTextCn.tsx b/web/app/components/base/icons/src/image/llm/WxyyTextCn.tsx new file mode 100644 index 0000000000..32108adab4 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/WxyyTextCn.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import cn from '@/utils/classnames' +import s from './WxyyTextCn.module.css' + +const Icon = ( + { + ref, + className, + ...restProps + }: React.DetailedHTMLProps, HTMLSpanElement> & { + ref?: React.RefObject; + }, +) => + +Icon.displayName = 'WxyyTextCn' + +export default Icon diff --git a/web/app/components/base/icons/src/image/llm/index.ts b/web/app/components/base/icons/src/image/llm/index.ts new file mode 100644 index 0000000000..3a4e64ac18 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/index.ts @@ -0,0 +1,9 @@ +export { default as BaichuanTextCn } from './BaichuanTextCn' +export { default as MinimaxText } from './MinimaxText' +export { default as Minimax } from './Minimax' +export { default as TongyiTextCn } from './TongyiTextCn' +export { default as TongyiText } from './TongyiText' +export { default as Tongyi } from './Tongyi' +export { default as WxyyTextCn } from './WxyyTextCn' +export { default as WxyyText } from './WxyyText' +export { default as Wxyy } from './Wxyy' diff --git a/web/app/components/base/icons/src/public/billing/AwsMarketplaceDark.tsx b/web/app/components/base/icons/src/public/billing/AwsMarketplaceDark.tsx index 5aa2d6c430..7096a4d2eb 100644 --- a/web/app/components/base/icons/src/public/billing/AwsMarketplaceDark.tsx +++ b/web/app/components/base/icons/src/public/billing/AwsMarketplaceDark.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlue.tsx b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlue.tsx index 85697f9dae..8d3e6a8a8a 100644 --- a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlue.tsx +++ b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlue.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlueLight.tsx b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlueLight.tsx index bf4264f1bd..f44856be61 100644 --- a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlueLight.tsx +++ b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlueLight.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectOrange.tsx b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectOrange.tsx index bd6cda4470..fe76f5917f 100644 --- a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectOrange.tsx +++ b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectOrange.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectPurple.tsx b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectPurple.tsx index b70808ef8c..f5c5e7ba3a 100644 --- a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectPurple.tsx +++ b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectPurple.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectTeal.tsx b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectTeal.tsx index ddd04a1911..0d2a07e405 100644 --- a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectTeal.tsx +++ b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectTeal.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/dataset-card/ExternalKnowledgeBase.tsx b/web/app/components/base/icons/src/public/knowledge/dataset-card/ExternalKnowledgeBase.tsx index ea6ce30704..06bb8086bc 100644 --- a/web/app/components/base/icons/src/public/knowledge/dataset-card/ExternalKnowledgeBase.tsx +++ b/web/app/components/base/icons/src/public/knowledge/dataset-card/ExternalKnowledgeBase.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/dataset-card/General.tsx b/web/app/components/base/icons/src/public/knowledge/dataset-card/General.tsx index 6508ed57c6..6665039002 100644 --- a/web/app/components/base/icons/src/public/knowledge/dataset-card/General.tsx +++ b/web/app/components/base/icons/src/public/knowledge/dataset-card/General.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/dataset-card/Graph.tsx b/web/app/components/base/icons/src/public/knowledge/dataset-card/Graph.tsx index c1360c52ca..127367f873 100644 --- a/web/app/components/base/icons/src/public/knowledge/dataset-card/Graph.tsx +++ b/web/app/components/base/icons/src/public/knowledge/dataset-card/Graph.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/dataset-card/ParentChild.tsx b/web/app/components/base/icons/src/public/knowledge/dataset-card/ParentChild.tsx index 7c6c3baa7b..922cb2c825 100644 --- a/web/app/components/base/icons/src/public/knowledge/dataset-card/ParentChild.tsx +++ b/web/app/components/base/icons/src/public/knowledge/dataset-card/ParentChild.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/dataset-card/Qa.tsx b/web/app/components/base/icons/src/public/knowledge/dataset-card/Qa.tsx index 34ef88141e..ac41a8b153 100644 --- a/web/app/components/base/icons/src/public/knowledge/dataset-card/Qa.tsx +++ b/web/app/components/base/icons/src/public/knowledge/dataset-card/Qa.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsBlue.tsx b/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsBlue.tsx index 9fd923458e..cfd9570081 100644 --- a/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsBlue.tsx +++ b/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsBlue.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsGray.tsx b/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsGray.tsx index a646251629..2e40a70367 100644 --- a/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsGray.tsx +++ b/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsGray.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/online-drive/Folder.tsx b/web/app/components/base/icons/src/public/knowledge/online-drive/Folder.tsx index e7a3fdf167..c5c3ea5b72 100644 --- a/web/app/components/base/icons/src/public/knowledge/online-drive/Folder.tsx +++ b/web/app/components/base/icons/src/public/knowledge/online-drive/Folder.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/model/Checked.tsx b/web/app/components/base/icons/src/public/model/Checked.tsx new file mode 100644 index 0000000000..7854479cd2 --- /dev/null +++ b/web/app/components/base/icons/src/public/model/Checked.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Checked.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Checked' + +export default Icon diff --git a/web/app/components/base/icons/src/public/model/index.ts b/web/app/components/base/icons/src/public/model/index.ts new file mode 100644 index 0000000000..719a6f0309 --- /dev/null +++ b/web/app/components/base/icons/src/public/model/index.ts @@ -0,0 +1 @@ +export { default as Checked } from './Checked' diff --git a/web/app/components/base/icons/src/public/plugins/Google.tsx b/web/app/components/base/icons/src/public/plugins/Google.tsx new file mode 100644 index 0000000000..3e19ecd2f8 --- /dev/null +++ b/web/app/components/base/icons/src/public/plugins/Google.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Google.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Google' + +export default Icon diff --git a/web/app/components/base/icons/src/public/plugins/WebReader.tsx b/web/app/components/base/icons/src/public/plugins/WebReader.tsx new file mode 100644 index 0000000000..5606e32f88 --- /dev/null +++ b/web/app/components/base/icons/src/public/plugins/WebReader.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './WebReader.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'WebReader' + +export default Icon diff --git a/web/app/components/base/icons/src/public/plugins/Wikipedia.tsx b/web/app/components/base/icons/src/public/plugins/Wikipedia.tsx new file mode 100644 index 0000000000..c2fde5c1f8 --- /dev/null +++ b/web/app/components/base/icons/src/public/plugins/Wikipedia.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Wikipedia.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Wikipedia' + +export default Icon diff --git a/web/app/components/base/icons/src/public/plugins/index.ts b/web/app/components/base/icons/src/public/plugins/index.ts new file mode 100644 index 0000000000..87dc37167c --- /dev/null +++ b/web/app/components/base/icons/src/public/plugins/index.ts @@ -0,0 +1,7 @@ +export { default as Google } from './Google' +export { default as PartnerDark } from './PartnerDark' +export { default as PartnerLight } from './PartnerLight' +export { default as VerifiedDark } from './VerifiedDark' +export { default as VerifiedLight } from './VerifiedLight' +export { default as WebReader } from './WebReader' +export { default as Wikipedia } from './Wikipedia' diff --git a/web/app/components/base/icons/src/public/thought/DataSet.tsx b/web/app/components/base/icons/src/public/thought/DataSet.tsx index e279c77ec7..f35ff4efbc 100644 --- a/web/app/components/base/icons/src/public/thought/DataSet.tsx +++ b/web/app/components/base/icons/src/public/thought/DataSet.tsx @@ -18,4 +18,3 @@ const Icon = ( Icon.displayName = 'DataSet' export default Icon - diff --git a/web/app/components/base/icons/src/public/thought/Loading.tsx b/web/app/components/base/icons/src/public/thought/Loading.tsx new file mode 100644 index 0000000000..af959fba40 --- /dev/null +++ b/web/app/components/base/icons/src/public/thought/Loading.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Loading.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Loading' + +export default Icon diff --git a/web/app/components/base/icons/src/public/thought/Search.tsx b/web/app/components/base/icons/src/public/thought/Search.tsx new file mode 100644 index 0000000000..ecd98048d5 --- /dev/null +++ b/web/app/components/base/icons/src/public/thought/Search.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Search.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Search' + +export default Icon diff --git a/web/app/components/base/icons/src/public/thought/ThoughtList.tsx b/web/app/components/base/icons/src/public/thought/ThoughtList.tsx new file mode 100644 index 0000000000..e7f0e312ef --- /dev/null +++ b/web/app/components/base/icons/src/public/thought/ThoughtList.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './ThoughtList.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'ThoughtList' + +export default Icon diff --git a/web/app/components/base/icons/src/public/thought/WebReader.tsx b/web/app/components/base/icons/src/public/thought/WebReader.tsx new file mode 100644 index 0000000000..5606e32f88 --- /dev/null +++ b/web/app/components/base/icons/src/public/thought/WebReader.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './WebReader.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'WebReader' + +export default Icon diff --git a/web/app/components/base/icons/src/public/thought/index.ts b/web/app/components/base/icons/src/public/thought/index.ts index 35adcb50bb..8a45489dbf 100644 --- a/web/app/components/base/icons/src/public/thought/index.ts +++ b/web/app/components/base/icons/src/public/thought/index.ts @@ -1,2 +1,5 @@ export { default as DataSet } from './DataSet' - +export { default as Loading } from './Loading' +export { default as Search } from './Search' +export { default as ThoughtList } from './ThoughtList' +export { default as WebReader } from './WebReader' diff --git a/web/app/components/base/icons/src/public/tracing/index.ts b/web/app/components/base/icons/src/public/tracing/index.ts index 9eaf42b7e0..8911798b56 100644 --- a/web/app/components/base/icons/src/public/tracing/index.ts +++ b/web/app/components/base/icons/src/public/tracing/index.ts @@ -8,10 +8,10 @@ export { default as LangsmithIconBig } from './LangsmithIconBig' export { default as LangsmithIcon } from './LangsmithIcon' export { default as OpikIconBig } from './OpikIconBig' export { default as OpikIcon } from './OpikIcon' -export { default as PhoenixIconBig } from './PhoenixIconBig' -export { default as PhoenixIcon } from './PhoenixIcon' export { default as TencentIconBig } from './TencentIconBig' export { default as TencentIcon } from './TencentIcon' +export { default as PhoenixIconBig } from './PhoenixIconBig' +export { default as PhoenixIcon } from './PhoenixIcon' export { default as TracingIcon } from './TracingIcon' export { default as WeaveIconBig } from './WeaveIconBig' export { default as WeaveIcon } from './WeaveIcon' diff --git a/web/app/components/base/icons/src/vender/knowledge/AddChunks.tsx b/web/app/components/base/icons/src/vender/knowledge/AddChunks.tsx index fc1270ae66..8068f7113c 100644 --- a/web/app/components/base/icons/src/vender/knowledge/AddChunks.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/AddChunks.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/ArrowShape.tsx b/web/app/components/base/icons/src/vender/knowledge/ArrowShape.tsx index 72ae12c7dd..b93cd2a325 100644 --- a/web/app/components/base/icons/src/vender/knowledge/ArrowShape.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/ArrowShape.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/Divider.tsx b/web/app/components/base/icons/src/vender/knowledge/Divider.tsx index 56606448be..8f7537b0db 100644 --- a/web/app/components/base/icons/src/vender/knowledge/Divider.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/Divider.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/Economic.tsx b/web/app/components/base/icons/src/vender/knowledge/Economic.tsx index c69560689e..52e2262fc1 100644 --- a/web/app/components/base/icons/src/vender/knowledge/Economic.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/Economic.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/FullTextSearch.tsx b/web/app/components/base/icons/src/vender/knowledge/FullTextSearch.tsx index 0e36656343..714e63ecc0 100644 --- a/web/app/components/base/icons/src/vender/knowledge/FullTextSearch.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/FullTextSearch.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/GeneralChunk.tsx b/web/app/components/base/icons/src/vender/knowledge/GeneralChunk.tsx index 6e75ed920a..e269f3ad91 100644 --- a/web/app/components/base/icons/src/vender/knowledge/GeneralChunk.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/GeneralChunk.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/HighQuality.tsx b/web/app/components/base/icons/src/vender/knowledge/HighQuality.tsx index 880e63a003..964e4f1a2b 100644 --- a/web/app/components/base/icons/src/vender/knowledge/HighQuality.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/HighQuality.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/HybridSearch.tsx b/web/app/components/base/icons/src/vender/knowledge/HybridSearch.tsx index 45d76c2fd1..b9a83245ee 100644 --- a/web/app/components/base/icons/src/vender/knowledge/HybridSearch.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/HybridSearch.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/ParentChildChunk.tsx b/web/app/components/base/icons/src/vender/knowledge/ParentChildChunk.tsx index 949cd508de..87664b706a 100644 --- a/web/app/components/base/icons/src/vender/knowledge/ParentChildChunk.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/ParentChildChunk.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/QuestionAndAnswer.tsx b/web/app/components/base/icons/src/vender/knowledge/QuestionAndAnswer.tsx index 6ebc279a15..2492e63710 100644 --- a/web/app/components/base/icons/src/vender/knowledge/QuestionAndAnswer.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/QuestionAndAnswer.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/SearchMenu.tsx b/web/app/components/base/icons/src/vender/knowledge/SearchMenu.tsx index 4826abb20f..497f24a984 100644 --- a/web/app/components/base/icons/src/vender/knowledge/SearchMenu.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/SearchMenu.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/VectorSearch.tsx b/web/app/components/base/icons/src/vender/knowledge/VectorSearch.tsx index 2346033f89..fa22a54587 100644 --- a/web/app/components/base/icons/src/vender/knowledge/VectorSearch.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/VectorSearch.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning.json b/web/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning.json new file mode 100644 index 0000000000..e131493a55 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning.json @@ -0,0 +1,26 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "12", + "height": "12", + "viewBox": "0 0 12 12", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M6.43295 1.50009L11.1961 9.7501C11.3342 9.98925 11.2523 10.295 11.0131 10.4331C10.9371 10.477 10.8509 10.5001 10.7631 10.5001H1.23682C0.960676 10.5001 0.736816 10.2762 0.736816 10.0001C0.736816 9.9123 0.759921 9.8261 0.803806 9.7501L5.56695 1.50009C5.705 1.26094 6.0108 1.179 6.24995 1.31707C6.32595 1.36096 6.3891 1.42408 6.43295 1.50009ZM5.49995 8.0001V9.0001H6.49995V8.0001H5.49995ZM5.49995 4.50008V7.0001H6.49995V4.50008H5.49995Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "Warning" +} diff --git a/web/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning.tsx b/web/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning.tsx new file mode 100644 index 0000000000..b73363b2c2 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Warning.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Warning' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/alertsAndFeedback/index.ts b/web/app/components/base/icons/src/vender/line/alertsAndFeedback/index.ts index f0a0faf74d..4e721d70eb 100644 --- a/web/app/components/base/icons/src/vender/line/alertsAndFeedback/index.ts +++ b/web/app/components/base/icons/src/vender/line/alertsAndFeedback/index.ts @@ -1,3 +1,4 @@ export { default as AlertTriangle } from './AlertTriangle' export { default as ThumbsDown } from './ThumbsDown' export { default as ThumbsUp } from './ThumbsUp' +export { default as Warning } from './Warning' diff --git a/web/app/components/base/icons/src/vender/line/arrows/IconR.json b/web/app/components/base/icons/src/vender/line/arrows/IconR.json new file mode 100644 index 0000000000..31624cf04f --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/arrows/IconR.json @@ -0,0 +1,26 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "14", + "height": "14", + "viewBox": "0 0 14 14", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M9.43341 6.41661L6.30441 3.2876L7.12936 2.46265L11.6666 6.99994L7.12936 11.5372L6.30441 10.7122L9.43341 7.58327H2.33331V6.41661H9.43341Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "IconR" +} diff --git a/web/app/components/base/icons/src/vender/line/arrows/IconR.tsx b/web/app/components/base/icons/src/vender/line/arrows/IconR.tsx new file mode 100644 index 0000000000..0546223e95 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/arrows/IconR.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './IconR.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'IconR' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/arrows/index.ts b/web/app/components/base/icons/src/vender/line/arrows/index.ts index c329b3636e..78554c86f1 100644 --- a/web/app/components/base/icons/src/vender/line/arrows/index.ts +++ b/web/app/components/base/icons/src/vender/line/arrows/index.ts @@ -1,3 +1,4 @@ +export { default as IconR } from './IconR' export { default as ArrowNarrowLeft } from './ArrowNarrowLeft' export { default as ArrowUpRight } from './ArrowUpRight' export { default as ChevronDownDouble } from './ChevronDownDouble' diff --git a/web/app/components/base/icons/src/vender/line/communication/AiText.json b/web/app/components/base/icons/src/vender/line/communication/AiText.json new file mode 100644 index 0000000000..2473c64c22 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/communication/AiText.json @@ -0,0 +1,39 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "14", + "height": "14", + "viewBox": "0 0 14 14", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "g", + "attributes": { + "id": "ai-text" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "id": "Vector", + "d": "M2.33301 10.5H4.08301M2.33301 7H5.24967M2.33301 3.5H11.6663M9.91634 5.83333L10.7913 7.875L12.833 8.75L10.7913 9.625L9.91634 11.6667L9.04134 9.625L6.99967 8.75L9.04134 7.875L9.91634 5.83333Z", + "stroke": "currentColor", + "stroke-width": "1.25", + "stroke-linecap": "round", + "stroke-linejoin": "round" + }, + "children": [] + } + ] + } + ] + }, + "name": "AiText" +} diff --git a/web/app/components/base/icons/src/vender/line/communication/AiText.tsx b/web/app/components/base/icons/src/vender/line/communication/AiText.tsx new file mode 100644 index 0000000000..7d5a860038 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/communication/AiText.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './AiText.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'AiText' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/communication/index.ts b/web/app/components/base/icons/src/vender/line/communication/index.ts index 27118f1dde..3ab20e8bb4 100644 --- a/web/app/components/base/icons/src/vender/line/communication/index.ts +++ b/web/app/components/base/icons/src/vender/line/communication/index.ts @@ -1,3 +1,4 @@ +export { default as AiText } from './AiText' export { default as ChatBotSlim } from './ChatBotSlim' export { default as ChatBot } from './ChatBot' export { default as CuteRobot } from './CuteRobot' diff --git a/web/app/components/base/icons/src/vender/line/layout/AlignLeft01.tsx b/web/app/components/base/icons/src/vender/line/layout/AlignLeft01.tsx new file mode 100644 index 0000000000..0761e89f56 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/layout/AlignLeft01.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './AlignLeft01.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'AlignLeft01' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/layout/AlignRight01.tsx b/web/app/components/base/icons/src/vender/line/layout/AlignRight01.tsx new file mode 100644 index 0000000000..ffe1889ff8 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/layout/AlignRight01.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './AlignRight01.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'AlignRight01' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/layout/Grid01.tsx b/web/app/components/base/icons/src/vender/line/layout/Grid01.tsx new file mode 100644 index 0000000000..bc9b6115be --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/layout/Grid01.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Grid01.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Grid01' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/layout/index.ts b/web/app/components/base/icons/src/vender/line/layout/index.ts index a6aa205faa..7c12b1f58f 100644 --- a/web/app/components/base/icons/src/vender/line/layout/index.ts +++ b/web/app/components/base/icons/src/vender/line/layout/index.ts @@ -1 +1,4 @@ +export { default as AlignLeft01 } from './AlignLeft01' +export { default as AlignRight01 } from './AlignRight01' +export { default as Grid01 } from './Grid01' export { default as LayoutGrid02 } from './LayoutGrid02' diff --git a/web/app/components/base/icons/src/vender/line/users/User01.tsx b/web/app/components/base/icons/src/vender/line/users/User01.tsx new file mode 100644 index 0000000000..42f2144b97 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/users/User01.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './User01.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'User01' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/users/Users01.tsx b/web/app/components/base/icons/src/vender/line/users/Users01.tsx new file mode 100644 index 0000000000..b63daf7242 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/users/Users01.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Users01.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Users01' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/users/index.ts b/web/app/components/base/icons/src/vender/line/users/index.ts new file mode 100644 index 0000000000..9f8a35152f --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/users/index.ts @@ -0,0 +1,2 @@ +export { default as User01 } from './User01' +export { default as Users01 } from './Users01' diff --git a/web/app/components/base/icons/src/vender/line/weather/Stars02.tsx b/web/app/components/base/icons/src/vender/line/weather/Stars02.tsx new file mode 100644 index 0000000000..8a42448c70 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/weather/Stars02.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Stars02.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Stars02' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/weather/index.ts b/web/app/components/base/icons/src/vender/line/weather/index.ts new file mode 100644 index 0000000000..1a68bce765 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/weather/index.ts @@ -0,0 +1 @@ +export { default as Stars02 } from './Stars02' diff --git a/web/app/components/base/icons/src/vender/pipeline/InputField.tsx b/web/app/components/base/icons/src/vender/pipeline/InputField.tsx index 4c224844d0..981b2d38d2 100644 --- a/web/app/components/base/icons/src/vender/pipeline/InputField.tsx +++ b/web/app/components/base/icons/src/vender/pipeline/InputField.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/pipeline/PipelineFill.tsx b/web/app/components/base/icons/src/vender/pipeline/PipelineFill.tsx index e0c2cc5386..2a31601cb3 100644 --- a/web/app/components/base/icons/src/vender/pipeline/PipelineFill.tsx +++ b/web/app/components/base/icons/src/vender/pipeline/PipelineFill.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/pipeline/PipelineLine.tsx b/web/app/components/base/icons/src/vender/pipeline/PipelineLine.tsx index e18df7af48..5f37828ed5 100644 --- a/web/app/components/base/icons/src/vender/pipeline/PipelineLine.tsx +++ b/web/app/components/base/icons/src/vender/pipeline/PipelineLine.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/plugin/Trigger.json b/web/app/components/base/icons/src/vender/plugin/Trigger.json new file mode 100644 index 0000000000..409ef0e478 --- /dev/null +++ b/web/app/components/base/icons/src/vender/plugin/Trigger.json @@ -0,0 +1,73 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "16", + "height": "16", + "viewBox": "0 0 16 16", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "fill-rule": "evenodd", + "clip-rule": "evenodd", + "d": "M7.1499 6.35213L7.25146 6.38208L14.2248 9.03898L14.3172 9.08195C14.7224 9.30788 14.778 9.87906 14.424 10.179L14.342 10.2389L11.8172 11.817L10.2391 14.3417C9.96271 14.7839 9.32424 14.751 9.08219 14.317L9.03923 14.2245L6.38232 7.25122C6.18829 6.74188 6.64437 6.24196 7.1499 6.35213ZM9.81201 12.5084L10.7671 10.981L10.8114 10.9185C10.8589 10.8589 10.9163 10.8075 10.9813 10.7668L12.5086 9.81177L8.15251 8.15226L9.81201 12.5084Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M5.2124 10.3977L3.56266 12.0474L2.61995 11.1047L4.26969 9.455L5.2124 10.3977Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M3.66683 7.99992H1.3335V6.66659H3.66683V7.99992Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M5.2124 4.2688L4.26969 5.21151L2.61995 3.56177L3.56266 2.61906L5.2124 4.2688Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M12.0477 3.56177L10.3979 5.21151L9.45524 4.2688L11.105 2.61906L12.0477 3.56177Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M8.00016 3.66659H6.66683V1.33325H8.00016V3.66659Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "Trigger" +} diff --git a/web/app/components/base/icons/src/vender/plugin/Trigger.tsx b/web/app/components/base/icons/src/vender/plugin/Trigger.tsx new file mode 100644 index 0000000000..b8f6a56ca7 --- /dev/null +++ b/web/app/components/base/icons/src/vender/plugin/Trigger.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Trigger.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Trigger' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/plugin/index.ts b/web/app/components/base/icons/src/vender/plugin/index.ts index 943c764116..b345526eb7 100644 --- a/web/app/components/base/icons/src/vender/plugin/index.ts +++ b/web/app/components/base/icons/src/vender/plugin/index.ts @@ -1,2 +1,3 @@ export { default as BoxSparkleFill } from './BoxSparkleFill' export { default as LeftCorner } from './LeftCorner' +export { default as Trigger } from './Trigger' diff --git a/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownDoubleLine.json b/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownDoubleLine.json new file mode 100644 index 0000000000..17bc271b9e --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownDoubleLine.json @@ -0,0 +1,26 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "16", + "height": "16", + "viewBox": "0 0 16 16", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M8.00001 12.7761L12.1381 8.63804L11.1953 7.69524L8.00001 10.8905L4.80475 7.69524L3.86194 8.63804L8.00001 12.7761ZM8.00001 9.00951L12.1381 4.87146L11.1953 3.92865L8.00001 7.12391L4.80475 3.92865L3.86194 4.87146L8.00001 9.00951Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "ArrowDownDoubleLine" +} diff --git a/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownDoubleLine.tsx b/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownDoubleLine.tsx new file mode 100644 index 0000000000..166a5b624b --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownDoubleLine.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './ArrowDownDoubleLine.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'ArrowDownDoubleLine' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.json b/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.json new file mode 100644 index 0000000000..b150caf879 --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.json @@ -0,0 +1,27 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "16", + "height": "16", + "viewBox": "0 0 16 16", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M6.02888 6.23572C5.08558 6.23572 4.56458 7.33027 5.15943 8.06239L7.13069 10.4885C7.57898 11.0403 8.42124 11.0403 8.86962 10.4885L10.8408 8.06239C11.4357 7.33027 10.9147 6.23572 9.97134 6.23572H6.02888Z", + "fill": "currentColor", + "fill-opacity": "0.3" + }, + "children": [] + } + ] + }, + "name": "ArrowDownRoundFill" +} diff --git a/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.tsx b/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.tsx new file mode 100644 index 0000000000..24a1ea53fd --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './ArrowDownRoundFill.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'ArrowDownRoundFill' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/arrows/ArrowUpDoubleLine.json b/web/app/components/base/icons/src/vender/solid/arrows/ArrowUpDoubleLine.json new file mode 100644 index 0000000000..b76fc3e80c --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/arrows/ArrowUpDoubleLine.json @@ -0,0 +1,26 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "16", + "height": "16", + "viewBox": "0 0 16 16", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M8 3.22388L3.86194 7.36193L4.80475 8.30473L8 5.10949L11.1953 8.30473L12.1381 7.36193L8 3.22388ZM8 6.99046L3.86194 11.1285L4.80475 12.0713L8 8.87606L11.1953 12.0713L12.1381 11.1285L8 6.99046Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "ArrowUpDoubleLine" +} diff --git a/web/app/components/base/icons/src/vender/solid/arrows/ArrowUpDoubleLine.tsx b/web/app/components/base/icons/src/vender/solid/arrows/ArrowUpDoubleLine.tsx new file mode 100644 index 0000000000..06ba38ec70 --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/arrows/ArrowUpDoubleLine.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './ArrowUpDoubleLine.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'ArrowUpDoubleLine' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/arrows/ChevronDown.tsx b/web/app/components/base/icons/src/vender/solid/arrows/ChevronDown.tsx new file mode 100644 index 0000000000..643ddfbf79 --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/arrows/ChevronDown.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './ChevronDown.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'ChevronDown' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/arrows/HighPriority.tsx b/web/app/components/base/icons/src/vender/solid/arrows/HighPriority.tsx new file mode 100644 index 0000000000..af6fa05e5c --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/arrows/HighPriority.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './HighPriority.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'HighPriority' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/arrows/index.ts b/web/app/components/base/icons/src/vender/solid/arrows/index.ts new file mode 100644 index 0000000000..58ce9aa8ac --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/arrows/index.ts @@ -0,0 +1,5 @@ +export { default as ArrowDownDoubleLine } from './ArrowDownDoubleLine' +export { default as ArrowDownRoundFill } from './ArrowDownRoundFill' +export { default as ArrowUpDoubleLine } from './ArrowUpDoubleLine' +export { default as ChevronDown } from './ChevronDown' +export { default as HighPriority } from './HighPriority' diff --git a/web/app/components/base/icons/src/vender/solid/communication/AiText.json b/web/app/components/base/icons/src/vender/solid/communication/AiText.json new file mode 100644 index 0000000000..65860e58b9 --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/communication/AiText.json @@ -0,0 +1,53 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "24", + "height": "24", + "viewBox": "0 0 24 24", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M4 5C3.44772 5 3 5.44772 3 6C3 6.55228 3.44772 7 4 7H20C20.5523 7 21 6.55228 21 6C21 5.44772 20.5523 5 20 5H4Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M17.9191 9.60608C17.7616 9.2384 17.4 9 17 9C16.6 9 16.2384 9.2384 16.0809 9.60608L14.7384 12.7384L11.6061 14.0809C11.2384 14.2384 11 14.6 11 15C11 15.4 11.2384 15.7616 11.6061 15.9191L14.7384 17.2616L16.0809 20.3939C16.2384 20.7616 16.6 21 17 21C17.4 21 17.7616 20.7616 17.9191 20.3939L19.2616 17.2616L22.3939 15.9191C22.7616 15.7616 23 15.4 23 15C23 14.6 22.7616 14.2384 22.3939 14.0809L19.2616 12.7384L17.9191 9.60608Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M4 11C3.44772 11 3 11.4477 3 12C3 12.5523 3.44772 13 4 13H9C9.55228 13 10 12.5523 10 12C10 11.4477 9.55228 11 9 11H4Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M4 17C3.44772 17 3 17.4477 3 18C3 18.5523 3.44772 19 4 19H7C7.55228 19 8 18.5523 8 18C8 17.4477 7.55228 17 7 17H4Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "AiText" +} diff --git a/web/app/components/base/icons/src/vender/solid/communication/AiText.tsx b/web/app/components/base/icons/src/vender/solid/communication/AiText.tsx new file mode 100644 index 0000000000..7d5a860038 --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/communication/AiText.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './AiText.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'AiText' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/communication/index.ts b/web/app/components/base/icons/src/vender/solid/communication/index.ts index a1659b7b18..7d2a3a5a95 100644 --- a/web/app/components/base/icons/src/vender/solid/communication/index.ts +++ b/web/app/components/base/icons/src/vender/solid/communication/index.ts @@ -1,3 +1,4 @@ +export { default as AiText } from './AiText' export { default as BubbleTextMod } from './BubbleTextMod' export { default as ChatBot } from './ChatBot' export { default as CuteRobot } from './CuteRobot' diff --git a/web/app/components/base/icons/src/vender/solid/layout/Grid01.tsx b/web/app/components/base/icons/src/vender/solid/layout/Grid01.tsx new file mode 100644 index 0000000000..bc9b6115be --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/layout/Grid01.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Grid01.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Grid01' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/layout/index.ts b/web/app/components/base/icons/src/vender/solid/layout/index.ts new file mode 100644 index 0000000000..73a2513d51 --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/layout/index.ts @@ -0,0 +1 @@ +export { default as Grid01 } from './Grid01' diff --git a/web/app/components/base/icons/src/vender/workflow/ApiAggregate.json b/web/app/components/base/icons/src/vender/workflow/ApiAggregate.json new file mode 100644 index 0000000000..1057842352 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/ApiAggregate.json @@ -0,0 +1,26 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "16", + "height": "16", + "viewBox": "0 0 16 16", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M5.92578 11.0094C5.92578 10.0174 5.12163 9.21256 4.12956 9.21256C3.13752 9.2126 2.33333 10.0174 2.33333 11.0094C2.33349 12.0014 3.13762 12.8056 4.12956 12.8057C5.12153 12.8057 5.92562 12.0014 5.92578 11.0094ZM13.6667 11.0094C13.6667 10.0174 12.8625 9.2126 11.8704 9.21256C10.8784 9.21256 10.0742 10.0174 10.0742 11.0094C10.0744 12.0014 10.8785 12.8057 11.8704 12.8057C12.8624 12.8056 13.6665 12.0014 13.6667 11.0094ZM9.79622 4.32389C9.79619 3.33186 8.99205 2.52767 8 2.52767C7.00796 2.52767 6.20382 3.33186 6.20378 4.32389C6.20378 5.31596 7.00793 6.12012 8 6.12012C8.99207 6.12012 9.79622 5.31596 9.79622 4.32389ZM11.1296 4.32389C11.1296 5.82351 10.0748 7.07628 8.66667 7.38184V7.9196L9.74284 8.71387C10.3012 8.19607 11.0489 7.87923 11.8704 7.87923C13.5989 7.87927 15 9.28101 15 11.0094C14.9998 12.7377 13.5988 14.139 11.8704 14.139C10.1421 14.139 8.74104 12.7378 8.74089 11.0094C8.74089 10.5837 8.82585 10.1776 8.97982 9.80762L8 9.08366L7.01953 9.80762C7.17356 10.1777 7.25911 10.5836 7.25911 11.0094C7.25896 12.7378 5.85791 14.139 4.12956 14.139C2.40124 14.139 1.00016 12.7377 1 11.0094C1 9.28101 2.40114 7.87927 4.12956 7.87923C4.95094 7.87923 5.69819 8.19627 6.25651 8.71387L7.33333 7.9196V7.38184C5.92523 7.07628 4.87044 5.82351 4.87044 4.32389C4.87048 2.59548 6.27158 1.19434 8 1.19434C9.72843 1.19434 11.1295 2.59548 11.1296 4.32389Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "ApiAggregate" +} diff --git a/web/app/components/base/icons/src/vender/workflow/ApiAggregate.tsx b/web/app/components/base/icons/src/vender/workflow/ApiAggregate.tsx new file mode 100644 index 0000000000..64193e900b --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/ApiAggregate.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './ApiAggregate.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'ApiAggregate' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/workflow/Asterisk.json b/web/app/components/base/icons/src/vender/workflow/Asterisk.json new file mode 100644 index 0000000000..d7fa156d99 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/Asterisk.json @@ -0,0 +1,26 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "14", + "height": "14", + "viewBox": "0 0 14 14", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M7.58325 1.75L7.58314 5.98908L11.2549 3.86982L11.8382 4.88018L8.16705 6.99942L11.8382 9.11983L11.2549 10.1302L7.58314 8.01033L7.58325 12.25H6.41659L6.41647 8.01033L2.74495 10.1302L2.16162 9.11983L5.83254 7L2.16162 4.88018L2.74495 3.86982L6.41647 5.98908L6.41659 1.75H7.58325Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "Asterisk" +} diff --git a/web/app/components/base/icons/src/vender/workflow/Asterisk.tsx b/web/app/components/base/icons/src/vender/workflow/Asterisk.tsx new file mode 100644 index 0000000000..916b90429c --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/Asterisk.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Asterisk.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Asterisk' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/workflow/CalendarCheckLine.json b/web/app/components/base/icons/src/vender/workflow/CalendarCheckLine.json new file mode 100644 index 0000000000..8f77528653 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/CalendarCheckLine.json @@ -0,0 +1,26 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "14", + "height": "14", + "viewBox": "0 0 14 14", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M5.24984 0.583252V1.74992H8.74984V0.583252H9.9165V1.74992H12.2498C12.572 1.74992 12.8332 2.01109 12.8332 2.33325V11.6666C12.8332 11.9888 12.572 12.2499 12.2498 12.2499H1.74984C1.42767 12.2499 1.1665 11.9888 1.1665 11.6666V2.33325C1.1665 2.01109 1.42767 1.74992 1.74984 1.74992H4.08317V0.583252H5.24984ZM11.6665 5.83325H2.33317V11.0833H11.6665V5.83325ZM8.77055 6.49592L9.5955 7.32093L6.70817 10.2083L4.64578 8.14588L5.47073 7.32093L6.70817 8.55835L8.77055 6.49592ZM4.08317 2.91659H2.33317V4.66659H11.6665V2.91659H9.9165V3.49992H8.74984V2.91659H5.24984V3.49992H4.08317V2.91659Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "CalendarCheckLine" +} diff --git a/web/app/components/base/icons/src/vender/workflow/CalendarCheckLine.tsx b/web/app/components/base/icons/src/vender/workflow/CalendarCheckLine.tsx new file mode 100644 index 0000000000..e480da2f04 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/CalendarCheckLine.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './CalendarCheckLine.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'CalendarCheckLine' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/workflow/Schedule.json b/web/app/components/base/icons/src/vender/workflow/Schedule.json new file mode 100644 index 0000000000..1c2d181dc4 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/Schedule.json @@ -0,0 +1,46 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "16", + "height": "16", + "viewBox": "0 0 16 16", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M11.3333 9.33337C11.7015 9.33337 11.9999 9.63193 12 10V11.0573L12.8047 11.862L12.8503 11.9128C13.0638 12.1746 13.0487 12.5607 12.8047 12.8047C12.5606 13.0488 12.1746 13.0639 11.9128 12.8503L11.862 12.8047L10.862 11.8047C10.7371 11.6798 10.6667 11.5101 10.6667 11.3334V10C10.6668 9.63193 10.9652 9.33337 11.3333 9.33337Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "fill-rule": "evenodd", + "clip-rule": "evenodd", + "d": "M11.3333 7.33337C13.5425 7.33337 15.3333 9.12424 15.3333 11.3334C15.3333 13.5425 13.5425 15.3334 11.3333 15.3334C9.12419 15.3334 7.33333 13.5425 7.33333 11.3334C7.33333 9.12424 9.12419 7.33337 11.3333 7.33337ZM11.3333 8.66671C9.86057 8.66671 8.66667 9.86061 8.66667 11.3334C8.66667 12.8061 9.86057 14 11.3333 14C12.8061 14 14 12.8061 14 11.3334C14 9.86061 12.8061 8.66671 11.3333 8.66671Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M10.6667 1.33337C11.0349 1.33337 11.3333 1.63185 11.3333 2.00004V2.66671H12.6667C13.4031 2.66671 14 3.26367 14 4.00004V5.66671C14 6.0349 13.7015 6.33337 13.3333 6.33337C12.9651 6.33337 12.6667 6.0349 12.6667 5.66671V4.00004H3.33333V12.6667H5.66667C6.03486 12.6667 6.33333 12.9652 6.33333 13.3334C6.33333 13.7016 6.03486 14 5.66667 14H3.33333C2.59697 14 2 13.4031 2 12.6667V4.00004C2 3.26366 2.59696 2.66671 3.33333 2.66671H4.66667V2.00004C4.66667 1.63185 4.96514 1.33337 5.33333 1.33337C5.70152 1.33337 6 1.63185 6 2.00004V2.66671H10V2.00004C10 1.63185 10.2985 1.33337 10.6667 1.33337Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "Schedule" +} diff --git a/web/app/components/base/icons/src/vender/workflow/Schedule.tsx b/web/app/components/base/icons/src/vender/workflow/Schedule.tsx new file mode 100644 index 0000000000..71205efd0b --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/Schedule.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Schedule.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Schedule' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/workflow/TriggerAll.json b/web/app/components/base/icons/src/vender/workflow/TriggerAll.json new file mode 100644 index 0000000000..c324e8be04 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/TriggerAll.json @@ -0,0 +1,73 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "14", + "height": "14", + "viewBox": "0 0 14 14", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "fill-rule": "evenodd", + "clip-rule": "evenodd", + "d": "M5.34698 6.42505C5.10275 5.79268 5.67045 5.17005 6.29816 5.30916L6.42446 5.34758L13.0846 7.92049L13.1999 7.97518C13.7051 8.26089 13.7647 8.9802 13.3118 9.34432L13.207 9.41659L10.8196 10.8202L9.416 13.2076C9.08465 13.7711 8.28069 13.742 7.97459 13.2004L7.9199 13.0852L5.34698 6.42505ZM8.791 11.6392L9.73631 10.0325L9.73696 10.0318L9.7962 9.94458C9.86055 9.86164 9.94031 9.79125 10.0312 9.73755L10.0319 9.7369L11.6387 8.79159L6.99738 6.99797L8.791 11.6392Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M2.79751 8.9257C3.05781 8.66539 3.47985 8.66547 3.74021 8.9257C4.00057 9.18604 4.00056 9.60805 3.74021 9.86841L3.03318 10.5754C2.77283 10.8356 2.35078 10.8357 2.09047 10.5754C1.83032 10.3151 1.83033 9.89305 2.09047 9.63273L2.79751 8.9257Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M1.99998 5.66659C2.36817 5.66659 2.66665 5.96506 2.66665 6.33325C2.66665 6.70144 2.36817 6.99992 1.99998 6.99992H0.99998C0.63179 6.99992 0.333313 6.70144 0.333313 6.33325C0.333313 5.96506 0.63179 5.66659 0.99998 5.66659H1.99998Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M9.63279 2.09106C9.8931 1.83077 10.3151 1.83086 10.5755 2.09106C10.8358 2.35142 10.8359 2.77343 10.5755 3.03377L9.86847 3.7408C9.6081 4.00098 9.18605 4.0011 8.92576 3.7408C8.66559 3.4805 8.66562 3.05841 8.92576 2.7981L9.63279 2.09106Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M2.09113 2.09041C2.33521 1.84649 2.72126 1.83132 2.98305 2.04484L3.03383 2.09041L3.74087 2.79744L3.78644 2.84823C3.9999 3.11002 3.98476 3.49609 3.74087 3.74015C3.49682 3.9842 3.11079 3.9992 2.84894 3.78573L2.79816 3.74015L2.09113 3.03312L2.04555 2.98234C1.83199 2.72049 1.84705 2.33449 2.09113 2.09041Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M6.33331 0.333252C6.7015 0.333252 6.99998 0.631729 6.99998 0.999919V1.99992C6.99998 2.36811 6.7015 2.66659 6.33331 2.66659C5.96512 2.66659 5.66665 2.36811 5.66665 1.99992V0.999919C5.66665 0.631729 5.96512 0.333252 6.33331 0.333252Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "TriggerAll" +} diff --git a/web/app/components/base/icons/src/vender/workflow/TriggerAll.tsx b/web/app/components/base/icons/src/vender/workflow/TriggerAll.tsx new file mode 100644 index 0000000000..71f2dbdb36 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/TriggerAll.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './TriggerAll.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'TriggerAll' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/workflow/WebhookLine.json b/web/app/components/base/icons/src/vender/workflow/WebhookLine.json new file mode 100644 index 0000000000..8319fd25f3 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/WebhookLine.json @@ -0,0 +1,26 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "16", + "height": "16", + "viewBox": "0 0 16 16", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M5.91246 9.42618C5.77036 9.66084 5.70006 9.85191 5.81358 10.1502C6.12696 10.9742 5.68488 11.776 4.85394 11.9937C4.07033 12.199 3.30686 11.684 3.15138 10.8451C3.01362 10.1025 3.58988 9.37451 4.40859 9.25851C4.45305 9.25211 4.49808 9.24938 4.55563 9.24591C4.58692 9.24404 4.62192 9.24191 4.66252 9.23884L5.90792 7.15051C5.12463 6.37166 4.65841 5.46114 4.7616 4.33295C4.83455 3.53543 5.14813 2.84626 5.72135 2.28138C6.81916 1.19968 8.49403 1.02449 9.78663 1.85479C11.0282 2.65232 11.5967 4.20582 11.112 5.53545L9.97403 5.22671C10.1263 4.48748 10.0137 3.82362 9.5151 3.25494C9.1857 2.87947 8.76303 2.68267 8.28236 2.61015C7.31883 2.46458 6.37278 3.08364 6.09207 4.02937C5.77342 5.10275 6.25566 5.97954 7.5735 6.64023C7.0207 7.56944 6.47235 8.50124 5.91246 9.42618ZM9.18916 5.51562C9.5877 6.2187 9.99236 6.93244 10.3934 7.63958C12.4206 7.01244 13.9491 8.13458 14.4974 9.33604C15.1597 10.7873 14.707 12.5062 13.4062 13.4016C12.0711 14.3207 10.3827 14.1636 9.19976 12.983L10.1279 12.2063C11.2962 12.963 12.3181 12.9274 13.0767 12.0314C13.7236 11.2669 13.7096 10.1271 13.0439 9.37871C12.2757 8.51511 11.2467 8.48878 10.0029 9.31784C9.48696 8.40251 8.96196 7.49424 8.46236 6.57234C8.2939 6.2616 8.10783 6.08135 7.72816 6.01558C7.09403 5.90564 6.68463 5.36109 6.66007 4.75099C6.63593 4.14763 6.99136 3.60224 7.54696 3.38974C8.0973 3.17924 8.74316 3.34916 9.11336 3.81707C9.4159 4.19938 9.51203 4.62966 9.35283 5.10116C9.32283 5.19018 9.28689 5.27727 9.2475 5.37261C9.22869 5.418 9.20916 5.46538 9.18916 5.51562ZM7.7013 11.2634H10.1417C10.1757 11.3087 10.2075 11.3536 10.2386 11.3973C10.3034 11.4887 10.3649 11.5755 10.4367 11.6526C10.9536 12.2052 11.8263 12.2326 12.3788 11.7197C12.9514 11.1881 12.9773 10.2951 12.4362 9.74011C11.9068 9.19704 11.0019 9.14518 10.5103 9.72018C10.2117 10.0696 9.9057 10.1107 9.50936 10.1045C8.49423 10.0888 7.47843 10.0994 6.46346 10.0994C6.52934 11.5273 5.98953 12.417 4.9189 12.6283C3.87051 12.8352 2.90496 12.3003 2.56502 11.3243C2.17891 10.2153 2.65641 9.32838 4.0361 8.62444C3.93228 8.24838 3.8274 7.86778 3.72357 7.49071C2.21981 7.81844 1.09162 9.27738 1.20809 10.9187C1.31097 12.3676 2.47975 13.6544 3.90909 13.8849C4.68542 14.0102 5.41485 13.88 6.09157 13.4962C6.96216 13.0022 7.46736 12.2254 7.7013 11.2634Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "WebhookLine" +} diff --git a/web/app/components/base/icons/src/vender/workflow/WebhookLine.tsx b/web/app/components/base/icons/src/vender/workflow/WebhookLine.tsx new file mode 100644 index 0000000000..0379692808 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/WebhookLine.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './WebhookLine.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'WebhookLine' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/workflow/index.ts b/web/app/components/base/icons/src/vender/workflow/index.ts index 37b7306f7b..26ee3e4126 100644 --- a/web/app/components/base/icons/src/vender/workflow/index.ts +++ b/web/app/components/base/icons/src/vender/workflow/index.ts @@ -1,6 +1,9 @@ export { default as Agent } from './Agent' export { default as Answer } from './Answer' +export { default as ApiAggregate } from './ApiAggregate' export { default as Assigner } from './Assigner' +export { default as Asterisk } from './Asterisk' +export { default as CalendarCheckLine } from './CalendarCheckLine' export { default as Code } from './Code' export { default as Datasource } from './Datasource' export { default as DocsExtractor } from './DocsExtractor' @@ -19,6 +22,9 @@ export { default as LoopEnd } from './LoopEnd' export { default as Loop } from './Loop' export { default as ParameterExtractor } from './ParameterExtractor' export { default as QuestionClassifier } from './QuestionClassifier' +export { default as Schedule } from './Schedule' export { default as TemplatingTransform } from './TemplatingTransform' +export { default as TriggerAll } from './TriggerAll' export { default as VariableX } from './VariableX' +export { default as WebhookLine } from './WebhookLine' export { default as WindowCursor } from './WindowCursor' diff --git a/web/app/components/base/image-gallery/index.tsx b/web/app/components/base/image-gallery/index.tsx index 0f9061fdb6..fdb9711292 100644 --- a/web/app/components/base/image-gallery/index.tsx +++ b/web/app/components/base/image-gallery/index.tsx @@ -1,9 +1,9 @@ 'use client' +import ImagePreview from '@/app/components/base/image-uploader/image-preview' +import cn from '@/utils/classnames' import type { FC } from 'react' import React, { useState } from 'react' import s from './style.module.css' -import cn from '@/utils/classnames' -import ImagePreview from '@/app/components/base/image-uploader/image-preview' type Props = { srcs: string[] @@ -36,10 +36,8 @@ const ImageGallery: FC = ({ const imgStyle = getWidthStyle(imgNum) return (
- {/* TODO: support preview */} {srcs.map((src, index) => ( - - = ({ imagePreviewUrl && ( setImagePreviewUrl('')} title={''} /> + onCancel={() => setImagePreviewUrl('')} + title={''} + /> ) }
diff --git a/web/app/components/base/input-with-copy/index.spec.tsx b/web/app/components/base/input-with-copy/index.spec.tsx new file mode 100644 index 0000000000..f302f1715a --- /dev/null +++ b/web/app/components/base/input-with-copy/index.spec.tsx @@ -0,0 +1,150 @@ +import React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import '@testing-library/jest-dom' +import InputWithCopy from './index' + +// Mock the copy-to-clipboard library +jest.mock('copy-to-clipboard', () => jest.fn(() => true)) + +// Mock the i18n hook +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'common.operation.copy': 'Copy', + 'common.operation.copied': 'Copied', + 'appOverview.overview.appInfo.embedded.copy': 'Copy', + 'appOverview.overview.appInfo.embedded.copied': 'Copied', + } + return translations[key] || key + }, + }), +})) + +// Mock lodash-es debounce +jest.mock('lodash-es', () => ({ + debounce: (fn: any) => fn, +})) + +describe('InputWithCopy component', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders correctly with default props', () => { + const mockOnChange = jest.fn() + render() + const input = screen.getByDisplayValue('test value') + const copyButton = screen.getByRole('button') + expect(input).toBeInTheDocument() + expect(copyButton).toBeInTheDocument() + }) + + it('hides copy button when showCopyButton is false', () => { + const mockOnChange = jest.fn() + render() + const input = screen.getByDisplayValue('test value') + const copyButton = screen.queryByRole('button') + expect(input).toBeInTheDocument() + expect(copyButton).not.toBeInTheDocument() + }) + + it('copies input value when copy button is clicked', async () => { + const copyToClipboard = require('copy-to-clipboard') + const mockOnChange = jest.fn() + render() + + const copyButton = screen.getByRole('button') + fireEvent.click(copyButton) + + expect(copyToClipboard).toHaveBeenCalledWith('test value') + }) + + it('copies custom value when copyValue prop is provided', async () => { + const copyToClipboard = require('copy-to-clipboard') + const mockOnChange = jest.fn() + render() + + const copyButton = screen.getByRole('button') + fireEvent.click(copyButton) + + expect(copyToClipboard).toHaveBeenCalledWith('custom copy value') + }) + + it('calls onCopy callback when copy button is clicked', async () => { + const onCopyMock = jest.fn() + const mockOnChange = jest.fn() + render() + + const copyButton = screen.getByRole('button') + fireEvent.click(copyButton) + + expect(onCopyMock).toHaveBeenCalledWith('test value') + }) + + it('shows copied state after successful copy', async () => { + const mockOnChange = jest.fn() + render() + + const copyButton = screen.getByRole('button') + fireEvent.click(copyButton) + + // Hover over the button to trigger tooltip + fireEvent.mouseEnter(copyButton) + + // Check if the tooltip shows "Copied" state + await waitFor(() => { + expect(screen.getByText('Copied')).toBeInTheDocument() + }, { timeout: 2000 }) + }) + + it('passes through all input props correctly', () => { + const mockOnChange = jest.fn() + render( + , + ) + + const input = screen.getByDisplayValue('test value') + expect(input).toHaveAttribute('placeholder', 'Custom placeholder') + expect(input).toBeDisabled() + expect(input).toHaveAttribute('readonly') + expect(input).toHaveClass('custom-class') + }) + + it('handles empty value correctly', () => { + const copyToClipboard = require('copy-to-clipboard') + const mockOnChange = jest.fn() + render() + const input = screen.getByRole('textbox') + const copyButton = screen.getByRole('button') + + expect(input).toBeInTheDocument() + expect(copyButton).toBeInTheDocument() + + fireEvent.click(copyButton) + expect(copyToClipboard).toHaveBeenCalledWith('') + }) + + it('maintains focus on input after copy', async () => { + const mockOnChange = jest.fn() + render() + + const input = screen.getByDisplayValue('test value') + const copyButton = screen.getByRole('button') + + input.focus() + expect(input).toHaveFocus() + + fireEvent.click(copyButton) + + // Input should maintain focus after copy + expect(input).toHaveFocus() + }) +}) diff --git a/web/app/components/base/input-with-copy/index.tsx b/web/app/components/base/input-with-copy/index.tsx new file mode 100644 index 0000000000..87b7de5005 --- /dev/null +++ b/web/app/components/base/input-with-copy/index.tsx @@ -0,0 +1,104 @@ +'use client' +import React, { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { RiClipboardFill, RiClipboardLine } from '@remixicon/react' +import { debounce } from 'lodash-es' +import copy from 'copy-to-clipboard' +import type { InputProps } from '../input' +import Tooltip from '../tooltip' +import ActionButton from '../action-button' +import cn from '@/utils/classnames' + +export type InputWithCopyProps = { + showCopyButton?: boolean + copyValue?: string // Value to copy, defaults to input value + onCopy?: (value: string) => void // Callback when copy is triggered +} & Omit // Remove conflicting props + +const prefixEmbedded = 'appOverview.overview.appInfo.embedded' + +const InputWithCopy = React.forwardRef(( + { + showCopyButton = true, + copyValue, + onCopy, + value, + wrapperClassName, + ...inputProps + }, + ref, +) => { + const { t } = useTranslation() + const [isCopied, setIsCopied] = useState(false) + // Determine what value to copy + const valueToString = typeof value === 'string' ? value : String(value || '') + const finalCopyValue = copyValue || valueToString + + const onClickCopy = debounce(() => { + copy(finalCopyValue) + setIsCopied(true) + onCopy?.(finalCopyValue) + }, 100) + + const onMouseLeave = debounce(() => { + setIsCopied(false) + }, 100) + + useEffect(() => { + if (isCopied) { + const timeout = setTimeout(() => { + setIsCopied(false) + }, 2000) + return () => { + clearTimeout(timeout) + } + } + }, [isCopied]) + + return ( +
+ rest)(inputProps)} + /> + {showCopyButton && ( +
+ + + {isCopied ? ( + + ) : ( + + )} + + +
+ )} +
+ ) +}) + +InputWithCopy.displayName = 'InputWithCopy' + +export default InputWithCopy diff --git a/web/app/components/base/input/index.tsx b/web/app/components/base/input/index.tsx index 881aa1d610..688e1dd880 100644 --- a/web/app/components/base/input/index.tsx +++ b/web/app/components/base/input/index.tsx @@ -1,10 +1,11 @@ +import cn from '@/utils/classnames' +import { RiCloseCircleFill, RiErrorWarningLine, RiSearchLine } from '@remixicon/react' +import { type VariantProps, cva } from 'class-variance-authority' +import { noop } from 'lodash-es' import type { CSSProperties, ChangeEventHandler, FocusEventHandler } from 'react' import React from 'react' import { useTranslation } from 'react-i18next' -import { RiCloseCircleFill, RiErrorWarningLine, RiSearchLine } from '@remixicon/react' -import { type VariantProps, cva } from 'class-variance-authority' -import cn from '@/utils/classnames' -import { noop } from 'lodash-es' +import { CopyFeedbackNew } from '../copy-feedback' export const inputVariants = cva( '', @@ -24,6 +25,7 @@ export const inputVariants = cva( export type InputProps = { showLeftIcon?: boolean showClearIcon?: boolean + showCopyIcon?: boolean onClear?: () => void disabled?: boolean destructive?: boolean @@ -41,6 +43,7 @@ const Input = ({ destructive, showLeftIcon, showClearIcon, + showCopyIcon, onClear, wrapperClassName, className, @@ -92,8 +95,8 @@ const Input = ({ showLeftIcon && size === 'large' && 'pl-7', showClearIcon && value && 'pr-[26px]', showClearIcon && value && size === 'large' && 'pr-7', - destructive && 'pr-[26px]', - destructive && size === 'large' && 'pr-7', + (destructive || showCopyIcon) && 'pr-[26px]', + (destructive || showCopyIcon) && size === 'large' && 'pr-7', disabled && 'cursor-not-allowed border-transparent bg-components-input-bg-disabled text-components-input-text-filled-disabled hover:border-transparent hover:bg-components-input-bg-disabled', destructive && 'border-components-input-border-destructive bg-components-input-bg-destructive text-components-input-text-filled hover:border-components-input-border-destructive hover:bg-components-input-bg-destructive focus:border-components-input-border-destructive focus:bg-components-input-bg-destructive', className, @@ -115,6 +118,14 @@ const Input = ({ {destructive && ( )} + {showCopyIcon && ( +
+ +
+ )} { unit && (
diff --git a/web/app/components/base/linked-apps-panel/index.stories.tsx b/web/app/components/base/linked-apps-panel/index.stories.tsx index 786d1bdf56..da8abb0677 100644 --- a/web/app/components/base/linked-apps-panel/index.stories.tsx +++ b/web/app/components/base/linked-apps-panel/index.stories.tsx @@ -1,12 +1,13 @@ import type { Meta, StoryObj } from '@storybook/nextjs' import LinkedAppsPanel from '.' import type { RelatedApp } from '@/models/datasets' +import { AppModeEnum } from '@/types/app' const mockRelatedApps: RelatedApp[] = [ { id: 'app-cx', name: 'Customer Support Assistant', - mode: 'chat', + mode: AppModeEnum.CHAT, icon_type: 'emoji', icon: '\u{1F4AC}', icon_background: '#EEF2FF', @@ -15,7 +16,7 @@ const mockRelatedApps: RelatedApp[] = [ { id: 'app-ops', name: 'Ops Workflow Orchestrator', - mode: 'workflow', + mode: AppModeEnum.WORKFLOW, icon_type: 'emoji', icon: '\u{1F6E0}\u{FE0F}', icon_background: '#ECFDF3', @@ -24,7 +25,7 @@ const mockRelatedApps: RelatedApp[] = [ { id: 'app-research', name: 'Research Synthesizer', - mode: 'advanced-chat', + mode: AppModeEnum.ADVANCED_CHAT, icon_type: 'emoji', icon: '\u{1F9E0}', icon_background: '#FDF2FA', diff --git a/web/app/components/base/linked-apps-panel/index.tsx b/web/app/components/base/linked-apps-panel/index.tsx index c3c3f5b46c..561bd49c2a 100644 --- a/web/app/components/base/linked-apps-panel/index.tsx +++ b/web/app/components/base/linked-apps-panel/index.tsx @@ -6,6 +6,7 @@ import { RiArrowRightUpLine } from '@remixicon/react' import cn from '@/utils/classnames' import AppIcon from '@/app/components/base/app-icon' import type { RelatedApp } from '@/models/datasets' +import { AppModeEnum } from '@/types/app' type ILikedItemProps = { appStatus?: boolean @@ -14,11 +15,11 @@ type ILikedItemProps = { } const appTypeMap = { - 'chat': 'Chatbot', - 'completion': 'Completion', - 'agent-chat': 'Agent', - 'advanced-chat': 'Chatflow', - 'workflow': 'Workflow', + [AppModeEnum.CHAT]: 'Chatbot', + [AppModeEnum.COMPLETION]: 'Completion', + [AppModeEnum.AGENT_CHAT]: 'Agent', + [AppModeEnum.ADVANCED_CHAT]: 'Chatflow', + [AppModeEnum.WORKFLOW]: 'Workflow', } const LikedItem = ({ diff --git a/web/app/components/base/markdown-blocks/index.ts b/web/app/components/base/markdown-blocks/index.ts index ba68b4e8b1..ab6be2e9e7 100644 --- a/web/app/components/base/markdown-blocks/index.ts +++ b/web/app/components/base/markdown-blocks/index.ts @@ -5,9 +5,11 @@ export { default as AudioBlock } from './audio-block' export { default as CodeBlock } from './code-block' +export * from './plugin-img' +export * from './plugin-paragraph' export { default as Img } from './img' -export { default as Link } from './link' export { default as Paragraph } from './paragraph' +export { default as Link } from './link' export { default as PreCode } from './pre-code' export { default as ScriptBlock } from './script-block' export { default as VideoBlock } from './video-block' diff --git a/web/app/components/base/markdown-blocks/plugin-img.tsx b/web/app/components/base/markdown-blocks/plugin-img.tsx new file mode 100644 index 0000000000..ed1ee8fa0b --- /dev/null +++ b/web/app/components/base/markdown-blocks/plugin-img.tsx @@ -0,0 +1,48 @@ +/** + * @fileoverview Img component for rendering tags in Markdown. + * Extracted from the main markdown renderer for modularity. + * Uses the ImageGallery component to display images. + */ +import React, { useEffect, useMemo, useState } from 'react' +import ImageGallery from '@/app/components/base/image-gallery' +import { getMarkdownImageURL } from './utils' +import { usePluginReadmeAsset } from '@/service/use-plugins' +import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper' + +type ImgProps = { + src: string + pluginInfo?: SimplePluginInfo +} + +export const PluginImg: React.FC = ({ src, pluginInfo }) => { + const { pluginUniqueIdentifier, pluginId } = pluginInfo || {} + const { data: assetData } = usePluginReadmeAsset({ plugin_unique_identifier: pluginUniqueIdentifier, file_name: src }) + const [blobUrl, setBlobUrl] = useState() + + useEffect(() => { + if (!assetData) { + setBlobUrl(undefined) + return + } + + const objectUrl = URL.createObjectURL(assetData) + setBlobUrl(objectUrl) + + return () => { + URL.revokeObjectURL(objectUrl) + } + }, [assetData]) + + const imageUrl = useMemo(() => { + if (blobUrl) + return blobUrl + + return getMarkdownImageURL(src, pluginId) + }, [blobUrl, pluginId, src]) + + return ( +
+ +
+ ) +} diff --git a/web/app/components/base/markdown-blocks/plugin-paragraph.tsx b/web/app/components/base/markdown-blocks/plugin-paragraph.tsx new file mode 100644 index 0000000000..ae1e2d7101 --- /dev/null +++ b/web/app/components/base/markdown-blocks/plugin-paragraph.tsx @@ -0,0 +1,69 @@ +/** + * @fileoverview Paragraph component for rendering

tags in Markdown. + * Extracted from the main markdown renderer for modularity. + * Handles special rendering for paragraphs that directly contain an image. + */ +import ImageGallery from '@/app/components/base/image-gallery' +import { usePluginReadmeAsset } from '@/service/use-plugins' +import React, { useEffect, useMemo, useState } from 'react' +import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper' +import { getMarkdownImageURL } from './utils' + +type PluginParagraphProps = { + pluginInfo?: SimplePluginInfo + node?: any + children?: React.ReactNode +} + +export const PluginParagraph: React.FC = ({ pluginInfo, node, children }) => { + const { pluginUniqueIdentifier, pluginId } = pluginInfo || {} + const childrenNode = node?.children as Array | undefined + const firstChild = childrenNode?.[0] + const isImageParagraph = firstChild?.tagName === 'img' + const imageSrc = isImageParagraph ? firstChild?.properties?.src : undefined + + const { data: assetData } = usePluginReadmeAsset({ + plugin_unique_identifier: pluginUniqueIdentifier, + file_name: isImageParagraph && imageSrc ? imageSrc : '', + }) + + const [blobUrl, setBlobUrl] = useState() + + useEffect(() => { + if (!assetData) { + setBlobUrl(undefined) + return + } + + const objectUrl = URL.createObjectURL(assetData) + setBlobUrl(objectUrl) + + return () => { + URL.revokeObjectURL(objectUrl) + } + }, [assetData]) + + const imageUrl = useMemo(() => { + if (blobUrl) + return blobUrl + + if (isImageParagraph && imageSrc) + return getMarkdownImageURL(imageSrc, pluginId) + + return '' + }, [blobUrl, imageSrc, isImageParagraph, pluginId]) + + if (isImageParagraph) { + const remainingChildren = Array.isArray(children) && children.length > 1 ? children.slice(1) : undefined + + return ( +

+ + {remainingChildren && ( +
{remainingChildren}
+ )} +
+ ) + } + return

{children}

+} diff --git a/web/app/components/base/markdown-blocks/utils.ts b/web/app/components/base/markdown-blocks/utils.ts index d8df76aefc..f7dbe9b7ed 100644 --- a/web/app/components/base/markdown-blocks/utils.ts +++ b/web/app/components/base/markdown-blocks/utils.ts @@ -1,7 +1,14 @@ -import { ALLOW_UNSAFE_DATA_SCHEME } from '@/config' +import { ALLOW_UNSAFE_DATA_SCHEME, MARKETPLACE_API_PREFIX } from '@/config' export const isValidUrl = (url: string): boolean => { const validPrefixes = ['http:', 'https:', '//', 'mailto:'] if (ALLOW_UNSAFE_DATA_SCHEME) validPrefixes.push('data:') return validPrefixes.some(prefix => url.startsWith(prefix)) } + +export const getMarkdownImageURL = (url: string, pathname?: string) => { + const regex = /(^\.\/_assets|^_assets)/ + if (regex.test(url)) + return `${MARKETPLACE_API_PREFIX}${MARKETPLACE_API_PREFIX.endsWith('/') ? '' : '/'}plugins/${pathname ?? ''}${url.replace(regex, '/_assets')}` + return url +} diff --git a/web/app/components/base/markdown/index.tsx b/web/app/components/base/markdown/index.tsx index 19f39d8aaa..bb49fe1b14 100644 --- a/web/app/components/base/markdown/index.tsx +++ b/web/app/components/base/markdown/index.tsx @@ -3,7 +3,7 @@ import 'katex/dist/katex.min.css' import { flow } from 'lodash-es' import cn from '@/utils/classnames' import { preprocessLaTeX, preprocessThinkTag } from './markdown-utils' -import type { ReactMarkdownWrapperProps } from './react-markdown-wrapper' +import type { ReactMarkdownWrapperProps, SimplePluginInfo } from './react-markdown-wrapper' const ReactMarkdown = dynamic(() => import('./react-markdown-wrapper').then(mod => mod.ReactMarkdownWrapper), { ssr: false }) @@ -17,10 +17,11 @@ const ReactMarkdown = dynamic(() => import('./react-markdown-wrapper').then(mod export type MarkdownProps = { content: string className?: string + pluginInfo?: SimplePluginInfo } & Pick export const Markdown = (props: MarkdownProps) => { - const { customComponents = {} } = props + const { customComponents = {}, pluginInfo } = props const latexContent = flow([ preprocessThinkTag, preprocessLaTeX, @@ -28,7 +29,7 @@ export const Markdown = (props: MarkdownProps) => { return (
- +
) } diff --git a/web/app/components/base/markdown/react-markdown-wrapper.tsx b/web/app/components/base/markdown/react-markdown-wrapper.tsx index afe3d8a737..22964ec04f 100644 --- a/web/app/components/base/markdown/react-markdown-wrapper.tsx +++ b/web/app/components/base/markdown/react-markdown-wrapper.tsx @@ -1,35 +1,31 @@ -import ReactMarkdown from 'react-markdown' -import RemarkMath from 'remark-math' -import RemarkBreaks from 'remark-breaks' -import RehypeKatex from 'rehype-katex' -import RemarkGfm from 'remark-gfm' -import RehypeRaw from 'rehype-raw' +import { AudioBlock, Img, Link, MarkdownButton, MarkdownForm, Paragraph, PluginImg, PluginParagraph, ScriptBlock, ThinkBlock, VideoBlock } from '@/app/components/base/markdown-blocks' import { ENABLE_SINGLE_DOLLAR_LATEX } from '@/config' -import AudioBlock from '@/app/components/base/markdown-blocks/audio-block' -import Img from '@/app/components/base/markdown-blocks/img' -import Link from '@/app/components/base/markdown-blocks/link' -import MarkdownButton from '@/app/components/base/markdown-blocks/button' -import MarkdownForm from '@/app/components/base/markdown-blocks/form' -import Paragraph from '@/app/components/base/markdown-blocks/paragraph' -import ScriptBlock from '@/app/components/base/markdown-blocks/script-block' -import ThinkBlock from '@/app/components/base/markdown-blocks/think-block' -import VideoBlock from '@/app/components/base/markdown-blocks/video-block' +import dynamic from 'next/dynamic' +import type { FC } from 'react' +import ReactMarkdown from 'react-markdown' +import RehypeKatex from 'rehype-katex' +import RehypeRaw from 'rehype-raw' +import RemarkBreaks from 'remark-breaks' +import RemarkGfm from 'remark-gfm' +import RemarkMath from 'remark-math' import { customUrlTransform } from './markdown-utils' -import type { FC } from 'react' - -import dynamic from 'next/dynamic' - const CodeBlock = dynamic(() => import('@/app/components/base/markdown-blocks/code-block'), { ssr: false }) +export type SimplePluginInfo = { + pluginUniqueIdentifier: string + pluginId: string +} + export type ReactMarkdownWrapperProps = { latexContent: any customDisallowedElements?: string[] customComponents?: Record> + pluginInfo?: SimplePluginInfo } export const ReactMarkdownWrapper: FC = (props) => { - const { customComponents, latexContent } = props + const { customComponents, latexContent, pluginInfo } = props return ( = (props) => { rehypePlugins={[ RehypeKatex, RehypeRaw as any, - // The Rehype plug-in is used to remove the ref attribute of an element + // The Rehype plug-in is used to remove the ref attribute of an element () => { return (tree: any) => { const iterate = (node: any) => { @@ -64,11 +60,11 @@ export const ReactMarkdownWrapper: FC = (props) => { disallowedElements={['iframe', 'head', 'html', 'meta', 'link', 'style', 'body', ...(props.customDisallowedElements || [])]} components={{ code: CodeBlock, - img: Img, + img: (props: any) => pluginInfo ? : , video: VideoBlock, audio: AudioBlock, a: Link, - p: Paragraph, + p: (props: any) => pluginInfo ? : , button: MarkdownButton, form: MarkdownForm, script: ScriptBlock as any, diff --git a/web/app/components/base/modal/index.tsx b/web/app/components/base/modal/index.tsx index 426953261e..f091717191 100644 --- a/web/app/components/base/modal/index.tsx +++ b/web/app/components/base/modal/index.tsx @@ -8,6 +8,7 @@ import { noop } from 'lodash-es' type IModal = { className?: string wrapperClassName?: string + containerClassName?: string isShow: boolean onClose?: () => void title?: React.ReactNode @@ -16,11 +17,14 @@ type IModal = { closable?: boolean overflowVisible?: boolean highPriority?: boolean // For modals that need to appear above dropdowns + overlayOpacity?: boolean // For semi-transparent overlay instead of default + clickOutsideNotClose?: boolean // Prevent closing when clicking outside modal } export default function Modal({ className, wrapperClassName, + containerClassName, isShow, onClose = noop, title, @@ -29,19 +33,21 @@ export default function Modal({ closable = false, overflowVisible = false, highPriority = false, + overlayOpacity = false, + clickOutsideNotClose = false, }: IModal) { return ( - +
-
{ @@ -49,7 +55,7 @@ export default function Modal({ e.stopPropagation() }} > -
+
void @@ -26,6 +27,9 @@ type ModalProps = { footerSlot?: React.ReactNode bottomSlot?: React.ReactNode disabled?: boolean + containerClassName?: string + wrapperClassName?: string + clickOutsideNotClose?: boolean } const Modal = ({ onClose, @@ -44,24 +48,28 @@ const Modal = ({ footerSlot, bottomSlot, disabled, + containerClassName, + wrapperClassName, + clickOutsideNotClose = false, }: ModalProps) => { const { t } = useTranslation() return (
e.stopPropagation()} > -
+
{title} { subTitle && ( @@ -79,10 +87,10 @@ const Modal = ({
{ children && ( -
{children}
+
{children}
) } -
+
{footerSlot}
@@ -117,7 +125,11 @@ const Modal = ({
- {bottomSlot} + {bottomSlot && ( +
+ {bottomSlot} +
+ )}
diff --git a/web/app/components/base/node-status/index.tsx b/web/app/components/base/node-status/index.tsx new file mode 100644 index 0000000000..a09737809d --- /dev/null +++ b/web/app/components/base/node-status/index.tsx @@ -0,0 +1,74 @@ +'use client' +import AlertTriangle from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle' +import classNames from '@/utils/classnames' +import { RiErrorWarningFill } from '@remixicon/react' +import { type VariantProps, cva } from 'class-variance-authority' +import type { CSSProperties } from 'react' +import React from 'react' + +export enum NodeStatusEnum { + warning = 'warning', + error = 'error', +} + +const nodeStatusVariants = cva( + 'flex items-center gap-1 rounded-md px-2 py-1 system-xs-medium', + { + variants: { + status: { + [NodeStatusEnum.warning]: 'bg-state-warning-hover text-text-warning', + [NodeStatusEnum.error]: 'bg-state-destructive-hover text-text-destructive', + }, + }, + defaultVariants: { + status: NodeStatusEnum.warning, + }, + }, +) + +const StatusIconMap: Record = { + [NodeStatusEnum.warning]: { IconComponent: AlertTriangle, message: 'Warning' }, + [NodeStatusEnum.error]: { IconComponent: RiErrorWarningFill, message: 'Error' }, +} + +export type NodeStatusProps = { + message?: string + styleCss?: CSSProperties + iconClassName?: string +} & React.HTMLAttributes & VariantProps + +const NodeStatus = ({ + className, + status, + message, + styleCss, + iconClassName, + children, + ...props +}: NodeStatusProps) => { + const Icon = StatusIconMap[status ?? NodeStatusEnum.warning].IconComponent + const defaultMessage = StatusIconMap[status ?? NodeStatusEnum.warning].message + + return ( +
+ + {message ?? defaultMessage} + {children} +
+ ) +} + +NodeStatus.displayName = 'NodeStatus' + +export default React.memo(NodeStatus) diff --git a/web/app/components/base/notion-page-selector/base.tsx b/web/app/components/base/notion-page-selector/base.tsx index adf044c406..1f9ddeaebd 100644 --- a/web/app/components/base/notion-page-selector/base.tsx +++ b/web/app/components/base/notion-page-selector/base.tsx @@ -10,6 +10,7 @@ import { useInvalidPreImportNotionPages, usePreImportNotionPages } from '@/servi import Header from '../../datasets/create/website/base/header' import type { DataSourceCredential } from '../../header/account-setting/data-source-page-new/types' import Loading from '../loading' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' type NotionPageSelectorProps = { value?: string[] @@ -124,7 +125,7 @@ const NotionPageSelector = ({ }, [pagesMapAndSelectedPagesId, onPreview]) const handleConfigureNotion = useCallback(() => { - setShowAccountSettingModal({ payload: 'data-source' }) + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE }) }, [setShowAccountSettingModal]) if (isFetchingNotionPagesError) { diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx index e165b93a66..2bd67d0ced 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx @@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, + useMemo, useState, } from 'react' import { useTranslation } from 'react-i18next' @@ -18,7 +19,7 @@ import { DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND, UPDATE_WORKFLOW_NODES_MAP, } from './index' -import { isConversationVar, isENV, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' +import { isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' import Tooltip from '@/app/components/base/tooltip' import { isExceptionVariable } from '@/app/components/workflow/utils' import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel' @@ -65,25 +66,33 @@ const WorkflowVariableBlockComponent = ({ )() const [localWorkflowNodesMap, setLocalWorkflowNodesMap] = useState(workflowNodesMap) const node = localWorkflowNodesMap![variables[isRagVar ? 1 : 0]] - const isEnv = isENV(variables) - const isChatVar = isConversationVar(variables) + const isException = isExceptionVariable(varName, node?.type) - let variableValid = true - if (isEnv) { - if (environmentVariables) - variableValid = environmentVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`) - } - else if (isChatVar) { - if (conversationVariables) - variableValid = conversationVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`) - } - else if (isRagVar) { - if (ragVariables) - variableValid = ragVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}.${variables?.[2] ?? ''}`) - } - else { - variableValid = !!node - } + const variableValid = useMemo(() => { + let variableValid = true + const isEnv = isENV(variables) + const isChatVar = isConversationVar(variables) + const isGlobal = isGlobalVar(variables) + if (isGlobal) + return true + + if (isEnv) { + if (environmentVariables) + variableValid = environmentVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`) + } + else if (isChatVar) { + if (conversationVariables) + variableValid = conversationVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`) + } + else if (isRagVar) { + if (ragVariables) + variableValid = ragVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}.${variables?.[2] ?? ''}`) + } + else { + variableValid = !!node + } + return variableValid + }, [variables, node, environmentVariables, conversationVariables, isRagVar, ragVariables]) const reactflow = useReactFlow() const store = useStoreApi() diff --git a/web/app/components/base/search-input/index.tsx b/web/app/components/base/search-input/index.tsx index 3330b55330..abf1817e88 100644 --- a/web/app/components/base/search-input/index.tsx +++ b/web/app/components/base/search-input/index.tsx @@ -22,7 +22,7 @@ const SearchInput: FC = ({ const { t } = useTranslation() const [focus, setFocus] = useState(false) const isComposing = useRef(false) - const [internalValue, setInternalValue] = useState(value) + const [compositionValue, setCompositionValue] = useState('') return (
= ({ white && '!bg-white placeholder:!text-gray-400 hover:!bg-white group-hover:!bg-white', )} placeholder={placeholder || t('common.operation.search')!} - value={internalValue} + value={isComposing.current ? compositionValue : value} onChange={(e) => { - setInternalValue(e.target.value) - if (!isComposing.current) - onChange(e.target.value) + const newValue = e.target.value + if (isComposing.current) + setCompositionValue(newValue) + else + onChange(newValue) }} onCompositionStart={() => { isComposing.current = true + setCompositionValue(value) }} onCompositionEnd={(e) => { isComposing.current = false + setCompositionValue('') onChange(e.currentTarget.value) }} onFocus={() => setFocus(true)} @@ -64,7 +68,6 @@ const SearchInput: FC = ({ className='group/clear flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center' onClick={() => { onChange('') - setInternalValue('') }} > diff --git a/web/app/components/base/select/custom.tsx b/web/app/components/base/select/custom.tsx index 444c975f7e..f9032658c3 100644 --- a/web/app/components/base/select/custom.tsx +++ b/web/app/components/base/select/custom.tsx @@ -58,6 +58,7 @@ const CustomSelect = ({ onOpenChange, placement, offset, + triggerPopupSameWidth = true, } = containerProps || {} const { className: triggerClassName, @@ -85,6 +86,7 @@ const CustomSelect = ({ offset={offset || 4} open={mergedOpen} onOpenChange={handleOpenChange} + triggerPopupSameWidth={triggerPopupSameWidth} > handleOpenChange(!mergedOpen)} diff --git a/web/app/components/base/select/index.tsx b/web/app/components/base/select/index.tsx index f2ca32d660..1a096d7f93 100644 --- a/web/app/components/base/select/index.tsx +++ b/web/app/components/base/select/index.tsx @@ -26,6 +26,9 @@ const defaultItems = [ export type Item = { value: number | string name: string + isGroup?: boolean + disabled?: boolean + extra?: React.ReactNode } & Record export type ISelectProps = { @@ -70,14 +73,13 @@ const Select: FC = ({ const [open, setOpen] = useState(false) const [selectedItem, setSelectedItem] = useState(null) + // Ensure selectedItem is properly set when defaultValue or items change useEffect(() => { let defaultSelect = null - const existed = items.find((item: Item) => item.value === defaultValue) - if (existed) - defaultSelect = existed - + // Handle cases where defaultValue might be undefined, null, or empty string + defaultSelect = (defaultValue && items.find((item: Item) => item.value === defaultValue)) || null setSelectedItem(defaultSelect) - }, [defaultValue]) + }, [defaultValue, items]) const filteredItems: Item[] = query === '' @@ -193,14 +195,18 @@ const SimpleSelect: FC = ({ const [selectedItem, setSelectedItem] = useState(null) + // Enhanced: Preserve user selection, only reset when necessary useEffect(() => { - let defaultSelect = null - const existed = items.find((item: Item) => item.value === defaultValue) - if (existed) - defaultSelect = existed + // Only reset if no current selection or current selection is invalid + const isCurrentSelectionValid = selectedItem && items.some(item => item.value === selectedItem.value) - setSelectedItem(defaultSelect) - }, [defaultValue]) + if (!isCurrentSelectionValid) { + let defaultSelect = null + // Handle cases where defaultValue might be undefined, null, or empty string + defaultSelect = items.find((item: Item) => item.value === defaultValue) ?? null + setSelectedItem(defaultSelect) + } + }, [defaultValue, items, selectedItem]) const listboxRef = useRef(null) @@ -255,38 +261,47 @@ const SimpleSelect: FC = ({ {(!disabled) && ( - {items.map((item: Item) => ( - - {({ /* active, */ selected }) => ( - <> - {renderOption - ? renderOption({ item, selected }) - : (<> - {item.name} - {selected && !hideChecked && ( - - - )} - )} - - )} - - ))} + {items.map((item: Item) => + item.isGroup ? ( +
+ {item.name} +
+ ) : ( + + {({ /* active, */ selected }) => ( + <> + {renderOption + ? renderOption({ item, selected }) + : (<> + {item.name} + {selected && !hideChecked && ( + + + )} + )} + + )} + + ), + )}
)}
@@ -334,6 +349,7 @@ const PortalSelect: FC = ({ onOpenChange={setOpen} placement='bottom-start' offset={4} + triggerPopupSameWidth={true} > !readonly && setOpen(v => !v)} className='w-full'> {renderTrigger @@ -361,7 +377,7 @@ const PortalSelect: FC = ({
{items.map((item: Item) => (
= ({ {!hideChecked && item.value === value && ( )} + {item.extra}
))}
diff --git a/web/app/components/base/select/pure.tsx b/web/app/components/base/select/pure.tsx index cede31d2ba..3de8245025 100644 --- a/web/app/components/base/select/pure.tsx +++ b/web/app/components/base/select/pure.tsx @@ -1,5 +1,6 @@ import { useCallback, + useMemo, useState, } from 'react' import { useTranslation } from 'react-i18next' @@ -22,10 +23,8 @@ export type Option = { value: string } -export type PureSelectProps = { +type SharedPureSelectProps = { options: Option[] - value?: string - onChange?: (value: string) => void containerProps?: PortalToFollowElemOptions & { open?: boolean onOpenChange?: (open: boolean) => void @@ -38,22 +37,39 @@ export type PureSelectProps = { className?: string itemClassName?: string title?: string + titleClassName?: string }, placeholder?: string disabled?: boolean triggerPopupSameWidth?: boolean } -const PureSelect = ({ - options, - value, - onChange, - containerProps, - triggerProps, - popupProps, - placeholder, - disabled, - triggerPopupSameWidth, -}: PureSelectProps) => { + +type SingleSelectProps = { + multiple?: false + value?: string + onChange?: (value: string) => void +} + +type MultiSelectProps = { + multiple: true + value?: string[] + onChange?: (value: string[]) => void +} + +export type PureSelectProps = SharedPureSelectProps & (SingleSelectProps | MultiSelectProps) +const PureSelect = (props: PureSelectProps) => { + const { + options, + containerProps, + triggerProps, + popupProps, + placeholder, + disabled, + triggerPopupSameWidth, + multiple, + value, + onChange, + } = props const { t } = useTranslation() const { open, @@ -69,6 +85,7 @@ const PureSelect = ({ className: popupClassName, itemClassName: popupItemClassName, title: popupTitle, + titleClassName: popupTitleClassName, } = popupProps || {} const [localOpen, setLocalOpen] = useState(false) @@ -79,8 +96,13 @@ const PureSelect = ({ setLocalOpen(openValue) }, [onOpenChange]) - const selectedOption = options.find(option => option.value === value) - const triggerText = selectedOption?.label || placeholder || t('common.placeholder.select') + const triggerText = useMemo(() => { + const placeholderText = placeholder || t('common.placeholder.select') + if (multiple) + return value?.length ? t('common.dynamicSelect.selected', { count: value.length }) : placeholderText + + return options.find(option => option.value === value)?.label || placeholderText + }, [multiple, value, options, placeholder]) return (
{ popupTitle && ( -
+
{popupTitle}
) @@ -144,6 +169,14 @@ const PureSelect = ({ title={option.label} onClick={() => { if (disabled) return + if (multiple) { + const currentValues = value ?? [] + const nextValues = currentValues.includes(option.value) + ? currentValues.filter(valueItem => valueItem !== option.value) + : [...currentValues, option.value] + onChange?.(nextValues) + return + } onChange?.(option.value) handleOpenChange(false) }} @@ -152,7 +185,11 @@ const PureSelect = ({ {option.label}
{ - value === option.value && + ( + multiple + ? (value ?? []).includes(option.value) + : value === option.value + ) && }
)) diff --git a/web/app/components/base/textarea/index.tsx b/web/app/components/base/textarea/index.tsx index 7813eb7209..609f1ad51d 100644 --- a/web/app/components/base/textarea/index.tsx +++ b/web/app/components/base/textarea/index.tsx @@ -20,7 +20,7 @@ const textareaVariants = cva( ) export type TextareaProps = { - value: string + value: string | number disabled?: boolean destructive?: boolean styleCss?: CSSProperties diff --git a/web/app/components/base/timezone-label/__tests__/index.test.tsx b/web/app/components/base/timezone-label/__tests__/index.test.tsx new file mode 100644 index 0000000000..1c36ac929a --- /dev/null +++ b/web/app/components/base/timezone-label/__tests__/index.test.tsx @@ -0,0 +1,145 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import TimezoneLabel from '../index' + +// Mock the convertTimezoneToOffsetStr function +jest.mock('@/app/components/base/date-and-time-picker/utils/dayjs', () => ({ + convertTimezoneToOffsetStr: (timezone?: string) => { + if (!timezone) return 'UTC+0' + + // Mock implementation matching the actual timezone conversions + const timezoneOffsets: Record = { + 'Asia/Shanghai': 'UTC+8', + 'America/New_York': 'UTC-5', + 'Europe/London': 'UTC+0', + 'Pacific/Auckland': 'UTC+13', + 'Pacific/Niue': 'UTC-11', + 'UTC': 'UTC+0', + } + + return timezoneOffsets[timezone] || 'UTC+0' + }, +})) + +describe('TimezoneLabel', () => { + describe('Basic Rendering', () => { + it('should render timezone offset correctly', () => { + render() + expect(screen.getByText('UTC+8')).toBeInTheDocument() + }) + + it('should display UTC+0 for invalid timezone', () => { + render() + expect(screen.getByText('UTC+0')).toBeInTheDocument() + }) + + it('should handle UTC timezone', () => { + render() + expect(screen.getByText('UTC+0')).toBeInTheDocument() + }) + }) + + describe('Styling', () => { + it('should apply default tertiary text color', () => { + const { container } = render() + const span = container.querySelector('span') + expect(span).toHaveClass('text-text-tertiary') + expect(span).not.toHaveClass('text-text-quaternary') + }) + + it('should apply quaternary text color in inline mode', () => { + const { container } = render() + const span = container.querySelector('span') + expect(span).toHaveClass('text-text-quaternary') + }) + + it('should apply custom className', () => { + const { container } = render( + , + ) + const span = container.querySelector('span') + expect(span).toHaveClass('custom-class') + }) + + it('should maintain default classes with custom className', () => { + const { container } = render( + , + ) + const span = container.querySelector('span') + expect(span).toHaveClass('system-sm-regular') + expect(span).toHaveClass('text-text-tertiary') + expect(span).toHaveClass('custom-class') + }) + }) + + describe('Tooltip', () => { + it('should include timezone information in title attribute', () => { + const { container } = render() + const span = container.querySelector('span') + expect(span).toHaveAttribute('title', 'Timezone: Asia/Shanghai (UTC+8)') + }) + + it('should update tooltip for different timezones', () => { + const { container } = render() + const span = container.querySelector('span') + expect(span).toHaveAttribute('title', 'Timezone: America/New_York (UTC-5)') + }) + }) + + describe('Edge Cases', () => { + it('should handle positive offset timezones', () => { + render() + expect(screen.getByText('UTC+13')).toBeInTheDocument() + }) + + it('should handle negative offset timezones', () => { + render() + expect(screen.getByText('UTC-11')).toBeInTheDocument() + }) + + it('should handle zero offset timezone', () => { + render() + expect(screen.getByText('UTC+0')).toBeInTheDocument() + }) + }) + + describe('Props Variations', () => { + it('should render with only required timezone prop', () => { + render() + expect(screen.getByText('UTC+8')).toBeInTheDocument() + }) + + it('should render with all props', () => { + const { container } = render( + , + ) + const span = container.querySelector('span') + expect(screen.getByText('UTC-5')).toBeInTheDocument() + expect(span).toHaveClass('text-xs') + expect(span).toHaveClass('text-text-quaternary') + }) + }) + + describe('Memoization', () => { + it('should memoize offset calculation', () => { + const { rerender } = render() + expect(screen.getByText('UTC+8')).toBeInTheDocument() + + // Rerender with same props should not trigger recalculation + rerender() + expect(screen.getByText('UTC+8')).toBeInTheDocument() + }) + + it('should recalculate when timezone changes', () => { + const { rerender } = render() + expect(screen.getByText('UTC+8')).toBeInTheDocument() + + rerender() + expect(screen.getByText('UTC-5')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/timezone-label/index.tsx b/web/app/components/base/timezone-label/index.tsx new file mode 100644 index 0000000000..b151ceb9b8 --- /dev/null +++ b/web/app/components/base/timezone-label/index.tsx @@ -0,0 +1,56 @@ +import React, { useMemo } from 'react' +import { convertTimezoneToOffsetStr } from '@/app/components/base/date-and-time-picker/utils/dayjs' +import cn from '@/utils/classnames' + +export type TimezoneLabelProps = { + /** IANA timezone identifier (e.g., 'Asia/Shanghai', 'America/New_York') */ + timezone: string + /** Additional CSS classes to apply */ + className?: string + /** Use inline mode with lighter text color for secondary display */ + inline?: boolean +} + +/** + * TimezoneLabel component displays timezone information in UTC offset format. + * + * @example + * // Standard display + * + * // Output: UTC+8 + * + * @example + * // Inline mode with lighter color + * + * // Output: UTC-5 + * + * @example + * // Custom styling + * + */ +const TimezoneLabel: React.FC = ({ + timezone, + className, + inline = false, +}) => { + // Memoize offset calculation to avoid redundant computations + const offsetStr = useMemo( + () => convertTimezoneToOffsetStr(timezone), + [timezone], + ) + + return ( + + {offsetStr} + + ) +} + +export default React.memo(TimezoneLabel) diff --git a/web/app/components/base/tooltip/index.tsx b/web/app/components/base/tooltip/index.tsx index eb7ca56cb0..46680c8f5b 100644 --- a/web/app/components/base/tooltip/index.tsx +++ b/web/app/components/base/tooltip/index.tsx @@ -17,6 +17,7 @@ export type TooltipProps = { popupContent?: React.ReactNode children?: React.ReactNode popupClassName?: string + portalContentClassName?: string noDecoration?: boolean offset?: OffsetOptions needsDelay?: boolean @@ -32,6 +33,7 @@ const Tooltip: FC = ({ popupContent, children, popupClassName, + portalContentClassName, noDecoration, offset, asChild = true, @@ -104,7 +106,7 @@ const Tooltip: FC = ({ {children ||
} {popupContent && (
{
{/* Waveform visualization placeholder */}
- {new Array(40).fill(0).map((_, i) => ( + {Array.from({ length: 40 }).map((_, i) => (
= { apiRateLimit: 5000, documentProcessingPriority: Priority.standard, messageRequest: 200, + triggerEvents: 3000, annotatedResponse: 10, logHistory: 30, }, @@ -43,6 +44,7 @@ export const ALL_PLANS: Record = { apiRateLimit: NUM_INFINITE, documentProcessingPriority: Priority.priority, messageRequest: 5000, + triggerEvents: 20000, annotatedResponse: 2000, logHistory: NUM_INFINITE, }, @@ -60,6 +62,7 @@ export const ALL_PLANS: Record = { apiRateLimit: NUM_INFINITE, documentProcessingPriority: Priority.topPriority, messageRequest: 10000, + triggerEvents: NUM_INFINITE, annotatedResponse: 5000, logHistory: NUM_INFINITE, }, @@ -74,6 +77,8 @@ export const defaultPlan = { teamMembers: 1, annotatedResponse: 1, documentsUploadQuota: 0, + apiRateLimit: 0, + triggerEvents: 0, }, total: { documents: 50, @@ -82,5 +87,7 @@ export const defaultPlan = { teamMembers: 1, annotatedResponse: 10, documentsUploadQuota: 0, + apiRateLimit: ALL_PLANS.sandbox.apiRateLimit, + triggerEvents: ALL_PLANS.sandbox.triggerEvents, }, } diff --git a/web/app/components/billing/plan/index.tsx b/web/app/components/billing/plan/index.tsx index dd3908635b..4b68fcfb15 100644 --- a/web/app/components/billing/plan/index.tsx +++ b/web/app/components/billing/plan/index.tsx @@ -6,8 +6,10 @@ import { useRouter } from 'next/navigation' import { RiBook2Line, RiFileEditLine, + RiFlashlightLine, RiGraduationCapLine, RiGroupLine, + RiSpeedLine, } from '@remixicon/react' import { Plan, SelfHostedPlan } from '../type' import VectorSpaceInfo from '../usage-info/vector-space-info' @@ -43,6 +45,8 @@ const PlanComp: FC = ({ usage, total, } = plan + const perMonthUnit = ` ${t('billing.usagePage.perMonth')}` + const triggerEventUnit = plan.type === Plan.sandbox ? undefined : perMonthUnit const [showModal, setShowModal] = React.useState(false) const { mutateAsync } = useEducationVerify() @@ -119,6 +123,20 @@ const PlanComp: FC = ({ usage={usage.annotatedResponse} total={total.annotatedResponse} /> + +
+ + + & { vectorSpace: number } +export type UsagePlanInfo = Pick & { vectorSpace: number } export enum DocumentProcessingPriority { standard = 'standard', @@ -87,6 +88,14 @@ export type CurrentPlanInfoBackend = { size: number limit: number // total. 0 means unlimited } + api_rate_limit?: { + size: number + limit: number // total. 0 means unlimited + } + trigger_events?: { + size: number + limit: number // total. 0 means unlimited + } docs_processing: DocumentProcessingPriority can_replace_logo: boolean model_load_balancing_enabled: boolean diff --git a/web/app/components/billing/usage-info/index.tsx b/web/app/components/billing/usage-info/index.tsx index 30b4bca776..0ed8775772 100644 --- a/web/app/components/billing/usage-info/index.tsx +++ b/web/app/components/billing/usage-info/index.tsx @@ -15,6 +15,7 @@ type Props = { usage: number total: number unit?: string + unitPosition?: 'inline' | 'suffix' } const LOW = 50 @@ -27,7 +28,8 @@ const UsageInfo: FC = ({ tooltip, usage, total, - unit = '', + unit, + unitPosition = 'suffix', }) => { const { t } = useTranslation() @@ -41,6 +43,12 @@ const UsageInfo: FC = ({ return 'bg-components-progress-error-progress' })() + const isUnlimited = total === NUM_INFINITE + let totalDisplay: string | number = isUnlimited ? t('billing.plansCommon.unlimited') : total + if (!isUnlimited && unit && unitPosition === 'inline') + totalDisplay = `${total}${unit}` + const showUnit = !!unit && !isUnlimited && unitPosition === 'suffix' + return (
@@ -56,10 +64,17 @@ const UsageInfo: FC = ({ /> )}
-
- {usage} -
/
-
{total === NUM_INFINITE ? t('billing.plansCommon.unlimited') : `${total}${unit}`}
+
+
+ {usage} +
/
+
{totalDisplay}
+
+ {showUnit && ( +
+ {unit} +
+ )}
= ({ usage={usage.vectorSpace} total={total.vectorSpace} unit='MB' + unitPosition='inline' /> ) } diff --git a/web/app/components/billing/utils/index.ts b/web/app/components/billing/utils/index.ts index 111f02e3cf..00ab7913b5 100644 --- a/web/app/components/billing/utils/index.ts +++ b/web/app/components/billing/utils/index.ts @@ -1,5 +1,5 @@ import type { CurrentPlanInfoBackend } from '../type' -import { NUM_INFINITE } from '@/app/components/billing/config' +import { ALL_PLANS, NUM_INFINITE } from '@/app/components/billing/config' const parseLimit = (limit: number) => { if (limit === 0) @@ -9,14 +9,23 @@ const parseLimit = (limit: number) => { } export const parseCurrentPlan = (data: CurrentPlanInfoBackend) => { + const planType = data.billing.subscription.plan + const planPreset = ALL_PLANS[planType] + const resolveLimit = (limit?: number, fallback?: number) => { + const value = limit ?? fallback ?? 0 + return parseLimit(value) + } + return { - type: data.billing.subscription.plan, + type: planType, usage: { vectorSpace: data.vector_space.size, buildApps: data.apps?.size || 0, teamMembers: data.members.size, annotatedResponse: data.annotation_quota_limit.size, documentsUploadQuota: data.documents_upload_quota.size, + apiRateLimit: data.api_rate_limit?.size ?? 0, + triggerEvents: data.trigger_events?.size ?? 0, }, total: { vectorSpace: parseLimit(data.vector_space.limit), @@ -24,6 +33,8 @@ export const parseCurrentPlan = (data: CurrentPlanInfoBackend) => { teamMembers: parseLimit(data.members.limit), annotatedResponse: parseLimit(data.annotation_quota_limit.limit), documentsUploadQuota: parseLimit(data.documents_upload_quota.limit), + apiRateLimit: resolveLimit(data.api_rate_limit?.limit, planPreset?.apiRateLimit ?? NUM_INFINITE), + triggerEvents: resolveLimit(data.trigger_events?.limit, planPreset?.triggerEvents), }, } } diff --git a/web/app/components/datasets/create/file-uploader/index.tsx b/web/app/components/datasets/create/file-uploader/index.tsx index aee2192b6c..4aec0d4082 100644 --- a/web/app/components/datasets/create/file-uploader/index.tsx +++ b/web/app/components/datasets/create/file-uploader/index.tsx @@ -106,8 +106,6 @@ const FileUploader = ({ return isValidType && isValidSize }, [fileUploadConfig, notify, t, ACCEPTS]) - type UploadResult = Awaited> - const fileUpload = useCallback(async (fileItem: FileItem): Promise => { const formData = new FormData() formData.append('file', fileItem.file) diff --git a/web/app/components/datasets/create/index.tsx b/web/app/components/datasets/create/index.tsx index 11def1a8bc..b04bd85530 100644 --- a/web/app/components/datasets/create/index.tsx +++ b/web/app/components/datasets/create/index.tsx @@ -16,6 +16,7 @@ import { useGetDefaultDataSourceListAuth } from '@/service/use-datasource' import { produce } from 'immer' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import Loading from '@/app/components/base/loading' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' type DatasetUpdateFormProps = { datasetId?: string @@ -117,7 +118,7 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => { {step === 1 && ( setShowAccountSettingModal({ payload: 'data-source' })} + onSetting={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE })} datasetId={datasetId} dataSourceType={dataSourceType} dataSourceTypeDisable={!!datasetDetail?.data_source_type} @@ -141,7 +142,7 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => { {(step === 2 && (!datasetId || (datasetId && !!datasetDetail))) && ( setShowAccountSettingModal({ payload: 'provider' })} + onSetting={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })} indexingType={datasetDetail?.indexing_technique} datasetId={datasetId} dataSourceType={dataSourceType} diff --git a/web/app/components/datasets/create/website/firecrawl/index.tsx b/web/app/components/datasets/create/website/firecrawl/index.tsx index 8d207a0386..51c2c7d505 100644 --- a/web/app/components/datasets/create/website/firecrawl/index.tsx +++ b/web/app/components/datasets/create/website/firecrawl/index.tsx @@ -14,6 +14,7 @@ import Toast from '@/app/components/base/toast' import { checkFirecrawlTaskStatus, createFirecrawlTask } from '@/service/datasets' import { sleep } from '@/utils' import Header from '../base/header' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' const ERROR_I18N_PREFIX = 'common.errorMsg' const I18N_PREFIX = 'datasetCreation.stepOne.website' @@ -51,7 +52,7 @@ const FireCrawl: FC = ({ const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal) const handleSetting = useCallback(() => { setShowAccountSettingModal({ - payload: 'data-source', + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }, [setShowAccountSettingModal]) diff --git a/web/app/components/datasets/create/website/index.tsx b/web/app/components/datasets/create/website/index.tsx index 7190ca3228..ee7ace6815 100644 --- a/web/app/components/datasets/create/website/index.tsx +++ b/web/app/components/datasets/create/website/index.tsx @@ -13,6 +13,7 @@ import type { CrawlOptions, CrawlResultItem } from '@/models/datasets' import { DataSourceProvider } from '@/models/common' import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config' import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' type Props = { onPreview: (payload: CrawlResultItem) => void @@ -48,7 +49,7 @@ const Website: FC = ({ const handleOnConfig = useCallback(() => { setShowAccountSettingModal({ - payload: 'data-source', + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }, [setShowAccountSettingModal]) diff --git a/web/app/components/datasets/create/website/jina-reader/index.tsx b/web/app/components/datasets/create/website/jina-reader/index.tsx index 460c169fb4..b6e6177af2 100644 --- a/web/app/components/datasets/create/website/jina-reader/index.tsx +++ b/web/app/components/datasets/create/website/jina-reader/index.tsx @@ -14,6 +14,7 @@ import { checkJinaReaderTaskStatus, createJinaReaderTask } from '@/service/datas import { sleep } from '@/utils' import type { CrawlOptions, CrawlResultItem } from '@/models/datasets' import Header from '../base/header' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' const ERROR_I18N_PREFIX = 'common.errorMsg' const I18N_PREFIX = 'datasetCreation.stepOne.website' @@ -51,7 +52,7 @@ const JinaReader: FC = ({ const { setShowAccountSettingModal } = useModalContext() const handleSetting = useCallback(() => { setShowAccountSettingModal({ - payload: 'data-source', + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }, [setShowAccountSettingModal]) diff --git a/web/app/components/datasets/create/website/watercrawl/index.tsx b/web/app/components/datasets/create/website/watercrawl/index.tsx index 640b1c2063..67a3e53feb 100644 --- a/web/app/components/datasets/create/website/watercrawl/index.tsx +++ b/web/app/components/datasets/create/website/watercrawl/index.tsx @@ -14,6 +14,7 @@ import Toast from '@/app/components/base/toast' import { checkWatercrawlTaskStatus, createWatercrawlTask } from '@/service/datasets' import { sleep } from '@/utils' import Header from '../base/header' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' const ERROR_I18N_PREFIX = 'common.errorMsg' const I18N_PREFIX = 'datasetCreation.stepOne.website' @@ -51,7 +52,7 @@ const WaterCrawl: FC = ({ const { setShowAccountSettingModal } = useModalContext() const handleSetting = useCallback(() => { setShowAccountSettingModal({ - payload: 'data-source', + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }, [setShowAccountSettingModal]) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx index f5cbac909d..97d6721e00 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx @@ -16,6 +16,7 @@ import Title from './title' import { useGetDataSourceAuth } from '@/service/use-datasource' import Loading from '@/app/components/base/loading' import { useDocLink } from '@/context/i18n' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' type OnlineDocumentsProps = { isInPipeline?: boolean @@ -120,7 +121,7 @@ const OnlineDocuments = ({ const handleSetting = useCallback(() => { setShowAccountSettingModal({ - payload: 'data-source', + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }, [setShowAccountSettingModal]) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx index ed2820675c..da8fd5dcc0 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx @@ -15,6 +15,7 @@ import { useShallow } from 'zustand/react/shallow' import { useModalContextSelector } from '@/context/modal-context' import { useGetDataSourceAuth } from '@/service/use-datasource' import { useDocLink } from '@/context/i18n' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' type OnlineDriveProps = { nodeId: string @@ -180,7 +181,7 @@ const OnlineDrive = ({ const handleSetting = useCallback(() => { setShowAccountSettingModal({ - payload: 'data-source', + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }, [setShowAccountSettingModal]) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx index c46cbdf0f1..648f6a5d93 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx @@ -26,6 +26,7 @@ import { useShallow } from 'zustand/react/shallow' import { useModalContextSelector } from '@/context/modal-context' import { useGetDataSourceAuth } from '@/service/use-datasource' import { useDocLink } from '@/context/i18n' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' const I18N_PREFIX = 'datasetCreation.stepOne.website' @@ -139,7 +140,7 @@ const WebsiteCrawl = ({ const handleSetting = useCallback(() => { setShowAccountSettingModal({ - payload: 'data-source', + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }, [setShowAccountSettingModal]) diff --git a/web/app/components/develop/doc.tsx b/web/app/components/develop/doc.tsx index 82b6b00e44..28a3219535 100644 --- a/web/app/components/develop/doc.tsx +++ b/web/app/components/develop/doc.tsx @@ -18,7 +18,7 @@ import TemplateChatJa from './template/template_chat.ja.mdx' import I18n from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' import useTheme from '@/hooks/use-theme' -import { Theme } from '@/types/app' +import { AppModeEnum, Theme } from '@/types/app' import cn from '@/utils/classnames' type IDocProps = { @@ -115,7 +115,7 @@ const Doc = ({ appDetail }: IDocProps) => { } const Template = useMemo(() => { - if (appDetail?.mode === 'chat' || appDetail?.mode === 'agent-chat') { + if (appDetail?.mode === AppModeEnum.CHAT || appDetail?.mode === AppModeEnum.AGENT_CHAT) { switch (locale) { case LanguagesSupported[1]: return @@ -125,7 +125,7 @@ const Doc = ({ appDetail }: IDocProps) => { return } } - if (appDetail?.mode === 'advanced-chat') { + if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) { switch (locale) { case LanguagesSupported[1]: return @@ -135,7 +135,7 @@ const Doc = ({ appDetail }: IDocProps) => { return } } - if (appDetail?.mode === 'workflow') { + if (appDetail?.mode === AppModeEnum.WORKFLOW) { switch (locale) { case LanguagesSupported[1]: return @@ -145,7 +145,7 @@ const Doc = ({ appDetail }: IDocProps) => { return } } - if (appDetail?.mode === 'completion') { + if (appDetail?.mode === AppModeEnum.COMPLETION) { switch (locale) { case LanguagesSupported[1]: return diff --git a/web/app/components/explore/app-card/index.tsx b/web/app/components/explore/app-card/index.tsx index 0d6a9b4ad4..daf863b84d 100644 --- a/web/app/components/explore/app-card/index.tsx +++ b/web/app/components/explore/app-card/index.tsx @@ -6,6 +6,7 @@ import cn from '@/utils/classnames' import type { App } from '@/models/explore' import AppIcon from '@/app/components/base/app-icon' import { AppTypeIcon } from '../../app/type-selector' +import { AppModeEnum } from '@/types/app' export type AppCardProps = { app: App canCreate: boolean @@ -40,11 +41,11 @@ const AppCard = ({
{appBasicInfo.name}
- {appBasicInfo.mode === 'advanced-chat' &&
{t('app.types.advanced').toUpperCase()}
} - {appBasicInfo.mode === 'chat' &&
{t('app.types.chatbot').toUpperCase()}
} - {appBasicInfo.mode === 'agent-chat' &&
{t('app.types.agent').toUpperCase()}
} - {appBasicInfo.mode === 'workflow' &&
{t('app.types.workflow').toUpperCase()}
} - {appBasicInfo.mode === 'completion' &&
{t('app.types.completion').toUpperCase()}
} + {appBasicInfo.mode === AppModeEnum.ADVANCED_CHAT &&
{t('app.types.advanced').toUpperCase()}
} + {appBasicInfo.mode === AppModeEnum.CHAT &&
{t('app.types.chatbot').toUpperCase()}
} + {appBasicInfo.mode === AppModeEnum.AGENT_CHAT &&
{t('app.types.agent').toUpperCase()}
} + {appBasicInfo.mode === AppModeEnum.WORKFLOW &&
{t('app.types.workflow').toUpperCase()}
} + {appBasicInfo.mode === AppModeEnum.COMPLETION &&
{t('app.types.completion').toUpperCase()}
}
diff --git a/web/app/components/explore/create-app-modal/index.tsx b/web/app/components/explore/create-app-modal/index.tsx index e94999db04..84621858be 100644 --- a/web/app/components/explore/create-app-modal/index.tsx +++ b/web/app/components/explore/create-app-modal/index.tsx @@ -13,7 +13,7 @@ import Toast from '@/app/components/base/toast' import AppIcon from '@/app/components/base/app-icon' import { useProviderContext } from '@/context/provider-context' import AppsFull from '@/app/components/billing/apps-full-in-dialog' -import type { AppIconType } from '@/types/app' +import { type AppIconType, AppModeEnum } from '@/types/app' import { noop } from 'lodash-es' export type CreateAppModalProps = { @@ -158,7 +158,7 @@ const CreateAppModal = ({ />
{/* answer icon */} - {isEditModal && (appMode === 'chat' || appMode === 'advanced-chat' || appMode === 'agent-chat') && ( + {isEditModal && (appMode === AppModeEnum.CHAT || appMode === AppModeEnum.ADVANCED_CHAT || appMode === AppModeEnum.AGENT_CHAT) && (
{t('app.answerIcon.title')}
diff --git a/web/app/components/explore/installed-app/index.tsx b/web/app/components/explore/installed-app/index.tsx index 8032e173c6..18aab337d2 100644 --- a/web/app/components/explore/installed-app/index.tsx +++ b/web/app/components/explore/installed-app/index.tsx @@ -12,6 +12,7 @@ import AppUnavailable from '../../base/app-unavailable' import { useGetUserCanAccessApp } from '@/service/access-control' import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore' import type { AppData } from '@/models/share' +import { AppModeEnum } from '@/types/app' export type IInstalledAppProps = { id: string @@ -102,13 +103,13 @@ const InstalledApp: FC = ({ } return (
- {installedApp?.app.mode !== 'completion' && installedApp?.app.mode !== 'workflow' && ( + {installedApp?.app.mode !== AppModeEnum.COMPLETION && installedApp?.app.mode !== AppModeEnum.WORKFLOW && ( )} - {installedApp?.app.mode === 'completion' && ( + {installedApp?.app.mode === AppModeEnum.COMPLETION && ( )} - {installedApp?.app.mode === 'workflow' && ( + {installedApp?.app.mode === AppModeEnum.WORKFLOW && ( )}
diff --git a/web/app/components/goto-anything/context.tsx b/web/app/components/goto-anything/context.tsx index fee4b72c91..25fe2ddf96 100644 --- a/web/app/components/goto-anything/context.tsx +++ b/web/app/components/goto-anything/context.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react' import React, { createContext, useContext, useEffect, useState } from 'react' import { usePathname } from 'next/navigation' +import { isInWorkflowPage } from '../workflow/constants' /** * Interface for the GotoAnything context @@ -50,7 +51,7 @@ export const GotoAnythingProvider: React.FC = ({ chil } // Workflow pages: /app/[appId]/workflow or /workflow/[token] (shared) - const isWorkflow = /^\/app\/[^/]+\/workflow$/.test(pathname) || /^\/workflow\/[^/]+$/.test(pathname) + const isWorkflow = isInWorkflowPage() // RAG Pipeline pages: /datasets/[datasetId]/pipeline const isRagPipeline = /^\/datasets\/[^/]+\/pipeline$/.test(pathname) diff --git a/web/app/components/header/account-dropdown/compliance.tsx b/web/app/components/header/account-dropdown/compliance.tsx index b5849682e9..8dc4aeec32 100644 --- a/web/app/components/header/account-dropdown/compliance.tsx +++ b/web/app/components/header/account-dropdown/compliance.tsx @@ -16,6 +16,7 @@ import cn from '@/utils/classnames' import { useProviderContext } from '@/context/provider-context' import { Plan } from '@/app/components/billing/type' import { useModalContext } from '@/context/modal-context' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { getDocDownloadUrl } from '@/service/common' enum DocName { @@ -38,7 +39,7 @@ const UpgradeOrDownload: FC = ({ doc_name }) => { if (isFreePlan) setShowPricingModal() else - setShowAccountSettingModal({ payload: 'billing' }) + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING }) }, [isFreePlan, setShowAccountSettingModal, setShowPricingModal]) const { isPending, mutate: downloadCompliance } = useMutation({ diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index 30b2bfdf6f..d00cddc693 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -33,6 +33,7 @@ import cn from '@/utils/classnames' import { useGlobalPublicStore } from '@/context/global-public-context' import { useDocLink } from '@/context/i18n' import { useLogout } from '@/service/use-common' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' export default function AppSelector() { const itemClassName = ` @@ -122,7 +123,7 @@ export default function AppSelector() {
setShowAccountSettingModal({ payload: 'members' })}> + )} onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })}>
{t('common.userProfile.settings')}
diff --git a/web/app/components/header/account-setting/api-based-extension-page/selector.tsx b/web/app/components/header/account-setting/api-based-extension-page/selector.tsx index ce218540ee..549b5e7910 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/selector.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/selector.tsx @@ -16,6 +16,7 @@ import { } from '@/app/components/base/icons/src/vender/line/arrows' import { useModalContext } from '@/context/modal-context' import { fetchApiBasedExtensionList } from '@/service/common' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' type ApiBasedExtensionSelectorProps = { value: string @@ -83,7 +84,7 @@ const ApiBasedExtensionSelector: FC = ({ className='flex cursor-pointer items-center text-xs text-text-accent' onClick={() => { setOpen(false) - setShowAccountSettingModal({ payload: 'api-based-extension' }) + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION }) }} > {t('common.apiBasedExtension.selector.manage')} diff --git a/web/app/components/header/account-setting/constants.ts b/web/app/components/header/account-setting/constants.ts new file mode 100644 index 0000000000..2bf2f2eff5 --- /dev/null +++ b/web/app/components/header/account-setting/constants.ts @@ -0,0 +1,21 @@ +export const ACCOUNT_SETTING_MODAL_ACTION = 'showSettings' + +export const ACCOUNT_SETTING_TAB = { + PROVIDER: 'provider', + MEMBERS: 'members', + BILLING: 'billing', + DATA_SOURCE: 'data-source', + API_BASED_EXTENSION: 'api-based-extension', + CUSTOM: 'custom', + LANGUAGE: 'language', +} as const + +export type AccountSettingTab = typeof ACCOUNT_SETTING_TAB[keyof typeof ACCOUNT_SETTING_TAB] + +export const DEFAULT_ACCOUNT_SETTING_TAB = ACCOUNT_SETTING_TAB.MEMBERS + +export const isValidAccountSettingTab = (tab: string | null): tab is AccountSettingTab => { + if (!tab) + return false + return Object.values(ACCOUNT_SETTING_TAB).includes(tab as AccountSettingTab) +} diff --git a/web/app/components/header/account-setting/data-source-page-new/hooks/use-marketplace-all-plugins.ts b/web/app/components/header/account-setting/data-source-page-new/hooks/use-marketplace-all-plugins.ts index e4d9ba8950..01790d7002 100644 --- a/web/app/components/header/account-setting/data-source-page-new/hooks/use-marketplace-all-plugins.ts +++ b/web/app/components/header/account-setting/data-source-page-new/hooks/use-marketplace-all-plugins.ts @@ -8,7 +8,7 @@ import { useMarketplacePlugins, } from '@/app/components/plugins/marketplace/hooks' import type { Plugin } from '@/app/components/plugins/types' -import { PluginType } from '@/app/components/plugins/types' +import { PluginCategoryEnum } from '@/app/components/plugins/types' import { getMarketplacePluginsByCollectionId } from '@/app/components/plugins/marketplace/utils' export const useMarketplaceAllPlugins = (providers: any[], searchText: string) => { @@ -38,7 +38,7 @@ export const useMarketplaceAllPlugins = (providers: any[], searchText: string) = if (searchText) { queryPluginsWithDebounced({ query: searchText, - category: PluginType.datasource, + category: PluginCategoryEnum.datasource, exclude, type: 'plugin', sortBy: 'install_count', @@ -48,7 +48,7 @@ export const useMarketplaceAllPlugins = (providers: any[], searchText: string) = else { queryPlugins({ query: '', - category: PluginType.datasource, + category: PluginCategoryEnum.datasource, type: 'plugin', pageSize: 1000, exclude, diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index 8e71597e9c..49f6f62a08 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -31,6 +31,10 @@ import { useProviderContext } from '@/context/provider-context' import { useAppContext } from '@/context/app-context' import MenuDialog from '@/app/components/header/account-setting/menu-dialog' import Input from '@/app/components/base/input' +import { + ACCOUNT_SETTING_TAB, + type AccountSettingTab, +} from '@/app/components/header/account-setting/constants' const iconClassName = ` w-5 h-5 mr-2 @@ -38,11 +42,12 @@ const iconClassName = ` type IAccountSettingProps = { onCancel: () => void - activeTab?: string + activeTab?: AccountSettingTab + onTabChange?: (tab: AccountSettingTab) => void } type GroupItem = { - key: string + key: AccountSettingTab name: string description?: string icon: React.JSX.Element @@ -51,56 +56,71 @@ type GroupItem = { export default function AccountSetting({ onCancel, - activeTab = 'members', + activeTab = ACCOUNT_SETTING_TAB.MEMBERS, + onTabChange, }: IAccountSettingProps) { - const [activeMenu, setActiveMenu] = useState(activeTab) + const [activeMenu, setActiveMenu] = useState(activeTab) + useEffect(() => { + setActiveMenu(activeTab) + }, [activeTab]) const { t } = useTranslation() const { enableBilling, enableReplaceWebAppLogo } = useProviderContext() const { isCurrentWorkspaceDatasetOperator } = useAppContext() - const workplaceGroupItems = (() => { + const workplaceGroupItems: GroupItem[] = (() => { if (isCurrentWorkspaceDatasetOperator) return [] - return [ + + const items: GroupItem[] = [ { - key: 'provider', + key: ACCOUNT_SETTING_TAB.PROVIDER, name: t('common.settings.provider'), icon: , activeIcon: , }, { - key: 'members', + key: ACCOUNT_SETTING_TAB.MEMBERS, name: t('common.settings.members'), icon: , activeIcon: , }, - { - // Use key false to hide this item - key: enableBilling ? 'billing' : false, + ] + + if (enableBilling) { + items.push({ + key: ACCOUNT_SETTING_TAB.BILLING, name: t('common.settings.billing'), description: t('billing.plansCommon.receiptInfo'), icon: , activeIcon: , - }, + }) + } + + items.push( { - key: 'data-source', + key: ACCOUNT_SETTING_TAB.DATA_SOURCE, name: t('common.settings.dataSource'), icon: , activeIcon: , }, { - key: 'api-based-extension', + key: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION, name: t('common.settings.apiBasedExtension'), icon: , activeIcon: , }, - { - key: (enableReplaceWebAppLogo || enableBilling) ? 'custom' : false, + ) + + if (enableReplaceWebAppLogo || enableBilling) { + items.push({ + key: ACCOUNT_SETTING_TAB.CUSTOM, name: t('custom.custom'), icon: , activeIcon: , - }, - ].filter(item => !!item.key) as GroupItem[] + }) + } + + return items })() const media = useBreakpoints() @@ -117,7 +137,7 @@ export default function AccountSetting({ name: t('common.settings.generalGroup'), items: [ { - key: 'language', + key: ACCOUNT_SETTING_TAB.LANGUAGE, name: t('common.settings.language'), icon: , activeIcon: , @@ -167,7 +187,10 @@ export default function AccountSetting({ 'mb-0.5 flex h-[37px] cursor-pointer items-center rounded-lg p-1 pl-3 text-sm', activeMenu === item.key ? 'system-sm-semibold bg-state-base-active text-components-menu-item-text-active' : 'system-sm-medium text-components-menu-item-text')} title={item.name} - onClick={() => setActiveMenu(item.key)} + onClick={() => { + setActiveMenu(item.key) + onTabChange?.(item.key) + }} > {activeMenu === item.key ? item.activeIcon : item.icon} {!isMobile &&
{item.name}
} diff --git a/web/app/components/header/account-setting/model-provider-page/declarations.ts b/web/app/components/header/account-setting/model-provider-page/declarations.ts index 62cb1a96e9..134df7b3e8 100644 --- a/web/app/components/header/account-setting/model-provider-page/declarations.ts +++ b/web/app/components/header/account-setting/model-provider-page/declarations.ts @@ -14,7 +14,8 @@ export enum FormTypeEnum { secretInput = 'secret-input', select = 'select', radio = 'radio', - boolean = 'checkbox', + checkbox = 'checkbox', + boolean = 'boolean', files = 'files', file = 'file', modelSelector = 'model-selector', diff --git a/web/app/components/header/account-setting/model-provider-page/hooks.ts b/web/app/components/header/account-setting/model-provider-page/hooks.ts index 48dc609795..8cfd144681 100644 --- a/web/app/components/header/account-setting/model-provider-page/hooks.ts +++ b/web/app/components/header/account-setting/model-provider-page/hooks.ts @@ -35,7 +35,7 @@ import { useMarketplacePlugins, } from '@/app/components/plugins/marketplace/hooks' import type { Plugin } from '@/app/components/plugins/types' -import { PluginType } from '@/app/components/plugins/types' +import { PluginCategoryEnum } from '@/app/components/plugins/types' import { getMarketplacePluginsByCollectionId } from '@/app/components/plugins/marketplace/utils' import { useModalContextSelector } from '@/context/modal-context' import { useEventEmitterContextContext } from '@/context/event-emitter' @@ -278,7 +278,7 @@ export const useMarketplaceAllPlugins = (providers: ModelProvider[], searchText: if (searchText) { queryPluginsWithDebounced({ query: searchText, - category: PluginType.model, + category: PluginCategoryEnum.model, exclude, type: 'plugin', sortBy: 'install_count', @@ -288,7 +288,7 @@ export const useMarketplaceAllPlugins = (providers: ModelProvider[], searchText: else { queryPlugins({ query: '', - category: PluginType.model, + category: PluginCategoryEnum.model, type: 'plugin', pageSize: 1000, exclude, diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx index 164aeb5bc3..3c51762c52 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx @@ -264,7 +264,7 @@ function Form< ) } - if (formSchema.type === FormTypeEnum.boolean) { + if (formSchema.type === FormTypeEnum.checkbox) { const { variable, label, show_on, required, } = formSchema as CredentialFormSchemaRadio diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx index 82ba072b94..e56def4113 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx @@ -36,7 +36,6 @@ export type ModelParameterModalProps = { popupClassName?: string portalToFollowElemContentClassName?: string isAdvancedMode: boolean - mode: string modelId: string provider: string setModel: (model: { modelId: string; provider: string; mode?: string; features?: string[] }) => void diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx index b43fcd6301..ae7d863d91 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx @@ -15,6 +15,7 @@ import { useLanguage } from '../hooks' import PopupItem from './popup-item' import { XCircle } from '@/app/components/base/icons/src/vender/solid/general' import { useModalContext } from '@/context/modal-context' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { supportFunctionCall } from '@/utils/tool-call' import { tooltipManager } from '@/app/components/base/tooltip/TooltipManager' @@ -129,7 +130,7 @@ const Popup: FC = ({
{ onHide() - setShowAccountSettingModal({ payload: 'provider' }) + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER }) }}> {t('common.model.settingsLink')} diff --git a/web/app/components/header/app-nav/index.tsx b/web/app/components/header/app-nav/index.tsx index 97e08a1166..740e790630 100644 --- a/web/app/components/header/app-nav/index.tsx +++ b/web/app/components/header/app-nav/index.tsx @@ -19,6 +19,7 @@ import CreateFromDSLModal from '@/app/components/app/create-from-dsl-modal' import type { AppListResponse } from '@/models/app' import { useAppContext } from '@/context/app-context' import { useStore as useAppStore } from '@/app/components/app/store' +import { AppModeEnum } from '@/types/app' const getKey = ( pageIndex: number, @@ -79,7 +80,7 @@ const AppNav = () => { return `/app/${app.id}/overview` } else { - if (app.mode === 'workflow' || app.mode === 'advanced-chat') + if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT) return `/app/${app.id}/workflow` else return `/app/${app.id}/configuration` diff --git a/web/app/components/header/index.tsx b/web/app/components/header/index.tsx index fc511d2954..ef24d471e0 100644 --- a/web/app/components/header/index.tsx +++ b/web/app/components/header/index.tsx @@ -19,6 +19,7 @@ import PlanBadge from './plan-badge' import LicenseNav from './license-env' import { Plan } from '../billing/type' import { useGlobalPublicStore } from '@/context/global-public-context' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' const navClassName = ` flex items-center relative px-3 h-8 rounded-xl @@ -38,7 +39,7 @@ const Header = () => { if (isFreePlan) setShowPricingModal() else - setShowAccountSettingModal({ payload: 'billing' }) + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING }) }, [isFreePlan, setShowAccountSettingModal, setShowPricingModal]) if (isMobile) { diff --git a/web/app/components/header/nav/nav-selector/index.tsx b/web/app/components/header/nav/nav-selector/index.tsx index 6c9db287e7..4a13bc8a3c 100644 --- a/web/app/components/header/nav/nav-selector/index.tsx +++ b/web/app/components/header/nav/nav-selector/index.tsx @@ -15,7 +15,7 @@ import { AppTypeIcon } from '@/app/components/app/type-selector' import { useAppContext } from '@/context/app-context' import { useStore as useAppStore } from '@/app/components/app/store' import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files' -import type { AppIconType, AppMode } from '@/types/app' +import type { AppIconType, AppModeEnum } from '@/types/app' export type NavItem = { id: string @@ -25,7 +25,7 @@ export type NavItem = { icon: string icon_background: string | null icon_url: string | null - mode?: AppMode + mode?: AppModeEnum } export type INavSelectorProps = { navigationItems: NavItem[] diff --git a/web/app/components/plugins/card/index.tsx b/web/app/components/plugins/card/index.tsx index b3c09b5bfd..e20aef6220 100644 --- a/web/app/components/plugins/card/index.tsx +++ b/web/app/components/plugins/card/index.tsx @@ -1,21 +1,21 @@ 'use client' -import React from 'react' -import type { Plugin } from '../types' -import Icon from '../card/base/card-icon' -import CornerMark from './base/corner-mark' -import Title from './base/title' -import OrgInfo from './base/org-info' -import Description from './base/description' -import Placeholder from './base/placeholder' -import cn from '@/utils/classnames' -import { useGetLanguage } from '@/context/i18n' -import { getLanguage } from '@/i18n-config/language' -import { useSingleCategories } from '../hooks' -import { renderI18nObject } from '@/i18n-config' import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks' +import { useGetLanguage } from '@/context/i18n' +import { renderI18nObject } from '@/i18n-config' +import { getLanguage } from '@/i18n-config/language' +import cn from '@/utils/classnames' +import { RiAlertFill } from '@remixicon/react' +import React from 'react' import Partner from '../base/badges/partner' import Verified from '../base/badges/verified' -import { RiAlertFill } from '@remixicon/react' +import Icon from '../card/base/card-icon' +import { useCategories } from '../hooks' +import type { Plugin } from '../types' +import CornerMark from './base/corner-mark' +import Description from './base/description' +import OrgInfo from './base/org-info' +import Placeholder from './base/placeholder' +import Title from './base/title' export type Props = { className?: string @@ -49,10 +49,8 @@ const Card = ({ const defaultLocale = useGetLanguage() const locale = localeFromProps ? getLanguage(localeFromProps) : defaultLocale const { t } = useMixedTranslation(localeFromProps) - const { categoriesMap } = useSingleCategories(t) + const { categoriesMap } = useCategories(t, true) const { category, type, name, org, label, brief, icon, verified, badges = [] } = payload - const isBundle = !['plugin', 'model', 'tool', 'datasource', 'extension', 'agent-strategy'].includes(type) - const cornerMark = isBundle ? categoriesMap.bundle?.label : categoriesMap[category]?.label const getLocalizedText = (obj: Record | undefined) => obj ? renderI18nObject(obj, locale) : '' const isPartner = badges.includes('partner') @@ -70,7 +68,7 @@ const Card = ({ return (
- {!hideCornerMark && } + {!hideCornerMark && } {/* Header */}
diff --git a/web/app/components/plugins/constants.ts b/web/app/components/plugins/constants.ts index 7436611c79..d9203fd4ea 100644 --- a/web/app/components/plugins/constants.ts +++ b/web/app/components/plugins/constants.ts @@ -1,3 +1,5 @@ +import { PluginCategoryEnum } from './types' + export const tagKeys = [ 'agent', 'rag', @@ -20,10 +22,11 @@ export const tagKeys = [ ] export const categoryKeys = [ - 'model', - 'tool', - 'datasource', - 'agent-strategy', - 'extension', + PluginCategoryEnum.model, + PluginCategoryEnum.tool, + PluginCategoryEnum.datasource, + PluginCategoryEnum.agent, + PluginCategoryEnum.extension, 'bundle', + PluginCategoryEnum.trigger, ] diff --git a/web/app/components/plugins/hooks.ts b/web/app/components/plugins/hooks.ts index f22b2c4d69..8303a4cc46 100644 --- a/web/app/components/plugins/hooks.ts +++ b/web/app/components/plugins/hooks.ts @@ -1,10 +1,11 @@ +import type { TFunction } from 'i18next' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import type { TFunction } from 'i18next' import { categoryKeys, tagKeys, } from './constants' +import { PluginCategoryEnum } from './types' export type Tag = { name: string @@ -51,56 +52,24 @@ type Category = { label: string } -export const useCategories = (translateFromOut?: TFunction) => { +export const useCategories = (translateFromOut?: TFunction, isSingle?: boolean) => { const { t: translation } = useTranslation() const t = translateFromOut || translation const categories = useMemo(() => { return categoryKeys.map((category) => { - if (category === 'agent-strategy') { + if (category === PluginCategoryEnum.agent) { return { - name: 'agent-strategy', - label: t('plugin.category.agents'), + name: PluginCategoryEnum.agent, + label: isSingle ? t('plugin.categorySingle.agent') : t('plugin.category.agents'), } } return { name: category, - label: t(`plugin.category.${category}s`), + label: isSingle ? t(`plugin.categorySingle.${category}`) : t(`plugin.category.${category}s`), } }) - }, [t]) - - const categoriesMap = useMemo(() => { - return categories.reduce((acc, category) => { - acc[category.name] = category - return acc - }, {} as Record) - }, [categories]) - - return { - categories, - categoriesMap, - } -} - -export const useSingleCategories = (translateFromOut?: TFunction) => { - const { t: translation } = useTranslation() - const t = translateFromOut || translation - - const categories = useMemo(() => { - return categoryKeys.map((category) => { - if (category === 'agent-strategy') { - return { - name: 'agent-strategy', - label: t('plugin.categorySingle.agent'), - } - } - return { - name: category, - label: t(`plugin.categorySingle.${category}`), - } - }) - }, [t]) + }, [t, isSingle]) const categoriesMap = useMemo(() => { return categories.reduce((acc, category) => { diff --git a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx index 7c3ab29c49..264c4782cd 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx +++ b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx @@ -5,9 +5,10 @@ import { useInvalidateInstalledPluginList } from '@/service/use-plugins' import { useInvalidateAllBuiltInTools, useInvalidateAllToolProviders, useInvalidateRAGRecommendedPlugins } from '@/service/use-tools' import { useInvalidateStrategyProviders } from '@/service/use-strategy' import type { Plugin, PluginDeclaration, PluginManifestInMarket } from '../../types' -import { PluginType } from '../../types' +import { PluginCategoryEnum } from '../../types' import { useInvalidDataSourceList } from '@/service/use-pipeline' import { useInvalidDataSourceListAuth } from '@/service/use-datasource' +import { useInvalidateAllTriggerPlugins } from '@/service/use-triggers' const useRefreshPluginList = () => { const invalidateInstalledPluginList = useInvalidateInstalledPluginList() @@ -24,6 +25,8 @@ const useRefreshPluginList = () => { const invalidateStrategyProviders = useInvalidateStrategyProviders() + const invalidateAllTriggerPlugins = useInvalidateAllTriggerPlugins() + const invalidateRAGRecommendedPlugins = useInvalidateRAGRecommendedPlugins() return { refreshPluginList: (manifest?: PluginManifestInMarket | Plugin | PluginDeclaration | null, refreshAllType?: boolean) => { @@ -31,20 +34,23 @@ const useRefreshPluginList = () => { invalidateInstalledPluginList() // tool page, tool select - if ((manifest && PluginType.tool.includes(manifest.category)) || refreshAllType) { + if ((manifest && PluginCategoryEnum.tool.includes(manifest.category)) || refreshAllType) { invalidateAllToolProviders() invalidateAllBuiltInTools() invalidateRAGRecommendedPlugins() // TODO: update suggested tools. It's a function in hook useMarketplacePlugins,handleUpdatePlugins } - if ((manifest && PluginType.datasource.includes(manifest.category)) || refreshAllType) { + if ((manifest && PluginCategoryEnum.trigger.includes(manifest.category)) || refreshAllType) + invalidateAllTriggerPlugins() + + if ((manifest && PluginCategoryEnum.datasource.includes(manifest.category)) || refreshAllType) { invalidateAllDataSources() invalidateDataSourceListAuth() } // model select - if ((manifest && PluginType.model.includes(manifest.category)) || refreshAllType) { + if ((manifest && PluginCategoryEnum.model.includes(manifest.category)) || refreshAllType) { refreshModelProviders() refetchLLMModelList() refetchEmbeddingModelList() @@ -52,7 +58,7 @@ const useRefreshPluginList = () => { } // agent select - if ((manifest && PluginType.agent.includes(manifest.category)) || refreshAllType) + if ((manifest && PluginCategoryEnum.agent.includes(manifest.category)) || refreshAllType) invalidateStrategyProviders() }, } diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 10aead17c4..5bc9263aaa 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -65,10 +65,12 @@ export const useMarketplacePlugins = () => { } = useMutationPluginsFromMarketplace() const [prevPlugins, setPrevPlugins] = useState() + const resetPlugins = useCallback(() => { reset() setPrevPlugins(undefined) }, [reset]) + const handleUpdatePlugins = useCallback((pluginsSearchParams: PluginsSearchParams) => { mutateAsync(pluginsSearchParams).then((res) => { const currentPage = pluginsSearchParams.page || 1 @@ -85,9 +87,6 @@ export const useMarketplacePlugins = () => { } }) }, [mutateAsync]) - const queryPlugins = useCallback((pluginsSearchParams: PluginsSearchParams) => { - handleUpdatePlugins(pluginsSearchParams) - }, [handleUpdatePlugins]) const { run: queryPluginsWithDebounced, cancel: cancelQueryPluginsWithDebounced } = useDebounceFn((pluginsSearchParams: PluginsSearchParams) => { handleUpdatePlugins(pluginsSearchParams) @@ -99,7 +98,7 @@ export const useMarketplacePlugins = () => { plugins: prevPlugins, total: data?.data?.total, resetPlugins, - queryPlugins, + queryPlugins: handleUpdatePlugins, queryPluginsWithDebounced, cancelQueryPluginsWithDebounced, isLoading: isPending, diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index 4a40eb0e06..249be1ef83 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -1,4 +1,6 @@ 'use client' +import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin' +import cn from '@/utils/classnames' import { RiArchive2Line, RiBrain2Line, @@ -7,22 +9,22 @@ import { RiPuzzle2Line, RiSpeakAiLine, } from '@remixicon/react' -import { PluginType } from '../types' +import { useCallback, useEffect } from 'react' +import { PluginCategoryEnum } from '../types' import { useMarketplaceContext } from './context' import { useMixedTranslation, useSearchBoxAutoAnimate, } from './hooks' -import cn from '@/utils/classnames' -import { useCallback, useEffect } from 'react' export const PLUGIN_TYPE_SEARCH_MAP = { all: 'all', - model: PluginType.model, - tool: PluginType.tool, - agent: PluginType.agent, - extension: PluginType.extension, - datasource: PluginType.datasource, + model: PluginCategoryEnum.model, + tool: PluginCategoryEnum.tool, + agent: PluginCategoryEnum.agent, + extension: PluginCategoryEnum.extension, + datasource: PluginCategoryEnum.datasource, + trigger: PluginCategoryEnum.trigger, bundle: 'bundle', } type PluginTypeSwitchProps = { @@ -63,6 +65,11 @@ const PluginTypeSwitch = ({ text: t('plugin.category.datasources'), icon: , }, + { + value: PLUGIN_TYPE_SEARCH_MAP.trigger, + text: t('plugin.category.triggers'), + icon: , + }, { value: PLUGIN_TYPE_SEARCH_MAP.agent, text: t('plugin.category.agents'), diff --git a/web/app/components/plugins/marketplace/search-box/index.tsx b/web/app/components/plugins/marketplace/search-box/index.tsx index 0bc214ae1a..c398964b4e 100644 --- a/web/app/components/plugins/marketplace/search-box/index.tsx +++ b/web/app/components/plugins/marketplace/search-box/index.tsx @@ -19,6 +19,7 @@ type SearchBoxProps = { usedInMarketplace?: boolean onShowAddCustomCollectionModal?: () => void onAddedCustomTool?: () => void + autoFocus?: boolean } const SearchBox = ({ search, @@ -32,6 +33,7 @@ const SearchBox = ({ usedInMarketplace = false, supportAddCustomTool, onShowAddCustomCollectionModal, + autoFocus = false, }: SearchBoxProps) => { return (
@@ -82,11 +84,12 @@ const SearchBox = ({ { !usedInMarketplace && ( <> -
+
{ - if (pluginType === PluginType.tool) - return 'category=tool' + if ([PluginCategoryEnum.tool, PluginCategoryEnum.agent, PluginCategoryEnum.model, PluginCategoryEnum.datasource, PluginCategoryEnum.trigger].includes(pluginType as PluginCategoryEnum)) + return `category=${pluginType}` - if (pluginType === PluginType.agent) - return 'category=agent-strategy' - - if (pluginType === PluginType.model) - return 'category=model' - - if (pluginType === PluginType.extension) + if (pluginType === PluginCategoryEnum.extension) return 'category=endpoint' - if (pluginType === PluginType.datasource) - return 'category=datasource' - if (pluginType === 'bundle') return 'type=bundle' diff --git a/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx b/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx index 95676c656e..cb90b075b0 100644 --- a/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx @@ -6,7 +6,6 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' -import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security' import Modal from '@/app/components/base/modal/modal' import { CredentialTypeEnum } from '../types' import AuthForm from '@/app/components/base/form/form-scenarios/auth' @@ -23,6 +22,9 @@ import { useGetPluginCredentialSchemaHook, useUpdatePluginCredentialHook, } from '../hooks/use-credential' +import { ReadmeEntrance } from '../../readme-panel/entrance' +import { ReadmeShowType } from '../../readme-panel/store' +import { EncryptedBottom } from '@/app/components/base/encrypted-bottom' export type ApiKeyModalProps = { pluginPayload: PluginPayload @@ -134,25 +136,17 @@ const ApiKeyModal = ({ footerSlot={ (
) } - bottomSlot={ -
- - {t('common.modelProvider.encrypted.front')} - - PKCS1_OAEP - - {t('common.modelProvider.encrypted.back')} -
- } + bottomSlot={} onConfirm={handleConfirm} showExtraButton={!!editValues} onExtraButtonClick={onRemove} disabled={disabled || isLoading || doingAction} + clickOutsideNotClose={true} + wrapperClassName='!z-[101]' > + {pluginPayload.detail && ( + + )} { isLoading && (
diff --git a/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx b/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx index c10b06166b..256f6d0f4b 100644 --- a/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx @@ -23,6 +23,8 @@ import type { } from '@/app/components/base/form/types' import { useToastContext } from '@/app/components/base/toast' import Button from '@/app/components/base/button' +import { ReadmeEntrance } from '../../readme-panel/entrance' +import { ReadmeShowType } from '../../readme-panel/store' type OAuthClientSettingsProps = { pluginPayload: PluginPayload @@ -154,16 +156,20 @@ const OAuthClientSettings = ({
) } + containerClassName='pt-0' + wrapperClassName='!z-[101]' + clickOutsideNotClose={true} > - <> - - + {pluginPayload.detail && ( + + )} + ) } diff --git a/web/app/components/plugins/plugin-auth/types.ts b/web/app/components/plugins/plugin-auth/types.ts index fb23269b4b..9974586302 100644 --- a/web/app/components/plugins/plugin-auth/types.ts +++ b/web/app/components/plugins/plugin-auth/types.ts @@ -1,4 +1,5 @@ import type { CollectionType } from '../../tools/types' +import type { PluginDetail } from '../types' export type { AddApiKeyButtonProps } from './authorize/add-api-key-button' export type { AddOAuthButtonProps } from './authorize/add-oauth-button' @@ -7,12 +8,14 @@ export enum AuthCategory { tool = 'tool', datasource = 'datasource', model = 'model', + trigger = 'trigger', } export type PluginPayload = { category: AuthCategory provider: string - providerType: CollectionType | string + providerType?: CollectionType | string + detail?: PluginDetail } export enum CredentialTypeEnum { diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel.tsx index 12cd74e10a..edf15a4419 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel.tsx @@ -6,7 +6,7 @@ import AppInputsForm from '@/app/components/plugins/plugin-detail-panel/app-sele import { useAppDetail } from '@/service/use-apps' import { useAppWorkflow } from '@/service/use-workflow' import { useFileUploadConfig } from '@/service/use-common' -import { Resolution } from '@/types/app' +import { AppModeEnum, Resolution } from '@/types/app' import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' import type { App } from '@/types/app' import type { FileUpload } from '@/app/components/base/features/types' @@ -30,7 +30,7 @@ const AppInputsPanel = ({ }: Props) => { const { t } = useTranslation() const inputsRef = useRef(value?.inputs || {}) - const isBasicApp = appDetail.mode !== 'advanced-chat' && appDetail.mode !== 'workflow' + const isBasicApp = appDetail.mode !== AppModeEnum.ADVANCED_CHAT && appDetail.mode !== AppModeEnum.WORKFLOW const { data: fileUploadConfig } = useFileUploadConfig() const { data: currentApp, isFetching: isAppLoading } = useAppDetail(appDetail.id) const { data: currentWorkflow, isFetching: isWorkflowLoading } = useAppWorkflow(isBasicApp ? '' : appDetail.id) @@ -77,7 +77,7 @@ const AppInputsPanel = ({ required: false, } } - if(item.checkbox) { + if (item.checkbox) { return { ...item.checkbox, type: 'checkbox', @@ -148,7 +148,7 @@ const AppInputsPanel = ({ } }) || [] } - if ((currentApp.mode === 'completion' || currentApp.mode === 'workflow') && basicAppFileConfig.enabled) { + if ((currentApp.mode === AppModeEnum.COMPLETION || currentApp.mode === AppModeEnum.WORKFLOW) && basicAppFileConfig.enabled) { inputFormSchema.push({ label: 'Image Upload', variable: '#image#', diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx index 10c28507f7..43fb4b30e0 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx @@ -12,7 +12,7 @@ import type { } from '@floating-ui/react' import Input from '@/app/components/base/input' import AppIcon from '@/app/components/base/app-icon' -import type { App } from '@/types/app' +import { type App, AppModeEnum } from '@/types/app' import { useTranslation } from 'react-i18next' type Props = { @@ -118,15 +118,15 @@ const AppPicker: FC = ({ const getAppType = (app: App) => { switch (app.mode) { - case 'advanced-chat': + case AppModeEnum.ADVANCED_CHAT: return 'chatflow' - case 'agent-chat': + case AppModeEnum.AGENT_CHAT: return 'agent' - case 'chat': + case AppModeEnum.CHAT: return 'chat' - case 'completion': + case AppModeEnum.COMPLETION: return 'completion' - case 'workflow': + case AppModeEnum.WORKFLOW: return 'workflow' } } diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx index 9f326fa198..44ddb8360e 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx @@ -1,7 +1,26 @@ -import React, { useCallback, useMemo, useState } from 'react' -import { useTheme } from 'next-themes' -import { useTranslation } from 'react-i18next' -import { useBoolean } from 'ahooks' +import ActionButton from '@/app/components/base/action-button' +import Badge from '@/app/components/base/badge' +import Button from '@/app/components/base/button' +import Confirm from '@/app/components/base/confirm' +import { Github } from '@/app/components/base/icons/src/public/common' +import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin' +import Toast from '@/app/components/base/toast' +import Tooltip from '@/app/components/base/tooltip' +import { AuthCategory, PluginAuth } from '@/app/components/plugins/plugin-auth' +import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown' +import PluginInfo from '@/app/components/plugins/plugin-page/plugin-info' +import UpdateFromMarketplace from '@/app/components/plugins/update-plugin/from-market-place' +import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker' +import { API_PREFIX } from '@/config' +import { useAppContext } from '@/context/app-context' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useGetLanguage, useI18N } from '@/context/i18n' +import { useModalContext } from '@/context/modal-context' +import { useProviderContext } from '@/context/provider-context' +import { uninstallPlugin } from '@/service/plugins' +import { useAllToolProviders, useInvalidateAllToolProviders } from '@/service/use-tools' +import cn from '@/utils/classnames' +import { getMarketplaceUrl } from '@/utils/var' import { RiArrowLeftRightLine, RiBugLine, @@ -9,54 +28,35 @@ import { RiHardDrive3Line, RiVerifiedBadgeLine, } from '@remixicon/react' -import type { PluginDetail } from '../types' -import { PluginSource, PluginType } from '../types' -import Description from '../card/base/description' -import Icon from '../card/base/card-icon' -import Title from '../card/base/title' -import OrgInfo from '../card/base/org-info' -import { useGitHubReleases } from '../install-plugin/hooks' -import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker' -import UpdateFromMarketplace from '@/app/components/plugins/update-plugin/from-market-place' -import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown' -import PluginInfo from '@/app/components/plugins/plugin-page/plugin-info' -import ActionButton from '@/app/components/base/action-button' -import Button from '@/app/components/base/button' -import Badge from '@/app/components/base/badge' -import Confirm from '@/app/components/base/confirm' -import Tooltip from '@/app/components/base/tooltip' -import Toast from '@/app/components/base/toast' -import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin' -import { Github } from '@/app/components/base/icons/src/public/common' -import { uninstallPlugin } from '@/service/plugins' -import { useGetLanguage, useI18N } from '@/context/i18n' -import { useModalContext } from '@/context/modal-context' -import { useProviderContext } from '@/context/provider-context' -import { useInvalidateAllToolProviders } from '@/service/use-tools' -import { API_PREFIX } from '@/config' -import cn from '@/utils/classnames' -import { getMarketplaceUrl } from '@/utils/var' -import { PluginAuth } from '@/app/components/plugins/plugin-auth' -import { AuthCategory } from '@/app/components/plugins/plugin-auth' -import { useAllToolProviders } from '@/service/use-tools' -import DeprecationNotice from '../base/deprecation-notice' +import { useBoolean } from 'ahooks' +import { useTheme } from 'next-themes' +import React, { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import { AutoUpdateLine } from '../../base/icons/src/vender/system' -import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../reference-setting-modal/auto-update-setting/utils' +import DeprecationNotice from '../base/deprecation-notice' +import Icon from '../card/base/card-icon' +import Description from '../card/base/description' +import OrgInfo from '../card/base/org-info' +import Title from '../card/base/title' +import { useGitHubReleases } from '../install-plugin/hooks' import useReferenceSetting from '../plugin-page/use-reference-setting' import { AUTO_UPDATE_MODE } from '../reference-setting-modal/auto-update-setting/types' -import { useAppContext } from '@/context/app-context' -import { useGlobalPublicStore } from '@/context/global-public-context' +import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../reference-setting-modal/auto-update-setting/utils' +import type { PluginDetail } from '../types' +import { PluginCategoryEnum, PluginSource } from '../types' const i18nPrefix = 'plugin.action' type Props = { detail: PluginDetail - onHide: () => void - onUpdate: (isDelete?: boolean) => void + isReadmeView?: boolean + onHide?: () => void + onUpdate?: (isDelete?: boolean) => void } const DetailHeader = ({ detail, + isReadmeView = false, onHide, onUpdate, }: Props) => { @@ -85,8 +85,9 @@ const DetailHeader = ({ deprecated_reason, alternative_plugin_id, } = detail - const { author, category, name, label, description, icon, verified, tool } = detail.declaration - const isTool = category === PluginType.tool + + const { author, category, name, label, description, icon, verified, tool } = detail.declaration || detail + const isTool = category === PluginCategoryEnum.tool const providerBriefInfo = tool?.identity const providerKey = `${plugin_id}/${providerBriefInfo?.name}` const { data: collectionList = [] } = useAllToolProviders(isTool) @@ -128,13 +129,13 @@ const DetailHeader = ({ return false if (!autoUpgradeInfo || !isFromMarketplace) return false - if(autoUpgradeInfo.strategy_setting === 'disabled') + if (autoUpgradeInfo.strategy_setting === 'disabled') return false - if(autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all) + if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all) return true - if(autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && autoUpgradeInfo.include_plugins.includes(plugin_id)) + if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && autoUpgradeInfo.include_plugins.includes(plugin_id)) return true - if(autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && !autoUpgradeInfo.exclude_plugins.includes(plugin_id)) + if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && !autoUpgradeInfo.exclude_plugins.includes(plugin_id)) return true return false }, [autoUpgradeInfo, plugin_id, isFromMarketplace]) @@ -156,7 +157,7 @@ const DetailHeader = ({ if (needUpdate) { setShowUpdatePluginModal({ onSaveCallback: () => { - onUpdate() + onUpdate?.() }, payload: { type: PluginSource.github, @@ -176,7 +177,7 @@ const DetailHeader = ({ } const handleUpdatedFromMarketplace = () => { - onUpdate() + onUpdate?.() hideUpdateModal() } @@ -201,26 +202,26 @@ const DetailHeader = ({ hideDeleting() if (res.success) { hideDeleteConfirm() - onUpdate(true) - if (PluginType.model.includes(category)) + onUpdate?.(true) + if (PluginCategoryEnum.model.includes(category)) refreshModelProviders() - if (PluginType.tool.includes(category)) + if (PluginCategoryEnum.tool.includes(category)) invalidateAllToolProviders() } }, [showDeleting, id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders]) return ( -
+
-
- +
+
- {verified && <RiVerifiedBadgeLine className="ml-0.5 h-4 w-4 shrink-0 text-text-accent" />} - <PluginVersionPicker - disabled={!isFromMarketplace} + {verified && !isReadmeView && <RiVerifiedBadgeLine className="ml-0.5 h-4 w-4 shrink-0 text-text-accent" />} + {version && <PluginVersionPicker + disabled={!isFromMarketplace || isReadmeView} isShow={isShow} onShowChange={setIsShow} pluginID={plugin_id} @@ -240,15 +241,15 @@ const DetailHeader = ({ text={ <> <div>{isFromGitHub ? meta!.version : version}</div> - {isFromMarketplace && <RiArrowLeftRightLine className='ml-1 h-3 w-3 text-text-tertiary' />} + {isFromMarketplace && !isReadmeView && <RiArrowLeftRightLine className='ml-1 h-3 w-3 text-text-tertiary' />} </> } hasRedCornerMark={hasNewVersion} /> } - /> + />} {/* Auto update info */} - {isAutoUpgradeEnabled && ( + {isAutoUpgradeEnabled && !isReadmeView && ( <Tooltip popupContent={t('plugin.autoUpdate.nextUpdateTime', { time: timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(autoUpgradeInfo?.upgrade_time_of_day || 0, timezone!)).format('hh:mm A') })}> {/* add a a div to fix tooltip hover not show problem */} <div> @@ -276,44 +277,47 @@ const DetailHeader = ({ <OrgInfo packageNameClassName='w-auto' orgName={author} - packageName={name} + packageName={name?.includes('/') ? (name.split('/').pop() || '') : name} /> - <div className='system-xs-regular ml-1 mr-0.5 text-text-quaternary'>·</div> - {detail.source === PluginSource.marketplace && ( - <Tooltip popupContent={t('plugin.detailPanel.categoryTip.marketplace')} > - <div><BoxSparkleFill className='h-3.5 w-3.5 text-text-tertiary hover:text-text-accent' /></div> - </Tooltip> - )} - {detail.source === PluginSource.github && ( - <Tooltip popupContent={t('plugin.detailPanel.categoryTip.github')} > - <div><Github className='h-3.5 w-3.5 text-text-secondary hover:text-text-primary' /></div> - </Tooltip> - )} - {detail.source === PluginSource.local && ( - <Tooltip popupContent={t('plugin.detailPanel.categoryTip.local')} > - <div><RiHardDrive3Line className='h-3.5 w-3.5 text-text-tertiary' /></div> - </Tooltip> - )} - {detail.source === PluginSource.debugging && ( - <Tooltip popupContent={t('plugin.detailPanel.categoryTip.debugging')} > - <div><RiBugLine className='h-3.5 w-3.5 text-text-tertiary hover:text-text-warning' /></div> - </Tooltip> - )} + {source && <> + <div className='system-xs-regular ml-1 mr-0.5 text-text-quaternary'>·</div> + {source === PluginSource.marketplace && ( + <Tooltip popupContent={t('plugin.detailPanel.categoryTip.marketplace')} > + <div><BoxSparkleFill className='h-3.5 w-3.5 text-text-tertiary hover:text-text-accent' /></div> + </Tooltip> + )} + {source === PluginSource.github && ( + <Tooltip popupContent={t('plugin.detailPanel.categoryTip.github')} > + <div><Github className='h-3.5 w-3.5 text-text-secondary hover:text-text-primary' /></div> + </Tooltip> + )} + {source === PluginSource.local && ( + <Tooltip popupContent={t('plugin.detailPanel.categoryTip.local')} > + <div><RiHardDrive3Line className='h-3.5 w-3.5 text-text-tertiary' /></div> + </Tooltip> + )} + {source === PluginSource.debugging && ( + <Tooltip popupContent={t('plugin.detailPanel.categoryTip.debugging')} > + <div><RiBugLine className='h-3.5 w-3.5 text-text-tertiary hover:text-text-warning' /></div> + </Tooltip> + )} + </>} </div> </div> </div> - <div className='flex gap-1'> - <OperationDropdown - source={detail.source} - onInfo={showPluginInfo} - onCheckVersion={handleUpdate} - onRemove={showDeleteConfirm} - detailUrl={detailUrl} - /> - <ActionButton onClick={onHide}> - <RiCloseLine className='h-4 w-4' /> - </ActionButton> - </div> + {!isReadmeView && ( + <div className='flex gap-1'> + <OperationDropdown + source={source} + onInfo={showPluginInfo} + onCheckVersion={handleUpdate} + onRemove={showDeleteConfirm} + detailUrl={detailUrl} + /> + <ActionButton onClick={onHide}> + <RiCloseLine className='h-4 w-4' /> + </ActionButton> + </div>)} </div> {isFromMarketplace && ( <DeprecationNotice @@ -324,14 +328,15 @@ const DetailHeader = ({ className='mt-3' /> )} - <Description className='mb-2 mt-3 h-auto' text={description[locale]} descriptionLineRows={2}></Description> + {!isReadmeView && <Description className='mb-2 mt-3 h-auto' text={description[locale]} descriptionLineRows={2}></Description>} { - category === PluginType.tool && ( + category === PluginCategoryEnum.tool && !isReadmeView && ( <PluginAuth pluginPayload={{ provider: provider?.name || '', category: AuthCategory.tool, providerType: provider?.type || '', + detail, }} /> ) diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx index 00cd1b88ae..9c3765def3 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { useBoolean } from 'ahooks' import copy from 'copy-to-clipboard' import { RiClipboardLine, RiDeleteBinLine, RiEditLine, RiLoginCircleLine } from '@remixicon/react' -import type { EndpointListItem } from '../types' +import type { EndpointListItem, PluginDetail } from '../types' import EndpointModal from './endpoint-modal' import { NAME_FIELD } from './utils' import { addDefaultValue, toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema' @@ -22,11 +22,13 @@ import { } from '@/service/use-endpoints' type Props = { + pluginDetail: PluginDetail data: EndpointListItem handleChange: () => void } const EndpointCard = ({ + pluginDetail, data, handleChange, }: Props) => { @@ -206,10 +208,11 @@ const EndpointCard = ({ )} {isShowEndpointModal && ( <EndpointModal - formSchemas={formSchemas} + formSchemas={formSchemas as any} defaultValues={formValue} onCancel={hideEndpointModalConfirm} onSaved={handleUpdate} + pluginDetail={pluginDetail} /> )} </div> diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx index 5735022c5d..fff6775495 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx @@ -102,14 +102,16 @@ const EndpointList = ({ detail }: Props) => { key={index} data={item} handleChange={() => invalidateEndpointList(detail.plugin_id)} + pluginDetail={detail} /> ))} </div> {isShowEndpointModal && ( <EndpointModal - formSchemas={formSchemas} + formSchemas={formSchemas as any} onCancel={hideEndpointModal} onSaved={handleCreate} + pluginDetail={detail} /> )} </div> diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx index d4c0bc2d92..48aeecf1b2 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx @@ -10,12 +10,16 @@ import Form from '@/app/components/header/account-setting/model-provider-page/mo import Toast from '@/app/components/base/toast' import { useRenderI18nObject } from '@/hooks/use-i18n' import cn from '@/utils/classnames' +import { ReadmeEntrance } from '../readme-panel/entrance' +import type { PluginDetail } from '../types' +import type { FormSchema } from '../../base/form/types' type Props = { - formSchemas: any + formSchemas: FormSchema[] defaultValues?: any onCancel: () => void onSaved: (value: Record<string, any>) => void + pluginDetail: PluginDetail } const extractDefaultValues = (schemas: any[]) => { @@ -32,6 +36,7 @@ const EndpointModal: FC<Props> = ({ defaultValues = {}, onCancel, onSaved, + pluginDetail, }) => { const getValueFromI18nObject = useRenderI18nObject() const { t } = useTranslation() @@ -43,7 +48,7 @@ const EndpointModal: FC<Props> = ({ const handleSave = () => { for (const field of formSchemas) { if (field.required && !tempCredential[field.name]) { - Toast.notify({ type: 'error', message: t('common.errorMsg.fieldRequired', { field: getValueFromI18nObject(field.label) }) }) + Toast.notify({ type: 'error', message: t('common.errorMsg.fieldRequired', { field: typeof field.label === 'string' ? field.label : getValueFromI18nObject(field.label as Record<string, string>) }) }) return } } @@ -84,6 +89,7 @@ const EndpointModal: FC<Props> = ({ </ActionButton> </div> <div className='system-xs-regular mt-0.5 text-text-tertiary'>{t('plugin.detailPanel.endpointModalDesc')}</div> + <ReadmeEntrance pluginDetail={pluginDetail} className='px-0 pt-3' /> </div> <div className='grow overflow-y-auto'> <div className='px-4 py-2'> @@ -92,7 +98,7 @@ const EndpointModal: FC<Props> = ({ onChange={(v) => { setTempCredential(v) }} - formSchemas={formSchemas} + formSchemas={formSchemas as any} isEditMode={true} showOnVariableMap={{}} validating={false} diff --git a/web/app/components/plugins/plugin-detail-panel/index.tsx b/web/app/components/plugins/plugin-detail-panel/index.tsx index de248390f4..380d2329f6 100644 --- a/web/app/components/plugins/plugin-detail-panel/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/index.tsx @@ -1,15 +1,19 @@ 'use client' -import React from 'react' +import Drawer from '@/app/components/base/drawer' +import { PluginCategoryEnum, type PluginDetail } from '@/app/components/plugins/types' +import cn from '@/utils/classnames' import type { FC } from 'react' +import { useCallback, useEffect } from 'react' +import ActionList from './action-list' +import AgentStrategyList from './agent-strategy-list' +import DatasourceActionList from './datasource-action-list' import DetailHeader from './detail-header' import EndpointList from './endpoint-list' -import ActionList from './action-list' -import DatasourceActionList from './datasource-action-list' import ModelList from './model-list' -import AgentStrategyList from './agent-strategy-list' -import Drawer from '@/app/components/base/drawer' -import type { PluginDetail } from '@/app/components/plugins/types' -import cn from '@/utils/classnames' +import { SubscriptionList } from './subscription-list' +import { usePluginStore } from './store' +import { TriggerEventsList } from './trigger/event-list' +import { ReadmeEntrance } from '../readme-panel/entrance' type Props = { detail?: PluginDetail @@ -22,11 +26,24 @@ const PluginDetailPanel: FC<Props> = ({ onUpdate, onHide, }) => { - const handleUpdate = (isDelete = false) => { + const handleUpdate = useCallback((isDelete = false) => { if (isDelete) onHide() onUpdate() - } + }, [onHide, onUpdate]) + + const { setDetail } = usePluginStore() + + useEffect(() => { + setDetail(!detail ? undefined : { + plugin_id: detail.plugin_id, + provider: `${detail.plugin_id}/${detail.declaration.name}`, + plugin_unique_identifier: detail.plugin_unique_identifier || '', + declaration: detail.declaration, + name: detail.name, + id: detail.id, + }) + }, [detail]) if (!detail) return null @@ -43,17 +60,24 @@ const PluginDetailPanel: FC<Props> = ({ > {detail && ( <> - <DetailHeader - detail={detail} - onHide={onHide} - onUpdate={handleUpdate} - /> + <DetailHeader detail={detail} onUpdate={handleUpdate} onHide={onHide} /> <div className='grow overflow-y-auto'> - {!!detail.declaration.tool && <ActionList detail={detail} />} - {!!detail.declaration.agent_strategy && <AgentStrategyList detail={detail} />} - {!!detail.declaration.endpoint && <EndpointList detail={detail} />} - {!!detail.declaration.model && <ModelList detail={detail} />} - {!!detail.declaration.datasource && <DatasourceActionList detail={detail} />} + <div className='flex min-h-full flex-col'> + <div className='flex-1'> + {detail.declaration.category === PluginCategoryEnum.trigger && ( + <> + <SubscriptionList /> + <TriggerEventsList /> + </> + )} + {!!detail.declaration.tool && <ActionList detail={detail} />} + {!!detail.declaration.agent_strategy && <AgentStrategyList detail={detail} />} + {!!detail.declaration.endpoint && <EndpointList detail={detail} />} + {!!detail.declaration.model && <ModelList detail={detail} />} + {!!detail.declaration.datasource && <DatasourceActionList detail={detail} />} + </div> + <ReadmeEntrance pluginDetail={detail} className='mt-auto' /> + </div> </div> </> )} diff --git a/web/app/components/plugins/plugin-detail-panel/store.ts b/web/app/components/plugins/plugin-detail-panel/store.ts new file mode 100644 index 0000000000..931b08215d --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/store.ts @@ -0,0 +1,29 @@ +import { create } from 'zustand' +import type { + ParametersSchema, + PluginDeclaration, + PluginDetail, + PluginTriggerSubscriptionConstructor, +} from '../types' + +type TriggerDeclarationSummary = { + subscription_schema?: ParametersSchema[] + subscription_constructor?: PluginTriggerSubscriptionConstructor | null +} + +export type SimpleDetail = Pick<PluginDetail, 'plugin_id' | 'name' | 'plugin_unique_identifier' | 'id'> & { + provider: string + declaration: Partial<Omit<PluginDeclaration, 'trigger'>> & { + trigger?: TriggerDeclarationSummary + } +} + +type Shape = { + detail: SimpleDetail | undefined + setDetail: (detail?: SimpleDetail) => void +} + +export const usePluginStore = create<Shape>(set => ({ + detail: undefined, + setDetail: (detail?: SimpleDetail) => set({ detail }), +})) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx new file mode 100644 index 0000000000..17a46febdf --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx @@ -0,0 +1,449 @@ +'use client' +// import { CopyFeedbackNew } from '@/app/components/base/copy-feedback' +import { EncryptedBottom } from '@/app/components/base/encrypted-bottom' +import { BaseForm } from '@/app/components/base/form/components/base' +import type { FormRefObject } from '@/app/components/base/form/types' +import { FormTypeEnum } from '@/app/components/base/form/types' +import Modal from '@/app/components/base/modal/modal' +import Toast from '@/app/components/base/toast' +import { SupportedCreationMethods } from '@/app/components/plugins/types' +import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' +import type { BuildTriggerSubscriptionPayload } from '@/service/use-triggers' +import { + useBuildTriggerSubscription, + useCreateTriggerSubscriptionBuilder, + useTriggerSubscriptionBuilderLogs, + useUpdateTriggerSubscriptionBuilder, + useVerifyTriggerSubscriptionBuilder, +} from '@/service/use-triggers' +import { parsePluginErrorMessage } from '@/utils/error-parser' +import { isPrivateOrLocalAddress } from '@/utils/urlValidation' +import { RiLoader2Line } from '@remixicon/react' +import { debounce } from 'lodash-es' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import LogViewer from '../log-viewer' +import { usePluginSubscriptionStore } from '../store' +import { usePluginStore } from '../../store' + +type Props = { + onClose: () => void + createType: SupportedCreationMethods + builder?: TriggerSubscriptionBuilder +} + +const CREDENTIAL_TYPE_MAP: Record<SupportedCreationMethods, TriggerCredentialTypeEnum> = { + [SupportedCreationMethods.APIKEY]: TriggerCredentialTypeEnum.ApiKey, + [SupportedCreationMethods.OAUTH]: TriggerCredentialTypeEnum.Oauth2, + [SupportedCreationMethods.MANUAL]: TriggerCredentialTypeEnum.Unauthorized, +} + +enum ApiKeyStep { + Verify = 'verify', + Configuration = 'configuration', +} + +const defaultFormValues = { values: {}, isCheckValidated: false } + +const normalizeFormType = (type: FormTypeEnum | string): FormTypeEnum => { + if (Object.values(FormTypeEnum).includes(type as FormTypeEnum)) + return type as FormTypeEnum + + switch (type) { + case 'string': + case 'text': + return FormTypeEnum.textInput + case 'password': + case 'secret': + return FormTypeEnum.secretInput + case 'number': + case 'integer': + return FormTypeEnum.textNumber + case 'boolean': + return FormTypeEnum.boolean + default: + return FormTypeEnum.textInput + } +} + +const StatusStep = ({ isActive, text }: { isActive: boolean, text: string }) => { + return <div className={`system-2xs-semibold-uppercase flex items-center gap-1 ${isActive + ? 'text-state-accent-solid' + : 'text-text-tertiary'}`}> + {/* Active indicator dot */} + {isActive && ( + <div className='h-1 w-1 rounded-full bg-state-accent-solid'></div> + )} + {text} + </div> +} + +const MultiSteps = ({ currentStep }: { currentStep: ApiKeyStep }) => { + const { t } = useTranslation() + return <div className='mb-6 flex w-1/3 items-center gap-2'> + <StatusStep isActive={currentStep === ApiKeyStep.Verify} text={t('pluginTrigger.modal.steps.verify')} /> + <div className='h-px w-3 shrink-0 bg-divider-deep'></div> + <StatusStep isActive={currentStep === ApiKeyStep.Configuration} text={t('pluginTrigger.modal.steps.configuration')} /> + </div> +} + +export const CommonCreateModal = ({ onClose, createType, builder }: Props) => { + const { t } = useTranslation() + const detail = usePluginStore(state => state.detail) + const { refresh } = usePluginSubscriptionStore() + + const [currentStep, setCurrentStep] = useState<ApiKeyStep>(createType === SupportedCreationMethods.APIKEY ? ApiKeyStep.Verify : ApiKeyStep.Configuration) + + const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>(builder) + const isInitializedRef = useRef(false) + + const { mutate: verifyCredentials, isPending: isVerifyingCredentials } = useVerifyTriggerSubscriptionBuilder() + const { mutateAsync: createBuilder /* isPending: isCreatingBuilder */ } = useCreateTriggerSubscriptionBuilder() + const { mutate: buildSubscription, isPending: isBuilding } = useBuildTriggerSubscription() + const { mutate: updateBuilder } = useUpdateTriggerSubscriptionBuilder() + + const manualPropertiesSchema = detail?.declaration?.trigger?.subscription_schema || [] // manual + const manualPropertiesFormRef = React.useRef<FormRefObject>(null) + + const subscriptionFormRef = React.useRef<FormRefObject>(null) + + const autoCommonParametersSchema = detail?.declaration.trigger?.subscription_constructor?.parameters || [] // apikey and oauth + const autoCommonParametersFormRef = React.useRef<FormRefObject>(null) + + const rawApiKeyCredentialsSchema = detail?.declaration.trigger?.subscription_constructor?.credentials_schema || [] + const apiKeyCredentialsSchema = useMemo(() => { + return rawApiKeyCredentialsSchema.map(schema => ({ + ...schema, + tooltip: schema.help, + })) + }, [rawApiKeyCredentialsSchema]) + const apiKeyCredentialsFormRef = React.useRef<FormRefObject>(null) + + const { data: logData } = useTriggerSubscriptionBuilderLogs( + detail?.provider || '', + subscriptionBuilder?.id || '', + { + enabled: createType === SupportedCreationMethods.MANUAL, + refetchInterval: 3000, + }, + ) + + useEffect(() => { + const initializeBuilder = async () => { + isInitializedRef.current = true + try { + const response = await createBuilder({ + provider: detail?.provider || '', + credential_type: CREDENTIAL_TYPE_MAP[createType], + }) + setSubscriptionBuilder(response.subscription_builder) + } + catch (error) { + console.error('createBuilder error:', error) + Toast.notify({ + type: 'error', + message: t('pluginTrigger.modal.errors.createFailed'), + }) + } + } + if (!isInitializedRef.current && !subscriptionBuilder && detail?.provider) + initializeBuilder() + }, [subscriptionBuilder, detail?.provider, createType, createBuilder, t]) + + useEffect(() => { + if (subscriptionBuilder?.endpoint && subscriptionFormRef.current && currentStep === ApiKeyStep.Configuration) { + const form = subscriptionFormRef.current.getForm() + if (form) + form.setFieldValue('callback_url', subscriptionBuilder.endpoint) + if (isPrivateOrLocalAddress(subscriptionBuilder.endpoint)) { + console.log('isPrivateOrLocalAddress', isPrivateOrLocalAddress(subscriptionBuilder.endpoint)) + subscriptionFormRef.current?.setFields([{ + name: 'callback_url', + warnings: [t('pluginTrigger.modal.form.callbackUrl.privateAddressWarning')], + }]) + } + else { + subscriptionFormRef.current?.setFields([{ + name: 'callback_url', + warnings: [], + }]) + } + } + }, [subscriptionBuilder?.endpoint, currentStep, t]) + + const debouncedUpdate = useMemo( + () => debounce((provider: string, builderId: string, properties: Record<string, any>) => { + updateBuilder( + { + provider, + subscriptionBuilderId: builderId, + properties, + }, + { + onError: (error: any) => { + console.error('Failed to update subscription builder:', error) + Toast.notify({ + type: 'error', + message: error?.message || t('pluginTrigger.modal.errors.updateFailed'), + }) + }, + }, + ) + }, 500), + [updateBuilder, t], + ) + + const handleManualPropertiesChange = useCallback(() => { + if (!subscriptionBuilder || !detail?.provider) + return + + const formValues = manualPropertiesFormRef.current?.getFormValues({ needCheckValidatedValues: false }) || { values: {}, isCheckValidated: true } + + debouncedUpdate(detail.provider, subscriptionBuilder.id, formValues.values) + }, [subscriptionBuilder, detail?.provider, debouncedUpdate]) + + useEffect(() => { + return () => { + debouncedUpdate.cancel() + } + }, [debouncedUpdate]) + + const handleVerify = () => { + const apiKeyCredentialsFormValues = apiKeyCredentialsFormRef.current?.getFormValues({}) || defaultFormValues + const credentials = apiKeyCredentialsFormValues.values + + if (!Object.keys(credentials).length) { + Toast.notify({ + type: 'error', + message: 'Please fill in all required credentials', + }) + return + } + + apiKeyCredentialsFormRef.current?.setFields([{ + name: Object.keys(credentials)[0], + errors: [], + }]) + + verifyCredentials( + { + provider: detail?.provider || '', + subscriptionBuilderId: subscriptionBuilder?.id || '', + credentials, + }, + { + onSuccess: () => { + Toast.notify({ + type: 'success', + message: t('pluginTrigger.modal.apiKey.verify.success'), + }) + setCurrentStep(ApiKeyStep.Configuration) + }, + onError: async (error: any) => { + const errorMessage = await parsePluginErrorMessage(error) || t('pluginTrigger.modal.apiKey.verify.error') + apiKeyCredentialsFormRef.current?.setFields([{ + name: Object.keys(credentials)[0], + errors: [errorMessage], + }]) + }, + }, + ) + } + + const handleCreate = () => { + if (!subscriptionBuilder) { + Toast.notify({ + type: 'error', + message: 'Subscription builder not found', + }) + return + } + + const subscriptionFormValues = subscriptionFormRef.current?.getFormValues({}) + if (!subscriptionFormValues?.isCheckValidated) + return + + const subscriptionNameValue = subscriptionFormValues?.values?.subscription_name as string + + const params: BuildTriggerSubscriptionPayload = { + provider: detail?.provider || '', + subscriptionBuilderId: subscriptionBuilder.id, + name: subscriptionNameValue, + } + + if (createType !== SupportedCreationMethods.MANUAL) { + if (autoCommonParametersSchema.length > 0) { + const autoCommonParametersFormValues = autoCommonParametersFormRef.current?.getFormValues({}) || defaultFormValues + if (!autoCommonParametersFormValues?.isCheckValidated) + return + params.parameters = autoCommonParametersFormValues.values + } + } + else if (manualPropertiesSchema.length > 0) { + const manualFormValues = manualPropertiesFormRef.current?.getFormValues({}) || defaultFormValues + if (!manualFormValues?.isCheckValidated) + return + } + + buildSubscription( + params, + { + onSuccess: () => { + Toast.notify({ + type: 'success', + message: t('pluginTrigger.subscription.createSuccess'), + }) + onClose() + refresh?.() + }, + onError: async (error: any) => { + const errorMessage = await parsePluginErrorMessage(error) || t('pluginTrigger.subscription.createFailed') + Toast.notify({ + type: 'error', + message: errorMessage, + }) + }, + }, + ) + } + + const handleConfirm = () => { + if (currentStep === ApiKeyStep.Verify) + handleVerify() + else + handleCreate() + } + + const handleApiKeyCredentialsChange = () => { + apiKeyCredentialsFormRef.current?.setFields([{ + name: apiKeyCredentialsSchema[0].name, + errors: [], + }]) + } + + return ( + <Modal + title={t(`pluginTrigger.modal.${createType === SupportedCreationMethods.APIKEY ? 'apiKey' : createType.toLowerCase()}.title`)} + confirmButtonText={ + currentStep === ApiKeyStep.Verify + ? isVerifyingCredentials ? t('pluginTrigger.modal.common.verifying') : t('pluginTrigger.modal.common.verify') + : isBuilding ? t('pluginTrigger.modal.common.creating') : t('pluginTrigger.modal.common.create') + } + onClose={onClose} + onCancel={onClose} + onConfirm={handleConfirm} + disabled={isVerifyingCredentials || isBuilding} + bottomSlot={currentStep === ApiKeyStep.Verify ? <EncryptedBottom /> : null} + size={createType === SupportedCreationMethods.MANUAL ? 'md' : 'sm'} + containerClassName='min-h-[360px]' + clickOutsideNotClose + > + {createType === SupportedCreationMethods.APIKEY && <MultiSteps currentStep={currentStep} />} + {currentStep === ApiKeyStep.Verify && ( + <> + {apiKeyCredentialsSchema.length > 0 && ( + <div className='mb-4'> + <BaseForm + formSchemas={apiKeyCredentialsSchema} + ref={apiKeyCredentialsFormRef} + labelClassName='system-sm-medium mb-2 flex items-center gap-1 text-text-primary' + preventDefaultSubmit={true} + formClassName='space-y-4' + onChange={handleApiKeyCredentialsChange} + /> + </div> + )} + </> + )} + {currentStep === ApiKeyStep.Configuration && <div className='max-h-[70vh]'> + <BaseForm + formSchemas={[ + { + name: 'subscription_name', + label: t('pluginTrigger.modal.form.subscriptionName.label'), + placeholder: t('pluginTrigger.modal.form.subscriptionName.placeholder'), + type: FormTypeEnum.textInput, + required: true, + }, + { + name: 'callback_url', + label: t('pluginTrigger.modal.form.callbackUrl.label'), + placeholder: t('pluginTrigger.modal.form.callbackUrl.placeholder'), + type: FormTypeEnum.textInput, + required: false, + default: subscriptionBuilder?.endpoint || '', + disabled: true, + tooltip: t('pluginTrigger.modal.form.callbackUrl.tooltip'), + showCopy: true, + }, + ]} + ref={subscriptionFormRef} + labelClassName='system-sm-medium mb-2 flex items-center gap-1 text-text-primary' + formClassName='space-y-4 mb-4' + /> + {/* <div className='system-xs-regular mb-6 mt-[-1rem] text-text-tertiary'> + {t('pluginTrigger.modal.form.callbackUrl.description')} + </div> */} + {createType !== SupportedCreationMethods.MANUAL && autoCommonParametersSchema.length > 0 && ( + <BaseForm + formSchemas={autoCommonParametersSchema.map((schema) => { + const normalizedType = normalizeFormType(schema.type as FormTypeEnum | string) + return { + ...schema, + tooltip: schema.description, + type: normalizedType, + dynamicSelectParams: normalizedType === FormTypeEnum.dynamicSelect ? { + plugin_id: detail?.plugin_id || '', + provider: detail?.provider || '', + action: 'provider', + parameter: schema.name, + credential_id: subscriptionBuilder?.id || '', + } : undefined, + fieldClassName: schema.type === FormTypeEnum.boolean ? 'flex items-center justify-between' : undefined, + labelClassName: schema.type === FormTypeEnum.boolean ? 'mb-0' : undefined, + } + })} + ref={autoCommonParametersFormRef} + labelClassName='system-sm-medium mb-2 flex items-center gap-1 text-text-primary' + formClassName='space-y-4' + /> + )} + {createType === SupportedCreationMethods.MANUAL && <> + {manualPropertiesSchema.length > 0 && ( + <div className='mb-6'> + <BaseForm + formSchemas={manualPropertiesSchema.map(schema => ({ + ...schema, + tooltip: schema.description, + }))} + ref={manualPropertiesFormRef} + labelClassName='system-sm-medium mb-2 flex items-center gap-1 text-text-primary' + formClassName='space-y-4' + onChange={handleManualPropertiesChange} + /> + </div> + )} + <div className='mb-6'> + <div className='mb-3 flex items-center gap-2'> + <div className='system-xs-medium-uppercase text-text-tertiary'> + {t('pluginTrigger.modal.manual.logs.title')} + </div> + <div className='h-px flex-1 bg-gradient-to-r from-divider-regular to-transparent' /> + </div> + + <div className='mb-1 flex items-center justify-center gap-1 rounded-lg bg-background-section p-3'> + <div className='h-3.5 w-3.5'> + <RiLoader2Line className='h-full w-full animate-spin' /> + </div> + <div className='system-xs-regular text-text-tertiary'> + {t('pluginTrigger.modal.manual.logs.loading', { pluginName: detail?.name || '' })} + </div> + </div> + <LogViewer logs={logData?.logs || []} /> + </div> + </>} + </div>} + </Modal> + ) +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx new file mode 100644 index 0000000000..7515ba4b4a --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx @@ -0,0 +1,242 @@ +import { ActionButton, ActionButtonState } from '@/app/components/base/action-button' +import Badge from '@/app/components/base/badge' +import { Button } from '@/app/components/base/button' +import type { Option } from '@/app/components/base/select/custom' +import CustomSelect from '@/app/components/base/select/custom' +import Toast from '@/app/components/base/toast' +import Tooltip from '@/app/components/base/tooltip' +import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import { openOAuthPopup } from '@/hooks/use-oauth' +import { useInitiateTriggerOAuth, useTriggerOAuthConfig, useTriggerProviderInfo } from '@/service/use-triggers' +import cn from '@/utils/classnames' +import { RiAddLine, RiEqualizer2Line } from '@remixicon/react' +import { useBoolean } from 'ahooks' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { SupportedCreationMethods } from '../../../types' +import { usePluginStore } from '../../store' +import { useSubscriptionList } from '../use-subscription-list' +import { CommonCreateModal } from './common-modal' +import { OAuthClientSettingsModal } from './oauth-client' + +export enum CreateButtonType { + FULL_BUTTON = 'full-button', + ICON_BUTTON = 'icon-button', +} + +type Props = { + className?: string + buttonType?: CreateButtonType + shape?: 'square' | 'circle' +} + +const MAX_COUNT = 10 + +export const DEFAULT_METHOD = 'default' + +export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BUTTON, shape = 'square' }: Props) => { + const { t } = useTranslation() + const { subscriptions } = useSubscriptionList() + const subscriptionCount = subscriptions?.length || 0 + const [selectedCreateInfo, setSelectedCreateInfo] = useState<{ type: SupportedCreationMethods, builder?: TriggerSubscriptionBuilder } | null>(null) + + const detail = usePluginStore(state => state.detail) + + const { data: providerInfo } = useTriggerProviderInfo(detail?.provider || '') + const supportedMethods = providerInfo?.supported_creation_methods || [] + const { data: oauthConfig, refetch: refetchOAuthConfig } = useTriggerOAuthConfig(detail?.provider || '', supportedMethods.includes(SupportedCreationMethods.OAUTH)) + const { mutate: initiateOAuth } = useInitiateTriggerOAuth() + + const methodType = supportedMethods.length === 1 ? supportedMethods[0] : DEFAULT_METHOD + + const [isShowClientSettingsModal, { + setTrue: showClientSettingsModal, + setFalse: hideClientSettingsModal, + }] = useBoolean(false) + + const buttonTextMap = useMemo(() => { + return { + [SupportedCreationMethods.OAUTH]: t('pluginTrigger.subscription.createButton.oauth'), + [SupportedCreationMethods.APIKEY]: t('pluginTrigger.subscription.createButton.apiKey'), + [SupportedCreationMethods.MANUAL]: t('pluginTrigger.subscription.createButton.manual'), + [DEFAULT_METHOD]: t('pluginTrigger.subscription.empty.button'), + } + }, [t]) + + const onClickClientSettings = (e: React.MouseEvent<HTMLDivElement | HTMLButtonElement>) => { + e.stopPropagation() + e.preventDefault() + showClientSettingsModal() + } + + const allOptions = useMemo(() => { + const showCustomBadge = oauthConfig?.custom_enabled && oauthConfig?.custom_configured + + return [ + { + value: SupportedCreationMethods.OAUTH, + label: t('pluginTrigger.subscription.addType.options.oauth.title'), + tag: !showCustomBadge ? null : <Badge className='ml-1 mr-0.5'> + {t('plugin.auth.custom')} + </Badge>, + extra: <Tooltip popupContent={t('pluginTrigger.subscription.addType.options.oauth.clientSettings')}> + <ActionButton onClick={onClickClientSettings}> + <RiEqualizer2Line className='h-4 w-4 text-text-tertiary' /> + </ActionButton> + </Tooltip>, + show: supportedMethods.includes(SupportedCreationMethods.OAUTH), + }, + { + value: SupportedCreationMethods.APIKEY, + label: t('pluginTrigger.subscription.addType.options.apikey.title'), + show: supportedMethods.includes(SupportedCreationMethods.APIKEY), + }, + { + value: SupportedCreationMethods.MANUAL, + label: t('pluginTrigger.subscription.addType.options.manual.description'), + extra: <Tooltip popupContent={t('pluginTrigger.subscription.addType.options.manual.tip')} />, + show: supportedMethods.includes(SupportedCreationMethods.MANUAL), + }, + ] + }, [t, oauthConfig, supportedMethods, methodType]) + + const onChooseCreateType = async (type: SupportedCreationMethods) => { + if (type === SupportedCreationMethods.OAUTH) { + if (oauthConfig?.configured) { + initiateOAuth(detail?.provider || '', { + onSuccess: (response) => { + openOAuthPopup(response.authorization_url, (callbackData) => { + if (callbackData) { + Toast.notify({ + type: 'success', + message: t('pluginTrigger.modal.oauth.authorization.authSuccess'), + }) + setSelectedCreateInfo({ type: SupportedCreationMethods.OAUTH, builder: response.subscription_builder }) + } + }) + }, + onError: () => { + Toast.notify({ + type: 'error', + message: t('pluginTrigger.modal.oauth.authorization.authFailed'), + }) + }, + }) + } + else { + showClientSettingsModal() + } + } + else { + setSelectedCreateInfo({ type }) + } + } + + const onClickCreate = (e: React.MouseEvent<HTMLButtonElement>) => { + if (subscriptionCount >= MAX_COUNT) { + e.stopPropagation() + return + } + + if (methodType === DEFAULT_METHOD || (methodType === SupportedCreationMethods.OAUTH && supportedMethods.length === 1)) + return + + e.stopPropagation() + e.preventDefault() + onChooseCreateType(methodType) + } + + if (!supportedMethods.length) + return null + + return <> + <CustomSelect<Option & { show: boolean; extra?: React.ReactNode; tag?: React.ReactNode }> + options={allOptions.filter(option => option.show)} + value={methodType} + onChange={value => onChooseCreateType(value as any)} + containerProps={{ + open: (methodType === DEFAULT_METHOD || (methodType === SupportedCreationMethods.OAUTH && supportedMethods.length === 1)) ? undefined : false, + placement: 'bottom-start', + offset: 4, + triggerPopupSameWidth: buttonType === CreateButtonType.FULL_BUTTON, + }} + triggerProps={{ + className: cn('h-8 bg-transparent px-0 hover:bg-transparent', methodType !== DEFAULT_METHOD && supportedMethods.length > 1 && 'pointer-events-none', buttonType === CreateButtonType.FULL_BUTTON && 'grow'), + }} + popupProps={{ + wrapperClassName: 'z-[1000]', + }} + CustomTrigger={() => { + return buttonType === CreateButtonType.FULL_BUTTON ? ( + <Button + variant='primary' + size='medium' + className='flex w-full items-center justify-between px-0' + onClick={onClickCreate} + > + <div className='flex flex-1 items-center justify-center'> + <RiAddLine className='mr-2 size-4' /> + {buttonTextMap[methodType]} + {methodType === SupportedCreationMethods.OAUTH && oauthConfig?.custom_enabled && oauthConfig?.custom_configured && <Badge + className='ml-1 mr-0.5 border-text-primary-on-surface bg-components-badge-bg-dimm text-text-primary-on-surface' + > + {t('plugin.auth.custom')} + </Badge>} + </div> + {methodType === SupportedCreationMethods.OAUTH + && <div className='ml-auto flex items-center'> + <div className="h-4 w-px bg-text-primary-on-surface opacity-15" /> + <Tooltip popupContent={t('pluginTrigger.subscription.addType.options.oauth.clientSettings')}> + <div onClick={onClickClientSettings} className='p-2'> + <RiEqualizer2Line className='size-4 text-components-button-primary-text' /> + </div> + </Tooltip> + </div> + } + </Button> + ) : ( + <Tooltip + popupContent={subscriptionCount >= MAX_COUNT ? t('pluginTrigger.subscription.maxCount', { num: MAX_COUNT }) : t(`pluginTrigger.subscription.addType.options.${methodType.toLowerCase()}.description`)} + disabled={!(supportedMethods?.length === 1 || subscriptionCount >= MAX_COUNT)}> + <ActionButton + onClick={onClickCreate} + className={cn( + 'float-right', + shape === 'circle' && '!rounded-full border-[0.5px] border-components-button-secondary-border-hover bg-components-button-secondary-bg-hover text-components-button-secondary-accent-text shadow-xs hover:border-components-button-secondary-border-disabled hover:bg-components-button-secondary-bg-disabled hover:text-components-button-secondary-accent-text-disabled', + )} + state={subscriptionCount >= MAX_COUNT ? ActionButtonState.Disabled : ActionButtonState.Default} + > + <RiAddLine className='size-4' /> + </ActionButton> + </Tooltip> + ) + }} + CustomOption={option => ( + <> + <div className='mr-8 flex grow items-center gap-1 truncate px-1'> + {option.label} + {option.tag} + </div> + {option.extra} + </> + )} + /> + {selectedCreateInfo && ( + <CommonCreateModal + createType={selectedCreateInfo.type} + builder={selectedCreateInfo.builder} + onClose={() => setSelectedCreateInfo(null)} + /> + )} + {isShowClientSettingsModal && ( + <OAuthClientSettingsModal + oauthConfig={oauthConfig} + onClose={() => { + hideClientSettingsModal() + refetchOAuthConfig() + }} + showOAuthCreateModal={builder => setSelectedCreateInfo({ type: SupportedCreationMethods.OAUTH, builder })} + /> + )} + </> +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx new file mode 100644 index 0000000000..ef182a70aa --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx @@ -0,0 +1,257 @@ +'use client' +import Button from '@/app/components/base/button' +import { BaseForm } from '@/app/components/base/form/components/base' +import type { FormRefObject } from '@/app/components/base/form/types' +import Modal from '@/app/components/base/modal/modal' +import Toast from '@/app/components/base/toast' +import type { TriggerOAuthClientParams, TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card' +import { openOAuthPopup } from '@/hooks/use-oauth' +import type { ConfigureTriggerOAuthPayload } from '@/service/use-triggers' +import { + useConfigureTriggerOAuth, + useDeleteTriggerOAuth, + useInitiateTriggerOAuth, + useVerifyTriggerSubscriptionBuilder, +} from '@/service/use-triggers' +import { + RiClipboardLine, + RiInformation2Fill, +} from '@remixicon/react' +import React, { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { usePluginStore } from '../../store' + +type Props = { + oauthConfig?: TriggerOAuthConfig + onClose: () => void + showOAuthCreateModal: (builder: TriggerSubscriptionBuilder) => void +} + +enum AuthorizationStatusEnum { + Pending = 'pending', + Success = 'success', + Failed = 'failed', +} + +enum ClientTypeEnum { + Default = 'default', + Custom = 'custom', +} + +export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreateModal }: Props) => { + const { t } = useTranslation() + const detail = usePluginStore(state => state.detail) + const { system_configured, params, oauth_client_schema } = oauthConfig || {} + const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>() + const [authorizationStatus, setAuthorizationStatus] = useState<AuthorizationStatusEnum>() + + const [clientType, setClientType] = useState<ClientTypeEnum>(system_configured ? ClientTypeEnum.Default : ClientTypeEnum.Custom) + + const clientFormRef = React.useRef<FormRefObject>(null) + + const oauthClientSchema = useMemo(() => { + if (oauth_client_schema && oauth_client_schema.length > 0 && params) { + const oauthConfigPramaKeys = Object.keys(params || {}) + for (const schema of oauth_client_schema) { + if (oauthConfigPramaKeys.includes(schema.name)) + schema.default = params?.[schema.name] + } + return oauth_client_schema + } + return [] + }, [oauth_client_schema, params]) + + const providerName = detail?.provider || '' + const { mutate: initiateOAuth } = useInitiateTriggerOAuth() + const { mutate: verifyBuilder } = useVerifyTriggerSubscriptionBuilder() + const { mutate: configureOAuth } = useConfigureTriggerOAuth() + const { mutate: deleteOAuth } = useDeleteTriggerOAuth() + + const handleAuthorization = () => { + setAuthorizationStatus(AuthorizationStatusEnum.Pending) + initiateOAuth(providerName, { + onSuccess: (response) => { + setSubscriptionBuilder(response.subscription_builder) + openOAuthPopup(response.authorization_url, (callbackData) => { + if (callbackData) { + Toast.notify({ + type: 'success', + message: t('pluginTrigger.modal.oauth.authorization.authSuccess'), + }) + onClose() + showOAuthCreateModal(response.subscription_builder) + } + }) + }, + onError: () => { + setAuthorizationStatus(AuthorizationStatusEnum.Failed) + Toast.notify({ + type: 'error', + message: t('pluginTrigger.modal.oauth.authorization.authFailed'), + }) + }, + }) + } + + useEffect(() => { + if (providerName && subscriptionBuilder && authorizationStatus === AuthorizationStatusEnum.Pending) { + const pollInterval = setInterval(() => { + verifyBuilder( + { + provider: providerName, + subscriptionBuilderId: subscriptionBuilder.id, + }, + { + onSuccess: (response) => { + if (response.verified) { + setAuthorizationStatus(AuthorizationStatusEnum.Success) + clearInterval(pollInterval) + } + }, + onError: () => { + // Continue polling - auth might still be in progress + }, + }, + ) + }, 3000) + + return () => clearInterval(pollInterval) + } + }, [subscriptionBuilder, authorizationStatus, verifyBuilder, providerName, t]) + + const handleRemove = () => { + deleteOAuth(providerName, { + onSuccess: () => { + onClose() + Toast.notify({ + type: 'success', + message: t('pluginTrigger.modal.oauth.remove.success'), + }) + }, + onError: (error: any) => { + Toast.notify({ + type: 'error', + message: error?.message || t('pluginTrigger.modal.oauth.remove.failed'), + }) + }, + }) + } + + const handleSave = (needAuth: boolean) => { + const isCustom = clientType === ClientTypeEnum.Custom + const params: ConfigureTriggerOAuthPayload = { + provider: providerName, + enabled: isCustom, + } + + if (isCustom) { + const clientFormValues = clientFormRef.current?.getFormValues({}) as { values: TriggerOAuthClientParams, isCheckValidated: boolean } + if (!clientFormValues.isCheckValidated) + return + const clientParams = clientFormValues.values + if (clientParams.client_id === oauthConfig?.params.client_id) + clientParams.client_id = '[__HIDDEN__]' + + if (clientParams.client_secret === oauthConfig?.params.client_secret) + clientParams.client_secret = '[__HIDDEN__]' + + params.client_params = clientParams + } + + configureOAuth(params, { + onSuccess: () => { + if (needAuth) { + handleAuthorization() + } + else { + onClose() + Toast.notify({ + type: 'success', + message: t('pluginTrigger.modal.oauth.save.success'), + }) + } + }, + }) + } + + return ( + <Modal + title={t('pluginTrigger.modal.oauth.title')} + confirmButtonText={authorizationStatus === AuthorizationStatusEnum.Pending ? t('pluginTrigger.modal.common.authorizing') + : authorizationStatus === AuthorizationStatusEnum.Success ? t('pluginTrigger.modal.oauth.authorization.waitingJump') : t('plugin.auth.saveAndAuth')} + cancelButtonText={t('plugin.auth.saveOnly')} + extraButtonText={t('common.operation.cancel')} + showExtraButton + clickOutsideNotClose + extraButtonVariant='secondary' + onExtraButtonClick={onClose} + onClose={onClose} + onCancel={() => handleSave(false)} + onConfirm={() => handleSave(true)} + footerSlot={ + oauthConfig?.custom_enabled && oauthConfig?.params && clientType === ClientTypeEnum.Custom && ( + <div className='grow'> + <Button + variant='secondary' + className='text-components-button-destructive-secondary-text' + // disabled={disabled || doingAction || !editValues} + onClick={handleRemove} + > + {t('common.operation.remove')} + </Button> + </div> + ) + } + > + <div className='system-sm-medium mb-2 text-text-secondary'>{t('pluginTrigger.subscription.addType.options.oauth.clientTitle')}</div> + {oauthConfig?.system_configured && <div className='mb-4 flex w-full items-start justify-between gap-2'> + {[ClientTypeEnum.Default, ClientTypeEnum.Custom].map(option => ( + <OptionCard + key={option} + title={t(`pluginTrigger.subscription.addType.options.oauth.${option}`)} + onSelect={() => setClientType(option)} + selected={clientType === option} + className="flex-1" + /> + ))} + </div>} + {clientType === ClientTypeEnum.Custom && oauthConfig?.redirect_uri && ( + <div className='mb-4 flex items-start gap-3 rounded-xl bg-background-section-burn p-4'> + <div className='rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg p-2 shadow-xs shadow-shadow-shadow-3'> + <RiInformation2Fill className='h-5 w-5 shrink-0 text-text-accent' /> + </div> + <div className='flex-1 text-text-secondary'> + <div className='system-sm-regular whitespace-pre-wrap leading-4'> + {t('pluginTrigger.modal.oauthRedirectInfo')} + </div> + <div className='system-sm-medium my-1.5 break-all leading-4'> + {oauthConfig.redirect_uri} + </div> + <Button + variant='secondary' + size='small' + onClick={() => { + navigator.clipboard.writeText(oauthConfig.redirect_uri) + Toast.notify({ + type: 'success', + message: t('common.actionMsg.copySuccessfully'), + }) + }}> + <RiClipboardLine className='mr-1 h-[14px] w-[14px]' /> + {t('common.operation.copy')} + </Button> + </div> + </div> + )} + {clientType === ClientTypeEnum.Custom && oauthClientSchema.length > 0 && ( + <BaseForm + formSchemas={oauthClientSchema} + ref={clientFormRef} + labelClassName='system-sm-medium mb-2 block text-text-secondary' + formClassName='space-y-4' + /> + )} + </Modal > + ) +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx new file mode 100644 index 0000000000..178983c6b1 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx @@ -0,0 +1,75 @@ +import Confirm from '@/app/components/base/confirm' +import Input from '@/app/components/base/input' +import Toast from '@/app/components/base/toast' +import { useDeleteTriggerSubscription } from '@/service/use-triggers' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { usePluginSubscriptionStore } from './store' + +type Props = { + onClose: (deleted: boolean) => void + isShow: boolean + currentId: string + currentName: string + workflowsInUse: number +} + +const tPrefix = 'pluginTrigger.subscription.list.item.actions.deleteConfirm' + +export const DeleteConfirm = (props: Props) => { + const { onClose, isShow, currentId, currentName, workflowsInUse } = props + const { refresh } = usePluginSubscriptionStore() + const { mutate: deleteSubscription, isPending: isDeleting } = useDeleteTriggerSubscription() + const { t } = useTranslation() + const [inputName, setInputName] = useState('') + + const onConfirm = () => { + if (workflowsInUse > 0 && inputName !== currentName) { + Toast.notify({ + type: 'error', + message: t(`${tPrefix}.confirmInputWarning`), + // temporarily + className: 'z-[10000001]', + }) + return + } + deleteSubscription(currentId, { + onSuccess: () => { + Toast.notify({ + type: 'success', + message: t(`${tPrefix}.success`, { name: currentName }), + className: 'z-[10000001]', + }) + refresh?.() + onClose(true) + }, + onError: (error: any) => { + Toast.notify({ + type: 'error', + message: error?.message || t(`${tPrefix}.error`, { name: currentName }), + className: 'z-[10000001]', + }) + }, + }) + } + return <Confirm + title={t(`${tPrefix}.title`, { name: currentName })} + confirmText={t(`${tPrefix}.confirm`)} + content={workflowsInUse > 0 ? <> + {t(`${tPrefix}.contentWithApps`, { count: workflowsInUse })} + <div className='system-sm-medium mb-2 mt-6 text-text-secondary'>{t(`${tPrefix}.confirmInputTip`, { name: currentName })}</div> + <Input + value={inputName} + onChange={e => setInputName(e.target.value)} + placeholder={t(`${tPrefix}.confirmInputPlaceholder`, { name: currentName })} + /> + </> + : t(`${tPrefix}.content`)} + isShow={isShow} + isLoading={isDeleting} + isDisabled={isDeleting} + onConfirm={onConfirm} + onCancel={() => onClose(false)} + maskClosable={false} + /> +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/index.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/index.tsx new file mode 100644 index 0000000000..8acb8f40df --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/index.tsx @@ -0,0 +1,51 @@ +import { withErrorBoundary } from '@/app/components/base/error-boundary' +import Loading from '@/app/components/base/loading' +import { SubscriptionListView } from './list-view' +import { SubscriptionSelectorView } from './selector-view' +import { useSubscriptionList } from './use-subscription-list' + +export enum SubscriptionListMode { + PANEL = 'panel', + SELECTOR = 'selector', +} + +export type SimpleSubscription = { + id: string, + name: string +} + +type SubscriptionListProps = { + mode?: SubscriptionListMode + selectedId?: string + onSelect?: (v: SimpleSubscription, callback?: () => void) => void +} + +export { SubscriptionSelectorEntry } from './selector-entry' + +export const SubscriptionList = withErrorBoundary(({ + mode = SubscriptionListMode.PANEL, + selectedId, + onSelect, +}: SubscriptionListProps) => { + const { isLoading, refetch } = useSubscriptionList() + if (isLoading) { + return ( + <div className='flex items-center justify-center py-4'> + <Loading /> + </div> + ) + } + + if (mode === SubscriptionListMode.SELECTOR) { + return ( + <SubscriptionSelectorView + selectedId={selectedId} + onSelect={(v) => { + onSelect?.(v, refetch) + }} + /> + ) + } + + return <SubscriptionListView /> +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx new file mode 100644 index 0000000000..a64d2f4070 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx @@ -0,0 +1,50 @@ +'use client' +import Tooltip from '@/app/components/base/tooltip' +import cn from '@/utils/classnames' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { CreateButtonType, CreateSubscriptionButton } from './create' +import SubscriptionCard from './subscription-card' +import { useSubscriptionList } from './use-subscription-list' + +type SubscriptionListViewProps = { + showTopBorder?: boolean +} + +export const SubscriptionListView: React.FC<SubscriptionListViewProps> = ({ + showTopBorder = false, +}) => { + const { t } = useTranslation() + const { subscriptions } = useSubscriptionList() + + const subscriptionCount = subscriptions?.length || 0 + + return ( + <div className={cn('border-divider-subtle px-4 py-2', showTopBorder && 'border-t')}> + <div className='relative flex items-center justify-between'> + {subscriptionCount > 0 && ( + <div className='flex h-8 shrink-0 items-center gap-1'> + <span className='system-sm-semibold-uppercase text-text-secondary'> + {t('pluginTrigger.subscription.listNum', { num: subscriptionCount })} + </span> + <Tooltip popupContent={t('pluginTrigger.subscription.list.tip')} /> + </div> + )} + <CreateSubscriptionButton + buttonType={subscriptionCount > 0 ? CreateButtonType.ICON_BUTTON : CreateButtonType.FULL_BUTTON} + /> + </div> + + {subscriptionCount > 0 && ( + <div className='flex flex-col gap-1'> + {subscriptions?.map(subscription => ( + <SubscriptionCard + key={subscription.id} + data={subscription} + /> + ))} + </div> + )} + </div> + ) +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.tsx new file mode 100644 index 0000000000..8b16d2c60a --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.tsx @@ -0,0 +1,193 @@ +'use client' +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + RiArrowDownSLine, + RiArrowRightSLine, + RiCheckboxCircleFill, + RiErrorWarningFill, + RiFileCopyLine, +} from '@remixicon/react' +import cn from '@/utils/classnames' +import Toast from '@/app/components/base/toast' +import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' +import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' +import type { TriggerLogEntity } from '@/app/components/workflow/block-selector/types' +import dayjs from 'dayjs' + +type Props = { + logs: TriggerLogEntity[] + className?: string +} + +enum LogTypeEnum { + REQUEST = 'request', + RESPONSE = 'response', +} + +const LogViewer = ({ logs, className }: Props) => { + const { t } = useTranslation() + const [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set()) + + const toggleLogExpansion = (logId: string) => { + const newExpanded = new Set(expandedLogs) + if (newExpanded.has(logId)) + newExpanded.delete(logId) + else + newExpanded.add(logId) + + setExpandedLogs(newExpanded) + } + + const parseRequestData = (data: any) => { + if (typeof data === 'string' && data.startsWith('payload=')) { + try { + const urlDecoded = decodeURIComponent(data.substring(8)) // Remove 'payload=' + return JSON.parse(urlDecoded) + } + catch { + return data + } + } + + if (typeof data === 'object') + return data + + try { + return JSON.parse(data) + } + catch { + return data + } + } + + const renderJsonContent = (originalData: any, title: LogTypeEnum) => { + const parsedData = title === LogTypeEnum.REQUEST ? { headers: originalData.headers, data: parseRequestData(originalData.data) } : originalData + const isJsonObject = typeof parsedData === 'object' + + if (isJsonObject) { + return ( + <CodeEditor + readOnly + title={<div className="system-xs-semibold-uppercase text-text-secondary">{title}</div>} + language={CodeLanguage.json} + value={parsedData} + isJSONStringifyBeauty + nodeId="" + /> + ) + } + + return ( + <div className='rounded-md bg-components-input-bg-normal'> + <div className='flex items-center justify-between px-2 py-1'> + <div className='system-xs-semibold-uppercase text-text-secondary'> + {title} + </div> + <button + onClick={(e) => { + e.stopPropagation() + navigator.clipboard.writeText(String(parsedData)) + Toast.notify({ + type: 'success', + message: t('common.actionMsg.copySuccessfully'), + }) + }} + className='rounded-md p-0.5 hover:bg-components-panel-border' + > + <RiFileCopyLine className='h-4 w-4 text-text-tertiary' /> + </button> + </div> + <div className='px-2 pb-2 pt-1'> + <pre className='code-xs-regular whitespace-pre-wrap break-all text-text-secondary'> + {String(parsedData)} + </pre> + </div> + </div> + ) + } + + if (!logs || logs.length === 0) + return null + + return ( + <div className={cn('flex flex-col gap-1', className)}> + {logs.map((log, index) => { + const logId = log.id || index.toString() + const isExpanded = expandedLogs.has(logId) + const isSuccess = log.response.status_code === 200 + const isError = log.response.status_code >= 400 + + return ( + <div + key={logId} + className={cn( + 'relative overflow-hidden rounded-lg border bg-components-panel-on-panel-item-bg shadow-sm hover:bg-components-panel-on-panel-item-bg-hover', + isError && 'border-state-destructive-border', + !isError && isExpanded && 'border-components-panel-border', + !isError && !isExpanded && 'border-components-panel-border-subtle', + )} + > + {isError && ( + <div className='pointer-events-none absolute left-0 top-0 h-7 w-[179px]'> + <svg xmlns="http://www.w3.org/2000/svg" width="179" height="28" viewBox="0 0 179 28" fill="none" className='h-full w-full'> + <g filter="url(#filter0_f_error_glow)"> + <circle cx="27" cy="14" r="32" fill="#F04438" fillOpacity="0.25" /> + </g> + <defs> + <filter id="filter0_f_error_glow" x="-125" y="-138" width="304" height="304" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB"> + <feFlood floodOpacity="0" result="BackgroundImageFix" /> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> + <feGaussianBlur stdDeviation="60" result="effect1_foregroundBlur" /> + </filter> + </defs> + </svg> + </div> + )} + + <button + onClick={() => toggleLogExpansion(logId)} + className={cn( + 'flex w-full items-center justify-between px-2 py-1.5 text-left', + isExpanded ? 'pb-1 pt-2' : 'min-h-7', + )} + > + <div className='flex items-center gap-0'> + {isExpanded ? ( + <RiArrowDownSLine className='h-4 w-4 text-text-tertiary' /> + ) : ( + <RiArrowRightSLine className='h-4 w-4 text-text-tertiary' /> + )} + <div className='system-xs-semibold-uppercase text-text-secondary'> + {t(`pluginTrigger.modal.manual.logs.${LogTypeEnum.REQUEST}`)} #{index + 1} + </div> + </div> + + <div className='flex items-center gap-1'> + <div className='system-xs-regular text-text-tertiary'> + {dayjs(log.created_at).format('HH:mm:ss')} + </div> + <div className='h-3.5 w-3.5'> + {isSuccess ? ( + <RiCheckboxCircleFill className='h-full w-full text-text-success' /> + ) : ( + <RiErrorWarningFill className='h-full w-full text-text-destructive' /> + )} + </div> + </div> + </button> + + {isExpanded && ( + <div className='flex flex-col gap-1 px-1 pb-1'> + {renderJsonContent(log.request, LogTypeEnum.REQUEST)} + {renderJsonContent(log.response, LogTypeEnum.RESPONSE)} + </div> + )} + </div> + ) + })} + </div> + ) +} + +export default LogViewer diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx new file mode 100644 index 0000000000..c23e022ac5 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx @@ -0,0 +1,126 @@ +'use client' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' +import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list' +import { SubscriptionList, SubscriptionListMode } from '@/app/components/plugins/plugin-detail-panel/subscription-list' +import cn from '@/utils/classnames' +import { RiArrowDownSLine, RiWebhookLine } from '@remixicon/react' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useSubscriptionList } from './use-subscription-list' + +type SubscriptionTriggerButtonProps = { + selectedId?: string + onClick?: () => void + isOpen?: boolean + className?: string +} + +const SubscriptionTriggerButton: React.FC<SubscriptionTriggerButtonProps> = ({ + selectedId, + onClick, + isOpen = false, + className, +}) => { + const { t } = useTranslation() + const { subscriptions } = useSubscriptionList() + + const statusConfig = useMemo(() => { + if (!selectedId) { + if (isOpen) { + return { + label: t('pluginTrigger.subscription.selectPlaceholder'), + color: 'yellow' as const, + } + } + return { + label: t('pluginTrigger.subscription.noSubscriptionSelected'), + color: 'red' as const, + } + } + + if (subscriptions && subscriptions.length > 0) { + const selectedSubscription = subscriptions?.find(sub => sub.id === selectedId) + + if (!selectedSubscription) { + return { + label: t('pluginTrigger.subscription.subscriptionRemoved'), + color: 'red' as const, + } + } + + return { + label: selectedSubscription.name, + color: 'green' as const, + } + } + + return { + label: t('pluginTrigger.subscription.noSubscriptionSelected'), + color: 'red' as const, + } + }, [selectedId, subscriptions, t, isOpen]) + + return ( + <button + className={cn( + 'flex h-8 items-center gap-1 rounded-lg px-2 transition-colors', + 'hover:bg-state-base-hover-alt', + isOpen && 'bg-state-base-hover-alt', + className, + )} + onClick={onClick} + > + <RiWebhookLine className={cn('h-3.5 w-3.5 shrink-0 text-text-secondary', statusConfig.color === 'red' && 'text-components-button-destructive-secondary-text')} /> + <span className={cn('system-xs-medium truncate text-components-button-ghost-text', statusConfig.color === 'red' && 'text-components-button-destructive-secondary-text')}> + {statusConfig.label} + </span> + <RiArrowDownSLine + className={cn( + 'ml-auto h-4 w-4 shrink-0 text-text-quaternary transition-transform', + isOpen && 'rotate-180', + statusConfig.color === 'red' && 'text-components-button-destructive-secondary-text', + )} + /> + </button> + ) +} + +export const SubscriptionSelectorEntry = ({ selectedId, onSelect }: { + selectedId?: string, + onSelect: (v: SimpleSubscription, callback?: () => void) => void +}) => { + const [isOpen, setIsOpen] = useState(false) + + return <PortalToFollowElem + placement='bottom-start' + offset={4} + open={isOpen} + onOpenChange={setIsOpen} + > + <PortalToFollowElemTrigger asChild> + <div> + <SubscriptionTriggerButton + selectedId={selectedId} + onClick={() => setIsOpen(!isOpen)} + isOpen={isOpen} + /> + </div> + </PortalToFollowElemTrigger> + <PortalToFollowElemContent className='z-[11]'> + <div className='rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg'> + <SubscriptionList + mode={SubscriptionListMode.SELECTOR} + selectedId={selectedId} + onSelect={(...args) => { + onSelect(...args) + setIsOpen(false) + }} + /> + </div> + </PortalToFollowElemContent> + </PortalToFollowElem> +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx new file mode 100644 index 0000000000..04b078e347 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx @@ -0,0 +1,90 @@ +'use client' +import ActionButton from '@/app/components/base/action-button' +import Tooltip from '@/app/components/base/tooltip' +import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' +import cn from '@/utils/classnames' +import { RiCheckLine, RiDeleteBinLine, RiWebhookLine } from '@remixicon/react' +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { CreateButtonType, CreateSubscriptionButton } from './create' +import { DeleteConfirm } from './delete-confirm' +import { useSubscriptionList } from './use-subscription-list' + +type SubscriptionSelectorProps = { + selectedId?: string + onSelect?: ({ id, name }: { id: string, name: string }) => void +} + +export const SubscriptionSelectorView: React.FC<SubscriptionSelectorProps> = ({ + selectedId, + onSelect, +}) => { + const { t } = useTranslation() + const { subscriptions } = useSubscriptionList() + const [deletedSubscription, setDeletedSubscription] = useState<TriggerSubscription | null>(null) + const subscriptionCount = subscriptions?.length || 0 + + return ( + <div className='w-[320px] p-1'> + {subscriptionCount > 0 && <div className='ml-7 mr-1.5 flex h-8 items-center justify-between'> + <div className='flex shrink-0 items-center gap-1'> + <span className='system-sm-semibold-uppercase text-text-secondary'> + {t('pluginTrigger.subscription.listNum', { num: subscriptionCount })} + </span> + <Tooltip popupContent={t('pluginTrigger.subscription.list.tip')} /> + </div> + <CreateSubscriptionButton + buttonType={CreateButtonType.ICON_BUTTON} + shape='circle' + /> + </div>} + <div className='max-h-[320px] overflow-y-auto'> + {subscriptions?.map(subscription => ( + <div + key={subscription.id} + className={cn( + 'group flex w-full items-center justify-between rounded-lg p-1 text-left transition-colors', + 'hover:bg-state-base-hover has-[.subscription-delete-btn:hover]:!bg-state-destructive-hover', + selectedId === subscription.id && 'bg-state-base-hover', + )} + > + <button + type='button' + className='flex flex-1 items-center text-left' + onClick={() => onSelect?.(subscription)} + > + <div className='flex items-center'> + {selectedId === subscription.id && ( + <RiCheckLine className='mr-2 h-4 w-4 shrink-0 text-text-accent' /> + )} + <RiWebhookLine className={cn('mr-1.5 h-3.5 w-3.5 text-text-secondary', selectedId !== subscription.id && 'ml-6')} /> + <span className='system-md-regular leading-6 text-text-secondary'> + {subscription.name} + </span> + </div> + </button> + <ActionButton onClick={(e) => { + e.stopPropagation() + setDeletedSubscription(subscription) + }} className='subscription-delete-btn hidden shrink-0 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive group-hover:flex'> + <RiDeleteBinLine className='size-4' /> + </ActionButton> + </div> + ))} + </div> + {deletedSubscription && ( + <DeleteConfirm + onClose={(deleted) => { + if (deleted) + onSelect?.({ id: '', name: '' }) + setDeletedSubscription(null) + }} + isShow={!!deletedSubscription} + currentId={deletedSubscription.id} + currentName={deletedSubscription.name} + workflowsInUse={deletedSubscription.workflows_in_use} + /> + )} + </div> + ) +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/store.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/store.ts new file mode 100644 index 0000000000..24840e9971 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/store.ts @@ -0,0 +1,11 @@ +import { create } from 'zustand' + +type ShapeSubscription = { + refresh?: () => void + setRefresh: (refresh: () => void) => void +} + +export const usePluginSubscriptionStore = create<ShapeSubscription>(set => ({ + refresh: undefined, + setRefresh: (refresh: () => void) => set({ refresh }), +})) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx new file mode 100644 index 0000000000..b2a86b5c76 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx @@ -0,0 +1,85 @@ +'use client' +import ActionButton from '@/app/components/base/action-button' +import Tooltip from '@/app/components/base/tooltip' +import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' +import cn from '@/utils/classnames' +import { + RiDeleteBinLine, + RiWebhookLine, +} from '@remixicon/react' +import { useBoolean } from 'ahooks' +import { useTranslation } from 'react-i18next' +import { DeleteConfirm } from './delete-confirm' + +type Props = { + data: TriggerSubscription +} + +const SubscriptionCard = ({ data }: Props) => { + const { t } = useTranslation() + const [isShowDeleteModal, { + setTrue: showDeleteModal, + setFalse: hideDeleteModal, + }] = useBoolean(false) + + return ( + <> + <div + className={cn( + 'group relative cursor-pointer rounded-lg border-[0.5px] px-4 py-3 shadow-xs transition-all', + 'border-components-panel-border-subtle bg-components-panel-on-panel-item-bg', + 'hover:bg-components-panel-on-panel-item-bg-hover', + 'has-[.subscription-delete-btn:hover]:!border-state-destructive-border has-[.subscription-delete-btn:hover]:!bg-state-destructive-hover', + )} + > + <div className='flex items-center justify-between'> + <div className='flex h-6 items-center gap-1'> + <RiWebhookLine className='h-4 w-4 text-text-secondary' /> + <span className='system-md-semibold text-text-secondary'> + {data.name} + </span> + </div> + + <ActionButton + onClick={showDeleteModal} + className='subscription-delete-btn hidden transition-colors hover:bg-state-destructive-hover hover:text-text-destructive group-hover:block' + > + <RiDeleteBinLine className='h-4 w-4' /> + </ActionButton> + </div> + + <div className='mt-1 flex items-center justify-between'> + <Tooltip + disabled={!data.endpoint} + popupContent={data.endpoint && ( + <div className='max-w-[320px] break-all'> + {data.endpoint} + </div> + )} + position='left' + > + <div className='system-xs-regular flex-1 truncate text-text-tertiary'> + {data.endpoint} + </div> + </Tooltip> + <div className="mx-2 text-xs text-text-tertiary opacity-30">·</div> + <div className='system-xs-regular shrink-0 text-text-tertiary'> + {data.workflows_in_use > 0 ? t('pluginTrigger.subscription.list.item.usedByNum', { num: data.workflows_in_use }) : t('pluginTrigger.subscription.list.item.noUsed')} + </div> + </div> + </div> + + {isShowDeleteModal && ( + <DeleteConfirm + onClose={hideDeleteModal} + isShow={isShowDeleteModal} + currentId={data.id} + currentName={data.name} + workflowsInUse={data.workflows_in_use} + /> + )} + </> + ) +} + +export default SubscriptionCard diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.ts new file mode 100644 index 0000000000..ff3e903a31 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.ts @@ -0,0 +1,23 @@ +import { useEffect } from 'react' +import { useTriggerSubscriptions } from '@/service/use-triggers' +import { usePluginStore } from '../store' +import { usePluginSubscriptionStore } from './store' + +export const useSubscriptionList = () => { + const detail = usePluginStore(state => state.detail) + const { setRefresh } = usePluginSubscriptionStore() + + const { data: subscriptions, isLoading, refetch } = useTriggerSubscriptions(detail?.provider || '') + + useEffect(() => { + if (refetch) + setRefresh(refetch) + }, [refetch, setRefresh]) + + return { + detail, + subscriptions, + isLoading, + refetch, + } +} diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx index d56d48d6d5..ea7892be32 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx @@ -40,6 +40,7 @@ import { AuthCategory, PluginAuthInAgent, } from '@/app/components/plugins/plugin-auth' +import { ReadmeEntrance } from '../../readme-panel/entrance' type Props = { disabled?: boolean @@ -272,7 +273,10 @@ const ToolSelector: FC<Props> = ({ {/* base form */} <div className='flex flex-col gap-3 px-4 py-2'> <div className='flex flex-col gap-1'> - <div className='system-sm-semibold flex h-6 items-center text-text-secondary'>{t('plugin.detailPanel.toolSelector.toolLabel')}</div> + <div className='system-sm-semibold flex h-6 items-center justify-between text-text-secondary'> + {t('plugin.detailPanel.toolSelector.toolLabel')} + <ReadmeEntrance pluginDetail={currentProvider as any} showShortTip className='pb-0' /> + </div> <ToolPicker placement='bottom' offset={offset} @@ -315,6 +319,7 @@ const ToolSelector: FC<Props> = ({ provider: currentProvider.name, category: AuthCategory.tool, providerType: currentProvider.type, + detail: currentProvider as any, }} credentialId={value?.credential_id} onAuthorizationItemClick={handleAuthorizationItemClick} diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form.tsx index b79ee78664..88bf7f0dfd 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form.tsx @@ -54,7 +54,7 @@ const ReasoningConfigForm: React.FC<Props> = ({ const getVarKindType = (type: FormTypeEnum) => { if (type === FormTypeEnum.file || type === FormTypeEnum.files) return VarKindType.variable - if (type === FormTypeEnum.select || type === FormTypeEnum.boolean || type === FormTypeEnum.textNumber || type === FormTypeEnum.array || type === FormTypeEnum.object) + if (type === FormTypeEnum.select || type === FormTypeEnum.checkbox || type === FormTypeEnum.textNumber || type === FormTypeEnum.array || type === FormTypeEnum.object) return VarKindType.constant if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) return VarKindType.mixed @@ -164,7 +164,7 @@ const ReasoningConfigForm: React.FC<Props> = ({ const isArray = type === FormTypeEnum.array const isShowJSONEditor = isObject || isArray const isFile = type === FormTypeEnum.file || type === FormTypeEnum.files - const isBoolean = type === FormTypeEnum.boolean + const isBoolean = type === FormTypeEnum.checkbox const isSelect = type === FormTypeEnum.select const isAppSelector = type === FormTypeEnum.appSelector const isModelSelector = type === FormTypeEnum.modelSelector diff --git a/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx b/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx new file mode 100644 index 0000000000..2083f34263 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx @@ -0,0 +1,157 @@ +'use client' +import ActionButton from '@/app/components/base/action-button' +import Divider from '@/app/components/base/divider' +import Drawer from '@/app/components/base/drawer' +import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' +import Icon from '@/app/components/plugins/card/base/card-icon' +import Description from '@/app/components/plugins/card/base/description' +import OrgInfo from '@/app/components/plugins/card/base/org-info' +import { triggerEventParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' +import type { TriggerProviderApiEntity } from '@/app/components/workflow/block-selector/types' +import Field from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field' +import cn from '@/utils/classnames' +import { + RiArrowLeftLine, + RiCloseLine, +} from '@remixicon/react' +import type { TFunction } from 'i18next' +import type { FC } from 'react' +import { useTranslation } from 'react-i18next' +import type { TriggerEvent } from '@/app/components/plugins/types' + +type EventDetailDrawerProps = { + eventInfo: TriggerEvent + providerInfo: TriggerProviderApiEntity + onClose: () => void +} + +const getType = (type: string, t: TFunction) => { + if (type === 'number-input') + return t('tools.setBuiltInTools.number') + if (type === 'text-input') + return t('tools.setBuiltInTools.string') + if (type === 'checkbox') + return 'boolean' + if (type === 'file') + return t('tools.setBuiltInTools.file') + return type +} + +// Convert JSON Schema to StructuredOutput format +const convertSchemaToField = (schema: any): any => { + const field: any = { + type: Array.isArray(schema.type) ? schema.type[0] : schema.type || 'string', + } + + if (schema.description) + field.description = schema.description + + if (schema.properties) { + field.properties = Object.entries(schema.properties).reduce((acc, [key, value]: [string, any]) => ({ + ...acc, + [key]: convertSchemaToField(value), + }), {}) + } + + if (schema.required) + field.required = schema.required + + if (schema.items) + field.items = convertSchemaToField(schema.items) + + if (schema.enum) + field.enum = schema.enum + + return field +} + +export const EventDetailDrawer: FC<EventDetailDrawerProps> = (props) => { + const { eventInfo, providerInfo, onClose } = props + const language = useLanguage() + const { t } = useTranslation() + const parametersSchemas = triggerEventParametersToFormSchemas(eventInfo.parameters) + + // Convert output_schema properties to array for direct rendering + const outputFields = eventInfo.output_schema?.properties + ? Object.entries(eventInfo.output_schema.properties).map(([name, schema]: [string, any]) => ({ + name, + field: convertSchemaToField(schema), + required: eventInfo.output_schema.required?.includes(name) || false, + })) + : [] + + return ( + <Drawer + isOpen + clickOutsideNotOpen={false} + onClose={onClose} + footer={null} + mask={false} + positionCenter={false} + panelClassName={cn('mb-2 mr-2 mt-[64px] !w-[420px] !max-w-[420px] justify-start rounded-2xl border-[0.5px] border-components-panel-border !bg-components-panel-bg !p-0 shadow-xl')} + > + <div className='relative border-b border-divider-subtle p-4 pb-3'> + <div className='absolute right-3 top-3'> + <ActionButton onClick={onClose}> + <RiCloseLine className='h-4 w-4' /> + </ActionButton> + </div> + <div + className='system-xs-semibold-uppercase mb-2 flex cursor-pointer items-center gap-1 text-text-accent-secondary' + onClick={onClose} + > + <RiArrowLeftLine className='h-4 w-4' /> + {t('plugin.detailPanel.operation.back')} + </div> + <div className='flex items-center gap-1'> + <Icon size='tiny' className='h-6 w-6' src={providerInfo.icon!} /> + <OrgInfo + packageNameClassName='w-auto' + orgName={providerInfo.author} + packageName={providerInfo.name.split('/').pop() || ''} + /> + </div> + <div className='system-md-semibold mt-1 text-text-primary'>{eventInfo?.identity?.label[language]}</div> + <Description className='mb-2 mt-3 h-auto' text={eventInfo.description[language]} descriptionLineRows={2}></Description> + </div> + <div className='flex h-full flex-col gap-2 overflow-y-auto px-4 pb-2 pt-4'> + <div className='system-sm-semibold-uppercase text-text-secondary'>{t('tools.setBuiltInTools.parameters')}</div> + {parametersSchemas.length > 0 ? ( + parametersSchemas.map((item, index) => ( + <div key={index} className='py-1'> + <div className='flex items-center gap-2'> + <div className='code-sm-semibold text-text-secondary'>{item.label[language]}</div> + <div className='system-xs-regular text-text-tertiary'> + {getType(item.type, t)} + </div> + {item.required && ( + <div className='system-xs-medium text-text-warning-secondary'>{t('tools.setBuiltInTools.required')}</div> + )} + </div> + {item.description && ( + <div className='system-xs-regular mt-0.5 text-text-tertiary'> + {item.description?.[language]} + </div> + )} + </div> + )) + ) : <div className='system-xs-regular text-text-tertiary'>{t('pluginTrigger.events.item.noParameters')}</div>} + <Divider className='mb-2 mt-1 h-px' /> + <div className='flex flex-col gap-2'> + <div className='system-sm-semibold-uppercase text-text-secondary'>{t('pluginTrigger.events.output')}</div> + <div className='relative left-[-7px]'> + {outputFields.map(item => ( + <Field + key={item.name} + name={item.name} + payload={item.field} + required={item.required} + rootClassName='code-sm-semibold text-text-secondary' + /> + ))} + </div> + </div> + </div> + </Drawer> + ) +} diff --git a/web/app/components/plugins/plugin-detail-panel/trigger/event-list.tsx b/web/app/components/plugins/plugin-detail-panel/trigger/event-list.tsx new file mode 100644 index 0000000000..93f2fcc9c7 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/trigger/event-list.tsx @@ -0,0 +1,71 @@ +import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' +import type { TriggerEvent } from '@/app/components/plugins/types' +import type { TriggerProviderApiEntity } from '@/app/components/workflow/block-selector/types' +import { useTriggerProviderInfo } from '@/service/use-triggers' +import cn from '@/utils/classnames' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { usePluginStore } from '../store' +import { EventDetailDrawer } from './event-detail-drawer' + +type TriggerEventCardProps = { + eventInfo: TriggerEvent + providerInfo: TriggerProviderApiEntity +} + +const TriggerEventCard = ({ eventInfo, providerInfo }: TriggerEventCardProps) => { + const { identity, description } = eventInfo + const language = useLanguage() + const [showDetail, setShowDetail] = useState(false) + const title = identity.label?.[language] ?? identity.label?.en_US ?? '' + const descriptionText = description?.[language] ?? description?.en_US ?? '' + return ( + <> + <div + className={cn('bg-components-panel-item-bg cursor-pointer rounded-xl border-[0.5px] border-components-panel-border-subtle px-4 py-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover')} + onClick={() => setShowDetail(true)} + > + <div className='system-md-semibold pb-0.5 text-text-secondary'>{title}</div> + <div className='system-xs-regular line-clamp-2 text-text-tertiary' title={descriptionText}>{descriptionText}</div> + </div> + {showDetail && ( + <EventDetailDrawer + eventInfo={eventInfo} + providerInfo={providerInfo} + onClose={() => setShowDetail(false)} + /> + )} + </> + ) +} + +export const TriggerEventsList = () => { + const { t } = useTranslation() + const detail = usePluginStore(state => state.detail) + + const { data: providerInfo } = useTriggerProviderInfo(detail?.provider || '') + const triggerEvents = providerInfo?.events || [] + + if (!providerInfo || !triggerEvents.length) + return null + + return ( + <div className='px-4 pb-4 pt-2'> + <div className='mb-1 py-1'> + <div className='system-sm-semibold-uppercase mb-1 flex h-6 items-center justify-between text-text-secondary'> + {t('pluginTrigger.events.actionNum', { num: triggerEvents.length, event: t(`pluginTrigger.events.${triggerEvents.length > 1 ? 'events' : 'event'}`) })} + </div> + </div> + <div className='flex flex-col gap-2'> + { + triggerEvents.map((triggerEvent: TriggerEvent) => ( + <TriggerEventCard + key={`${detail?.plugin_id}${triggerEvent.identity?.name || ''}`} + eventInfo={triggerEvent} + providerInfo={providerInfo} + />)) + } + </div> + </div> + ) +} diff --git a/web/app/components/plugins/plugin-item/action.tsx b/web/app/components/plugins/plugin-item/action.tsx index 6d7ff388ea..80dfd78f12 100644 --- a/web/app/components/plugins/plugin-item/action.tsx +++ b/web/app/components/plugins/plugin-item/action.tsx @@ -14,7 +14,7 @@ import { useGitHubReleases } from '../install-plugin/hooks' import Toast from '@/app/components/base/toast' import { useModalContext } from '@/context/modal-context' import { useInvalidateInstalledPluginList } from '@/service/use-plugins' -import type { PluginType } from '@/app/components/plugins/types' +import type { PluginCategoryEnum } from '@/app/components/plugins/types' const i18nPrefix = 'plugin.action' @@ -23,7 +23,7 @@ type Props = { installationId: string pluginUniqueIdentifier: string pluginName: string - category: PluginType + category: PluginCategoryEnum usedInApps: number isShowFetchNewVersion: boolean isShowInfo: boolean @@ -92,11 +92,18 @@ const Action: FC<Props> = ({ const handleDelete = useCallback(async () => { showDeleting() - const res = await uninstallPlugin(installationId) - hideDeleting() - if (res.success) { - hideDeleteConfirm() - onDelete() + try{ + const res = await uninstallPlugin(installationId) + if (res.success) { + hideDeleteConfirm() + onDelete() + } + } + catch (error) { + console.error('uninstallPlugin error', error) + } + finally { + hideDeleting() } }, [installationId, onDelete]) return ( diff --git a/web/app/components/plugins/plugin-item/index.tsx b/web/app/components/plugins/plugin-item/index.tsx index ed7cf47bb7..9352df23c8 100644 --- a/web/app/components/plugins/plugin-item/index.tsx +++ b/web/app/components/plugins/plugin-item/index.tsx @@ -1,7 +1,12 @@ 'use client' -import type { FC } from 'react' -import React, { useCallback, useMemo } from 'react' -import { useTheme } from 'next-themes' +import Tooltip from '@/app/components/base/tooltip' +import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' +import { API_PREFIX } from '@/config' +import { useAppContext } from '@/context/app-context' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useRenderI18nObject } from '@/hooks/use-i18n' +import cn from '@/utils/classnames' +import { getMarketplaceUrl } from '@/utils/var' import { RiArrowRightUpLine, RiBugLine, @@ -10,26 +15,21 @@ import { RiLoginCircleLine, RiVerifiedBadgeLine, } from '@remixicon/react' +import { useTheme } from 'next-themes' +import type { FC } from 'react' +import React, { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { usePluginPageContext } from '../plugin-page/context' -import { Github } from '../../base/icons/src/public/common' +import { gte } from 'semver' import Badge from '../../base/badge' -import { type PluginDetail, PluginSource, PluginType } from '../types' +import { Github } from '../../base/icons/src/public/common' import CornerMark from '../card/base/corner-mark' import Description from '../card/base/description' import OrgInfo from '../card/base/org-info' import Title from '../card/base/title' +import { useCategories } from '../hooks' +import { usePluginPageContext } from '../plugin-page/context' +import { PluginCategoryEnum, type PluginDetail, PluginSource } from '../types' import Action from './action' -import cn from '@/utils/classnames' -import { API_PREFIX } from '@/config' -import { useSingleCategories } from '../hooks' -import { useRenderI18nObject } from '@/hooks/use-i18n' -import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' -import { useAppContext } from '@/context/app-context' -import { gte } from 'semver' -import Tooltip from '@/app/components/base/tooltip' -import { getMarketplaceUrl } from '@/utils/var' -import { useGlobalPublicStore } from '@/context/global-public-context' type Props = { className?: string @@ -42,7 +42,7 @@ const PluginItem: FC<Props> = ({ }) => { const { t } = useTranslation() const { theme } = useTheme() - const { categoriesMap } = useSingleCategories() + const { categoriesMap } = useCategories(t, true) const currentPluginID = usePluginPageContext(v => v.currentPluginID) const setCurrentPluginID = usePluginPageContext(v => v.setCurrentPluginID) const { refreshPluginList } = useRefreshPluginList() @@ -150,7 +150,7 @@ const PluginItem: FC<Props> = ({ packageName={name} packageNameClassName='w-auto max-w-[150px]' /> - {category === PluginType.extension && ( + {category === PluginCategoryEnum.extension && ( <> <div className='system-xs-regular mx-2 text-text-quaternary'>·</div> <div className='system-xs-regular flex items-center gap-x-1 overflow-hidden text-text-tertiary'> diff --git a/web/app/components/plugins/readme-panel/constants.ts b/web/app/components/plugins/readme-panel/constants.ts new file mode 100644 index 0000000000..7d6782e665 --- /dev/null +++ b/web/app/components/plugins/readme-panel/constants.ts @@ -0,0 +1,6 @@ +export const BUILTIN_TOOLS_ARRAY = [ + 'code', + 'audio', + 'time', + 'webscraper', +] diff --git a/web/app/components/plugins/readme-panel/entrance.tsx b/web/app/components/plugins/readme-panel/entrance.tsx new file mode 100644 index 0000000000..f3b4c98412 --- /dev/null +++ b/web/app/components/plugins/readme-panel/entrance.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { RiBookReadLine } from '@remixicon/react' +import cn from '@/utils/classnames' +import { ReadmeShowType, useReadmePanelStore } from './store' +import { BUILTIN_TOOLS_ARRAY } from './constants' +import type { PluginDetail } from '../types' + +export const ReadmeEntrance = ({ + pluginDetail, + showType = ReadmeShowType.drawer, + className, + showShortTip = false, +}: { + pluginDetail: PluginDetail + showType?: ReadmeShowType + className?: string + showShortTip?: boolean +}) => { + const { t } = useTranslation() + const { setCurrentPluginDetail } = useReadmePanelStore() + + const handleReadmeClick = () => { + if (pluginDetail) + setCurrentPluginDetail(pluginDetail, showType) + } + if (!pluginDetail || BUILTIN_TOOLS_ARRAY.includes(pluginDetail.id)) + return null + + return ( + <div className={cn('flex flex-col items-start justify-center gap-2 pb-4 pt-0', showType === ReadmeShowType.drawer && 'px-4', className)}> + {!showShortTip && <div className="relative h-1 w-8 shrink-0"> + <div className="h-px w-full bg-divider-regular"></div> + </div>} + + <button + onClick={handleReadmeClick} + className="flex w-full items-center justify-start gap-1 text-text-tertiary transition-opacity hover:text-text-accent-light-mode-only" + > + <div className="relative flex h-3 w-3 items-center justify-center overflow-hidden"> + <RiBookReadLine className="h-3 w-3" /> + </div> + <span className="text-xs font-normal leading-4"> + {!showShortTip ? t('plugin.readmeInfo.needHelpCheckReadme') : t('plugin.readmeInfo.title')} + </span> + </button> + </div> + ) +} diff --git a/web/app/components/plugins/readme-panel/index.tsx b/web/app/components/plugins/readme-panel/index.tsx new file mode 100644 index 0000000000..70d1e0db2c --- /dev/null +++ b/web/app/components/plugins/readme-panel/index.tsx @@ -0,0 +1,120 @@ +'use client' +import ActionButton from '@/app/components/base/action-button' +import Loading from '@/app/components/base/loading' +import { Markdown } from '@/app/components/base/markdown' +import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { usePluginReadme } from '@/service/use-plugins' +import cn from '@/utils/classnames' +import { RiBookReadLine, RiCloseLine } from '@remixicon/react' +import type { FC } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import DetailHeader from '../plugin-detail-panel/detail-header' +import { ReadmeShowType, useReadmePanelStore } from './store' + +const ReadmePanel: FC = () => { + const { currentPluginDetail, setCurrentPluginDetail } = useReadmePanelStore() + const { detail, showType } = currentPluginDetail || {} + const { t } = useTranslation() + const language = useLanguage() + + const pluginUniqueIdentifier = detail?.plugin_unique_identifier || '' + + const { data: readmeData, isLoading, error } = usePluginReadme( + { plugin_unique_identifier: pluginUniqueIdentifier, language: language === 'zh-Hans' ? undefined : language }, + ) + + const onClose = () => { + setCurrentPluginDetail() + } + + if (!detail) return null + + const children = ( + <div className="flex h-full w-full flex-col overflow-hidden"> + <div className="rounded-t-xl bg-background-body px-4 py-4"> + <div className="mb-3 flex items-center justify-between"> + <div className="flex items-center gap-1"> + <RiBookReadLine className="h-3 w-3 text-text-tertiary" /> + <span className="text-xs font-medium uppercase text-text-tertiary"> + {t('plugin.readmeInfo.title')} + </span> + </div> + <ActionButton onClick={onClose}> + <RiCloseLine className='h-4 w-4' /> + </ActionButton> + </div> + <DetailHeader detail={detail} isReadmeView={true} /> + </div> + + <div className="flex-1 overflow-y-auto px-4 py-3"> + {(() => { + if (isLoading) { + return ( + <div className="flex h-40 items-center justify-center"> + <Loading type="area" /> + </div> + ) + } + + if (error) { + return ( + <div className="py-8 text-center text-text-tertiary"> + <p>{t('plugin.readmeInfo.failedToFetch')}</p> + </div> + ) + } + + if (readmeData?.readme) { + return ( + <Markdown + content={readmeData.readme} + pluginInfo={{ pluginUniqueIdentifier, pluginId: detail.plugin_id }} + /> + ) + } + + return ( + <div className="py-8 text-center text-text-tertiary"> + <p>{t('plugin.readmeInfo.noReadmeAvailable')}</p> + </div> + ) + })()} + </div> + </div> + ) + + const portalContent = showType === ReadmeShowType.drawer + ? ( + <div className='pointer-events-none fixed inset-0 z-[9997] flex justify-start'> + <div + className={cn( + 'pointer-events-auto mb-2 ml-2 mr-2 mt-16 w-[600px] max-w-[600px] justify-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0 shadow-xl', + )} + > + {children} + </div> + </div> + ) + : ( + <div className='pointer-events-none fixed inset-0 z-[9997] flex items-center justify-center p-2'> + <div + className={cn( + 'pointer-events-auto relative h-[calc(100vh-16px)] w-full max-w-[800px] rounded-2xl bg-components-panel-bg p-0 shadow-xl', + )} + onClick={(event) => { + event.stopPropagation() + }} + > + {children} + </div> + </div> + ) + + return createPortal( + portalContent, + document.body, + ) +} + +export default ReadmePanel diff --git a/web/app/components/plugins/readme-panel/store.ts b/web/app/components/plugins/readme-panel/store.ts new file mode 100644 index 0000000000..29c989db10 --- /dev/null +++ b/web/app/components/plugins/readme-panel/store.ts @@ -0,0 +1,25 @@ +import { create } from 'zustand' +import type { PluginDetail } from '@/app/components/plugins/types' + +export enum ReadmeShowType { + drawer = 'drawer', + modal = 'modal', +} + +type Shape = { + currentPluginDetail?: { + detail: PluginDetail + showType: ReadmeShowType + } + setCurrentPluginDetail: (detail?: PluginDetail, showType?: ReadmeShowType) => void +} + +export const useReadmePanelStore = create<Shape>(set => ({ + currentPluginDetail: undefined, + setCurrentPluginDetail: (detail?: PluginDetail, showType?: ReadmeShowType) => set({ + currentPluginDetail: !detail ? undefined : { + detail, + showType: showType ?? ReadmeShowType.drawer, + }, + }), +})) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx index 2d00788142..dfbeaad9cb 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx @@ -15,6 +15,7 @@ import { RiTimeLine } from '@remixicon/react' import cn from '@/utils/classnames' import { convertTimezoneToOffsetStr } from '@/app/components/base/date-and-time-picker/utils/dayjs' import { useModalContextSelector } from '@/context/modal-context' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' const i18nPrefix = 'plugin.autoUpdate' @@ -30,7 +31,7 @@ const SettingTimeZone: FC<{ }) => { const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal) return ( - <span className='body-xs-regular cursor-pointer text-text-accent' onClick={() => setShowAccountSettingModal({ payload: 'language' })} >{children}</span> + <span className='body-xs-regular cursor-pointer text-text-accent' onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.LANGUAGE })} >{children}</span> ) } const AutoUpdateSetting: FC<Props> = ({ @@ -143,6 +144,7 @@ const AutoUpdateSetting: FC<Props> = ({ title={t(`${i18nPrefix}.updateTime`)} minuteFilter={minuteFilter} renderTrigger={renderTimePickerTrigger} + placement='bottom-end' /> <div className='body-xs-regular mt-1 text-right text-text-tertiary'> <Trans diff --git a/web/app/components/plugins/types.ts b/web/app/components/plugins/types.ts index 2e061d7d69..d9659df3ad 100644 --- a/web/app/components/plugins/types.ts +++ b/web/app/components/plugins/types.ts @@ -3,12 +3,16 @@ import type { ToolCredential } from '@/app/components/tools/types' import type { Locale } from '@/i18n-config' import type { AgentFeature } from '@/app/components/workflow/nodes/agent/types' import type { AutoUpdateConfig } from './reference-setting-modal/auto-update-setting/types' -export enum PluginType { +import type { FormTypeEnum } from '../base/form/types' +import type { TypeWithI18N } from '@/app/components/base/form/types' + +export enum PluginCategoryEnum { tool = 'tool', model = 'model', extension = 'extension', agent = 'agent-strategy', datasource = 'datasource', + trigger = 'trigger', } export enum PluginSource { @@ -68,7 +72,7 @@ export type PluginDeclaration = { author: string icon: string name: string - category: PluginType + category: PluginCategoryEnum label: Record<Locale, string> description: Record<Locale, string> created_at: string @@ -82,6 +86,111 @@ export type PluginDeclaration = { tags: string[] agent_strategy: any meta: PluginDeclarationMeta + trigger: PluginTriggerDefinition +} + +export type PluginTriggerSubscriptionConstructor = { + credentials_schema: CredentialsSchema[] + oauth_schema: OauthSchema + parameters: ParametersSchema[] +} + +export type PluginTriggerDefinition = { + events: TriggerEvent[] + identity: Identity + subscription_constructor: PluginTriggerSubscriptionConstructor + subscription_schema: ParametersSchema[] +} + +export type CredentialsSchema = { + name: string + label: Record<Locale, string> + description: Record<Locale, string> + type: FormTypeEnum + scope: any + required: boolean + default: any + options: any + help: Record<Locale, string> + url: string + placeholder: Record<Locale, string> +} + +export type OauthSchema = { + client_schema: CredentialsSchema[] + credentials_schema: CredentialsSchema[] +} + +export type ParametersSchema = { + name: string + label: Record<Locale, string> + type: FormTypeEnum + auto_generate: any + template: any + scope: any + required: boolean + multiple: boolean + default?: string[] + min: any + max: any + precision: any + options?: Array<{ + value: string + label: Record<Locale, string> + icon?: string + }> + description: Record<Locale, string> +} + +export type PropertiesSchema = { + type: FormTypeEnum + name: string + scope: any + required: boolean + default: any + options: Array<{ + value: string + label: Record<Locale, string> + icon?: string + }> + label: Record<Locale, string> + help: Record<Locale, string> + url: any + placeholder: any +} + +export type TriggerEventParameter = { + name: string + label: TypeWithI18N + type: string + auto_generate: any + template: any + scope: any + required: boolean + multiple: boolean + default: any + min: any + max: any + precision: any + options?: Array<{ + value: string + label: TypeWithI18N + icon?: string + }> + description?: TypeWithI18N +} + +export type TriggerEvent = { + name: string + identity: { + author: string + name: string + label: TypeWithI18N + provider?: string + } + description: TypeWithI18N + parameters: TriggerEventParameter[] + output_schema: Record<string, any> } export type PluginManifestInMarket = { @@ -90,7 +199,7 @@ export type PluginManifestInMarket = { org: string icon: string label: Record<Locale, string> - category: PluginType + category: PluginCategoryEnum version: string // combine the other place to it latest_version: string brief: Record<Locale, string> @@ -104,6 +213,12 @@ export type PluginManifestInMarket = { from: Dependency['type'] } +export enum SupportedCreationMethods { + OAUTH = 'OAUTH', + APIKEY = 'APIKEY', + MANUAL = 'MANUAL', +} + export type PluginDetail = { id: string created_at: string @@ -127,7 +242,7 @@ export type PluginDetail = { } export type PluginInfoFromMarketPlace = { - category: PluginType + category: PluginCategoryEnum latest_package_identifier: string latest_version: string } @@ -149,7 +264,7 @@ export type Plugin = { // Repo readme.md content introduction: string repository: string - category: PluginType + category: PluginCategoryEnum install_count: number endpoint: { settings: CredentialFormSchemaBase[] @@ -179,7 +294,7 @@ export type ReferenceSetting = { } export type UpdateFromMarketPlacePayload = { - category: PluginType + category: PluginCategoryEnum originalPackageInfo: { id: string payload: PluginDeclaration @@ -202,7 +317,7 @@ export type UpdateFromGitHubPayload = { export type UpdatePluginPayload = { type: PluginSource - category: PluginType + category: PluginCategoryEnum marketPlace?: UpdateFromMarketPlacePayload github?: UpdateFromGitHubPayload } @@ -469,15 +584,18 @@ export type StrategyDetail = { features: AgentFeature[] } +export type Identity = { + author: string + name: string + label: Record<Locale, string> + description: Record<Locale, string> + icon: string + icon_dark?: string + tags: string[] +} + export type StrategyDeclaration = { - identity: { - author: string - name: string - description: Record<Locale, string> - icon: string - label: Record<Locale, string> - tags: string[] - }, + identity: Identity, plugin_id: string strategies: StrategyDetail[] } diff --git a/web/app/components/rag-pipeline/components/panel/index.tsx b/web/app/components/rag-pipeline/components/panel/index.tsx index e2fd958405..793248e3eb 100644 --- a/web/app/components/rag-pipeline/components/panel/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/index.tsx @@ -22,14 +22,19 @@ const InputFieldEditorPanel = dynamic(() => import('./input-field/editor'), { const PreviewPanel = dynamic(() => import('./input-field/preview'), { ssr: false, }) - +const GlobalVariablePanel = dynamic(() => import('@/app/components/workflow/panel/global-variable-panel'), { + ssr: false, +}) const RagPipelinePanelOnRight = () => { const historyWorkflowData = useStore(s => s.historyWorkflowData) const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel) + const showGlobalVariablePanel = useStore(s => s.showGlobalVariablePanel) + return ( <> {historyWorkflowData && <Record />} {showDebugAndPreviewPanel && <TestRunPanel />} + {showGlobalVariablePanel && <GlobalVariablePanel />} </> ) } diff --git a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts index d9de69716e..b70a2e6a34 100644 --- a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts @@ -7,6 +7,7 @@ import { import { useNodesReadOnly, } from '@/app/components/workflow/hooks/use-workflow' +import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback' import { API_PREFIX } from '@/config' import { syncWorkflowDraft } from '@/service/workflow' import { usePipelineRefreshDraft } from '.' @@ -83,7 +84,7 @@ export const useNodesSyncDraft = () => { } }, [getPostParams, getNodesReadOnly]) - const doSyncWorkflowDraft = useCallback(async ( + const performSync = useCallback(async ( notRefreshWhenSyncError?: boolean, callback?: { onSuccess?: () => void @@ -121,6 +122,8 @@ export const useNodesSyncDraft = () => { } }, [getPostParams, getNodesReadOnly, workflowStore, handleRefreshWorkflowDraft]) + const doSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly) + return { doSyncWorkflowDraft, syncWorkflowDraftWhenPageClose, diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-init.ts b/web/app/components/rag-pipeline/hooks/use-pipeline-init.ts index c70bce8523..6af72bee05 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-init.ts +++ b/web/app/components/rag-pipeline/hooks/use-pipeline-init.ts @@ -60,7 +60,10 @@ export const usePipelineInit = () => { if (error && error.json && !error.bodyUsed && datasetId) { error.json().then((err: any) => { if (err.code === 'draft_workflow_not_exist') { - workflowStore.setState({ notInitialWorkflow: true }) + workflowStore.setState({ + notInitialWorkflow: true, + shouldAutoOpenStartNodeSelector: true, + }) syncWorkflowDraft({ url: `/rag/pipelines/${datasetId}/workflows/draft`, params: { diff --git a/web/app/components/tools/add-tool-modal/empty.tsx b/web/app/components/tools/add-tool-modal/empty.tsx index 5759589c8e..4d69dc1076 100644 --- a/web/app/components/tools/add-tool-modal/empty.tsx +++ b/web/app/components/tools/add-tool-modal/empty.tsx @@ -35,7 +35,7 @@ const Empty = ({ const hasTitle = t(`tools.addToolModal.${renderType}.title`) !== `tools.addToolModal.${renderType}.title` return ( - <div className='flex h-[336px] flex-col items-center justify-center'> + <div className='flex flex-col items-center justify-center'> <NoToolPlaceholder className={theme === 'dark' ? 'invert' : ''} /> <div className='mb-1 mt-2 text-[13px] font-medium leading-[18px] text-text-primary'> {hasTitle ? t(`tools.addToolModal.${renderType}.title`) : 'No tools available'} diff --git a/web/app/components/tools/marketplace/hooks.ts b/web/app/components/tools/marketplace/hooks.ts index 0790d52721..e3fad24710 100644 --- a/web/app/components/tools/marketplace/hooks.ts +++ b/web/app/components/tools/marketplace/hooks.ts @@ -9,7 +9,7 @@ import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, } from '@/app/components/plugins/marketplace/hooks' -import { PluginType } from '@/app/components/plugins/types' +import { PluginCategoryEnum } from '@/app/components/plugins/types' import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils' import { useAllToolProviders } from '@/service/use-tools' @@ -49,7 +49,7 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin if (searchPluginText) { queryPluginsWithDebounced({ - category: PluginType.tool, + category: PluginCategoryEnum.tool, query: searchPluginText, tags: filterPluginTags, exclude, @@ -59,7 +59,7 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin return } queryPlugins({ - category: PluginType.tool, + category: PluginCategoryEnum.tool, query: searchPluginText, tags: filterPluginTags, exclude, @@ -70,8 +70,8 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin else { if (isSuccess) { queryMarketplaceCollectionsAndPlugins({ - category: PluginType.tool, - condition: getMarketplaceListCondition(PluginType.tool), + category: PluginCategoryEnum.tool, + condition: getMarketplaceListCondition(PluginCategoryEnum.tool), exclude, type: 'plugin', }) @@ -95,7 +95,7 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin pageRef.current++ queryPlugins({ - category: PluginType.tool, + category: PluginCategoryEnum.tool, query: searchPluginText, tags: filterPluginTags, exclude, diff --git a/web/app/components/tools/mcp/mcp-service-card.tsx b/web/app/components/tools/mcp/mcp-service-card.tsx index 6c86932b32..1f40b1e4b3 100644 --- a/web/app/components/tools/mcp/mcp-service-card.tsx +++ b/web/app/components/tools/mcp/mcp-service-card.tsx @@ -13,7 +13,7 @@ import CopyFeedback from '@/app/components/base/copy-feedback' import Confirm from '@/app/components/base/confirm' import type { AppDetailResponse } from '@/models/app' import { useAppContext } from '@/context/app-context' -import type { AppSSO } from '@/types/app' +import { AppModeEnum, type AppSSO } from '@/types/app' import Indicator from '@/app/components/header/indicator' import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal' import { useAppWorkflow } from '@/service/use-workflow' @@ -26,6 +26,7 @@ import { import { BlockEnum } from '@/app/components/workflow/types' import cn from '@/utils/classnames' import { fetchAppDetail } from '@/service/apps' +import { useDocLink } from '@/context/i18n' export type IAppCardProps = { appInfo: AppDetailResponse & Partial<AppSSO> @@ -35,6 +36,7 @@ function MCPServiceCard({ appInfo, }: IAppCardProps) { const { t } = useTranslation() + const docLink = useDocLink() const appId = appInfo.id const { mutateAsync: updateMCPServer } = useUpdateMCPServer() const { mutateAsync: refreshMCPServerCode, isPending: genLoading } = useRefreshMCPServerCode() @@ -43,7 +45,7 @@ function MCPServiceCard({ const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [showMCPServerModal, setShowMCPServerModal] = useState(false) - const isAdvancedApp = appInfo?.mode === 'advanced-chat' || appInfo?.mode === 'workflow' + const isAdvancedApp = appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW const isBasicApp = !isAdvancedApp const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '') const [basicAppConfig, setBasicAppConfig] = useState<any>({}) @@ -69,11 +71,16 @@ function MCPServiceCard({ const { data: detail } = useMCPServerDetail(appId) const { id, status, server_code } = detail ?? {} + const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW const appUnpublished = isAdvancedApp ? !currentWorkflow?.graph : !basicAppConfig.updated_at const serverPublished = !!id const serverActivated = status === 'active' const serverURL = serverPublished ? `${appInfo.api_base_url.replace('/v1', '')}/mcp/server/${server_code}/mcp` : '***********' - const toggleDisabled = !isCurrentWorkspaceEditor || appUnpublished + const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start) + const missingStartNode = isWorkflowApp && !hasStartNode + const hasInsufficientPermissions = !isCurrentWorkspaceEditor + const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode + const isMinimalState = appUnpublished || missingStartNode const [activated, setActivated] = useState(serverActivated) @@ -136,12 +143,12 @@ function MCPServiceCard({ return ( <> - <div className={cn('w-full max-w-full rounded-xl border-l-[0.5px] border-t border-effects-highlight')}> + <div className={cn('w-full max-w-full rounded-xl border-l-[0.5px] border-t border-effects-highlight', isMinimalState && 'h-12')}> <div className='rounded-xl bg-background-default'> - <div className='flex w-full flex-col items-start justify-center gap-3 self-stretch border-b-[0.5px] border-divider-subtle p-3'> + <div className={cn('flex w-full flex-col items-start justify-center gap-3 self-stretch p-3', isMinimalState ? 'border-0' : 'border-b-[0.5px] border-divider-subtle')}> <div className='flex w-full items-center gap-3 self-stretch'> <div className='flex grow items-center'> - <div className='mr-3 shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-indigo-indigo-500 p-1 shadow-md'> + <div className='mr-2 shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 p-1 shadow-md'> <Mcp className='h-4 w-4 text-text-primary-on-surface' /> </div> <div className="group w-full"> @@ -159,61 +166,86 @@ function MCPServiceCard({ </div> </div> <Tooltip - popupContent={appUnpublished ? t('tools.mcp.server.publishTip') : ''} + popupContent={ + toggleDisabled ? ( + appUnpublished ? ( + t('tools.mcp.server.publishTip') + ) : missingStartNode ? ( + <> + <div className="mb-1 text-xs font-normal text-text-secondary"> + {t('appOverview.overview.appInfo.enableTooltip.description')} + </div> + <div + className="cursor-pointer text-xs font-normal text-text-accent hover:underline" + onClick={() => window.open(docLink('/guides/workflow/node/user-input'), '_blank')} + > + {t('appOverview.overview.appInfo.enableTooltip.learnMore')} + </div> + </> + ) : '' + ) : '' + } + position="right" + popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg" + offset={24} > <div> <Switch defaultValue={activated} onChange={onChangeStatus} disabled={toggleDisabled} /> </div> </Tooltip> </div> - <div className='flex flex-col items-start justify-center self-stretch'> - <div className="system-xs-medium pb-1 text-text-tertiary"> - {t('tools.mcp.server.url')} - </div> - <div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2"> - <div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1"> - <div className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-secondary"> - {serverURL} - </div> + {!isMinimalState && ( + <div className='flex flex-col items-start justify-center self-stretch'> + <div className="system-xs-medium pb-1 text-text-tertiary"> + {t('tools.mcp.server.url')} </div> - {serverPublished && ( - <> - <CopyFeedback - content={serverURL} - className={'!size-6'} - /> - <Divider type="vertical" className="!mx-0.5 !h-3.5 shrink-0" /> - {isCurrentWorkspaceManager && ( - <Tooltip - popupContent={t('appOverview.overview.appInfo.regenerate') || ''} - > - <div - className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover" - onClick={() => setShowConfirmDelete(true)} + <div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2"> + <div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1"> + <div className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-secondary"> + {serverURL} + </div> + </div> + {serverPublished && ( + <> + <CopyFeedback + content={serverURL} + className={'!size-6'} + /> + <Divider type="vertical" className="!mx-0.5 !h-3.5 shrink-0" /> + {isCurrentWorkspaceManager && ( + <Tooltip + popupContent={t('appOverview.overview.appInfo.regenerate') || ''} > - <RiLoopLeftLine className={cn('h-4 w-4 text-text-tertiary hover:text-text-secondary', genLoading && 'animate-spin')}/> - </div> - </Tooltip> - )} - </> - )} + <div + className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover" + onClick={() => setShowConfirmDelete(true)} + > + <RiLoopLeftLine className={cn('h-4 w-4 text-text-tertiary hover:text-text-secondary', genLoading && 'animate-spin')}/> + </div> + </Tooltip> + )} + </> + )} + </div> </div> - </div> + )} </div> - <div className='flex items-center gap-1 self-stretch p-3'> - <Button - disabled={toggleDisabled} - size='small' - variant='ghost' - onClick={() => setShowMCPServerModal(true)} - > + {!isMinimalState && ( + <div className='flex items-center gap-1 self-stretch p-3'> + <Button + disabled={toggleDisabled} + size='small' + variant='ghost' + onClick={() => setShowMCPServerModal(true)} + > - <div className="flex items-center justify-center gap-[1px]"> - <RiEditLine className="h-3.5 w-3.5" /> - <div className="system-xs-medium px-[3px] text-text-tertiary">{serverPublished ? t('tools.mcp.server.edit') : t('tools.mcp.server.addDescription')}</div> - </div> - </Button> - </div> + <div className="flex items-center justify-center gap-[1px]"> + <RiEditLine className="h-3.5 w-3.5" /> + <div className="system-xs-medium px-[3px] text-text-tertiary">{serverPublished ? t('tools.mcp.server.edit') : t('tools.mcp.server.addDescription')}</div> + </div> + </Button> + </div> + )} </div> </div> {showMCPServerModal && ( diff --git a/web/app/components/tools/types.ts b/web/app/components/tools/types.ts index 1bfccc04e5..1b76afc5c7 100644 --- a/web/app/components/tools/types.ts +++ b/web/app/components/tools/types.ts @@ -34,6 +34,7 @@ export enum CollectionType { workflow = 'workflow', mcp = 'mcp', datasource = 'datasource', + trigger = 'trigger', } export type Emoji = { @@ -65,6 +66,7 @@ export type Collection = { masked_headers?: Record<string, string> is_authorized?: boolean provider?: string + credential_id?: string is_dynamic_registration?: boolean authentication?: { client_id?: string @@ -84,6 +86,7 @@ export type ToolParameter = { form: string llm_description: string required: boolean + multiple: boolean default: string options?: { label: TypeWithI18N @@ -93,7 +96,33 @@ export type ToolParameter = { max?: number } +export type TriggerParameter = { + name: string + label: TypeWithI18N + human_description: TypeWithI18N + type: string + form: string + llm_description: string + required: boolean + multiple: boolean + default: string + options?: { + label: TypeWithI18N + value: string + }[] +} + // Action +export type Event = { + name: string + author: string + label: TypeWithI18N + description: TypeWithI18N + parameters: TriggerParameter[] + labels: string[] + output_schema: Record<string, any> +} + export type Tool = { name: string author: string diff --git a/web/app/components/tools/utils/to-form-schema.ts b/web/app/components/tools/utils/to-form-schema.ts index 8e85a5f9b0..69f5dd5f2f 100644 --- a/web/app/components/tools/utils/to-form-schema.ts +++ b/web/app/components/tools/utils/to-form-schema.ts @@ -1,6 +1,7 @@ -import type { ToolCredential, ToolParameter } from '../types' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' +import type { TriggerEventParameter } from '../../plugins/types' +import type { ToolCredential, ToolParameter } from '../types' export const toType = (type: string) => { switch (type) { @@ -14,6 +15,21 @@ export const toType = (type: string) => { return type } } + +export const triggerEventParametersToFormSchemas = (parameters: TriggerEventParameter[]) => { + if (!parameters?.length) + return [] + + return parameters.map((parameter) => { + return { + ...parameter, + type: toType(parameter.type), + _type: parameter.type, + tooltip: parameter.description, + } + }) +} + export const toolParametersToFormSchemas = (parameters: ToolParameter[]) => { if (!parameters) return [] @@ -165,7 +181,7 @@ export const getConfiguredValue = (value: Record<string, any>, formSchemas: { va const getVarKindType = (type: FormTypeEnum) => { if (type === FormTypeEnum.file || type === FormTypeEnum.files) return VarKindType.variable - if (type === FormTypeEnum.select || type === FormTypeEnum.boolean || type === FormTypeEnum.textNumber) + if (type === FormTypeEnum.select || type === FormTypeEnum.checkbox || type === FormTypeEnum.textNumber) return VarKindType.constant if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) return VarKindType.mixed diff --git a/web/app/components/tools/workflow-tool/configure-button.tsx b/web/app/components/tools/workflow-tool/configure-button.tsx index 095ed369b2..bf0d789ff9 100644 --- a/web/app/components/tools/workflow-tool/configure-button.tsx +++ b/web/app/components/tools/workflow-tool/configure-button.tsx @@ -28,6 +28,7 @@ type Props = { inputs?: InputVar[] handlePublish: (params?: PublishWorkflowParams) => Promise<void> onRefreshData?: () => void + disabledReason?: string } const WorkflowToolConfigureButton = ({ @@ -41,6 +42,7 @@ const WorkflowToolConfigureButton = ({ inputs, handlePublish, onRefreshData, + disabledReason, }: Props) => { const { t } = useTranslation() const router = useRouter() @@ -200,7 +202,8 @@ const WorkflowToolConfigureButton = ({ {t('workflow.common.configureRequired')} </span> )} - </div>) + </div> + ) : ( <div className='flex items-center justify-start gap-2 p-2 pl-2.5' @@ -214,6 +217,11 @@ const WorkflowToolConfigureButton = ({ </div> </div> )} + {disabledReason && ( + <div className='mt-1 px-2.5 pb-2 text-xs leading-[18px] text-text-tertiary'> + {disabledReason} + </div> + )} {published && ( <div className='border-t-[0.5px] border-divider-regular px-2.5 py-2'> <div className='flex justify-between gap-x-2'> @@ -221,7 +229,7 @@ const WorkflowToolConfigureButton = ({ size='small' className='w-[140px]' onClick={() => setShowModal(true)} - disabled={!isCurrentWorkspaceManager} + disabled={!isCurrentWorkspaceManager || disabled} > {t('workflow.common.configure')} {outdated && <Indicator className='ml-1' color={'yellow'} />} @@ -230,14 +238,17 @@ const WorkflowToolConfigureButton = ({ size='small' className='w-[140px]' onClick={() => router.push('/tools?category=workflow')} + disabled={disabled} > {t('workflow.common.manageInTools')} <RiArrowRightUpLine className='ml-1 h-4 w-4' /> </Button> </div> - {outdated && <div className='mt-1 text-xs leading-[18px] text-text-warning'> - {t('workflow.common.workflowAsToolTip')} - </div>} + {outdated && ( + <div className='mt-1 text-xs leading-[18px] text-text-warning'> + {t('workflow.common.workflowAsToolTip')} + </div> + )} </div> )} </div> diff --git a/web/app/components/workflow-app/components/workflow-children.tsx b/web/app/components/workflow-app/components/workflow-children.tsx index af61e8a849..1c8ed0cdf9 100644 --- a/web/app/components/workflow-app/components/workflow-children.tsx +++ b/web/app/components/workflow-app/components/workflow-children.tsx @@ -1,19 +1,32 @@ import { memo, + useCallback, useState, } from 'react' import type { EnvironmentVariable } from '@/app/components/workflow/types' import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants' +import { START_INITIAL_POSITION } from '@/app/components/workflow/constants' +import { generateNewNode } from '@/app/components/workflow/utils' import { useStore } from '@/app/components/workflow/store' +import { useStoreApi } from 'reactflow' import PluginDependency from '../../workflow/plugin-dependency' import { + useAutoGenerateWebhookUrl, useDSL, usePanelInteractions, } from '@/app/components/workflow/hooks' +import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft' import { useEventEmitterContextContext } from '@/context/event-emitter' import WorkflowHeader from './workflow-header' import WorkflowPanel from './workflow-panel' import dynamic from 'next/dynamic' +import { BlockEnum } from '@/app/components/workflow/types' +import type { + PluginDefaultValue, + TriggerDefaultValue, +} from '@/app/components/workflow/block-selector/types' +import { useAutoOnboarding } from '../hooks/use-auto-onboarding' +import { useAvailableNodesMetaData } from '../hooks' const Features = dynamic(() => import('@/app/components/workflow/features'), { ssr: false, @@ -24,6 +37,34 @@ const UpdateDSLModal = dynamic(() => import('@/app/components/workflow/update-ds const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), { ssr: false, }) +const WorkflowOnboardingModal = dynamic(() => import('./workflow-onboarding-modal'), { + ssr: false, +}) + +const getTriggerPluginNodeData = ( + triggerConfig: TriggerDefaultValue, + fallbackTitle?: string, + fallbackDesc?: string, +) => { + return { + plugin_id: triggerConfig.plugin_id, + provider_id: triggerConfig.provider_name, + provider_type: triggerConfig.provider_type, + provider_name: triggerConfig.provider_name, + event_name: triggerConfig.event_name, + event_label: triggerConfig.event_label, + event_description: triggerConfig.event_description, + title: triggerConfig.event_label || triggerConfig.title || fallbackTitle, + desc: triggerConfig.event_description || fallbackDesc, + output_schema: { ...triggerConfig.output_schema }, + parameters_schema: triggerConfig.paramSchemas ? [...triggerConfig.paramSchemas] : [], + config: { ...triggerConfig.params }, + subscription_id: triggerConfig.subscription_id, + plugin_unique_identifier: triggerConfig.plugin_unique_identifier, + is_team_authorization: triggerConfig.is_team_authorization, + meta: triggerConfig.meta ? { ...triggerConfig.meta } : undefined, + } +} const WorkflowChildren = () => { const { eventEmitter } = useEventEmitterContextContext() @@ -31,6 +72,14 @@ const WorkflowChildren = () => { const showFeaturesPanel = useStore(s => s.showFeaturesPanel) const showImportDSLModal = useStore(s => s.showImportDSLModal) const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal) + const showOnboarding = useStore(s => s.showOnboarding) + const setShowOnboarding = useStore(s => s.setShowOnboarding) + const setHasSelectedStartNode = useStore(s => s.setHasSelectedStartNode) + const setShouldAutoOpenStartNodeSelector = useStore(s => s.setShouldAutoOpenStartNodeSelector) + const reactFlowStore = useStoreApi() + const availableNodesMetaData = useAvailableNodesMetaData() + const { handleSyncWorkflowDraft } = useNodesSyncDraft() + const { handleOnboardingClose } = useAutoOnboarding() const { handlePaneContextmenuCancel, } = usePanelInteractions() @@ -44,12 +93,84 @@ const WorkflowChildren = () => { setSecretEnvList(v.payload.data as EnvironmentVariable[]) }) + const autoGenerateWebhookUrl = useAutoGenerateWebhookUrl() + + const handleCloseOnboarding = useCallback(() => { + handleOnboardingClose() + }, [handleOnboardingClose]) + + const handleSelectStartNode = useCallback((nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => { + const nodeDefault = availableNodesMetaData.nodesMap?.[nodeType] + if (!nodeDefault?.defaultValue) + return + + const baseNodeData = { ...nodeDefault.defaultValue } + + const mergedNodeData = (() => { + if (nodeType !== BlockEnum.TriggerPlugin || !toolConfig) { + return { + ...baseNodeData, + ...toolConfig, + } + } + + const triggerNodeData = getTriggerPluginNodeData( + toolConfig as TriggerDefaultValue, + baseNodeData.title, + baseNodeData.desc, + ) + + return { + ...baseNodeData, + ...triggerNodeData, + config: { + ...(baseNodeData as { config?: Record<string, any> }).config, + ...triggerNodeData.config, + }, + } + })() + + const { newNode } = generateNewNode({ + data: { + ...mergedNodeData, + } as any, + position: START_INITIAL_POSITION, + }) + + const { setNodes, setEdges } = reactFlowStore.getState() + setNodes([newNode]) + setEdges([]) + + setShowOnboarding?.(false) + setHasSelectedStartNode?.(true) + setShouldAutoOpenStartNodeSelector?.(true) + + handleSyncWorkflowDraft(true, false, { + onSuccess: () => { + autoGenerateWebhookUrl(newNode.id) + console.log('Node successfully saved to draft') + }, + onError: () => { + console.error('Failed to save node to draft') + }, + }) + }, [availableNodesMetaData, setShowOnboarding, setHasSelectedStartNode, reactFlowStore, handleSyncWorkflowDraft]) + return ( <> <PluginDependency /> { showFeaturesPanel && <Features /> } + { + showOnboarding && ( + <WorkflowOnboardingModal + isShow={showOnboarding} + onClose={handleCloseOnboarding} + onSelectStartNode={handleSelectStartNode} + /> + ) + } { showImportDSLModal && ( <UpdateDSLModal diff --git a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx index 05b37c1469..d229006177 100644 --- a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx +++ b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx @@ -3,7 +3,7 @@ import { useCallback, useMemo, } from 'react' -import { useEdges, useNodes, useStore as useReactflowStore } from 'reactflow' +import { useEdges, useNodes } from 'reactflow' import { RiApps2AddLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { @@ -15,6 +15,7 @@ import { useChecklistBeforePublish, useNodesReadOnly, useNodesSyncDraft, + // useWorkflowRunValidation, } from '@/app/components/workflow/hooks' import Button from '@/app/components/base/button' import AppPublisher from '@/app/components/app/app-publisher' @@ -22,36 +23,44 @@ import { useFeatures } from '@/app/components/base/features/hooks' import type { CommonEdgeType, CommonNodeType, + Node, } from '@/app/components/workflow/types' import { BlockEnum, InputVarType, + isTriggerNode, } from '@/app/components/workflow/types' import { useToastContext } from '@/app/components/base/toast' import { useInvalidateAppWorkflow, usePublishWorkflow, useResetWorkflowVersionHistory } from '@/service/use-workflow' +import { useInvalidateAppTriggers } from '@/service/use-tools' import type { PublishWorkflowParams } from '@/types/workflow' import { fetchAppDetail } from '@/service/apps' import { useStore as useAppStore } from '@/app/components/app/store' import useTheme from '@/hooks/use-theme' import cn from '@/utils/classnames' +import { useIsChatMode } from '@/app/components/workflow/hooks' +import type { StartNodeType } from '@/app/components/workflow/nodes/start/types' const FeaturesTrigger = () => { const { t } = useTranslation() const { theme } = useTheme() + const isChatMode = useIsChatMode() const workflowStore = useWorkflowStore() const appDetail = useAppStore(s => s.appDetail) const appID = appDetail?.id const setAppDetail = useAppStore(s => s.setAppDetail) - const { - nodesReadOnly, - getNodesReadOnly, - } = useNodesReadOnly() + const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly() const publishedAt = useStore(s => s.publishedAt) const draftUpdatedAt = useStore(s => s.draftUpdatedAt) const toolPublished = useStore(s => s.toolPublished) - const startVariables = useReactflowStore( - s => s.getNodes().find(node => node.data.type === BlockEnum.Start)?.data.variables, - ) + const lastPublishedHasUserInput = useStore(s => s.lastPublishedHasUserInput) + + const nodes = useNodes<CommonNodeType>() + const hasWorkflowNodes = nodes.length > 0 + const startNode = nodes.find(node => node.data.type === BlockEnum.Start) + const startVariables = (startNode as Node<StartNodeType>)?.data?.variables + const edges = useEdges<CommonEdgeType>() + const fileSettings = useFeatures(s => s.features.file) const variables = useMemo(() => { const data = startVariables || [] @@ -73,6 +82,22 @@ const FeaturesTrigger = () => { const { handleCheckBeforePublish } = useChecklistBeforePublish() const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { notify } = useToastContext() + const startNodeIds = useMemo( + () => nodes.filter(node => node.data.type === BlockEnum.Start).map(node => node.id), + [nodes], + ) + const hasUserInputNode = useMemo(() => { + if (!startNodeIds.length) + return false + return edges.some(edge => startNodeIds.includes(edge.source)) + }, [edges, startNodeIds]) + // Track trigger presence so the publisher can adjust UI (e.g. hide missing start section). + const hasTriggerNode = useMemo(() => ( + nodes.some(node => isTriggerNode(node.data.type as BlockEnum)) + ), [nodes]) + + const resetWorkflowVersionHistory = useResetWorkflowVersionHistory() + const invalidateAppTriggers = useInvalidateAppTriggers() const handleShowFeatures = useCallback(() => { const { @@ -85,8 +110,6 @@ const FeaturesTrigger = () => { setShowFeaturesPanel(!showFeaturesPanel) }, [workflowStore, getNodesReadOnly]) - const resetWorkflowVersionHistory = useResetWorkflowVersionHistory() - const updateAppDetail = useCallback(async () => { try { const res = await fetchAppDetail({ url: '/apps', id: appID! }) @@ -96,14 +119,17 @@ const FeaturesTrigger = () => { console.error(error) } }, [appID, setAppDetail]) + const { mutateAsync: publishWorkflow } = usePublishWorkflow() - const nodes = useNodes<CommonNodeType>() - const edges = useEdges<CommonEdgeType>() + // const { validateBeforeRun } = useWorkflowRunValidation() const needWarningNodes = useChecklist(nodes, edges) const updatePublishedWorkflow = useInvalidateAppWorkflow() const onPublish = useCallback(async (params?: PublishWorkflowParams) => { // First check if there are any items in the checklist + // if (!validateBeforeRun()) + // throw new Error('Checklist has unresolved items') + if (needWarningNodes.length > 0) { notify({ type: 'error', message: t('workflow.panel.checklistTip') }) throw new Error('Checklist has unresolved items') @@ -121,14 +147,16 @@ const FeaturesTrigger = () => { notify({ type: 'success', message: t('common.api.actionSuccess') }) updatePublishedWorkflow(appID!) updateAppDetail() + invalidateAppTriggers(appID!) workflowStore.getState().setPublishedAt(res.created_at) + workflowStore.getState().setLastPublishedHasUserInput(hasUserInputNode) resetWorkflowVersionHistory() } } else { throw new Error('Checklist failed') } - }, [needWarningNodes, handleCheckBeforePublish, publishWorkflow, notify, appID, t, updatePublishedWorkflow, updateAppDetail, workflowStore, resetWorkflowVersionHistory]) + }, [needWarningNodes, handleCheckBeforePublish, publishWorkflow, notify, appID, t, updatePublishedWorkflow, updateAppDetail, workflowStore, resetWorkflowVersionHistory, invalidateAppTriggers]) const onPublisherToggle = useCallback((state: boolean) => { if (state) @@ -141,27 +169,34 @@ const FeaturesTrigger = () => { return ( <> - <Button - className={cn( - 'text-components-button-secondary-text', - theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm', - )} - onClick={handleShowFeatures} - > - <RiApps2AddLine className='mr-1 h-4 w-4 text-components-button-secondary-text' /> - {t('workflow.common.features')} - </Button> + {/* Feature button is only visible in chatflow mode (advanced-chat) */} + {isChatMode && ( + <Button + className={cn( + 'text-components-button-secondary-text', + theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm', + )} + onClick={handleShowFeatures} + > + <RiApps2AddLine className='mr-1 h-4 w-4 text-components-button-secondary-text' /> + {t('workflow.common.features')} + </Button> + )} <AppPublisher {...{ publishedAt, draftUpdatedAt, - disabled: nodesReadOnly, + disabled: nodesReadOnly || !hasWorkflowNodes, toolPublished, inputs: variables, onRefreshData: handleToolConfigureUpdate, onPublish, onToggle: onPublisherToggle, + workflowToolAvailable: lastPublishedHasUserInput, crossAxisOffset: 4, + missingStartNode: !startNode, + hasTriggerNode, + publishDisabled: !hasWorkflowNodes, }} /> </> diff --git a/web/app/components/workflow-app/components/workflow-header/index.tsx b/web/app/components/workflow-app/components/workflow-header/index.tsx index 53a050146e..c0b8a37b87 100644 --- a/web/app/components/workflow-app/components/workflow-header/index.tsx +++ b/web/app/components/workflow-app/components/workflow-header/index.tsx @@ -41,8 +41,8 @@ const WorkflowHeader = () => { return { normal: { components: { - left: <ChatVariableTrigger />, middle: <FeaturesTrigger />, + chatVariableTrigger: <ChatVariableTrigger />, }, runAndHistoryProps: { showRunButton: !isChatMode, diff --git a/web/app/components/workflow-app/components/workflow-main.tsx b/web/app/components/workflow-app/components/workflow-main.tsx index f979a12f26..e90b2904c9 100644 --- a/web/app/components/workflow-app/components/workflow-main.tsx +++ b/web/app/components/workflow-app/components/workflow-main.tsx @@ -66,6 +66,10 @@ const WorkflowMain = ({ handleStartWorkflowRun, handleWorkflowStartRunInChatflow, handleWorkflowStartRunInWorkflow, + handleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow, } = useWorkflowStartRun() const availableNodesMetaData = useAvailableNodesMetaData() const { getWorkflowRunAndTraceUrl } = useGetRunAndTraceUrl() @@ -108,6 +112,10 @@ const WorkflowMain = ({ handleStartWorkflowRun, handleWorkflowStartRunInChatflow, handleWorkflowStartRunInWorkflow, + handleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow, availableNodesMetaData, getWorkflowRunAndTraceUrl, exportCheck, @@ -141,6 +149,10 @@ const WorkflowMain = ({ handleStartWorkflowRun, handleWorkflowStartRunInChatflow, handleWorkflowStartRunInWorkflow, + handleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow, availableNodesMetaData, getWorkflowRunAndTraceUrl, exportCheck, diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx new file mode 100644 index 0000000000..747a232ca7 --- /dev/null +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx @@ -0,0 +1,99 @@ +'use client' +import type { FC } from 'react' +import { + useCallback, + useEffect, +} from 'react' +import { useTranslation } from 'react-i18next' +import { BlockEnum } from '@/app/components/workflow/types' +import type { PluginDefaultValue } from '@/app/components/workflow/block-selector/types' +import Modal from '@/app/components/base/modal' +import StartNodeSelectionPanel from './start-node-selection-panel' +import { useDocLink } from '@/context/i18n' + +type WorkflowOnboardingModalProps = { + isShow: boolean + onClose: () => void + onSelectStartNode: (nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => void +} + +const WorkflowOnboardingModal: FC<WorkflowOnboardingModalProps> = ({ + isShow, + onClose, + onSelectStartNode, +}) => { + const { t } = useTranslation() + const docLink = useDocLink() + + const handleSelectUserInput = useCallback(() => { + onSelectStartNode(BlockEnum.Start) + onClose() // Close modal after selection + }, [onSelectStartNode, onClose]) + + const handleTriggerSelect = useCallback((nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => { + onSelectStartNode(nodeType, toolConfig) + onClose() // Close modal after selection + }, [onSelectStartNode, onClose]) + + useEffect(() => { + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isShow) + onClose() + } + document.addEventListener('keydown', handleEsc) + return () => document.removeEventListener('keydown', handleEsc) + }, [isShow, onClose]) + + return ( + <> + <Modal + isShow={isShow} + onClose={onClose} + className="w-[618px] max-w-[618px] rounded-2xl border border-effects-highlight bg-background-default-subtle shadow-lg" + overlayOpacity + closable + clickOutsideNotClose + > + <div className="pb-4"> + {/* Header */} + <div className="mb-6"> + <h3 className="title-2xl-semi-bold mb-2 text-text-primary"> + {t('workflow.onboarding.title')} + </h3> + <div className="body-xs-regular leading-4 text-text-tertiary"> + {t('workflow.onboarding.description')}{' '} + <a + href={docLink('/guides/workflow/node/start')} + target="_blank" + rel="noopener noreferrer" + className="hover:text-text-accent-hover cursor-pointer text-text-accent underline" + > + {t('workflow.onboarding.learnMore')} + </a>{' '} + {t('workflow.onboarding.aboutStartNode')} + </div> + </div> + + {/* Content */} + <StartNodeSelectionPanel + onSelectUserInput={handleSelectUserInput} + onSelectTrigger={handleTriggerSelect} + /> + </div> + </Modal> + + {/* ESC tip below modal */} + {isShow && ( + <div className="body-xs-regular pointer-events-none fixed left-1/2 top-1/2 z-[70] flex -translate-x-1/2 translate-y-[165px] items-center gap-1 text-text-quaternary"> + <span>{t('workflow.onboarding.escTip.press')}</span> + <kbd className="system-kbd inline-flex h-4 min-w-4 items-center justify-center rounded bg-components-kbd-bg-gray px-1 text-text-tertiary"> + {t('workflow.onboarding.escTip.key')} + </kbd> + <span>{t('workflow.onboarding.escTip.toDismiss')}</span> + </div> + )} + </> + ) +} + +export default WorkflowOnboardingModal diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx new file mode 100644 index 0000000000..e28de39fdd --- /dev/null +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx @@ -0,0 +1,53 @@ +'use client' +import type { FC, ReactNode } from 'react' +import cn from '@/utils/classnames' + +type StartNodeOptionProps = { + icon: ReactNode + title: string + subtitle?: string + description: string + onClick: () => void +} + +const StartNodeOption: FC<StartNodeOptionProps> = ({ + icon, + title, + subtitle, + description, + onClick, +}) => { + return ( + <div + onClick={onClick} + className={cn( + 'hover:border-components-panel-border-active flex h-40 w-[280px] cursor-pointer flex-col gap-2 rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-4 shadow-sm transition-all hover:shadow-md', + )} + > + {/* Icon */} + <div className="shrink-0"> + {icon} + </div> + + {/* Text content */} + <div className="flex h-[74px] flex-col gap-1 py-0.5"> + <div className="h-5 leading-5"> + <h3 className="system-md-semi-bold text-text-primary"> + {title} + {subtitle && ( + <span className="system-md-regular text-text-quaternary"> {subtitle}</span> + )} + </h3> + </div> + + <div className="h-12 leading-4"> + <p className="system-xs-regular text-text-tertiary"> + {description} + </p> + </div> + </div> + </div> + ) +} + +export default StartNodeOption diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx new file mode 100644 index 0000000000..de934a13b2 --- /dev/null +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx @@ -0,0 +1,80 @@ +'use client' +import type { FC } from 'react' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import StartNodeOption from './start-node-option' +import NodeSelector from '@/app/components/workflow/block-selector' +import { Home } from '@/app/components/base/icons/src/vender/workflow' +import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow' +import { BlockEnum } from '@/app/components/workflow/types' +import type { PluginDefaultValue } from '@/app/components/workflow/block-selector/types' +import { TabsEnum } from '@/app/components/workflow/block-selector/types' + +type StartNodeSelectionPanelProps = { + onSelectUserInput: () => void + onSelectTrigger: (nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => void +} + +const StartNodeSelectionPanel: FC<StartNodeSelectionPanelProps> = ({ + onSelectUserInput, + onSelectTrigger, +}) => { + const { t } = useTranslation() + const [showTriggerSelector, setShowTriggerSelector] = useState(false) + + const handleTriggerClick = useCallback(() => { + setShowTriggerSelector(true) + }, []) + + const handleTriggerSelect = useCallback((nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => { + setShowTriggerSelector(false) + onSelectTrigger(nodeType, toolConfig) + }, [onSelectTrigger]) + + return ( + <div className="grid grid-cols-2 gap-4"> + <StartNodeOption + icon={ + <div className="flex h-9 w-9 items-center justify-center rounded-[10px] border-[0.5px] border-transparent bg-util-colors-blue-brand-blue-brand-500 p-2"> + <Home className="h-5 w-5 text-white" /> + </div> + } + title={t('workflow.onboarding.userInputFull')} + description={t('workflow.onboarding.userInputDescription')} + onClick={onSelectUserInput} + /> + + <NodeSelector + open={showTriggerSelector} + onOpenChange={setShowTriggerSelector} + onSelect={handleTriggerSelect} + placement="right" + offset={-200} + noBlocks={true} + showStartTab={true} + defaultActiveTab={TabsEnum.Start} + forceShowStartContent={true} + availableBlocksTypes={[ + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, + ]} + trigger={() => ( + <StartNodeOption + icon={ + <div className="flex h-9 w-9 items-center justify-center rounded-[10px] border-[0.5px] border-transparent bg-util-colors-blue-brand-blue-brand-500 p-2"> + <TriggerAll className="h-5 w-5 text-white" /> + </div> + } + title={t('workflow.onboarding.trigger')} + description={t('workflow.onboarding.triggerDescription')} + onClick={handleTriggerClick} + /> + )} + popupClassName="z-[1200]" + /> + </div> + ) +} + +export default StartNodeSelectionPanel diff --git a/web/app/components/workflow-app/hooks/use-auto-onboarding.ts b/web/app/components/workflow-app/hooks/use-auto-onboarding.ts new file mode 100644 index 0000000000..e4f5774adf --- /dev/null +++ b/web/app/components/workflow-app/hooks/use-auto-onboarding.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect } from 'react' +import { useStoreApi } from 'reactflow' +import { useWorkflowStore } from '@/app/components/workflow/store' + +export const useAutoOnboarding = () => { + const store = useStoreApi() + const workflowStore = useWorkflowStore() + + const checkAndShowOnboarding = useCallback(() => { + const { getNodes } = store.getState() + const { + showOnboarding, + hasShownOnboarding, + notInitialWorkflow, + setShowOnboarding, + setHasShownOnboarding, + setShouldAutoOpenStartNodeSelector, + } = workflowStore.getState() + + // Skip if already showing onboarding or it's the initial workflow creation + if (showOnboarding || notInitialWorkflow) + return + + const nodes = getNodes() + + // Check if canvas is completely empty (no nodes at all) + // Only trigger onboarding when canvas is completely blank to avoid data loss + const isCompletelyEmpty = nodes.length === 0 + + // Show onboarding only if canvas is completely empty and we haven't shown it before in this session + if (isCompletelyEmpty && !hasShownOnboarding) { + setShowOnboarding?.(true) + setHasShownOnboarding?.(true) + setShouldAutoOpenStartNodeSelector?.(true) + } + }, [store, workflowStore]) + + const handleOnboardingClose = useCallback(() => { + const { + setShowOnboarding, + setHasShownOnboarding, + setShouldAutoOpenStartNodeSelector, + hasSelectedStartNode, + setHasSelectedStartNode, + } = workflowStore.getState() + setShowOnboarding?.(false) + setHasShownOnboarding?.(true) + if (hasSelectedStartNode) + setHasSelectedStartNode?.(false) + else + setShouldAutoOpenStartNodeSelector?.(false) + }, [workflowStore]) + + // Check on mount and when nodes change + useEffect(() => { + // Small delay to ensure the workflow data is loaded + const timer = setTimeout(() => { + checkAndShowOnboarding() + }, 500) + + return () => clearTimeout(timer) + }, [checkAndShowOnboarding]) + + return { + checkAndShowOnboarding, + handleOnboardingClose, + } +} diff --git a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts index ba51b06401..aefcd33102 100644 --- a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts +++ b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts @@ -1,7 +1,10 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useGetLanguage } from '@/context/i18n' +import { useDocLink } from '@/context/i18n' import StartDefault from '@/app/components/workflow/nodes/start/default' +import TriggerWebhookDefault from '@/app/components/workflow/nodes/trigger-webhook/default' +import TriggerScheduleDefault from '@/app/components/workflow/nodes/trigger-schedule/default' +import TriggerPluginDefault from '@/app/components/workflow/nodes/trigger-plugin/default' import EndDefault from '@/app/components/workflow/nodes/end/default' import AnswerDefault from '@/app/components/workflow/nodes/answer/default' import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node' @@ -12,7 +15,7 @@ import { BlockEnum } from '@/app/components/workflow/types' export const useAvailableNodesMetaData = () => { const { t } = useTranslation() const isChatMode = useIsChatMode() - const language = useGetLanguage() + const docLink = useDocLink() const mergedNodesMetaData = useMemo(() => [ ...WORKFLOW_COMMON_NODES, @@ -20,28 +23,27 @@ export const useAvailableNodesMetaData = () => { ...( isChatMode ? [AnswerDefault] - : [EndDefault] + : [ + EndDefault, + TriggerWebhookDefault, + TriggerScheduleDefault, + TriggerPluginDefault, + ] ), ], [isChatMode]) - const prefixLink = useMemo(() => { - if (language === 'zh_Hans') - return 'https://docs.dify.ai/zh-hans/guides/workflow/node/' - - return 'https://docs.dify.ai/guides/workflow/node/' - }, [language]) - const availableNodesMetaData = useMemo(() => mergedNodesMetaData.map((node) => { const { metaData } = node const title = t(`workflow.blocks.${metaData.type}`) const description = t(`workflow.blocksAbout.${metaData.type}`) + const helpLinkPath = `guides/workflow/node/${metaData.helpLinkUri}` return { ...node, metaData: { ...metaData, title, description, - helpLinkUri: `${prefixLink}${metaData.helpLinkUri}`, + helpLinkUri: docLink(helpLinkPath), }, defaultValue: { ...node.defaultValue, @@ -49,7 +51,7 @@ export const useAvailableNodesMetaData = () => { title, }, } - }), [mergedNodesMetaData, t, prefixLink]) + }), [mergedNodesMetaData, t, docLink]) const availableNodesMetaDataMap = useMemo(() => availableNodesMetaData.reduce((acc, node) => { acc![node.metaData.type] = node diff --git a/web/app/components/workflow-app/hooks/use-is-chat-mode.ts b/web/app/components/workflow-app/hooks/use-is-chat-mode.ts index 3cdfc77b2a..d286c1a540 100644 --- a/web/app/components/workflow-app/hooks/use-is-chat-mode.ts +++ b/web/app/components/workflow-app/hooks/use-is-chat-mode.ts @@ -1,7 +1,8 @@ import { useStore as useAppStore } from '@/app/components/app/store' +import { AppModeEnum } from '@/types/app' export const useIsChatMode = () => { const appDetail = useAppStore(s => s.appDetail) - return appDetail?.mode === 'advanced-chat' + return appDetail?.mode === AppModeEnum.ADVANCED_CHAT } diff --git a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts index c1f40c9d8c..56d9021feb 100644 --- a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts @@ -1,14 +1,9 @@ import { useCallback } from 'react' import { produce } from 'immer' import { useStoreApi } from 'reactflow' -import { useParams } from 'next/navigation' -import { - useWorkflowStore, -} from '@/app/components/workflow/store' -import { BlockEnum } from '@/app/components/workflow/types' -import { - useNodesReadOnly, -} from '@/app/components/workflow/hooks/use-workflow' +import { useWorkflowStore } from '@/app/components/workflow/store' +import { useNodesReadOnly } from '@/app/components/workflow/hooks/use-workflow' +import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback' import { syncWorkflowDraft } from '@/service/workflow' import { useFeaturesStore } from '@/app/components/base/features/hooks' import { API_PREFIX } from '@/config' @@ -20,7 +15,6 @@ export const useNodesSyncDraft = () => { const featuresStore = useFeaturesStore() const { getNodesReadOnly } = useNodesReadOnly() const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft() - const params = useParams() const getPostParams = useCallback(() => { const { @@ -28,65 +22,60 @@ export const useNodesSyncDraft = () => { edges, transform, } = store.getState() - const nodes = getNodes() + const nodes = getNodes().filter(node => !node.data?._isTempNode) const [x, y, zoom] = transform const { appId, conversationVariables, environmentVariables, syncWorkflowDraftHash, + isWorkflowDataLoaded, } = workflowStore.getState() - if (appId && !!nodes.length) { - const hasStartNode = nodes.find(node => node.data.type === BlockEnum.Start) + if (!appId || !isWorkflowDataLoaded) + return null - if (!hasStartNode) - return - - const features = featuresStore!.getState().features - const producedNodes = produce(nodes, (draft) => { - draft.forEach((node) => { - Object.keys(node.data).forEach((key) => { - if (key.startsWith('_')) - delete node.data[key] - }) + const features = featuresStore!.getState().features + const producedNodes = produce(nodes, (draft) => { + draft.forEach((node) => { + Object.keys(node.data).forEach((key) => { + if (key.startsWith('_')) + delete node.data[key] }) }) - const producedEdges = produce(edges.filter(edge => !edge.data?._isTemp), (draft) => { - draft.forEach((edge) => { - Object.keys(edge.data).forEach((key) => { - if (key.startsWith('_')) - delete edge.data[key] - }) + }) + const producedEdges = produce(edges.filter(edge => !edge.data?._isTemp), (draft) => { + draft.forEach((edge) => { + Object.keys(edge.data).forEach((key) => { + if (key.startsWith('_')) + delete edge.data[key] }) }) - return { - url: `/apps/${appId}/workflows/draft`, - params: { - graph: { - nodes: producedNodes, - edges: producedEdges, - viewport: { - x, - y, - zoom, - }, - }, - features: { - opening_statement: features.opening?.enabled ? (features.opening?.opening_statement || '') : '', - suggested_questions: features.opening?.enabled ? (features.opening?.suggested_questions || []) : [], - suggested_questions_after_answer: features.suggested, - text_to_speech: features.text2speech, - speech_to_text: features.speech2text, - retriever_resource: features.citation, - sensitive_word_avoidance: features.moderation, - file_upload: features.file, - }, - environment_variables: environmentVariables, - conversation_variables: conversationVariables, - hash: syncWorkflowDraftHash, + }) + const viewport = { x, y, zoom } + + return { + url: `/apps/${appId}/workflows/draft`, + params: { + graph: { + nodes: producedNodes, + edges: producedEdges, + viewport, }, - } + features: { + opening_statement: features.opening?.enabled ? (features.opening?.opening_statement || '') : '', + suggested_questions: features.opening?.enabled ? (features.opening?.suggested_questions || []) : [], + suggested_questions_after_answer: features.suggested, + text_to_speech: features.text2speech, + speech_to_text: features.speech2text, + retriever_resource: features.citation, + sensitive_word_avoidance: features.moderation, + file_upload: features.file, + }, + environment_variables: environmentVariables, + conversation_variables: conversationVariables, + hash: syncWorkflowDraftHash, + }, } }, [store, featuresStore, workflowStore]) @@ -95,15 +84,11 @@ export const useNodesSyncDraft = () => { return const postParams = getPostParams() - if (postParams) { - navigator.sendBeacon( - `${API_PREFIX}/apps/${params.appId}/workflows/draft`, - JSON.stringify(postParams.params), - ) - } - }, [getPostParams, params.appId, getNodesReadOnly]) + if (postParams) + navigator.sendBeacon(`${API_PREFIX}${postParams.url}`, JSON.stringify(postParams.params)) + }, [getPostParams, getNodesReadOnly]) - const doSyncWorkflowDraft = useCallback(async ( + const performSync = useCallback(async ( notRefreshWhenSyncError?: boolean, callback?: { onSuccess?: () => void @@ -141,6 +126,8 @@ export const useNodesSyncDraft = () => { } }, [workflowStore, getPostParams, getNodesReadOnly, handleRefreshWorkflowDraft]) + const doSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly) + return { doSyncWorkflowDraft, syncWorkflowDraftWhenPageClose, diff --git a/web/app/components/workflow-app/hooks/use-workflow-init.ts b/web/app/components/workflow-app/hooks/use-workflow-init.ts index fadd2007bc..a0a6cc22a1 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-init.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-init.ts @@ -18,7 +18,20 @@ import { import type { FetchWorkflowDraftResponse } from '@/types/workflow' import { useWorkflowConfig } from '@/service/use-workflow' import type { FileUploadConfigResponse } from '@/models/common' +import type { Edge, Node } from '@/app/components/workflow/types' +import { BlockEnum } from '@/app/components/workflow/types' +import { AppModeEnum } from '@/types/app' +const hasConnectedUserInput = (nodes: Node[] = [], edges: Edge[] = []): boolean => { + const startNodeIds = nodes + .filter(node => node?.data?.type === BlockEnum.Start) + .map(node => node.id) + + if (!startNodeIds.length) + return false + + return edges.some(edge => startNodeIds.includes(edge.source)) +} export const useWorkflowInit = () => { const workflowStore = useWorkflowStore() const { @@ -53,6 +66,7 @@ export const useWorkflowInit = () => { }, {} as Record<string, string>), environmentVariables: res.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || [], conversationVariables: res.conversation_variables || [], + isWorkflowDataLoaded: true, }) setSyncWorkflowDraftHash(res.hash) setIsLoading(false) @@ -61,13 +75,22 @@ export const useWorkflowInit = () => { if (error && error.json && !error.bodyUsed && appDetail) { error.json().then((err: any) => { if (err.code === 'draft_workflow_not_exist') { - workflowStore.setState({ notInitialWorkflow: true }) + const isAdvancedChat = appDetail.mode === AppModeEnum.ADVANCED_CHAT + workflowStore.setState({ + notInitialWorkflow: true, + showOnboarding: !isAdvancedChat, + shouldAutoOpenStartNodeSelector: !isAdvancedChat, + hasShownOnboarding: false, + }) + const nodesData = isAdvancedChat ? nodesTemplate : [] + const edgesData = isAdvancedChat ? edgesTemplate : [] + syncWorkflowDraft({ url: `/apps/${appDetail.id}/workflows/draft`, params: { graph: { - nodes: nodesTemplate, - edges: edgesTemplate, + nodes: nodesData, + edges: edgesData, }, features: { retriever_resource: { enabled: true }, @@ -101,9 +124,14 @@ export const useWorkflowInit = () => { }, {} as Record<string, any>), }) workflowStore.getState().setPublishedAt(publishedWorkflow?.created_at) + const graph = publishedWorkflow?.graph + workflowStore.getState().setLastPublishedHasUserInput( + hasConnectedUserInput(graph?.nodes, graph?.edges), + ) } catch (e) { console.error(e) + workflowStore.getState().setLastPublishedHasUserInput(false) } }, [workflowStore, appDetail]) diff --git a/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts b/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts index c944e10c4c..910ddd4a8d 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts @@ -16,18 +16,43 @@ export const useWorkflowRefreshDraft = () => { setEnvironmentVariables, setEnvSecrets, setConversationVariables, + setIsWorkflowDataLoaded, + isWorkflowDataLoaded, + debouncedSyncWorkflowDraft, } = workflowStore.getState() + + if (debouncedSyncWorkflowDraft && typeof (debouncedSyncWorkflowDraft as any).cancel === 'function') + (debouncedSyncWorkflowDraft as any).cancel() + + const wasLoaded = isWorkflowDataLoaded + if (wasLoaded) + setIsWorkflowDataLoaded(false) setIsSyncingWorkflowDraft(true) - fetchWorkflowDraft(`/apps/${appId}/workflows/draft`).then((response) => { - handleUpdateWorkflowCanvas(response.graph as WorkflowDataUpdater) - setSyncWorkflowDraftHash(response.hash) - setEnvSecrets((response.environment_variables || []).filter(env => env.value_type === 'secret').reduce((acc, env) => { - acc[env.id] = env.value - return acc - }, {} as Record<string, string>)) - setEnvironmentVariables(response.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || []) - setConversationVariables(response.conversation_variables || []) - }).finally(() => setIsSyncingWorkflowDraft(false)) + fetchWorkflowDraft(`/apps/${appId}/workflows/draft`) + .then((response) => { + // Ensure we have a valid workflow structure with viewport + const workflowData: WorkflowDataUpdater = { + nodes: response.graph?.nodes || [], + edges: response.graph?.edges || [], + viewport: response.graph?.viewport || { x: 0, y: 0, zoom: 1 }, + } + handleUpdateWorkflowCanvas(workflowData) + setSyncWorkflowDraftHash(response.hash) + setEnvSecrets((response.environment_variables || []).filter(env => env.value_type === 'secret').reduce((acc, env) => { + acc[env.id] = env.value + return acc + }, {} as Record<string, string>)) + setEnvironmentVariables(response.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || []) + setConversationVariables(response.conversation_variables || []) + setIsWorkflowDataLoaded(true) + }) + .catch(() => { + if (wasLoaded) + setIsWorkflowDataLoaded(true) + }) + .finally(() => { + setIsSyncingWorkflowDraft(false) + }) }, [handleUpdateWorkflowCanvas, workflowStore]) return { diff --git a/web/app/components/workflow-app/hooks/use-workflow-run.ts b/web/app/components/workflow-app/hooks/use-workflow-run.ts index 0cfcd6099b..3ab1c522e7 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-run.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-run.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import { useReactFlow, useStoreApi, @@ -12,7 +12,8 @@ import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow- import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event' import { useStore as useAppStore } from '@/app/components/app/store' import type { IOtherOptions } from '@/service/base' -import { ssePost } from '@/service/base' +import Toast from '@/app/components/base/toast' +import { handleStream, ssePost } from '@/service/base' import { stopWorkflowRun } from '@/service/workflow' import { useFeaturesStore } from '@/app/components/base/features/hooks' import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager' @@ -22,6 +23,35 @@ import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useInvalidAllLastRun } from '@/service/use-workflow' import { useSetWorkflowVarsWithValue } from '../../workflow/hooks/use-fetch-workflow-inspect-vars' import { useConfigsMap } from './use-configs-map' +import { post } from '@/service/base' +import { ContentType } from '@/service/fetch' +import { TriggerType } from '@/app/components/workflow/header/test-run-menu' +import { AppModeEnum } from '@/types/app' + +type HandleRunMode = TriggerType +type HandleRunOptions = { + mode?: HandleRunMode + scheduleNodeId?: string + webhookNodeId?: string + pluginNodeId?: string + allNodeIds?: string[] +} + +type DebuggableTriggerType = Exclude<TriggerType, TriggerType.UserInput> + +const controllerKeyMap: Record<DebuggableTriggerType, string> = { + [TriggerType.Webhook]: '__webhookDebugAbortController', + [TriggerType.Plugin]: '__pluginDebugAbortController', + [TriggerType.All]: '__allTriggersDebugAbortController', + [TriggerType.Schedule]: '__scheduleDebugAbortController', +} + +const debugLabelMap: Record<DebuggableTriggerType, string> = { + [TriggerType.Webhook]: 'Webhook', + [TriggerType.Plugin]: 'Plugin', + [TriggerType.All]: 'All', + [TriggerType.Schedule]: 'Schedule', +} export const useWorkflowRun = () => { const store = useStoreApi() @@ -39,6 +69,8 @@ export const useWorkflowRun = () => { ...configsMap, }) + const abortControllerRef = useRef<AbortController | null>(null) + const { handleWorkflowStarted, handleWorkflowFinished, @@ -111,7 +143,10 @@ export const useWorkflowRun = () => { const handleRun = useCallback(async ( params: any, callback?: IOtherOptions, + options?: HandleRunOptions, ) => { + const runMode: HandleRunMode = options?.mode ?? TriggerType.UserInput + const resolvedParams = params ?? {} const { getNodes, setNodes, @@ -139,6 +174,7 @@ export const useWorkflowRun = () => { onNodeRetry, onAgentLog, onError, + onCompleted, ...restCallback } = callback || {} workflowStore.setState({ historyWorkflowData: undefined }) @@ -150,175 +186,531 @@ export const useWorkflowRun = () => { clientHeight, } = workflowContainer! - const isInWorkflowDebug = appDetail?.mode === 'workflow' + const isInWorkflowDebug = appDetail?.mode === AppModeEnum.WORKFLOW let url = '' - if (appDetail?.mode === 'advanced-chat') + if (runMode === TriggerType.Plugin || runMode === TriggerType.Webhook || runMode === TriggerType.Schedule) { + if (!appDetail?.id) { + console.error('handleRun: missing app id for trigger plugin run') + return + } + url = `/apps/${appDetail.id}/workflows/draft/trigger/run` + } + else if (runMode === TriggerType.All) { + if (!appDetail?.id) { + console.error('handleRun: missing app id for trigger run all') + return + } + url = `/apps/${appDetail.id}/workflows/draft/trigger/run-all` + } + else if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) { url = `/apps/${appDetail.id}/advanced-chat/workflows/draft/run` - - if (isInWorkflowDebug) + } + else if (isInWorkflowDebug && appDetail?.id) { url = `/apps/${appDetail.id}/workflows/draft/run` + } + + let requestBody = {} + + if (runMode === TriggerType.Schedule) + requestBody = { node_id: options?.scheduleNodeId } + + else if (runMode === TriggerType.Webhook) + requestBody = { node_id: options?.webhookNodeId } + + else if (runMode === TriggerType.Plugin) + requestBody = { node_id: options?.pluginNodeId } + + else if (runMode === TriggerType.All) + requestBody = { node_ids: options?.allNodeIds } + + else + requestBody = resolvedParams + + if (!url) + return + + if (runMode === TriggerType.Schedule && !options?.scheduleNodeId) { + console.error('handleRun: schedule trigger run requires node id') + return + } + + if (runMode === TriggerType.Webhook && !options?.webhookNodeId) { + console.error('handleRun: webhook trigger run requires node id') + return + } + + if (runMode === TriggerType.Plugin && !options?.pluginNodeId) { + console.error('handleRun: plugin trigger run requires node id') + return + } + + if (runMode === TriggerType.All && !options?.allNodeIds && options?.allNodeIds?.length === 0) { + console.error('handleRun: all trigger run requires node ids') + return + } + + abortControllerRef.current?.abort() + abortControllerRef.current = null const { setWorkflowRunningData, + setIsListening, + setShowVariableInspectPanel, + setListeningTriggerType, + setListeningTriggerNodeIds, + setListeningTriggerIsAll, + setListeningTriggerNodeId, } = workflowStore.getState() - setWorkflowRunningData({ - result: { - inputs_truncated: false, - process_data_truncated: false, - outputs_truncated: false, - status: WorkflowRunningStatus.Running, - }, - tracing: [], - resultText: '', - }) + + if ( + runMode === TriggerType.Webhook + || runMode === TriggerType.Plugin + || runMode === TriggerType.All + || runMode === TriggerType.Schedule + ) { + setIsListening(true) + setShowVariableInspectPanel(true) + setListeningTriggerIsAll(runMode === TriggerType.All) + if (runMode === TriggerType.All) + setListeningTriggerNodeIds(options?.allNodeIds ?? []) + else if (runMode === TriggerType.Webhook && options?.webhookNodeId) + setListeningTriggerNodeIds([options.webhookNodeId]) + else if (runMode === TriggerType.Schedule && options?.scheduleNodeId) + setListeningTriggerNodeIds([options.scheduleNodeId]) + else if (runMode === TriggerType.Plugin && options?.pluginNodeId) + setListeningTriggerNodeIds([options.pluginNodeId]) + else + setListeningTriggerNodeIds([]) + setWorkflowRunningData({ + result: { + status: WorkflowRunningStatus.Running, + inputs_truncated: false, + process_data_truncated: false, + outputs_truncated: false, + }, + tracing: [], + resultText: '', + }) + } + else { + setIsListening(false) + setListeningTriggerType(null) + setListeningTriggerNodeId(null) + setListeningTriggerNodeIds([]) + setListeningTriggerIsAll(false) + setWorkflowRunningData({ + result: { + status: WorkflowRunningStatus.Running, + inputs_truncated: false, + process_data_truncated: false, + outputs_truncated: false, + }, + tracing: [], + resultText: '', + }) + } let ttsUrl = '' let ttsIsPublic = false - if (params.token) { + if (resolvedParams.token) { ttsUrl = '/text-to-audio' ttsIsPublic = true } - else if (params.appId) { + else if (resolvedParams.appId) { if (pathname.search('explore/installed') > -1) - ttsUrl = `/installed-apps/${params.appId}/text-to-audio` + ttsUrl = `/installed-apps/${resolvedParams.appId}/text-to-audio` else - ttsUrl = `/apps/${params.appId}/text-to-audio` + ttsUrl = `/apps/${resolvedParams.appId}/text-to-audio` } const player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', noop) + const clearAbortController = () => { + abortControllerRef.current = null + delete (window as any).__webhookDebugAbortController + delete (window as any).__pluginDebugAbortController + delete (window as any).__scheduleDebugAbortController + delete (window as any).__allTriggersDebugAbortController + } + + const clearListeningState = () => { + const state = workflowStore.getState() + state.setIsListening(false) + state.setListeningTriggerType(null) + state.setListeningTriggerNodeId(null) + state.setListeningTriggerNodeIds([]) + state.setListeningTriggerIsAll(false) + } + + const wrappedOnError = (params: any) => { + clearAbortController() + handleWorkflowFailed() + clearListeningState() + + if (onError) + onError(params) + } + + const wrappedOnCompleted: IOtherOptions['onCompleted'] = async (hasError?: boolean, errorMessage?: string) => { + clearAbortController() + clearListeningState() + if (onCompleted) + onCompleted(hasError, errorMessage) + } + + const baseSseOptions: IOtherOptions = { + ...restCallback, + onWorkflowStarted: (params) => { + const state = workflowStore.getState() + if (state.workflowRunningData) { + state.setWorkflowRunningData(produce(state.workflowRunningData, (draft) => { + draft.resultText = '' + })) + } + handleWorkflowStarted(params) + + if (onWorkflowStarted) + onWorkflowStarted(params) + }, + onWorkflowFinished: (params) => { + clearListeningState() + handleWorkflowFinished(params) + + if (onWorkflowFinished) + onWorkflowFinished(params) + if (isInWorkflowDebug) { + fetchInspectVars({}) + invalidAllLastRun() + } + }, + onNodeStarted: (params) => { + handleWorkflowNodeStarted( + params, + { + clientWidth, + clientHeight, + }, + ) + + if (onNodeStarted) + onNodeStarted(params) + }, + onNodeFinished: (params) => { + handleWorkflowNodeFinished(params) + + if (onNodeFinished) + onNodeFinished(params) + }, + onIterationStart: (params) => { + handleWorkflowNodeIterationStarted( + params, + { + clientWidth, + clientHeight, + }, + ) + + if (onIterationStart) + onIterationStart(params) + }, + onIterationNext: (params) => { + handleWorkflowNodeIterationNext(params) + + if (onIterationNext) + onIterationNext(params) + }, + onIterationFinish: (params) => { + handleWorkflowNodeIterationFinished(params) + + if (onIterationFinish) + onIterationFinish(params) + }, + onLoopStart: (params) => { + handleWorkflowNodeLoopStarted( + params, + { + clientWidth, + clientHeight, + }, + ) + + if (onLoopStart) + onLoopStart(params) + }, + onLoopNext: (params) => { + handleWorkflowNodeLoopNext(params) + + if (onLoopNext) + onLoopNext(params) + }, + onLoopFinish: (params) => { + handleWorkflowNodeLoopFinished(params) + + if (onLoopFinish) + onLoopFinish(params) + }, + onNodeRetry: (params) => { + handleWorkflowNodeRetry(params) + + if (onNodeRetry) + onNodeRetry(params) + }, + onAgentLog: (params) => { + handleWorkflowAgentLog(params) + + if (onAgentLog) + onAgentLog(params) + }, + onTextChunk: (params) => { + handleWorkflowTextChunk(params) + }, + onTextReplace: (params) => { + handleWorkflowTextReplace(params) + }, + onTTSChunk: (messageId: string, audio: string) => { + if (!audio || audio === '') + return + player.playAudioWithAudio(audio, true) + AudioPlayerManager.getInstance().resetMsgId(messageId) + }, + onTTSEnd: (messageId: string, audio: string) => { + player.playAudioWithAudio(audio, false) + }, + onError: wrappedOnError, + onCompleted: wrappedOnCompleted, + } + + const waitWithAbort = (signal: AbortSignal, delay: number) => new Promise<void>((resolve) => { + const timer = window.setTimeout(resolve, delay) + signal.addEventListener('abort', () => { + clearTimeout(timer) + resolve() + }, { once: true }) + }) + + const runTriggerDebug = async (debugType: DebuggableTriggerType) => { + const controller = new AbortController() + abortControllerRef.current = controller + + const controllerKey = controllerKeyMap[debugType] + + ; (window as any)[controllerKey] = controller + + const debugLabel = debugLabelMap[debugType] + + const poll = async (): Promise<void> => { + try { + const response = await post<Response>(url, { + body: requestBody, + signal: controller.signal, + }, { + needAllResponseContent: true, + }) + + if (controller.signal.aborted) + return + + if (!response) { + const message = `${debugLabel} debug request failed` + Toast.notify({ type: 'error', message }) + clearAbortController() + return + } + + const contentType = response.headers.get('content-type') || '' + + if (contentType.includes(ContentType.json)) { + let data: any = null + try { + data = await response.json() + } + catch (jsonError) { + console.error(`handleRun: ${debugLabel.toLowerCase()} debug response parse error`, jsonError) + Toast.notify({ type: 'error', message: `${debugLabel} debug request failed` }) + clearAbortController() + clearListeningState() + return + } + + if (controller.signal.aborted) + return + + if (data?.status === 'waiting') { + const delay = Number(data.retry_in) || 2000 + await waitWithAbort(controller.signal, delay) + if (controller.signal.aborted) + return + await poll() + return + } + + const errorMessage = data?.message || `${debugLabel} debug failed` + Toast.notify({ type: 'error', message: errorMessage }) + clearAbortController() + setWorkflowRunningData({ + result: { + status: WorkflowRunningStatus.Failed, + error: errorMessage, + inputs_truncated: false, + process_data_truncated: false, + outputs_truncated: false, + }, + tracing: [], + }) + clearListeningState() + return + } + + clearListeningState() + handleStream( + response, + baseSseOptions.onData ?? noop, + baseSseOptions.onCompleted, + baseSseOptions.onThought, + baseSseOptions.onMessageEnd, + baseSseOptions.onMessageReplace, + baseSseOptions.onFile, + baseSseOptions.onWorkflowStarted, + baseSseOptions.onWorkflowFinished, + baseSseOptions.onNodeStarted, + baseSseOptions.onNodeFinished, + baseSseOptions.onIterationStart, + baseSseOptions.onIterationNext, + baseSseOptions.onIterationFinish, + baseSseOptions.onLoopStart, + baseSseOptions.onLoopNext, + baseSseOptions.onLoopFinish, + baseSseOptions.onNodeRetry, + baseSseOptions.onParallelBranchStarted, + baseSseOptions.onParallelBranchFinished, + baseSseOptions.onTextChunk, + baseSseOptions.onTTSChunk, + baseSseOptions.onTTSEnd, + baseSseOptions.onTextReplace, + baseSseOptions.onAgentLog, + baseSseOptions.onDataSourceNodeProcessing, + baseSseOptions.onDataSourceNodeCompleted, + baseSseOptions.onDataSourceNodeError, + ) + } + catch (error) { + if (controller.signal.aborted) + return + if (error instanceof Response) { + const data = await error.clone().json() as Record<string, any> + const { error: respError } = data || {} + Toast.notify({ type: 'error', message: respError }) + clearAbortController() + setWorkflowRunningData({ + result: { + status: WorkflowRunningStatus.Failed, + error: respError, + inputs_truncated: false, + process_data_truncated: false, + outputs_truncated: false, + }, + tracing: [], + }) + } + clearListeningState() + } + } + + await poll() + } + + if (runMode === TriggerType.Schedule) { + await runTriggerDebug(TriggerType.Schedule) + return + } + + if (runMode === TriggerType.Webhook) { + await runTriggerDebug(TriggerType.Webhook) + return + } + + if (runMode === TriggerType.Plugin) { + await runTriggerDebug(TriggerType.Plugin) + return + } + + if (runMode === TriggerType.All) { + await runTriggerDebug(TriggerType.All) + return + } + ssePost( url, { - body: params, + body: requestBody, }, { - onWorkflowStarted: (params) => { - handleWorkflowStarted(params) - - if (onWorkflowStarted) - onWorkflowStarted(params) + ...baseSseOptions, + getAbortController: (controller: AbortController) => { + abortControllerRef.current = controller }, - onWorkflowFinished: (params) => { - handleWorkflowFinished(params) - - if (onWorkflowFinished) - onWorkflowFinished(params) - if (isInWorkflowDebug) { - fetchInspectVars({}) - invalidAllLastRun() - } - }, - onError: (params) => { - handleWorkflowFailed() - - if (onError) - onError(params) - }, - onNodeStarted: (params) => { - handleWorkflowNodeStarted( - params, - { - clientWidth, - clientHeight, - }, - ) - - if (onNodeStarted) - onNodeStarted(params) - }, - onNodeFinished: (params) => { - handleWorkflowNodeFinished(params) - - if (onNodeFinished) - onNodeFinished(params) - }, - onIterationStart: (params) => { - handleWorkflowNodeIterationStarted( - params, - { - clientWidth, - clientHeight, - }, - ) - - if (onIterationStart) - onIterationStart(params) - }, - onIterationNext: (params) => { - handleWorkflowNodeIterationNext(params) - - if (onIterationNext) - onIterationNext(params) - }, - onIterationFinish: (params) => { - handleWorkflowNodeIterationFinished(params) - - if (onIterationFinish) - onIterationFinish(params) - }, - onLoopStart: (params) => { - handleWorkflowNodeLoopStarted( - params, - { - clientWidth, - clientHeight, - }, - ) - - if (onLoopStart) - onLoopStart(params) - }, - onLoopNext: (params) => { - handleWorkflowNodeLoopNext(params) - - if (onLoopNext) - onLoopNext(params) - }, - onLoopFinish: (params) => { - handleWorkflowNodeLoopFinished(params) - - if (onLoopFinish) - onLoopFinish(params) - }, - onNodeRetry: (params) => { - handleWorkflowNodeRetry(params) - - if (onNodeRetry) - onNodeRetry(params) - }, - onAgentLog: (params) => { - handleWorkflowAgentLog(params) - - if (onAgentLog) - onAgentLog(params) - }, - onTextChunk: (params) => { - handleWorkflowTextChunk(params) - }, - onTextReplace: (params) => { - handleWorkflowTextReplace(params) - }, - onTTSChunk: (messageId: string, audio: string) => { - if (!audio || audio === '') - return - player.playAudioWithAudio(audio, true) - AudioPlayerManager.getInstance().resetMsgId(messageId) - }, - onTTSEnd: (messageId: string, audio: string) => { - player.playAudioWithAudio(audio, false) - }, - ...restCallback, }, ) }, [store, doSyncWorkflowDraft, workflowStore, pathname, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, handleWorkflowFailed, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace], ) const handleStopRun = useCallback((taskId: string) => { - const appId = useAppStore.getState().appDetail?.id + const setStoppedState = () => { + const { + setWorkflowRunningData, + setIsListening, + setShowVariableInspectPanel, + setListeningTriggerType, + setListeningTriggerNodeId, + } = workflowStore.getState() - stopWorkflowRun(`/apps/${appId}/workflow-runs/tasks/${taskId}/stop`) - }, []) + setWorkflowRunningData({ + result: { + status: WorkflowRunningStatus.Stopped, + inputs_truncated: false, + process_data_truncated: false, + outputs_truncated: false, + }, + tracing: [], + resultText: '', + }) + setIsListening(false) + setListeningTriggerType(null) + setListeningTriggerNodeId(null) + setShowVariableInspectPanel(true) + } + + if (taskId) { + const appId = useAppStore.getState().appDetail?.id + stopWorkflowRun(`/apps/${appId}/workflow-runs/tasks/${taskId}/stop`) + setStoppedState() + return + } + + // Try webhook debug controller from global variable first + const webhookController = (window as any).__webhookDebugAbortController + if (webhookController) + webhookController.abort() + + const pluginController = (window as any).__pluginDebugAbortController + if (pluginController) + pluginController.abort() + + const scheduleController = (window as any).__scheduleDebugAbortController + if (scheduleController) + scheduleController.abort() + + const allTriggerController = (window as any).__allTriggersDebugAbortController + if (allTriggerController) + allTriggerController.abort() + + // Also try the ref + if (abortControllerRef.current) + abortControllerRef.current.abort() + + abortControllerRef.current = null + setStoppedState() + }, [workflowStore]) const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => { const nodes = publishedWorkflow.graph.nodes.map(node => ({ ...node, selected: false, data: { ...node.data, selected: false } })) diff --git a/web/app/components/workflow-app/hooks/use-workflow-start-run.tsx b/web/app/components/workflow-app/hooks/use-workflow-start-run.tsx index 3f5ea1c1df..d2e3b3e3c9 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-start-run.tsx +++ b/web/app/components/workflow-app/hooks/use-workflow-start-run.tsx @@ -12,6 +12,7 @@ import { useNodesSyncDraft, useWorkflowRun, } from '.' +import { TriggerType } from '@/app/components/workflow/header/test-run-menu' export const useWorkflowStartRun = () => { const store = useStoreApi() @@ -40,9 +41,11 @@ export const useWorkflowStartRun = () => { setShowDebugAndPreviewPanel, setShowInputsPanel, setShowEnvPanel, + setShowGlobalVariablePanel, } = workflowStore.getState() setShowEnvPanel(false) + setShowGlobalVariablePanel(false) if (showDebugAndPreviewPanel) { handleCancelDebugAndPreviewPanel() @@ -61,6 +64,203 @@ export const useWorkflowStartRun = () => { } }, [store, workflowStore, featuresStore, handleCancelDebugAndPreviewPanel, handleRun, doSyncWorkflowDraft]) + const handleWorkflowTriggerScheduleRunInWorkflow = useCallback(async (nodeId?: string) => { + if (!nodeId) + return + + const { + workflowRunningData, + showDebugAndPreviewPanel, + setShowDebugAndPreviewPanel, + setShowInputsPanel, + setShowEnvPanel, + setShowGlobalVariablePanel, + setListeningTriggerType, + setListeningTriggerNodeId, + setListeningTriggerNodeIds, + setListeningTriggerIsAll, + } = workflowStore.getState() + + if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) + return + + const { getNodes } = store.getState() + const nodes = getNodes() + const scheduleNode = nodes.find(node => node.id === nodeId && node.data.type === BlockEnum.TriggerSchedule) + + if (!scheduleNode) { + console.warn('handleWorkflowTriggerScheduleRunInWorkflow: schedule node not found', nodeId) + return + } + + setShowEnvPanel(false) + setShowGlobalVariablePanel(false) + + if (showDebugAndPreviewPanel) { + handleCancelDebugAndPreviewPanel() + return + } + + setListeningTriggerType(BlockEnum.TriggerSchedule) + setListeningTriggerNodeId(nodeId) + setListeningTriggerNodeIds([nodeId]) + setListeningTriggerIsAll(false) + + await doSyncWorkflowDraft() + handleRun( + {}, + undefined, + { + mode: TriggerType.Schedule, + scheduleNodeId: nodeId, + }, + ) + setShowDebugAndPreviewPanel(true) + setShowInputsPanel(false) + }, [store, workflowStore, handleCancelDebugAndPreviewPanel, handleRun, doSyncWorkflowDraft]) + + const handleWorkflowTriggerWebhookRunInWorkflow = useCallback(async ({ nodeId }: { nodeId: string }) => { + if (!nodeId) + return + + const { + workflowRunningData, + showDebugAndPreviewPanel, + setShowDebugAndPreviewPanel, + setShowInputsPanel, + setShowEnvPanel, + setShowGlobalVariablePanel, + setListeningTriggerType, + setListeningTriggerNodeId, + setListeningTriggerNodeIds, + setListeningTriggerIsAll, + } = workflowStore.getState() + + if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) + return + + const { getNodes } = store.getState() + const nodes = getNodes() + const webhookNode = nodes.find(node => node.id === nodeId && node.data.type === BlockEnum.TriggerWebhook) + + if (!webhookNode) { + console.warn('handleWorkflowTriggerWebhookRunInWorkflow: webhook node not found', nodeId) + return + } + + setShowEnvPanel(false) + setShowGlobalVariablePanel(false) + + if (!showDebugAndPreviewPanel) + setShowDebugAndPreviewPanel(true) + + setShowInputsPanel(false) + setListeningTriggerType(BlockEnum.TriggerWebhook) + setListeningTriggerNodeId(nodeId) + setListeningTriggerNodeIds([nodeId]) + setListeningTriggerIsAll(false) + + await doSyncWorkflowDraft() + handleRun( + { node_id: nodeId }, + undefined, + { + mode: TriggerType.Webhook, + webhookNodeId: nodeId, + }, + ) + }, [store, workflowStore, handleRun, doSyncWorkflowDraft]) + + const handleWorkflowTriggerPluginRunInWorkflow = useCallback(async (nodeId?: string) => { + if (!nodeId) + return + const { + workflowRunningData, + showDebugAndPreviewPanel, + setShowDebugAndPreviewPanel, + setShowInputsPanel, + setShowEnvPanel, + setShowGlobalVariablePanel, + setListeningTriggerType, + setListeningTriggerNodeId, + setListeningTriggerNodeIds, + setListeningTriggerIsAll, + } = workflowStore.getState() + + if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) + return + + const { getNodes } = store.getState() + const nodes = getNodes() + const pluginNode = nodes.find(node => node.id === nodeId && node.data.type === BlockEnum.TriggerPlugin) + + if (!pluginNode) { + console.warn('handleWorkflowTriggerPluginRunInWorkflow: plugin node not found', nodeId) + return + } + + setShowEnvPanel(false) + setShowGlobalVariablePanel(false) + + if (!showDebugAndPreviewPanel) + setShowDebugAndPreviewPanel(true) + + setShowInputsPanel(false) + setListeningTriggerType(BlockEnum.TriggerPlugin) + setListeningTriggerNodeId(nodeId) + setListeningTriggerNodeIds([nodeId]) + setListeningTriggerIsAll(false) + + await doSyncWorkflowDraft() + handleRun( + { node_id: nodeId }, + undefined, + { + mode: TriggerType.Plugin, + pluginNodeId: nodeId, + }, + ) + }, [store, workflowStore, handleRun, doSyncWorkflowDraft]) + + const handleWorkflowRunAllTriggersInWorkflow = useCallback(async (nodeIds: string[]) => { + if (!nodeIds.length) + return + const { + workflowRunningData, + showDebugAndPreviewPanel, + setShowDebugAndPreviewPanel, + setShowInputsPanel, + setShowEnvPanel, + setShowGlobalVariablePanel, + setListeningTriggerIsAll, + setListeningTriggerNodeIds, + setListeningTriggerNodeId, + } = workflowStore.getState() + + if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) + return + + setShowEnvPanel(false) + setShowGlobalVariablePanel(false) + setShowInputsPanel(false) + setListeningTriggerIsAll(true) + setListeningTriggerNodeIds(nodeIds) + setListeningTriggerNodeId(null) + + if (!showDebugAndPreviewPanel) + setShowDebugAndPreviewPanel(true) + + await doSyncWorkflowDraft() + handleRun( + { node_ids: nodeIds }, + undefined, + { + mode: TriggerType.All, + allNodeIds: nodeIds, + }, + ) + }, [store, workflowStore, handleRun, doSyncWorkflowDraft]) + const handleWorkflowStartRunInChatflow = useCallback(async () => { const { showDebugAndPreviewPanel, @@ -68,10 +268,12 @@ export const useWorkflowStartRun = () => { setHistoryWorkflowData, setShowEnvPanel, setShowChatVariablePanel, + setShowGlobalVariablePanel, } = workflowStore.getState() setShowEnvPanel(false) setShowChatVariablePanel(false) + setShowGlobalVariablePanel(false) if (showDebugAndPreviewPanel) handleCancelDebugAndPreviewPanel() @@ -92,5 +294,9 @@ export const useWorkflowStartRun = () => { handleStartWorkflowRun, handleWorkflowStartRunInWorkflow, handleWorkflowStartRunInChatflow, + handleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow, } } diff --git a/web/app/components/workflow-app/index.tsx b/web/app/components/workflow-app/index.tsx index df83b3ca26..fcd247ef22 100644 --- a/web/app/components/workflow-app/index.tsx +++ b/web/app/components/workflow-app/index.tsx @@ -10,6 +10,10 @@ import { import { useWorkflowInit, } from './hooks/use-workflow-init' +import { useAppTriggers } from '@/service/use-tools' +import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status' +import { useStore as useAppStore } from '@/app/components/app/store' +import { useWorkflowStore } from '@/app/components/workflow/store' import { initialEdges, initialNodes, @@ -24,13 +28,13 @@ import { WorkflowContextProvider, } from '@/app/components/workflow/context' import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store' -import { useWorkflowStore } from '@/app/components/workflow/store' import { createWorkflowSlice } from './store/workflow/workflow-slice' import WorkflowAppMain from './components/workflow-main' import { useSearchParams } from 'next/navigation' import { fetchRunDetail } from '@/service/log' import { useGetRunAndTraceUrl } from './hooks/use-get-run-and-trace-url' +import { AppModeEnum } from '@/types/app' const WorkflowAppWithAdditionalContext = () => { const { @@ -38,8 +42,46 @@ const WorkflowAppWithAdditionalContext = () => { isLoading, fileUploadConfigResponse, } = useWorkflowInit() + const workflowStore = useWorkflowStore() const { isLoadingCurrentWorkspace, currentWorkspace } = useAppContext() + // Initialize trigger status at application level + const { setTriggerStatuses } = useTriggerStatusStore() + const appDetail = useAppStore(s => s.appDetail) + const appId = appDetail?.id + const isWorkflowMode = appDetail?.mode === AppModeEnum.WORKFLOW + const { data: triggersResponse } = useAppTriggers(isWorkflowMode ? appId : undefined, { + staleTime: 5 * 60 * 1000, // 5 minutes cache + refetchOnWindowFocus: false, + }) + + // Sync trigger statuses to store when data loads + useEffect(() => { + if (triggersResponse?.data) { + // Map API status to EntryNodeStatus: 'enabled' stays 'enabled', all others become 'disabled' + const statusMap = triggersResponse.data.reduce((acc, trigger) => { + acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled' + return acc + }, {} as Record<string, 'enabled' | 'disabled'>) + + setTriggerStatuses(statusMap) + } + }, [triggersResponse?.data, setTriggerStatuses]) + + // Cleanup on unmount + useEffect(() => { + return () => { + // Reset the loaded flag when component unmounts + workflowStore.setState({ isWorkflowDataLoaded: false }) + + // Cancel any pending debounced sync operations + const { debouncedSyncWorkflowDraft } = workflowStore.getState() + // The debounced function from lodash has a cancel method + if (debouncedSyncWorkflowDraft && 'cancel' in debouncedSyncWorkflowDraft) + (debouncedSyncWorkflowDraft as any).cancel() + } + }, [workflowStore]) + const nodesData = useMemo(() => { if (data) return initialNodes(data.graph.nodes, data.graph.edges) @@ -54,7 +96,6 @@ const WorkflowAppWithAdditionalContext = () => { }, [data]) const searchParams = useSearchParams() - const workflowStore = useWorkflowStore() const { getWorkflowRunAndTraceUrl } = useGetRunAndTraceUrl() const replayRunId = searchParams.get('replayRunId') diff --git a/web/app/components/workflow-app/store/workflow/workflow-slice.ts b/web/app/components/workflow-app/store/workflow/workflow-slice.ts index f26d9b509b..72230629f0 100644 --- a/web/app/components/workflow-app/store/workflow/workflow-slice.ts +++ b/web/app/components/workflow-app/store/workflow/workflow-slice.ts @@ -5,8 +5,16 @@ export type WorkflowSliceShape = { appName: string notInitialWorkflow: boolean setNotInitialWorkflow: (notInitialWorkflow: boolean) => void + shouldAutoOpenStartNodeSelector: boolean + setShouldAutoOpenStartNodeSelector: (shouldAutoOpen: boolean) => void nodesDefaultConfigs: Record<string, any> setNodesDefaultConfigs: (nodesDefaultConfigs: Record<string, any>) => void + showOnboarding: boolean + setShowOnboarding: (showOnboarding: boolean) => void + hasSelectedStartNode: boolean + setHasSelectedStartNode: (hasSelectedStartNode: boolean) => void + hasShownOnboarding: boolean + setHasShownOnboarding: (hasShownOnboarding: boolean) => void } export type CreateWorkflowSlice = StateCreator<WorkflowSliceShape> @@ -15,6 +23,14 @@ export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = set => ({ appName: '', notInitialWorkflow: false, setNotInitialWorkflow: notInitialWorkflow => set(() => ({ notInitialWorkflow })), + shouldAutoOpenStartNodeSelector: false, + setShouldAutoOpenStartNodeSelector: shouldAutoOpenStartNodeSelector => set(() => ({ shouldAutoOpenStartNodeSelector })), nodesDefaultConfigs: {}, setNodesDefaultConfigs: nodesDefaultConfigs => set(() => ({ nodesDefaultConfigs })), + showOnboarding: false, + setShowOnboarding: showOnboarding => set(() => ({ showOnboarding })), + hasSelectedStartNode: false, + setHasSelectedStartNode: hasSelectedStartNode => set(() => ({ hasSelectedStartNode })), + hasShownOnboarding: false, + setHasShownOnboarding: hasShownOnboarding => set(() => ({ hasShownOnboarding })), }) diff --git a/web/app/components/workflow/__tests__/trigger-status-sync.test.tsx b/web/app/components/workflow/__tests__/trigger-status-sync.test.tsx new file mode 100644 index 0000000000..dc208047cb --- /dev/null +++ b/web/app/components/workflow/__tests__/trigger-status-sync.test.tsx @@ -0,0 +1,339 @@ +import React, { useCallback } from 'react' +import { act, render } from '@testing-library/react' +import { useTriggerStatusStore } from '../store/trigger-status' +import { isTriggerNode } from '../types' +import type { EntryNodeStatus } from '../store/trigger-status' + +// Mock the isTriggerNode function +jest.mock('../types', () => ({ + isTriggerNode: jest.fn(), +})) + +const mockIsTriggerNode = isTriggerNode as jest.MockedFunction<typeof isTriggerNode> + +// Test component that mimics BaseNode's usage pattern +const TestTriggerNode: React.FC<{ + nodeId: string + nodeType: string +}> = ({ nodeId, nodeType }) => { + const triggerStatus = useTriggerStatusStore(state => + mockIsTriggerNode(nodeType) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled', + ) + + return ( + <div data-testid={`node-${nodeId}`} data-status={triggerStatus}> + Status: {triggerStatus} + </div> + ) +} + +// Test component that mimics TriggerCard's usage pattern +const TestTriggerController: React.FC = () => { + const { setTriggerStatus, setTriggerStatuses } = useTriggerStatusStore() + + const handleToggle = (nodeId: string, enabled: boolean) => { + const newStatus = enabled ? 'enabled' : 'disabled' + setTriggerStatus(nodeId, newStatus) + } + + const handleBatchUpdate = (statuses: Record<string, EntryNodeStatus>) => { + setTriggerStatuses(statuses) + } + + return ( + <div> + <button + data-testid="toggle-node-1" + onClick={() => handleToggle('node-1', true)} + > + Enable Node 1 + </button> + <button + data-testid="toggle-node-2" + onClick={() => handleToggle('node-2', false)} + > + Disable Node 2 + </button> + <button + data-testid="batch-update" + onClick={() => handleBatchUpdate({ + 'node-1': 'disabled', + 'node-2': 'enabled', + 'node-3': 'enabled', + })} + > + Batch Update + </button> + </div> + ) +} + +describe('Trigger Status Synchronization Integration', () => { + beforeEach(() => { + // Clear store state + act(() => { + const store = useTriggerStatusStore.getState() + store.clearTriggerStatuses() + }) + + // Reset mocks + jest.clearAllMocks() + }) + + describe('Real-time Status Synchronization', () => { + it('should sync status changes between trigger controller and nodes', () => { + mockIsTriggerNode.mockReturnValue(true) + + const { getByTestId } = render( + <> + <TestTriggerController /> + <TestTriggerNode nodeId="node-1" nodeType="trigger-webhook" /> + <TestTriggerNode nodeId="node-2" nodeType="trigger-schedule" /> + </>, + ) + + // Initial state - should be 'disabled' by default + expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'disabled') + expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'disabled') + + // Enable node-1 + act(() => { + getByTestId('toggle-node-1').click() + }) + + expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'enabled') + expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'disabled') + + // Disable node-2 (should remain disabled) + act(() => { + getByTestId('toggle-node-2').click() + }) + + expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'enabled') + expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'disabled') + }) + + it('should handle batch status updates correctly', () => { + mockIsTriggerNode.mockReturnValue(true) + + const { getByTestId } = render( + <> + <TestTriggerController /> + <TestTriggerNode nodeId="node-1" nodeType="trigger-webhook" /> + <TestTriggerNode nodeId="node-2" nodeType="trigger-schedule" /> + <TestTriggerNode nodeId="node-3" nodeType="trigger-plugin" /> + </>, + ) + + // Initial state + expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'disabled') + expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'disabled') + expect(getByTestId('node-node-3')).toHaveAttribute('data-status', 'disabled') + + // Batch update + act(() => { + getByTestId('batch-update').click() + }) + + expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'disabled') + expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'enabled') + expect(getByTestId('node-node-3')).toHaveAttribute('data-status', 'enabled') + }) + + it('should handle mixed node types (trigger vs non-trigger)', () => { + // Mock different node types + mockIsTriggerNode.mockImplementation((nodeType: string) => { + return nodeType.startsWith('trigger-') + }) + + const { getByTestId } = render( + <> + <TestTriggerController /> + <TestTriggerNode nodeId="node-1" nodeType="trigger-webhook" /> + <TestTriggerNode nodeId="node-2" nodeType="start" /> + <TestTriggerNode nodeId="node-3" nodeType="llm" /> + </>, + ) + + // Trigger node should use store status, non-trigger nodes should be 'enabled' + expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'disabled') // trigger node + expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'enabled') // start node + expect(getByTestId('node-node-3')).toHaveAttribute('data-status', 'enabled') // llm node + + // Update trigger node status + act(() => { + getByTestId('toggle-node-1').click() + }) + + expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'enabled') // updated + expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'enabled') // unchanged + expect(getByTestId('node-node-3')).toHaveAttribute('data-status', 'enabled') // unchanged + }) + }) + + describe('Store State Management', () => { + it('should maintain state consistency across multiple components', () => { + mockIsTriggerNode.mockReturnValue(true) + + // Render multiple instances of the same node + const { getByTestId, rerender } = render( + <> + <TestTriggerController /> + <TestTriggerNode nodeId="shared-node" nodeType="trigger-webhook" /> + </>, + ) + + // Update status + act(() => { + getByTestId('toggle-node-1').click() // This updates node-1, not shared-node + }) + + // Add another component with the same nodeId + rerender( + <> + <TestTriggerController /> + <TestTriggerNode nodeId="shared-node" nodeType="trigger-webhook" /> + <TestTriggerNode nodeId="shared-node" nodeType="trigger-webhook" /> + </>, + ) + + // Both components should show the same status + const nodes = document.querySelectorAll('[data-testid="node-shared-node"]') + expect(nodes).toHaveLength(2) + nodes.forEach((node) => { + expect(node).toHaveAttribute('data-status', 'disabled') + }) + }) + + it('should handle rapid status changes correctly', () => { + mockIsTriggerNode.mockReturnValue(true) + + const { getByTestId } = render( + <> + <TestTriggerController /> + <TestTriggerNode nodeId="node-1" nodeType="trigger-webhook" /> + </>, + ) + + // Rapid consecutive updates + act(() => { + // Multiple rapid clicks + getByTestId('toggle-node-1').click() // enable + getByTestId('toggle-node-2').click() // disable (different node) + getByTestId('toggle-node-1').click() // enable again + }) + + // Should reflect the final state + expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'enabled') + }) + }) + + describe('Error Scenarios', () => { + it('should handle non-existent node IDs gracefully', () => { + mockIsTriggerNode.mockReturnValue(true) + + const { getByTestId } = render( + <TestTriggerNode nodeId="non-existent-node" nodeType="trigger-webhook" />, + ) + + // Should default to 'disabled' for non-existent nodes + expect(getByTestId('node-non-existent-node')).toHaveAttribute('data-status', 'disabled') + }) + + it('should handle component unmounting gracefully', () => { + mockIsTriggerNode.mockReturnValue(true) + + const { getByTestId, unmount } = render( + <> + <TestTriggerController /> + <TestTriggerNode nodeId="node-1" nodeType="trigger-webhook" /> + </>, + ) + + // Update status + act(() => { + getByTestId('toggle-node-1').click() + }) + + // Unmount components + expect(() => unmount()).not.toThrow() + + // Store should still maintain the state + const store = useTriggerStatusStore.getState() + expect(store.triggerStatuses['node-1']).toBe('enabled') + }) + }) + + describe('Performance Optimization', () => { + // Component that uses optimized selector with useCallback + const OptimizedTriggerNode: React.FC<{ + nodeId: string + nodeType: string + }> = ({ nodeId, nodeType }) => { + const triggerStatusSelector = useCallback((state: any) => + mockIsTriggerNode(nodeType) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled', + [nodeId, nodeType], + ) + const triggerStatus = useTriggerStatusStore(triggerStatusSelector) + + return ( + <div data-testid={`optimized-node-${nodeId}`} data-status={triggerStatus}> + Status: {triggerStatus} + </div> + ) + } + + it('should work correctly with optimized selector using useCallback', () => { + mockIsTriggerNode.mockImplementation(nodeType => nodeType === 'trigger-webhook') + + const { getByTestId } = render( + <> + <OptimizedTriggerNode nodeId="node-1" nodeType="trigger-webhook" /> + <OptimizedTriggerNode nodeId="node-2" nodeType="start" /> + <TestTriggerController /> + </>, + ) + + // Initial state + expect(getByTestId('optimized-node-node-1')).toHaveAttribute('data-status', 'disabled') + expect(getByTestId('optimized-node-node-2')).toHaveAttribute('data-status', 'enabled') + + // Update status via controller + act(() => { + getByTestId('toggle-node-1').click() + }) + + // Verify optimized component updates correctly + expect(getByTestId('optimized-node-node-1')).toHaveAttribute('data-status', 'enabled') + expect(getByTestId('optimized-node-node-2')).toHaveAttribute('data-status', 'enabled') + }) + + it('should handle selector dependency changes correctly', () => { + mockIsTriggerNode.mockImplementation(nodeType => nodeType === 'trigger-webhook') + + const TestComponent: React.FC<{ nodeType: string }> = ({ nodeType }) => { + const triggerStatusSelector = useCallback((state: any) => + mockIsTriggerNode(nodeType) ? (state.triggerStatuses['test-node'] || 'disabled') : 'enabled', + ['test-node', nodeType], // Dependencies should match implementation + ) + const status = useTriggerStatusStore(triggerStatusSelector) + return <div data-testid="test-component" data-status={status} /> + } + + const { getByTestId, rerender } = render(<TestComponent nodeType="trigger-webhook" />) + + // Initial trigger node + expect(getByTestId('test-component')).toHaveAttribute('data-status', 'disabled') + + // Set status for the node + act(() => { + useTriggerStatusStore.getState().setTriggerStatus('test-node', 'enabled') + }) + expect(getByTestId('test-component')).toHaveAttribute('data-status', 'enabled') + + // Change node type to non-trigger - should return 'enabled' regardless of store + rerender(<TestComponent nodeType="start" />) + expect(getByTestId('test-component')).toHaveAttribute('data-status', 'enabled') + }) + }) +}) diff --git a/web/app/components/workflow/block-icon.tsx b/web/app/components/workflow/block-icon.tsx index 60fa813cd9..a4f53f2a64 100644 --- a/web/app/components/workflow/block-icon.tsx +++ b/web/app/components/workflow/block-icon.tsx @@ -21,8 +21,10 @@ import { LoopEnd, ParameterExtractor, QuestionClassifier, + Schedule, TemplatingTransform, VariableX, + WebhookLine, } from '@/app/components/base/icons/src/vender/workflow' import AppIcon from '@/app/components/base/app-icon' import cn from '@/utils/classnames' @@ -38,35 +40,45 @@ const ICON_CONTAINER_CLASSNAME_SIZE_MAP: Record<string, string> = { sm: 'w-5 h-5 rounded-md shadow-xs', md: 'w-6 h-6 rounded-lg shadow-md', } + +const DEFAULT_ICON_MAP: Record<BlockEnum, React.ComponentType<{ className: string }>> = { + [BlockEnum.Start]: Home, + [BlockEnum.LLM]: Llm, + [BlockEnum.Code]: Code, + [BlockEnum.End]: End, + [BlockEnum.IfElse]: IfElse, + [BlockEnum.HttpRequest]: Http, + [BlockEnum.Answer]: Answer, + [BlockEnum.KnowledgeRetrieval]: KnowledgeRetrieval, + [BlockEnum.QuestionClassifier]: QuestionClassifier, + [BlockEnum.TemplateTransform]: TemplatingTransform, + [BlockEnum.VariableAssigner]: VariableX, + [BlockEnum.VariableAggregator]: VariableX, + [BlockEnum.Assigner]: Assigner, + [BlockEnum.Tool]: VariableX, + [BlockEnum.IterationStart]: VariableX, + [BlockEnum.Iteration]: Iteration, + [BlockEnum.LoopStart]: VariableX, + [BlockEnum.Loop]: Loop, + [BlockEnum.LoopEnd]: LoopEnd, + [BlockEnum.ParameterExtractor]: ParameterExtractor, + [BlockEnum.DocExtractor]: DocsExtractor, + [BlockEnum.ListFilter]: ListFilter, + [BlockEnum.Agent]: Agent, + [BlockEnum.KnowledgeBase]: KnowledgeBase, + [BlockEnum.DataSource]: Datasource, + [BlockEnum.DataSourceEmpty]: () => null, + [BlockEnum.TriggerSchedule]: Schedule, + [BlockEnum.TriggerWebhook]: WebhookLine, + [BlockEnum.TriggerPlugin]: VariableX, +} + const getIcon = (type: BlockEnum, className: string) => { - return { - [BlockEnum.Start]: <Home className={className} />, - [BlockEnum.LLM]: <Llm className={className} />, - [BlockEnum.Code]: <Code className={className} />, - [BlockEnum.End]: <End className={className} />, - [BlockEnum.IfElse]: <IfElse className={className} />, - [BlockEnum.HttpRequest]: <Http className={className} />, - [BlockEnum.Answer]: <Answer className={className} />, - [BlockEnum.KnowledgeRetrieval]: <KnowledgeRetrieval className={className} />, - [BlockEnum.QuestionClassifier]: <QuestionClassifier className={className} />, - [BlockEnum.TemplateTransform]: <TemplatingTransform className={className} />, - [BlockEnum.VariableAssigner]: <VariableX className={className} />, - [BlockEnum.VariableAggregator]: <VariableX className={className} />, - [BlockEnum.Assigner]: <Assigner className={className} />, - [BlockEnum.Tool]: <VariableX className={className} />, - [BlockEnum.IterationStart]: <VariableX className={className} />, - [BlockEnum.Iteration]: <Iteration className={className} />, - [BlockEnum.LoopStart]: <VariableX className={className} />, - [BlockEnum.Loop]: <Loop className={className} />, - [BlockEnum.LoopEnd]: <LoopEnd className={className} />, - [BlockEnum.ParameterExtractor]: <ParameterExtractor className={className} />, - [BlockEnum.DocExtractor]: <DocsExtractor className={className} />, - [BlockEnum.ListFilter]: <ListFilter className={className} />, - [BlockEnum.Agent]: <Agent className={className} />, - [BlockEnum.KnowledgeBase]: <KnowledgeBase className={className} />, - [BlockEnum.DataSource]: <Datasource className={className} />, - [BlockEnum.DataSourceEmpty]: <></>, - }[type] + const DefaultIcon = DEFAULT_ICON_MAP[type] + if (!DefaultIcon) + return null + + return <DefaultIcon className={className} /> } const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = { [BlockEnum.Start]: 'bg-util-colors-blue-brand-blue-brand-500', @@ -92,6 +104,9 @@ const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = { [BlockEnum.Agent]: 'bg-util-colors-indigo-indigo-500', [BlockEnum.KnowledgeBase]: 'bg-util-colors-warning-warning-500', [BlockEnum.DataSource]: 'bg-components-icon-bg-midnight-solid', + [BlockEnum.TriggerSchedule]: 'bg-util-colors-violet-violet-500', + [BlockEnum.TriggerWebhook]: 'bg-util-colors-blue-blue-500', + [BlockEnum.TriggerPlugin]: 'bg-util-colors-blue-blue-500', } const BlockIcon: FC<BlockIconProps> = ({ type, @@ -99,8 +114,8 @@ const BlockIcon: FC<BlockIconProps> = ({ className, toolIcon, }) => { - const isToolOrDataSource = type === BlockEnum.Tool || type === BlockEnum.DataSource - const showDefaultIcon = !isToolOrDataSource || !toolIcon + const isToolOrDataSourceOrTriggerPlugin = type === BlockEnum.Tool || type === BlockEnum.DataSource || type === BlockEnum.TriggerPlugin + const showDefaultIcon = !isToolOrDataSourceOrTriggerPlugin || !toolIcon return ( <div className={ @@ -114,11 +129,15 @@ const BlockIcon: FC<BlockIconProps> = ({ > { showDefaultIcon && ( - getIcon(type, size === 'xs' ? 'w-3 h-3' : 'w-3.5 h-3.5') + getIcon(type, + (type === BlockEnum.TriggerSchedule || type === BlockEnum.TriggerWebhook) + ? (size === 'xs' ? 'w-4 h-4' : 'w-4.5 h-4.5') + : (size === 'xs' ? 'w-3 h-3' : 'w-3.5 h-3.5'), + ) ) } { - isToolOrDataSource && toolIcon && ( + !showDefaultIcon && ( <> { typeof toolIcon === 'string' diff --git a/web/app/components/workflow/block-selector/all-start-blocks.tsx b/web/app/components/workflow/block-selector/all-start-blocks.tsx new file mode 100644 index 0000000000..a089978bdd --- /dev/null +++ b/web/app/components/workflow/block-selector/all-start-blocks.tsx @@ -0,0 +1,179 @@ +'use client' +import { + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import type { BlockEnum, OnSelectBlock } from '../types' +import type { TriggerDefaultValue, TriggerWithProvider } from './types' +import StartBlocks from './start-blocks' +import TriggerPluginList from './trigger-plugin/list' +import { ENTRY_NODE_TYPES } from './constants' +import cn from '@/utils/classnames' +import Link from 'next/link' +import { RiArrowRightUpLine } from '@remixicon/react' +import { getMarketplaceUrl } from '@/utils/var' +import Button from '@/app/components/base/button' +import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general' +import { BlockEnum as BlockEnumValue } from '../types' +import FeaturedTriggers from './featured-triggers' +import Divider from '@/app/components/base/divider' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useAllTriggerPlugins, useInvalidateAllTriggerPlugins } from '@/service/use-triggers' +import { useFeaturedTriggersRecommendations } from '@/service/use-plugins' + +const marketplaceFooterClassName = 'system-sm-medium z-10 flex h-8 flex-none cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg' + +type AllStartBlocksProps = { + className?: string + searchText: string + onSelect: (type: BlockEnum, trigger?: TriggerDefaultValue) => void + availableBlocksTypes?: BlockEnum[] + tags?: string[] + allowUserInputSelection?: boolean // Allow user input option even when trigger node already exists (e.g. when no Start node yet or changing node type). +} + +const AllStartBlocks = ({ + className, + searchText, + onSelect, + availableBlocksTypes, + tags = [], + allowUserInputSelection = false, +}: AllStartBlocksProps) => { + const { t } = useTranslation() + const [hasStartBlocksContent, setHasStartBlocksContent] = useState(false) + const [hasPluginContent, setHasPluginContent] = useState(false) + const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) + + const entryNodeTypes = availableBlocksTypes?.length + ? availableBlocksTypes + : ENTRY_NODE_TYPES + const enableTriggerPlugin = entryNodeTypes.includes(BlockEnumValue.TriggerPlugin) + const { data: triggerProviders = [] } = useAllTriggerPlugins(enableTriggerPlugin) + const providerMap = useMemo(() => { + const map = new Map<string, TriggerWithProvider>() + triggerProviders.forEach((provider) => { + const keys = [ + provider.plugin_id, + provider.plugin_unique_identifier, + provider.id, + ].filter(Boolean) as string[] + keys.forEach((key) => { + if (!map.has(key)) + map.set(key, provider) + }) + }) + return map + }, [triggerProviders]) + const invalidateTriggers = useInvalidateAllTriggerPlugins() + const trimmedSearchText = searchText.trim() + const hasSearchText = trimmedSearchText.length > 0 + const { + plugins: featuredPlugins = [], + isLoading: featuredLoading, + } = useFeaturedTriggersRecommendations(enableTriggerPlugin && enable_marketplace && !hasSearchText) + + const shouldShowFeatured = enableTriggerPlugin + && enable_marketplace + && !hasSearchText + + const handleStartBlocksContentChange = useCallback((hasContent: boolean) => { + setHasStartBlocksContent(hasContent) + }, []) + + const handlePluginContentChange = useCallback((hasContent: boolean) => { + setHasPluginContent(hasContent) + }, []) + + const hasAnyContent = hasStartBlocksContent || hasPluginContent || shouldShowFeatured + const shouldShowEmptyState = hasSearchText && !hasAnyContent + + useEffect(() => { + if (!enableTriggerPlugin && hasPluginContent) + setHasPluginContent(false) + }, [enableTriggerPlugin, hasPluginContent]) + + return ( + <div className={cn('min-w-[400px] max-w-[500px]', className)}> + <div className='flex max-h-[640px] flex-col'> + <div className='flex-1 overflow-y-auto'> + <div className={cn(shouldShowEmptyState && 'hidden')}> + {shouldShowFeatured && ( + <> + <FeaturedTriggers + plugins={featuredPlugins} + providerMap={providerMap} + onSelect={onSelect} + isLoading={featuredLoading} + onInstallSuccess={async () => { + invalidateTriggers() + }} + /> + <div className='px-3'> + <Divider className='!h-px' /> + </div> + </> + )} + <div className='px-3 pb-1 pt-2'> + <span className='system-xs-medium text-text-primary'>{t('workflow.tabs.allTriggers')}</span> + </div> + <StartBlocks + searchText={trimmedSearchText} + onSelect={onSelect as OnSelectBlock} + availableBlocksTypes={entryNodeTypes as unknown as BlockEnum[]} + hideUserInput={!allowUserInputSelection} + onContentStateChange={handleStartBlocksContentChange} + /> + + {enableTriggerPlugin && ( + <TriggerPluginList + onSelect={onSelect} + searchText={trimmedSearchText} + onContentStateChange={handlePluginContentChange} + tags={tags} + /> + )} + </div> + + {shouldShowEmptyState && ( + <div className='flex h-full flex-col items-center justify-center gap-3 py-12 text-center'> + <SearchMenu className='h-8 w-8 text-text-quaternary' /> + <div className='text-sm font-medium text-text-secondary'> + {t('workflow.tabs.noPluginsFound')} + </div> + <Link + href='https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml' + target='_blank' + > + <Button + size='small' + variant='secondary-accent' + className='h-6 cursor-pointer px-3 text-xs' + > + {t('workflow.tabs.requestToCommunity')} + </Button> + </Link> + </div> + )} + </div> + + {!shouldShowEmptyState && ( + // Footer - Same as Tools tab marketplace footer + <Link + className={marketplaceFooterClassName} + href={getMarketplaceUrl('')} + target='_blank' + > + <span>{t('plugin.findMoreInMarketplace')}</span> + <RiArrowRightUpLine className='ml-0.5 h-3 w-3' /> + </Link> + )} + </div> + </div> + ) +} + +export default AllStartBlocks diff --git a/web/app/components/workflow/block-selector/all-tools.tsx b/web/app/components/workflow/block-selector/all-tools.tsx index 7db8b9acf5..d330eb182b 100644 --- a/web/app/components/workflow/block-selector/all-tools.tsx +++ b/web/app/components/workflow/block-selector/all-tools.tsx @@ -1,16 +1,12 @@ import type { Dispatch, + RefObject, SetStateAction, } from 'react' -import { - useEffect, - useMemo, - useRef, - useState, -} from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import type { BlockEnum, - OnSelectBlock, ToolWithProvider, } from '../types' import type { ToolDefaultValue, ToolValue } from './types' @@ -19,13 +15,24 @@ import Tools from './tools' import { useToolTabs } from './hooks' import ViewTypeSelect, { ViewType } from './view-type-select' import cn from '@/utils/classnames' -import { useGetLanguage } from '@/context/i18n' +import Button from '@/app/components/base/button' +import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general' import type { ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list' import PluginList, { type ListProps } from '@/app/components/workflow/block-selector/market-place-plugin/list' -import { PluginType } from '../../plugins/types' +import type { Plugin } from '../../plugins/types' +import { PluginCategoryEnum } from '../../plugins/types' import { useMarketplacePlugins } from '../../plugins/marketplace/hooks' import { useGlobalPublicStore } from '@/context/global-public-context' import RAGToolRecommendations from './rag-tool-recommendations' +import FeaturedTools from './featured-tools' +import Link from 'next/link' +import Divider from '@/app/components/base/divider' +import { RiArrowRightUpLine } from '@remixicon/react' +import { getMarketplaceUrl } from '@/utils/var' +import { useGetLanguage } from '@/context/i18n' +import type { OnSelectBlock } from '@/app/components/workflow/types' + +const marketplaceFooterClassName = 'system-sm-medium z-10 flex h-8 flex-none cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg' type AllToolsProps = { className?: string @@ -36,13 +43,17 @@ type AllToolsProps = { customTools: ToolWithProvider[] workflowTools: ToolWithProvider[] mcpTools: ToolWithProvider[] - onSelect: OnSelectBlock + onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void canNotSelectMultiple?: boolean onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void selectedTools?: ToolValue[] canChooseMCPTool?: boolean - onTagsChange: Dispatch<SetStateAction<string[]>> + onTagsChange?: Dispatch<SetStateAction<string[]>> isInRAGPipeline?: boolean + featuredPlugins?: Plugin[] + featuredLoading?: boolean + showFeatured?: boolean + onFeaturedInstallSuccess?: () => Promise<void> | void } const DEFAULT_TAGS: AllToolsProps['tags'] = [] @@ -63,15 +74,33 @@ const AllTools = ({ canChooseMCPTool, onTagsChange, isInRAGPipeline = false, + featuredPlugins = [], + featuredLoading = false, + showFeatured = false, + onFeaturedInstallSuccess, }: AllToolsProps) => { + const { t } = useTranslation() const language = useGetLanguage() const tabs = useToolTabs() const [activeTab, setActiveTab] = useState(ToolTypeEnum.All) const [activeView, setActiveView] = useState<ViewType>(ViewType.flat) - const hasFilter = searchText || tags.length > 0 + const trimmedSearchText = searchText.trim() + const hasSearchText = trimmedSearchText.length > 0 + const hasTags = tags.length > 0 + const hasFilter = hasSearchText || hasTags const isMatchingKeywords = (text: string, keywords: string) => { return text.toLowerCase().includes(keywords.toLowerCase()) } + const allProviders = useMemo(() => [...buildInTools, ...customTools, ...workflowTools, ...mcpTools], [buildInTools, customTools, workflowTools, mcpTools]) + const providerMap = useMemo(() => { + const map = new Map<string, ToolWithProvider>() + allProviders.forEach((provider) => { + const key = provider.plugin_id || provider.id + if (key) + map.set(key, provider) + }) + return map + }, [allProviders]) const tools = useMemo(() => { let mergedTools: ToolWithProvider[] = [] if (activeTab === ToolTypeEnum.All) @@ -85,15 +114,55 @@ const AllTools = ({ if (activeTab === ToolTypeEnum.MCP) mergedTools = mcpTools - if (!hasFilter) + const normalizedSearch = trimmedSearchText.toLowerCase() + const getLocalizedText = (text?: Record<string, string> | null) => { + if (!text) + return '' + + if (text[language]) + return text[language] + + if (text['en-US']) + return text['en-US'] + + const firstValue = Object.values(text).find(Boolean) + return firstValue || '' + } + + if (!hasFilter || !normalizedSearch) return mergedTools.filter(toolWithProvider => toolWithProvider.tools.length > 0) - return mergedTools.filter((toolWithProvider) => { - return isMatchingKeywords(toolWithProvider.name, searchText) || toolWithProvider.tools.some((tool) => { - return tool.label[language].toLowerCase().includes(searchText.toLowerCase()) || tool.name.toLowerCase().includes(searchText.toLowerCase()) + return mergedTools.reduce<ToolWithProvider[]>((acc, toolWithProvider) => { + const providerLabel = getLocalizedText(toolWithProvider.label) + const providerMatches = [ + toolWithProvider.name, + providerLabel, + ].some(text => isMatchingKeywords(text || '', normalizedSearch)) + + if (providerMatches) { + if (toolWithProvider.tools.length > 0) + acc.push(toolWithProvider) + return acc + } + + const matchedTools = toolWithProvider.tools.filter((tool) => { + const toolLabel = getLocalizedText(tool.label) + return [ + tool.name, + toolLabel, + ].some(text => isMatchingKeywords(text || '', normalizedSearch)) }) - }) - }, [activeTab, buildInTools, customTools, workflowTools, mcpTools, searchText, language, hasFilter]) + + if (matchedTools.length > 0) { + acc.push({ + ...toolWithProvider, + tools: matchedTools, + }) + } + + return acc + }, []) + }, [activeTab, buildInTools, customTools, workflowTools, mcpTools, trimmedSearchText, hasFilter, language]) const { queryPluginsWithDebounced: fetchPlugins, @@ -101,22 +170,38 @@ const AllTools = ({ } = useMarketplacePlugins() const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) + useEffect(() => { if (!enable_marketplace) return - if (searchText || tags.length > 0) { + if (hasFilter) { fetchPlugins({ query: searchText, tags, - category: PluginType.tool, + category: PluginCategoryEnum.tool, }) } - }, [searchText, tags, enable_marketplace]) + }, [searchText, tags, enable_marketplace, hasFilter, fetchPlugins]) const pluginRef = useRef<ListRef>(null) const wrapElemRef = useRef<HTMLDivElement>(null) const isSupportGroupView = [ToolTypeEnum.All, ToolTypeEnum.BuiltIn].includes(activeTab) const isShowRAGRecommendations = isInRAGPipeline && activeTab === ToolTypeEnum.All && !hasFilter + const hasToolsListContent = tools.length > 0 || isShowRAGRecommendations + const hasPluginContent = enable_marketplace && notInstalledPlugins.length > 0 + const shouldShowEmptyState = hasFilter && !hasToolsListContent && !hasPluginContent + const shouldShowFeatured = showFeatured + && enable_marketplace + && !isInRAGPipeline + && activeTab === ToolTypeEnum.All + && !hasFilter + const shouldShowMarketplaceFooter = enable_marketplace && !hasFilter + + const handleRAGSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { + if (!pluginDefaultValue) + return + onSelect(type, pluginDefaultValue as ToolDefaultValue) + }, [onSelect]) return ( <div className={cn('min-w-[400px] max-w-[500px]', className)}> @@ -142,41 +227,100 @@ const AllTools = ({ <ViewTypeSelect viewType={activeView} onChange={setActiveView} /> )} </div> - <div - ref={wrapElemRef} - className='max-h-[464px] overflow-y-auto' - onScroll={pluginRef.current?.handleScroll} - > - {isShowRAGRecommendations && ( - <RAGToolRecommendations - viewType={isSupportGroupView ? activeView : ViewType.flat} - onSelect={onSelect} - onTagsChange={onTagsChange} - /> - )} - <Tools - className={toolContentClassName} - tools={tools} - onSelect={onSelect} - canNotSelectMultiple={canNotSelectMultiple} - onSelectMultiple={onSelectMultiple} - toolType={activeTab} - viewType={isSupportGroupView ? activeView : ViewType.flat} - hasSearchText={!!searchText} - selectedTools={selectedTools} - canChooseMCPTool={canChooseMCPTool} - isShowRAGRecommendations={isShowRAGRecommendations} - /> - {/* Plugins from marketplace */} - {enable_marketplace && ( - <PluginList - ref={pluginRef} - wrapElemRef={wrapElemRef} - list={notInstalledPlugins} - searchText={searchText} - toolContentClassName={toolContentClassName} - tags={tags} - /> + <div className='flex max-h-[464px] flex-col'> + <div + ref={wrapElemRef} + className='flex-1 overflow-y-auto' + onScroll={pluginRef.current?.handleScroll} + > + <div className={cn(shouldShowEmptyState && 'hidden')}> + {isShowRAGRecommendations && onTagsChange && ( + <RAGToolRecommendations + viewType={isSupportGroupView ? activeView : ViewType.flat} + onSelect={handleRAGSelect} + onTagsChange={onTagsChange} + /> + )} + {shouldShowFeatured && ( + <> + <FeaturedTools + plugins={featuredPlugins} + providerMap={providerMap} + onSelect={onSelect} + selectedTools={selectedTools} + canChooseMCPTool={canChooseMCPTool} + isLoading={featuredLoading} + onInstallSuccess={async () => { + await onFeaturedInstallSuccess?.() + }} + /> + <div className='px-3'> + <Divider className='!h-px' /> + </div> + </> + )} + {hasToolsListContent && ( + <> + <div className='px-3 pb-1 pt-2'> + <span className='system-xs-medium text-text-primary'>{t('tools.allTools')}</span> + </div> + <Tools + className={toolContentClassName} + tools={tools} + onSelect={onSelect} + canNotSelectMultiple={canNotSelectMultiple} + onSelectMultiple={onSelectMultiple} + toolType={activeTab} + viewType={isSupportGroupView ? activeView : ViewType.flat} + hasSearchText={hasSearchText} + selectedTools={selectedTools} + canChooseMCPTool={canChooseMCPTool} + /> + </> + )} + {enable_marketplace && ( + <PluginList + ref={pluginRef} + wrapElemRef={wrapElemRef as RefObject<HTMLElement>} + list={notInstalledPlugins} + searchText={searchText} + toolContentClassName={toolContentClassName} + tags={tags} + hideFindMoreFooter + /> + )} + </div> + + {shouldShowEmptyState && ( + <div className='flex h-full flex-col items-center justify-center gap-3 py-12 text-center'> + <SearchMenu className='h-8 w-8 text-text-quaternary' /> + <div className='text-sm font-medium text-text-secondary'> + {t('workflow.tabs.noPluginsFound')} + </div> + <Link + href='https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml' + target='_blank' + > + <Button + size='small' + variant='secondary-accent' + className='h-6 cursor-pointer px-3 text-xs' + > + {t('workflow.tabs.requestToCommunity')} + </Button> + </Link> + </div> + )} + </div> + {shouldShowMarketplaceFooter && ( + <Link + className={marketplaceFooterClassName} + href={getMarketplaceUrl('')} + target='_blank' + > + <span>{t('plugin.findMoreInMarketplace')}</span> + <RiArrowRightUpLine className='ml-0.5 h-3 w-3' /> + </Link> )} </div> </div> diff --git a/web/app/components/workflow/block-selector/blocks.tsx b/web/app/components/workflow/block-selector/blocks.tsx index 18bf55f3f9..cae1ec32a5 100644 --- a/web/app/components/workflow/block-selector/blocks.tsx +++ b/web/app/components/workflow/block-selector/blocks.tsx @@ -10,28 +10,50 @@ import BlockIcon from '../block-icon' import { BlockEnum } from '../types' import type { NodeDefault } from '../types' import { BLOCK_CLASSIFICATIONS } from './constants' -import type { ToolDefaultValue } from './types' +import { useBlocks } from './hooks' import Tooltip from '@/app/components/base/tooltip' import Badge from '@/app/components/base/badge' type BlocksProps = { searchText: string - onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + onSelect: (type: BlockEnum) => void availableBlocksTypes?: BlockEnum[] - blocks: NodeDefault[] + blocks?: NodeDefault[] } const Blocks = ({ searchText, onSelect, availableBlocksTypes = [], - blocks, + blocks: blocksFromProps, }: BlocksProps) => { const { t } = useTranslation() const store = useStoreApi() + const blocksFromHooks = useBlocks() + + // Use external blocks if provided, otherwise fallback to hook-based blocks + const blocks = blocksFromProps || blocksFromHooks.map(block => ({ + metaData: { + classification: block.classification, + sort: 0, // Default sort order + type: block.type, + title: block.title, + author: 'Dify', + description: block.description, + }, + defaultValue: {}, + checkValid: () => ({ isValid: true }), + }) as NodeDefault) const groups = useMemo(() => { return BLOCK_CLASSIFICATIONS.reduce((acc, classification) => { - const list = groupBy(blocks, 'metaData.classification')[classification].filter((block) => { + const grouped = groupBy(blocks, 'metaData.classification') + const list = (grouped[classification] || []).filter((block) => { + // Filter out trigger types from Blocks tab + if (block.metaData.type === BlockEnum.TriggerWebhook + || block.metaData.type === BlockEnum.TriggerSchedule + || block.metaData.type === BlockEnum.TriggerPlugin) + return false + return block.metaData.title.toLowerCase().includes(searchText.toLowerCase()) && availableBlocksTypes.includes(block.metaData.type) }) @@ -44,7 +66,7 @@ const Blocks = ({ const isEmpty = Object.values(groups).every(list => !list.length) const renderGroup = useCallback((classification: string) => { - const list = groups[classification].sort((a, b) => a.metaData.sort - b.metaData.sort) + const list = groups[classification].sort((a, b) => (a.metaData.sort || 0) - (b.metaData.sort || 0)) const { getNodes } = store.getState() const nodes = getNodes() const hasKnowledgeBaseNode = nodes.some(node => node.data.type === BlockEnum.KnowledgeBase) @@ -71,7 +93,7 @@ const Blocks = ({ <Tooltip key={block.metaData.type} position='right' - popupClassName='w-[200px]' + popupClassName='w-[200px] rounded-xl' needsDelay={false} popupContent={( <div> @@ -112,7 +134,7 @@ const Blocks = ({ }, [groups, onSelect, t, store]) return ( - <div className='max-h-[480px] overflow-y-auto p-1'> + <div className='max-h-[480px] min-w-[400px] max-w-[500px] overflow-y-auto p-1'> { isEmpty && ( <div className='flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary'>{t('workflow.tabs.noResult')}</div> diff --git a/web/app/components/workflow/block-selector/constants.tsx b/web/app/components/workflow/block-selector/constants.tsx index ab0c9586dc..ec05985453 100644 --- a/web/app/components/workflow/block-selector/constants.tsx +++ b/web/app/components/workflow/block-selector/constants.tsx @@ -1,3 +1,5 @@ +import type { Block } from '../types' +import { BlockEnum } from '../types' import { BlockClassificationEnum } from './types' export const BLOCK_CLASSIFICATIONS: string[] = [ @@ -29,3 +31,125 @@ export const DEFAULT_FILE_EXTENSIONS_IN_LOCAL_FILE_DATA_SOURCE = [ 'ppt', 'md', ] + +export const START_BLOCKS: Block[] = [ + { + classification: BlockClassificationEnum.Default, + type: BlockEnum.Start, + title: 'User Input', + description: 'Traditional start node for user input', + }, + { + classification: BlockClassificationEnum.Default, + type: BlockEnum.TriggerSchedule, + title: 'Schedule Trigger', + description: 'Time-based workflow trigger', + }, + { + classification: BlockClassificationEnum.Default, + type: BlockEnum.TriggerWebhook, + title: 'Webhook Trigger', + description: 'HTTP callback trigger', + }, +] + +export const ENTRY_NODE_TYPES = [ + BlockEnum.Start, + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, +] as const + +export const BLOCKS: Block[] = [ + { + classification: BlockClassificationEnum.Default, + type: BlockEnum.LLM, + title: 'LLM', + }, + { + classification: BlockClassificationEnum.Default, + type: BlockEnum.KnowledgeRetrieval, + title: 'Knowledge Retrieval', + }, + { + classification: BlockClassificationEnum.Default, + type: BlockEnum.End, + title: 'End', + }, + { + classification: BlockClassificationEnum.Default, + type: BlockEnum.Answer, + title: 'Direct Answer', + }, + { + classification: BlockClassificationEnum.QuestionUnderstand, + type: BlockEnum.QuestionClassifier, + title: 'Question Classifier', + }, + { + classification: BlockClassificationEnum.Logic, + type: BlockEnum.IfElse, + title: 'IF/ELSE', + }, + { + classification: BlockClassificationEnum.Logic, + type: BlockEnum.LoopEnd, + title: 'Exit Loop', + description: '', + }, + { + classification: BlockClassificationEnum.Logic, + type: BlockEnum.Iteration, + title: 'Iteration', + }, + { + classification: BlockClassificationEnum.Logic, + type: BlockEnum.Loop, + title: 'Loop', + }, + { + classification: BlockClassificationEnum.Transform, + type: BlockEnum.Code, + title: 'Code', + }, + { + classification: BlockClassificationEnum.Transform, + type: BlockEnum.TemplateTransform, + title: 'Templating Transform', + }, + { + classification: BlockClassificationEnum.Transform, + type: BlockEnum.VariableAggregator, + title: 'Variable Aggregator', + }, + { + classification: BlockClassificationEnum.Transform, + type: BlockEnum.DocExtractor, + title: 'Doc Extractor', + }, + { + classification: BlockClassificationEnum.Transform, + type: BlockEnum.Assigner, + title: 'Variable Assigner', + }, + { + classification: BlockClassificationEnum.Transform, + type: BlockEnum.ParameterExtractor, + title: 'Parameter Extractor', + }, + { + classification: BlockClassificationEnum.Utilities, + type: BlockEnum.HttpRequest, + title: 'HTTP Request', + }, + { + classification: BlockClassificationEnum.Utilities, + type: BlockEnum.ListFilter, + title: 'List Filter', + }, + { + classification: BlockClassificationEnum.Default, + type: BlockEnum.Agent, + title: 'Agent', + }, +] diff --git a/web/app/components/workflow/block-selector/data-sources.tsx b/web/app/components/workflow/block-selector/data-sources.tsx index 441ede2334..b98a52dcff 100644 --- a/web/app/components/workflow/block-selector/data-sources.tsx +++ b/web/app/components/workflow/block-selector/data-sources.tsx @@ -17,7 +17,7 @@ import PluginList, { type ListRef } from '@/app/components/workflow/block-select import { useGlobalPublicStore } from '@/context/global-public-context' import { DEFAULT_FILE_EXTENSIONS_IN_LOCAL_FILE_DATA_SOURCE } from './constants' import { useMarketplacePlugins } from '../../plugins/marketplace/hooks' -import { PluginType } from '../../plugins/types' +import { PluginCategoryEnum } from '../../plugins/types' import { useGetLanguage } from '@/context/i18n' type AllToolsProps = { @@ -55,7 +55,7 @@ const DataSources = ({ }) }, [searchText, dataSources, language]) - const handleSelect = useCallback((_: any, toolDefaultValue: ToolDefaultValue) => { + const handleSelect = useCallback((_: BlockEnum, toolDefaultValue: ToolDefaultValue) => { let defaultValue: DataSourceDefaultValue = { plugin_id: toolDefaultValue?.provider_id, provider_type: toolDefaultValue?.provider_type, @@ -63,6 +63,7 @@ const DataSources = ({ datasource_name: toolDefaultValue?.tool_name, datasource_label: toolDefaultValue?.tool_label, title: toolDefaultValue?.title, + plugin_unique_identifier: toolDefaultValue?.plugin_unique_identifier, } // Update defaultValue with fileExtensions if this is the local file data source if (toolDefaultValue?.provider_id === 'langgenius/file' && toolDefaultValue?.provider_name === 'file') { @@ -86,16 +87,16 @@ const DataSources = ({ if (searchText) { fetchPlugins({ query: searchText, - category: PluginType.datasource, + category: PluginCategoryEnum.datasource, }) } }, [searchText, enable_marketplace]) return ( - <div className={cn(className)}> + <div className={cn('w-[400px] min-w-0 max-w-full', className)}> <div ref={wrapElemRef} - className='max-h-[464px] overflow-y-auto' + className='max-h-[464px] overflow-y-auto overflow-x-hidden' onScroll={pluginRef.current?.handleScroll} > <Tools diff --git a/web/app/components/workflow/block-selector/featured-tools.tsx b/web/app/components/workflow/block-selector/featured-tools.tsx new file mode 100644 index 0000000000..fe5c561362 --- /dev/null +++ b/web/app/components/workflow/block-selector/featured-tools.tsx @@ -0,0 +1,333 @@ +'use client' +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { BlockEnum, type ToolWithProvider } from '../types' +import type { ToolDefaultValue, ToolValue } from './types' +import type { Plugin } from '@/app/components/plugins/types' +import { useGetLanguage } from '@/context/i18n' +import BlockIcon from '../block-icon' +import Tooltip from '@/app/components/base/tooltip' +import { RiMoreLine } from '@remixicon/react' +import Loading from '@/app/components/base/loading' +import Link from 'next/link' +import { getMarketplaceUrl } from '@/utils/var' +import { ToolTypeEnum } from './types' +import { ViewType } from './view-type-select' +import Tools from './tools' +import { formatNumber } from '@/utils/format' +import Action from '@/app/components/workflow/block-selector/market-place-plugin/action' +import { ArrowDownDoubleLine, ArrowDownRoundFill, ArrowUpDoubleLine } from '@/app/components/base/icons/src/vender/solid/arrows' +import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' + +const MAX_RECOMMENDED_COUNT = 15 +const INITIAL_VISIBLE_COUNT = 5 + +type FeaturedToolsProps = { + plugins: Plugin[] + providerMap: Map<string, ToolWithProvider> + onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void + selectedTools?: ToolValue[] + canChooseMCPTool?: boolean + isLoading?: boolean + onInstallSuccess?: () => void +} + +const STORAGE_KEY = 'workflow_tools_featured_collapsed' + +const FeaturedTools = ({ + plugins, + providerMap, + onSelect, + selectedTools, + canChooseMCPTool, + isLoading = false, + onInstallSuccess, +}: FeaturedToolsProps) => { + const { t } = useTranslation() + const language = useGetLanguage() + const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT) + const [isCollapsed, setIsCollapsed] = useState<boolean>(() => { + if (typeof window === 'undefined') + return false + const stored = window.localStorage.getItem(STORAGE_KEY) + return stored === 'true' + }) + + useEffect(() => { + if (typeof window === 'undefined') + return + const stored = window.localStorage.getItem(STORAGE_KEY) + if (stored !== null) + setIsCollapsed(stored === 'true') + }, []) + + useEffect(() => { + if (typeof window === 'undefined') + return + window.localStorage.setItem(STORAGE_KEY, String(isCollapsed)) + }, [isCollapsed]) + + useEffect(() => { + setVisibleCount(INITIAL_VISIBLE_COUNT) + }, [plugins]) + + const limitedPlugins = useMemo( + () => plugins.slice(0, MAX_RECOMMENDED_COUNT), + [plugins], + ) + + const { + installedProviders, + uninstalledPlugins, + } = useMemo(() => { + const installed: ToolWithProvider[] = [] + const uninstalled: Plugin[] = [] + const visitedProviderIds = new Set<string>() + + limitedPlugins.forEach((plugin) => { + const provider = providerMap.get(plugin.plugin_id) + if (provider) { + if (!visitedProviderIds.has(provider.id)) { + installed.push(provider) + visitedProviderIds.add(provider.id) + } + } + else { + uninstalled.push(plugin) + } + }) + + return { + installedProviders: installed, + uninstalledPlugins: uninstalled, + } + }, [limitedPlugins, providerMap]) + + const totalQuota = Math.min(visibleCount, MAX_RECOMMENDED_COUNT) + + const visibleInstalledProviders = useMemo( + () => installedProviders.slice(0, totalQuota), + [installedProviders, totalQuota], + ) + + const remainingSlots = Math.max(totalQuota - visibleInstalledProviders.length, 0) + + const visibleUninstalledPlugins = useMemo( + () => (remainingSlots > 0 ? uninstalledPlugins.slice(0, remainingSlots) : []), + [uninstalledPlugins, remainingSlots], + ) + + const totalVisible = visibleInstalledProviders.length + visibleUninstalledPlugins.length + const maxAvailable = Math.min(MAX_RECOMMENDED_COUNT, installedProviders.length + uninstalledPlugins.length) + const hasMoreToShow = totalVisible < maxAvailable + const canToggleVisibility = maxAvailable > INITIAL_VISIBLE_COUNT + const isExpanded = canToggleVisibility && !hasMoreToShow + const showEmptyState = !isLoading && totalVisible === 0 + + return ( + <div className='px-3 pb-3 pt-2'> + <button + type='button' + className='flex w-full items-center rounded-md px-0 py-1 text-left text-text-primary' + onClick={() => setIsCollapsed(prev => !prev)} + > + <span className='system-xs-medium text-text-primary'>{t('workflow.tabs.featuredTools')}</span> + <ArrowDownRoundFill className={`ml-0.5 h-4 w-4 text-text-tertiary transition-transform ${isCollapsed ? '-rotate-90' : 'rotate-0'}`} /> + </button> + + {!isCollapsed && ( + <> + {isLoading && ( + <div className='py-3'> + <Loading type='app' /> + </div> + )} + + {showEmptyState && ( + <p className='system-xs-regular py-2 text-text-tertiary'> + <Link className='text-text-accent' href={getMarketplaceUrl('', { category: 'tool' })} target='_blank' rel='noopener noreferrer'> + {t('workflow.tabs.noFeaturedPlugins')} + </Link> + </p> + )} + + {!showEmptyState && !isLoading && ( + <> + {visibleInstalledProviders.length > 0 && ( + <Tools + className='p-0' + tools={visibleInstalledProviders} + onSelect={onSelect} + canNotSelectMultiple + toolType={ToolTypeEnum.All} + viewType={ViewType.flat} + hasSearchText={false} + selectedTools={selectedTools} + canChooseMCPTool={canChooseMCPTool} + /> + )} + + {visibleUninstalledPlugins.length > 0 && ( + <div className='mt-1 flex flex-col gap-1'> + {visibleUninstalledPlugins.map(plugin => ( + <FeaturedToolUninstalledItem + key={plugin.plugin_id} + plugin={plugin} + language={language} + onInstallSuccess={async () => { + await onInstallSuccess?.() + }} + t={t} + /> + ))} + </div> + )} + </> + )} + + {!isLoading && totalVisible > 0 && canToggleVisibility && ( + <div + className='group mt-1 flex cursor-pointer items-center gap-x-2 rounded-lg py-1 pl-3 pr-2 text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary' + onClick={() => { + setVisibleCount((count) => { + if (count >= maxAvailable) + return INITIAL_VISIBLE_COUNT + + return Math.min(count + INITIAL_VISIBLE_COUNT, maxAvailable) + }) + }} + > + <div className='flex items-center px-1 text-text-tertiary transition-colors group-hover:text-text-secondary'> + <RiMoreLine className='size-4 group-hover:hidden' /> + {isExpanded ? ( + <ArrowUpDoubleLine className='hidden size-4 group-hover:block' /> + ) : ( + <ArrowDownDoubleLine className='hidden size-4 group-hover:block' /> + )} + </div> + <div className='system-xs-regular'> + {t(isExpanded ? 'workflow.tabs.showLessFeatured' : 'workflow.tabs.showMoreFeatured')} + </div> + </div> + )} + </> + )} + </div> + ) +} + +type FeaturedToolUninstalledItemProps = { + plugin: Plugin + language: string + onInstallSuccess?: () => Promise<void> | void + t: (key: string, options?: Record<string, any>) => string +} + +function FeaturedToolUninstalledItem({ + plugin, + language, + onInstallSuccess, + t, +}: FeaturedToolUninstalledItemProps) { + const label = plugin.label?.[language] || plugin.name + const description = typeof plugin.brief === 'object' ? plugin.brief[language] : plugin.brief + const installCountLabel = t('plugin.install', { num: formatNumber(plugin.install_count || 0) }) + const [actionOpen, setActionOpen] = useState(false) + const [isActionHovered, setIsActionHovered] = useState(false) + const [isInstallModalOpen, setIsInstallModalOpen] = useState(false) + + useEffect(() => { + if (!actionOpen) + return + + const handleScroll = () => { + setActionOpen(false) + setIsActionHovered(false) + } + + window.addEventListener('scroll', handleScroll, true) + + return () => { + window.removeEventListener('scroll', handleScroll, true) + } + }, [actionOpen]) + + return ( + <> + <Tooltip + position='right' + needsDelay={false} + popupClassName='!p-0 !px-3 !py-2.5 !w-[224px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !rounded-xl !shadow-lg' + popupContent={( + <div> + <BlockIcon size='md' className='mb-2' type={BlockEnum.Tool} toolIcon={plugin.icon} /> + <div className='mb-1 text-sm leading-5 text-text-primary'>{label}</div> + <div className='text-xs leading-[18px] text-text-secondary'>{description}</div> + </div> + )} + disabled={!description || isActionHovered || actionOpen || isInstallModalOpen} + > + <div + className='group flex h-8 w-full items-center rounded-lg pl-3 pr-1 hover:bg-state-base-hover' + > + <div className='flex h-full min-w-0 items-center'> + <BlockIcon type={BlockEnum.Tool} toolIcon={plugin.icon} /> + <div className='ml-2 min-w-0'> + <div className='system-sm-medium truncate text-text-secondary'>{label}</div> + </div> + </div> + <div className='ml-auto flex h-full items-center gap-1 pl-1'> + <span className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}>{installCountLabel}</span> + <div + className={`system-xs-medium flex h-full items-center gap-1 text-components-button-secondary-accent-text [&_.action-btn]:h-6 [&_.action-btn]:min-h-0 [&_.action-btn]:w-6 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? 'flex' : 'hidden group-hover:flex'}`} + onMouseEnter={() => setIsActionHovered(true)} + onMouseLeave={() => { + if (!actionOpen) + setIsActionHovered(false) + }} + > + <button + type='button' + className='cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover' + onClick={() => { + setActionOpen(false) + setIsInstallModalOpen(true) + setIsActionHovered(true) + }} + > + {t('plugin.installAction')} + </button> + <Action + open={actionOpen} + onOpenChange={(value) => { + setActionOpen(value) + setIsActionHovered(value) + }} + author={plugin.org} + name={plugin.name} + version={plugin.latest_version} + /> + </div> + </div> + </div> + </Tooltip> + {isInstallModalOpen && ( + <InstallFromMarketplace + uniqueIdentifier={plugin.latest_package_identifier} + manifest={plugin} + onSuccess={async () => { + setIsInstallModalOpen(false) + setIsActionHovered(false) + await onInstallSuccess?.() + }} + onClose={() => { + setIsInstallModalOpen(false) + setIsActionHovered(false) + }} + /> + )} + </> + ) +} + +export default FeaturedTools diff --git a/web/app/components/workflow/block-selector/featured-triggers.tsx b/web/app/components/workflow/block-selector/featured-triggers.tsx new file mode 100644 index 0000000000..561ebc1784 --- /dev/null +++ b/web/app/components/workflow/block-selector/featured-triggers.tsx @@ -0,0 +1,326 @@ +'use client' +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { BlockEnum } from '../types' +import type { TriggerDefaultValue, TriggerWithProvider } from './types' +import type { Plugin } from '@/app/components/plugins/types' +import { useGetLanguage } from '@/context/i18n' +import BlockIcon from '../block-icon' +import Tooltip from '@/app/components/base/tooltip' +import { RiMoreLine } from '@remixicon/react' +import Loading from '@/app/components/base/loading' +import Link from 'next/link' +import { getMarketplaceUrl } from '@/utils/var' +import TriggerPluginItem from './trigger-plugin/item' +import { formatNumber } from '@/utils/format' +import Action from '@/app/components/workflow/block-selector/market-place-plugin/action' +import { ArrowDownDoubleLine, ArrowDownRoundFill, ArrowUpDoubleLine } from '@/app/components/base/icons/src/vender/solid/arrows' +import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' + +const MAX_RECOMMENDED_COUNT = 15 +const INITIAL_VISIBLE_COUNT = 5 + +type FeaturedTriggersProps = { + plugins: Plugin[] + providerMap: Map<string, TriggerWithProvider> + onSelect: (type: BlockEnum, trigger?: TriggerDefaultValue) => void + isLoading?: boolean + onInstallSuccess?: () => void | Promise<void> +} + +const STORAGE_KEY = 'workflow_triggers_featured_collapsed' + +const FeaturedTriggers = ({ + plugins, + providerMap, + onSelect, + isLoading = false, + onInstallSuccess, +}: FeaturedTriggersProps) => { + const { t } = useTranslation() + const language = useGetLanguage() + const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT) + const [isCollapsed, setIsCollapsed] = useState<boolean>(() => { + if (typeof window === 'undefined') + return false + const stored = window.localStorage.getItem(STORAGE_KEY) + return stored === 'true' + }) + + useEffect(() => { + if (typeof window === 'undefined') + return + const stored = window.localStorage.getItem(STORAGE_KEY) + if (stored !== null) + setIsCollapsed(stored === 'true') + }, []) + + useEffect(() => { + if (typeof window === 'undefined') + return + window.localStorage.setItem(STORAGE_KEY, String(isCollapsed)) + }, [isCollapsed]) + + useEffect(() => { + setVisibleCount(INITIAL_VISIBLE_COUNT) + }, [plugins]) + + const limitedPlugins = useMemo( + () => plugins.slice(0, MAX_RECOMMENDED_COUNT), + [plugins], + ) + + const { + installedProviders, + uninstalledPlugins, + } = useMemo(() => { + const installed: TriggerWithProvider[] = [] + const uninstalled: Plugin[] = [] + const visitedProviderIds = new Set<string>() + + limitedPlugins.forEach((plugin) => { + const provider = providerMap.get(plugin.plugin_id) || providerMap.get(plugin.latest_package_identifier) + if (provider) { + if (!visitedProviderIds.has(provider.id)) { + installed.push(provider) + visitedProviderIds.add(provider.id) + } + } + else { + uninstalled.push(plugin) + } + }) + + return { + installedProviders: installed, + uninstalledPlugins: uninstalled, + } + }, [limitedPlugins, providerMap]) + + const totalQuota = Math.min(visibleCount, MAX_RECOMMENDED_COUNT) + + const visibleInstalledProviders = useMemo( + () => installedProviders.slice(0, totalQuota), + [installedProviders, totalQuota], + ) + + const remainingSlots = Math.max(totalQuota - visibleInstalledProviders.length, 0) + + const visibleUninstalledPlugins = useMemo( + () => (remainingSlots > 0 ? uninstalledPlugins.slice(0, remainingSlots) : []), + [uninstalledPlugins, remainingSlots], + ) + + const totalVisible = visibleInstalledProviders.length + visibleUninstalledPlugins.length + const maxAvailable = Math.min(MAX_RECOMMENDED_COUNT, installedProviders.length + uninstalledPlugins.length) + const hasMoreToShow = totalVisible < maxAvailable + const canToggleVisibility = maxAvailable > INITIAL_VISIBLE_COUNT + const isExpanded = canToggleVisibility && !hasMoreToShow + const showEmptyState = !isLoading && totalVisible === 0 + + return ( + <div className='px-3 pb-3 pt-2'> + <button + type='button' + className='flex w-full items-center rounded-md px-0 py-1 text-left text-text-primary' + onClick={() => setIsCollapsed(prev => !prev)} + > + <span className='system-xs-medium text-text-primary'>{t('workflow.tabs.featuredTools')}</span> + <ArrowDownRoundFill className={`ml-0.5 h-4 w-4 text-text-tertiary transition-transform ${isCollapsed ? '-rotate-90' : 'rotate-0'}`} /> + </button> + + {!isCollapsed && ( + <> + {isLoading && ( + <div className='py-3'> + <Loading type='app' /> + </div> + )} + + {showEmptyState && ( + <p className='system-xs-regular py-2 text-text-tertiary'> + <Link className='text-text-accent' href={getMarketplaceUrl('', { category: 'trigger' })} target='_blank' rel='noopener noreferrer'> + {t('workflow.tabs.noFeaturedTriggers')} + </Link> + </p> + )} + + {!showEmptyState && !isLoading && ( + <> + {visibleInstalledProviders.length > 0 && ( + <div className='mt-1'> + {visibleInstalledProviders.map(provider => ( + <TriggerPluginItem + key={provider.id} + payload={provider} + hasSearchText={false} + onSelect={onSelect} + /> + ))} + </div> + )} + + {visibleUninstalledPlugins.length > 0 && ( + <div className='mt-1 flex flex-col gap-1'> + {visibleUninstalledPlugins.map(plugin => ( + <FeaturedTriggerUninstalledItem + key={plugin.plugin_id} + plugin={plugin} + language={language} + onInstallSuccess={async () => { + await onInstallSuccess?.() + }} + t={t} + /> + ))} + </div> + )} + </> + )} + + {!isLoading && totalVisible > 0 && canToggleVisibility && ( + <div + className='group mt-1 flex cursor-pointer items-center gap-x-2 rounded-lg py-1 pl-3 pr-2 text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary' + onClick={() => { + setVisibleCount((count) => { + if (count >= maxAvailable) + return INITIAL_VISIBLE_COUNT + + return Math.min(count + INITIAL_VISIBLE_COUNT, maxAvailable) + }) + }} + > + <div className='flex items-center px-1 text-text-tertiary transition-colors group-hover:text-text-secondary'> + <RiMoreLine className='size-4 group-hover:hidden' /> + {isExpanded ? ( + <ArrowUpDoubleLine className='hidden size-4 group-hover:block' /> + ) : ( + <ArrowDownDoubleLine className='hidden size-4 group-hover:block' /> + )} + </div> + <div className='system-xs-regular'> + {t(isExpanded ? 'workflow.tabs.showLessFeatured' : 'workflow.tabs.showMoreFeatured')} + </div> + </div> + )} + </> + )} + </div> + ) +} + +type FeaturedTriggerUninstalledItemProps = { + plugin: Plugin + language: string + onInstallSuccess?: () => Promise<void> | void + t: (key: string, options?: Record<string, any>) => string +} + +function FeaturedTriggerUninstalledItem({ + plugin, + language, + onInstallSuccess, + t, +}: FeaturedTriggerUninstalledItemProps) { + const label = plugin.label?.[language] || plugin.name + const description = typeof plugin.brief === 'object' ? plugin.brief[language] : plugin.brief + const installCountLabel = t('plugin.install', { num: formatNumber(plugin.install_count || 0) }) + const [actionOpen, setActionOpen] = useState(false) + const [isActionHovered, setIsActionHovered] = useState(false) + const [isInstallModalOpen, setIsInstallModalOpen] = useState(false) + + useEffect(() => { + if (!actionOpen) + return + + const handleScroll = () => { + setActionOpen(false) + setIsActionHovered(false) + } + + window.addEventListener('scroll', handleScroll, true) + + return () => { + window.removeEventListener('scroll', handleScroll, true) + } + }, [actionOpen]) + + return ( + <> + <Tooltip + position='right' + needsDelay={false} + popupClassName='!p-0 !px-3 !py-2.5 !w-[224px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !rounded-xl !shadow-lg' + popupContent={( + <div> + <BlockIcon size='md' className='mb-2' type={BlockEnum.TriggerPlugin} toolIcon={plugin.icon} /> + <div className='mb-1 text-sm leading-5 text-text-primary'>{label}</div> + <div className='text-xs leading-[18px] text-text-secondary'>{description}</div> + </div> + )} + disabled={!description || isActionHovered || actionOpen || isInstallModalOpen} + > + <div + className='group flex h-8 w-full items-center rounded-lg pl-3 pr-1 hover:bg-state-base-hover' + > + <div className='flex h-full min-w-0 items-center'> + <BlockIcon type={BlockEnum.TriggerPlugin} toolIcon={plugin.icon} /> + <div className='ml-2 min-w-0'> + <div className='system-sm-medium truncate text-text-secondary'>{label}</div> + </div> + </div> + <div className='ml-auto flex h-full items-center gap-1 pl-1'> + <span className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}>{installCountLabel}</span> + <div + className={`system-xs-medium flex h-full items-center gap-1 text-components-button-secondary-accent-text [&_.action-btn]:h-6 [&_.action-btn]:min-h-0 [&_.action-btn]:w-6 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? 'flex' : 'hidden group-hover:flex'}`} + onMouseEnter={() => setIsActionHovered(true)} + onMouseLeave={() => { + if (!actionOpen) + setIsActionHovered(false) + }} + > + <button + type='button' + className='cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover' + onClick={() => { + setActionOpen(false) + setIsInstallModalOpen(true) + setIsActionHovered(true) + }} + > + {t('plugin.installAction')} + </button> + <Action + open={actionOpen} + onOpenChange={(value) => { + setActionOpen(value) + setIsActionHovered(value) + }} + author={plugin.org} + name={plugin.name} + version={plugin.latest_version} + /> + </div> + </div> + </div> + </Tooltip> + {isInstallModalOpen && ( + <InstallFromMarketplace + uniqueIdentifier={plugin.latest_package_identifier} + manifest={plugin} + onSuccess={async () => { + setIsInstallModalOpen(false) + setIsActionHovered(false) + await onInstallSuccess?.() + }} + onClose={() => { + setIsInstallModalOpen(false) + setIsActionHovered(false) + }} + /> + )} + </> + ) +} + +export default FeaturedTriggers diff --git a/web/app/components/workflow/block-selector/hooks.ts b/web/app/components/workflow/block-selector/hooks.ts index b974922e6b..e2dd14e16c 100644 --- a/web/app/components/workflow/block-selector/hooks.ts +++ b/web/app/components/workflow/block-selector/hooks.ts @@ -1,60 +1,123 @@ import { + useCallback, + useEffect, useMemo, useState, } from 'react' import { useTranslation } from 'react-i18next' +import { BLOCKS, START_BLOCKS } from './constants' import { TabsEnum, ToolTypeEnum, } from './types' -export const useTabs = (noBlocks?: boolean, noSources?: boolean, noTools?: boolean) => { +export const useBlocks = () => { const { t } = useTranslation() + + return BLOCKS.map((block) => { + return { + ...block, + title: t(`workflow.blocks.${block.type}`), + } + }) +} + +export const useStartBlocks = () => { + const { t } = useTranslation() + + return START_BLOCKS.map((block) => { + return { + ...block, + title: t(`workflow.blocks.${block.type}`), + } + }) +} + +export const useTabs = ({ + noBlocks, + noSources, + noTools, + noStart = true, + defaultActiveTab, + hasUserInputNode = false, + forceEnableStartTab = false, // When true, Start tab remains enabled even if trigger/user input nodes already exist. +}: { + noBlocks?: boolean + noSources?: boolean + noTools?: boolean + noStart?: boolean + defaultActiveTab?: TabsEnum + hasUserInputNode?: boolean + forceEnableStartTab?: boolean +}) => { + const { t } = useTranslation() + const shouldShowStartTab = !noStart + const shouldDisableStartTab = !forceEnableStartTab && hasUserInputNode const tabs = useMemo(() => { - return [ - ...( - noBlocks - ? [] - : [ - { - key: TabsEnum.Blocks, - name: t('workflow.tabs.blocks'), - }, - ] - ), - ...( - noSources - ? [] - : [ - { - key: TabsEnum.Sources, - name: t('workflow.tabs.sources'), - }, - ] - ), - ...( - noTools - ? [] - : [ - { - key: TabsEnum.Tools, - name: t('workflow.tabs.tools'), - }, - ] - ), - ] - }, [t, noBlocks, noSources, noTools]) + const tabConfigs = [{ + key: TabsEnum.Blocks, + name: t('workflow.tabs.blocks'), + show: !noBlocks, + }, { + key: TabsEnum.Sources, + name: t('workflow.tabs.sources'), + show: !noSources, + }, { + key: TabsEnum.Tools, + name: t('workflow.tabs.tools'), + show: !noTools, + }, + { + key: TabsEnum.Start, + name: t('workflow.tabs.start'), + show: shouldShowStartTab, + disabled: shouldDisableStartTab, + }] + + return tabConfigs.filter(tab => tab.show) + }, [t, noBlocks, noSources, noTools, shouldShowStartTab, shouldDisableStartTab]) + + const getValidTabKey = useCallback((targetKey?: TabsEnum) => { + if (!targetKey) + return undefined + const tab = tabs.find(tabItem => tabItem.key === targetKey) + if (!tab || tab.disabled) + return undefined + return tab.key + }, [tabs]) + const initialTab = useMemo(() => { - if (noBlocks) - return noTools ? TabsEnum.Sources : TabsEnum.Tools + const fallbackTab = tabs.find(tab => !tab.disabled)?.key ?? TabsEnum.Blocks + const preferredDefault = getValidTabKey(defaultActiveTab) + if (preferredDefault) + return preferredDefault - if (noTools) - return noBlocks ? TabsEnum.Sources : TabsEnum.Blocks + const preferredOrder: TabsEnum[] = [] + if (!noBlocks) + preferredOrder.push(TabsEnum.Blocks) + if (!noTools) + preferredOrder.push(TabsEnum.Tools) + if (!noSources) + preferredOrder.push(TabsEnum.Sources) + if (!noStart) + preferredOrder.push(TabsEnum.Start) - return TabsEnum.Blocks - }, [noBlocks, noSources, noTools]) + for (const tabKey of preferredOrder) { + const validKey = getValidTabKey(tabKey) + if (validKey) + return validKey + } + + return fallbackTab + }, [defaultActiveTab, noBlocks, noSources, noTools, noStart, tabs, getValidTabKey]) const [activeTab, setActiveTab] = useState(initialTab) + useEffect(() => { + const currentTab = tabs.find(tab => tab.key === activeTab) + if (!currentTab || currentTab.disabled) + setActiveTab(initialTab) + }, [tabs, activeTab, initialTab]) + return { tabs, activeTab, diff --git a/web/app/components/workflow/block-selector/index.tsx b/web/app/components/workflow/block-selector/index.tsx index 54e8078e7b..9f7989265a 100644 --- a/web/app/components/workflow/block-selector/index.tsx +++ b/web/app/components/workflow/block-selector/index.tsx @@ -40,8 +40,8 @@ const NodeSelectorWrapper = (props: NodeSelectorProps) => { return ( <NodeSelector {...props} - blocks={blocks} - dataSources={dataSourceList || []} + blocks={props.blocks || blocks} + dataSources={props.dataSources || dataSourceList || []} /> ) } diff --git a/web/app/components/workflow/block-selector/main.tsx b/web/app/components/workflow/block-selector/main.tsx index 631b85cd8c..3e13384785 100644 --- a/web/app/components/workflow/block-selector/main.tsx +++ b/web/app/components/workflow/block-selector/main.tsx @@ -9,16 +9,18 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' +import { useNodes } from 'reactflow' import type { OffsetOptions, Placement, } from '@floating-ui/react' import type { - BlockEnum, + CommonNodeType, NodeDefault, OnSelectBlock, ToolWithProvider, } from '../types' +import { BlockEnum, isTriggerNode } from '../types' import Tabs from './tabs' import { TabsEnum } from './types' import { useTabs } from './hooks' @@ -51,6 +53,12 @@ export type NodeSelectorProps = { dataSources?: ToolWithProvider[] noBlocks?: boolean noTools?: boolean + showStartTab?: boolean + defaultActiveTab?: TabsEnum + forceShowStartContent?: boolean + ignoreNodeIds?: string[] + forceEnableStartTab?: boolean // Force enabling Start tab regardless of existing trigger/user input nodes (e.g., when changing Start node type). + allowUserInputSelection?: boolean // Override user-input availability; default logic blocks it when triggers exist. } const NodeSelector: FC<NodeSelectorProps> = ({ open: openFromProps, @@ -70,11 +78,47 @@ const NodeSelector: FC<NodeSelectorProps> = ({ dataSources = [], noBlocks = false, noTools = false, + showStartTab = false, + defaultActiveTab, + forceShowStartContent = false, + ignoreNodeIds = [], + forceEnableStartTab = false, + allowUserInputSelection, }) => { const { t } = useTranslation() + const nodes = useNodes() const [searchText, setSearchText] = useState('') const [tags, setTags] = useState<string[]>([]) const [localOpen, setLocalOpen] = useState(false) + // Exclude nodes explicitly ignored (such as the node currently being edited) when checking canvas state. + const filteredNodes = useMemo(() => { + if (!ignoreNodeIds.length) + return nodes + const ignoreSet = new Set(ignoreNodeIds) + return nodes.filter(node => !ignoreSet.has(node.id)) + }, [nodes, ignoreNodeIds]) + + const { hasTriggerNode, hasUserInputNode } = useMemo(() => { + const result = { + hasTriggerNode: false, + hasUserInputNode: false, + } + for (const node of filteredNodes) { + const nodeType = (node.data as CommonNodeType | undefined)?.type + if (!nodeType) + continue + if (nodeType === BlockEnum.Start) + result.hasUserInputNode = true + if (isTriggerNode(nodeType)) + result.hasTriggerNode = true + if (result.hasTriggerNode && result.hasUserInputNode) + break + } + return result + }, [filteredNodes]) + // Default rule: user input option is only available when no Start node nor Trigger node exists on canvas. + const defaultAllowUserInputSelection = !hasUserInputNode && !hasTriggerNode + const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection const open = openFromProps === undefined ? localOpen : openFromProps const handleOpenChange = useCallback((newOpen: boolean) => { setLocalOpen(newOpen) @@ -91,22 +135,34 @@ const NodeSelector: FC<NodeSelectorProps> = ({ e.stopPropagation() handleOpenChange(!open) }, [handleOpenChange, open, disabled]) - const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => { + + const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { handleOpenChange(false) - onSelect(type, toolDefaultValue) + onSelect(type, pluginDefaultValue) }, [handleOpenChange, onSelect]) const { activeTab, setActiveTab, tabs, - } = useTabs(noBlocks, !dataSources.length, noTools) + } = useTabs({ + noBlocks, + noSources: !dataSources.length, + noTools, + noStart: !showStartTab, + defaultActiveTab, + hasUserInputNode, + forceEnableStartTab, + }) const handleActiveTabChange = useCallback((newActiveTab: TabsEnum) => { setActiveTab(newActiveTab) }, [setActiveTab]) const searchPlaceholder = useMemo(() => { + if (activeTab === TabsEnum.Start) + return t('workflow.tabs.searchTrigger') + if (activeTab === TabsEnum.Blocks) return t('workflow.tabs.searchBlock') @@ -136,7 +192,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({ : ( <div className={` - z-10 flex h-4 + z-10 flex h-4 w-4 cursor-pointer items-center justify-center rounded-full bg-components-button-primary-bg text-text-primary-on-surface hover:bg-components-button-primary-bg-hover ${triggerClassName?.(open)} `} @@ -153,9 +209,21 @@ const NodeSelector: FC<NodeSelectorProps> = ({ tabs={tabs} activeTab={activeTab} blocks={blocks} + allowStartNodeSelection={canSelectUserInput} onActiveTabChange={handleActiveTabChange} filterElem={ <div className='relative m-2' onClick={e => e.stopPropagation()}> + {activeTab === TabsEnum.Start && ( + <SearchBox + autoFocus + search={searchText} + onSearchChange={setSearchText} + tags={tags} + onTagsChange={setTags} + placeholder={searchPlaceholder} + inputClassName='grow' + /> + )} {activeTab === TabsEnum.Blocks && ( <Input showLeftIcon @@ -180,6 +248,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({ )} {activeTab === TabsEnum.Tools && ( <SearchBox + autoFocus search={searchText} onSearchChange={setSearchText} tags={tags} @@ -198,6 +267,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({ dataSources={dataSources} noTools={noTools} onTagsChange={setTags} + forceShowStartContent={forceShowStartContent} /> </div> </PortalToFollowElemContent> diff --git a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx index 56ee420cff..034ecbad45 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx @@ -1,6 +1,6 @@ 'use client' import type { FC } from 'react' -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTheme } from 'next-themes' import { useTranslation } from 'react-i18next' import { RiMoreFill } from '@remixicon/react' @@ -15,6 +15,7 @@ import cn from '@/utils/classnames' import { useDownloadPlugin } from '@/service/use-plugins' import { downloadFile } from '@/utils/format' import { getMarketplaceUrl } from '@/utils/var' +import { useQueryClient } from '@tanstack/react-query' type Props = { open: boolean @@ -33,6 +34,7 @@ const OperationDropdown: FC<Props> = ({ }) => { const { t } = useTranslation() const { theme } = useTheme() + const queryClient = useQueryClient() const openRef = useRef(open) const setOpen = useCallback((v: boolean) => { onOpenChange(v) @@ -44,23 +46,32 @@ const OperationDropdown: FC<Props> = ({ }, [setOpen]) const [needDownload, setNeedDownload] = useState(false) - const { data: blob, isLoading } = useDownloadPlugin({ + const downloadInfo = useMemo(() => ({ organization: author, pluginName: name, version, - }, needDownload) + }), [author, name, version]) + const { data: blob, isLoading } = useDownloadPlugin(downloadInfo, needDownload) const handleDownload = useCallback(() => { if (isLoading) return + queryClient.removeQueries({ + queryKey: ['plugins', 'downloadPlugin', downloadInfo], + exact: true, + }) setNeedDownload(true) - }, [isLoading]) + }, [downloadInfo, isLoading, queryClient]) useEffect(() => { - if (blob) { - const fileName = `${author}-${name}_${version}.zip` - downloadFile({ data: blob, fileName }) - setNeedDownload(false) - } - }, [blob]) + if (!needDownload || !blob) + return + const fileName = `${author}-${name}_${version}.zip` + downloadFile({ data: blob, fileName }) + setNeedDownload(false) + queryClient.removeQueries({ + queryKey: ['plugins', 'downloadPlugin', downloadInfo], + exact: true, + }) + }, [author, blob, downloadInfo, name, needDownload, queryClient, version]) return ( <PortalToFollowElem open={open} @@ -77,7 +88,7 @@ const OperationDropdown: FC<Props> = ({ </ActionButton> </PortalToFollowElemTrigger> <PortalToFollowElemContent className='z-[9999]'> - <div className='w-[112px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg'> + <div className='min-w-[176px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg'> <div onClick={handleDownload} className='system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'>{t('common.operation.download')}</div> <a href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })} target='_blank' className='system-md-regular block cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'>{t('common.operation.viewDetails')}</a> </div> diff --git a/web/app/components/workflow/block-selector/market-place-plugin/item.tsx b/web/app/components/workflow/block-selector/market-place-plugin/item.tsx index 4826108c5c..3c9c9b9f59 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/item.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/item.tsx @@ -52,8 +52,13 @@ const Item: FC<Props> = ({ </div> </div> {/* Action */} - <div className={cn(!open ? 'hidden' : 'flex', 'system-xs-medium h-4 items-center space-x-1 text-components-button-secondary-accent-text group-hover/plugin:flex')}> - <div className='cursor-pointer px-1.5' onClick={showInstallModal}>{t('plugin.installAction')}</div> + <div className={cn(!open ? 'hidden' : 'flex', 'system-xs-medium h-4 items-center space-x-1 text-components-button-secondary-accent-text group-hover/plugin:flex')}> + <div + className='cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover' + onClick={showInstallModal} + > + {t('plugin.installAction')} + </div> <Action open={open} onOpenChange={setOpen} diff --git a/web/app/components/workflow/block-selector/market-place-plugin/list.tsx b/web/app/components/workflow/block-selector/market-place-plugin/list.tsx index 9f5ce22568..8c050b60d6 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/list.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/list.tsx @@ -1,5 +1,6 @@ 'use client' -import React, { useEffect, useImperativeHandle, useMemo, useRef } from 'react' +import { useEffect, useImperativeHandle, useMemo, useRef } from 'react' +import type { RefObject } from 'react' import { useTranslation } from 'react-i18next' import useStickyScroll, { ScrollPosition } from '../use-sticky-scroll' import Item from './item' @@ -17,6 +18,7 @@ export type ListProps = { tags: string[] toolContentClassName?: string disableMaxWidth?: boolean + hideFindMoreFooter?: boolean ref?: React.Ref<ListRef> } @@ -29,6 +31,7 @@ const List = ({ list, toolContentClassName, disableMaxWidth = false, + hideFindMoreFooter = false, ref, }: ListProps) => { const { t } = useTranslation() @@ -39,7 +42,7 @@ const List = ({ const { handleScroll, scrollPosition } = useStickyScroll({ wrapElemRef, - nextToStickyELemRef, + nextToStickyELemRef: nextToStickyELemRef as RefObject<HTMLElement>, }) const stickyClassName = useMemo(() => { switch (scrollPosition) { @@ -69,6 +72,9 @@ const List = ({ } if (noFilter) { + if (hideFindMoreFooter) + return null + return ( <Link className='system-sm-medium sticky bottom-0 z-10 flex h-8 cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg' diff --git a/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx b/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx index eecd874335..240c0814a1 100644 --- a/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx +++ b/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx @@ -1,5 +1,6 @@ +'use client' import type { Dispatch, SetStateAction } from 'react' -import React, { useCallback, useMemo } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import type { OnSelectBlock } from '@/app/components/workflow/types' import type { ViewType } from '@/app/components/workflow/block-selector/view-type-select' @@ -10,6 +11,7 @@ import { getMarketplaceUrl } from '@/utils/var' import { useRAGRecommendedPlugins } from '@/service/use-tools' import List from './list' import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils' +import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/arrows' type RAGToolRecommendationsProps = { viewType: ViewType @@ -17,12 +19,34 @@ type RAGToolRecommendationsProps = { onTagsChange: Dispatch<SetStateAction<string[]>> } +const STORAGE_KEY = 'workflow_rag_recommendations_collapsed' + const RAGToolRecommendations = ({ viewType, onSelect, onTagsChange, }: RAGToolRecommendationsProps) => { const { t } = useTranslation() + const [isCollapsed, setIsCollapsed] = useState<boolean>(() => { + if (typeof window === 'undefined') + return false + const stored = window.localStorage.getItem(STORAGE_KEY) + return stored === 'true' + }) + + useEffect(() => { + if (typeof window === 'undefined') + return + const stored = window.localStorage.getItem(STORAGE_KEY) + if (stored !== null) + setIsCollapsed(stored === 'true') + }, []) + + useEffect(() => { + if (typeof window === 'undefined') + return + window.localStorage.setItem(STORAGE_KEY, String(isCollapsed)) + }, [isCollapsed]) const { data: ragRecommendedPlugins, @@ -52,51 +76,60 @@ const RAGToolRecommendations = ({ return ( <div className='flex flex-col p-1'> - <div className='system-xs-medium px-3 pb-0.5 pt-1 text-text-tertiary'> - {t('pipeline.ragToolSuggestions.title')} - </div> - {/* For first time loading, show loading */} - {isLoadingRAGRecommendedPlugins && ( - <div className='py-2'> - <Loading type='app' /> - </div> - )} - {!isFetchingRAGRecommendedPlugins && recommendedPlugins.length === 0 && unInstalledPlugins.length === 0 && ( - <p className='system-xs-regular px-3 py-1 text-text-tertiary'> - <Trans - i18nKey='pipeline.ragToolSuggestions.noRecommendationPlugins' - components={{ - CustomLink: ( - <Link - className='text-text-accent' - target='_blank' - rel='noopener noreferrer' - href={getMarketplaceUrl('', { tags: 'rag' })} - /> - ), - }} - /> - </p> - )} - {(recommendedPlugins.length > 0 || unInstalledPlugins.length > 0) && ( + <button + type='button' + className='flex w-full items-center rounded-md px-3 pb-0.5 pt-1 text-left text-text-tertiary' + onClick={() => setIsCollapsed(prev => !prev)} + > + <span className='system-xs-medium text-text-tertiary'>{t('pipeline.ragToolSuggestions.title')}</span> + <ArrowDownRoundFill className={`ml-1 h-4 w-4 text-text-tertiary transition-transform ${isCollapsed ? '-rotate-90' : 'rotate-0'}`} /> + </button> + {!isCollapsed && ( <> - <List - tools={recommendedPlugins} - unInstalledPlugins={unInstalledPlugins} - onSelect={onSelect} - viewType={viewType} - /> - <div - className='flex cursor-pointer items-center gap-x-2 py-1 pl-3 pr-2' - onClick={loadMore} - > - <div className='px-1'> - <RiMoreLine className='size-4 text-text-tertiary' /> + {/* For first time loading, show loading */} + {isLoadingRAGRecommendedPlugins && ( + <div className='py-2'> + <Loading type='app' /> </div> - <div className='system-xs-regular text-text-tertiary'> - {t('common.operation.more')} - </div> - </div> + )} + {!isFetchingRAGRecommendedPlugins && recommendedPlugins.length === 0 && unInstalledPlugins.length === 0 && ( + <p className='system-xs-regular px-3 py-1 text-text-tertiary'> + <Trans + i18nKey='pipeline.ragToolSuggestions.noRecommendationPlugins' + components={{ + CustomLink: ( + <Link + className='text-text-accent' + target='_blank' + rel='noopener noreferrer' + href={getMarketplaceUrl('', { tags: 'rag' })} + /> + ), + }} + /> + </p> + )} + {(recommendedPlugins.length > 0 || unInstalledPlugins.length > 0) && ( + <> + <List + tools={recommendedPlugins} + unInstalledPlugins={unInstalledPlugins} + onSelect={onSelect} + viewType={viewType} + /> + <div + className='flex cursor-pointer items-center gap-x-2 py-1 pl-3 pr-2' + onClick={loadMore} + > + <div className='px-1'> + <RiMoreLine className='size-4 text-text-tertiary' /> + </div> + <div className='system-xs-regular text-text-tertiary'> + {t('common.operation.more')} + </div> + </div> + </> + )} </> )} </div> diff --git a/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx b/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx index 19378caf48..8c98fa9d7c 100644 --- a/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx +++ b/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx @@ -1,7 +1,4 @@ -import { - useMemo, - useRef, -} from 'react' +import { useCallback, useMemo, useRef } from 'react' import type { BlockEnum, ToolWithProvider } from '../../types' import type { ToolDefaultValue } from '../types' import { ViewType } from '../view-type-select' @@ -12,9 +9,10 @@ import ToolListTreeView from '../tool/tool-list-tree-view/list' import ToolListFlatView from '../tool/tool-list-flat-view/list' import UninstalledItem from './uninstalled-item' import type { Plugin } from '@/app/components/plugins/types' +import type { OnSelectBlock } from '@/app/components/workflow/types' type ListProps = { - onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + onSelect: OnSelectBlock tools: ToolWithProvider[] viewType: ViewType unInstalledPlugins: Plugin[] @@ -62,6 +60,10 @@ const List = ({ const toolRefs = useRef({}) + const handleSelect = useCallback((type: BlockEnum, tool: ToolDefaultValue) => { + onSelect(type, tool) + }, [onSelect]) + return ( <div className={cn('max-w-[100%] p-1', className)}> {!!tools.length && ( @@ -72,7 +74,7 @@ const List = ({ payload={listViewToolData} isShowLetterIndex={false} hasSearchText={false} - onSelect={onSelect} + onSelect={handleSelect} canNotSelectMultiple indexBar={null} /> @@ -80,7 +82,7 @@ const List = ({ <ToolListTreeView payload={treeViewToolsData} hasSearchText={false} - onSelect={onSelect} + onSelect={handleSelect} canNotSelectMultiple /> ) diff --git a/web/app/components/workflow/block-selector/start-blocks.tsx b/web/app/components/workflow/block-selector/start-blocks.tsx new file mode 100644 index 0000000000..31b6abce6c --- /dev/null +++ b/web/app/components/workflow/block-selector/start-blocks.tsx @@ -0,0 +1,139 @@ +import { + memo, + useCallback, + useEffect, + useMemo, +} from 'react' +import { useNodes } from 'reactflow' +import { useTranslation } from 'react-i18next' +import BlockIcon from '../block-icon' +import type { BlockEnum, CommonNodeType } from '../types' +import { BlockEnum as BlockEnumValues } from '../types' +// import { useNodeMetaData } from '../hooks' +import { START_BLOCKS } from './constants' +import type { TriggerDefaultValue } from './types' +import Tooltip from '@/app/components/base/tooltip' +import { useAvailableNodesMetaData } from '../../workflow-app/hooks' + +type StartBlocksProps = { + searchText: string + onSelect: (type: BlockEnum, triggerDefaultValue?: TriggerDefaultValue) => void + availableBlocksTypes?: BlockEnum[] + onContentStateChange?: (hasContent: boolean) => void + hideUserInput?: boolean +} + +const StartBlocks = ({ + searchText, + onSelect, + availableBlocksTypes = [], + onContentStateChange, + hideUserInput = false, // Allow parent to explicitly hide Start node option (e.g. when one already exists). +}: StartBlocksProps) => { + const { t } = useTranslation() + const nodes = useNodes() + // const nodeMetaData = useNodeMetaData() + const availableNodesMetaData = useAvailableNodesMetaData() + + const filteredBlocks = useMemo(() => { + // Check if Start node already exists in workflow + const hasStartNode = nodes.some(node => (node.data as CommonNodeType)?.type === BlockEnumValues.Start) + const normalizedSearch = searchText.toLowerCase() + const getDisplayName = (blockType: BlockEnum) => { + if (blockType === BlockEnumValues.TriggerWebhook) + return t('workflow.customWebhook') + + return t(`workflow.blocks.${blockType}`) + } + + return START_BLOCKS.filter((block) => { + // Hide User Input (Start) if it already exists in workflow or if hideUserInput is true + if (block.type === BlockEnumValues.Start && (hasStartNode || hideUserInput)) + return false + + // Filter by search text + const displayName = getDisplayName(block.type).toLowerCase() + if (!displayName.includes(normalizedSearch) && !block.title.toLowerCase().includes(normalizedSearch)) + return false + + // availableBlocksTypes now contains properly filtered entry node types from parent + return availableBlocksTypes.includes(block.type) + }) + }, [searchText, availableBlocksTypes, nodes, t, hideUserInput]) + + const isEmpty = filteredBlocks.length === 0 + + useEffect(() => { + onContentStateChange?.(!isEmpty) + }, [isEmpty, onContentStateChange]) + + const renderBlock = useCallback((block: { type: BlockEnum; title: string; description?: string }) => ( + <Tooltip + key={block.type} + position='right' + popupClassName='w-[224px] rounded-xl' + needsDelay={false} + popupContent={( + <div> + <BlockIcon + size='md' + className='mb-2' + type={block.type} + /> + <div className='system-md-medium mb-1 text-text-primary'> + {block.type === BlockEnumValues.TriggerWebhook + ? t('workflow.customWebhook') + : t(`workflow.blocks.${block.type}`) + } + </div> + <div className='system-xs-regular text-text-secondary'> + {t(`workflow.blocksAbout.${block.type}`)} + </div> + {(block.type === BlockEnumValues.TriggerWebhook || block.type === BlockEnumValues.TriggerSchedule) && ( + <div className='system-xs-regular mb-1 mt-1 text-text-tertiary'> + {t('tools.author')} {t('workflow.difyTeam')} + </div> + )} + </div> + )} + > + <div + className='flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover' + onClick={() => onSelect(block.type)} + > + <BlockIcon + className='mr-2 shrink-0' + type={block.type} + /> + <div className='flex w-0 grow items-center justify-between text-sm text-text-secondary'> + <span className='truncate'>{t(`workflow.blocks.${block.type}`)}</span> + {block.type === BlockEnumValues.Start && ( + <span className='system-xs-regular ml-2 shrink-0 text-text-quaternary'>{t('workflow.blocks.originalStartNode')}</span> + )} + </div> + </div> + </Tooltip> + ), [availableNodesMetaData, onSelect, t]) + + if (isEmpty) + return null + + return ( + <div className='p-1'> + <div className='mb-1'> + {filteredBlocks.map((block, index) => ( + <div key={block.type}> + {renderBlock(block)} + {block.type === BlockEnumValues.Start && index < filteredBlocks.length - 1 && ( + <div className='my-1 px-3'> + <div className='border-t border-divider-subtle' /> + </div> + )} + </div> + ))} + </div> + </div> + ) +} + +export default memo(StartBlocks) diff --git a/web/app/components/workflow/block-selector/tabs.tsx b/web/app/components/workflow/block-selector/tabs.tsx index 91d5ac3af6..ecdb8797c0 100644 --- a/web/app/components/workflow/block-selector/tabs.tsx +++ b/web/app/components/workflow/block-selector/tabs.tsx @@ -1,6 +1,7 @@ import type { Dispatch, FC, SetStateAction } from 'react' -import { memo } from 'react' -import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools' +import { memo, useEffect, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools, useInvalidateAllBuiltInTools } from '@/service/use-tools' import type { BlockEnum, NodeDefault, @@ -9,9 +10,15 @@ import type { } from '../types' import { TabsEnum } from './types' import Blocks from './blocks' +import AllStartBlocks from './all-start-blocks' import AllTools from './all-tools' import DataSources from './data-sources' import cn from '@/utils/classnames' +import { useFeaturedToolsRecommendations } from '@/service/use-plugins' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useWorkflowStore } from '../store' +import { basePath } from '@/utils/var' +import Tooltip from '@/app/components/base/tooltip' export type TabsProps = { activeTab: TabsEnum @@ -26,10 +33,13 @@ export type TabsProps = { tabs: Array<{ key: TabsEnum name: string + disabled?: boolean }> filterElem: React.ReactNode noBlocks?: boolean noTools?: boolean + forceShowStartContent?: boolean // Force show Start content even when noBlocks=true + allowStartNodeSelection?: boolean // Allow user input option even when trigger node already exists (e.g. change-node flow or when no Start node yet). } const Tabs: FC<TabsProps> = ({ activeTab, @@ -45,11 +55,75 @@ const Tabs: FC<TabsProps> = ({ filterElem, noBlocks, noTools, + forceShowStartContent = false, + allowStartNodeSelection = false, }) => { + const { t } = useTranslation() const { data: buildInTools } = useAllBuiltInTools() const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() const { data: mcpTools } = useAllMCPTools() + const invalidateBuiltInTools = useInvalidateAllBuiltInTools() + const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) + const workflowStore = useWorkflowStore() + const inRAGPipeline = dataSources.length > 0 + const { + plugins: featuredPlugins = [], + isLoading: isFeaturedLoading, + } = useFeaturedToolsRecommendations(enable_marketplace && !inRAGPipeline) + + const normalizeToolList = useMemo(() => { + return (list?: ToolWithProvider[]) => { + if (!list) + return list + if (!basePath) + return list + let changed = false + const normalized = list.map((provider) => { + if (typeof provider.icon === 'string') { + const icon = provider.icon + const shouldPrefix = Boolean(basePath) + && icon.startsWith('/') + && !icon.startsWith(`${basePath}/`) + + if (shouldPrefix) { + changed = true + return { + ...provider, + icon: `${basePath}${icon}`, + } + } + } + return provider + }) + return changed ? normalized : list + } + }, [basePath]) + + useEffect(() => { + workflowStore.setState((state) => { + const updates: Partial<typeof state> = {} + const normalizedBuiltIn = normalizeToolList(buildInTools) + const normalizedCustom = normalizeToolList(customTools) + const normalizedWorkflow = normalizeToolList(workflowTools) + const normalizedMCP = normalizeToolList(mcpTools) + + if (normalizedBuiltIn !== undefined && state.buildInTools !== normalizedBuiltIn) + updates.buildInTools = normalizedBuiltIn + if (normalizedCustom !== undefined && state.customTools !== normalizedCustom) + updates.customTools = normalizedCustom + if (normalizedWorkflow !== undefined && state.workflowTools !== normalizedWorkflow) + updates.workflowTools = normalizedWorkflow + if (normalizedMCP !== undefined && state.mcpTools !== normalizedMCP) + updates.mcpTools = normalizedMCP + if (!Object.keys(updates).length) + return state + return { + ...state, + ...updates, + } + }) + }, [workflowStore, normalizeToolList, buildInTools, customTools, workflowTools, mcpTools]) return ( <div onClick={e => e.stopPropagation()}> @@ -57,25 +131,64 @@ const Tabs: FC<TabsProps> = ({ !noBlocks && ( <div className='relative flex bg-background-section-burn pl-1 pt-1'> { - tabs.map(tab => ( - <div - key={tab.key} - className={cn( - 'system-sm-medium relative mr-0.5 flex h-8 cursor-pointer items-center rounded-t-lg px-3 ', - activeTab === tab.key - ? 'sm-no-bottom cursor-default bg-components-panel-bg text-text-accent' - : 'text-text-tertiary', - )} - onClick={() => onActiveTabChange(tab.key)} - > - {tab.name} - </div> - )) + tabs.map((tab) => { + const commonProps = { + 'className': cn( + 'system-sm-medium relative mr-0.5 flex h-8 items-center rounded-t-lg px-3', + tab.disabled + ? 'cursor-not-allowed text-text-disabled opacity-60' + : activeTab === tab.key + ? 'sm-no-bottom cursor-default bg-components-panel-bg text-text-accent' + : 'cursor-pointer text-text-tertiary', + ), + 'aria-disabled': tab.disabled, + 'onClick': () => { + if (tab.disabled || activeTab === tab.key) + return + onActiveTabChange(tab.key) + }, + } as const + if (tab.disabled) { + return ( + <Tooltip + key={tab.key} + position='top' + popupClassName='max-w-[200px]' + popupContent={t('workflow.tabs.startDisabledTip')} + > + <div {...commonProps}> + {tab.name} + </div> + </Tooltip> + ) + } + return ( + <div + key={tab.key} + {...commonProps} + > + {tab.name} + </div> + ) + }) } </div> ) } {filterElem} + { + activeTab === TabsEnum.Start && (!noBlocks || forceShowStartContent) && ( + <div className='border-t border-divider-subtle'> + <AllStartBlocks + allowUserInputSelection={allowStartNodeSelection} + searchText={searchText} + onSelect={onSelect} + availableBlocksTypes={availableBlocksTypes} + tags={tags} + /> + </div> + ) + } { activeTab === TabsEnum.Blocks && !noBlocks && ( <div className='border-t border-divider-subtle'> @@ -112,7 +225,13 @@ const Tabs: FC<TabsProps> = ({ mcpTools={mcpTools || []} canChooseMCPTool onTagsChange={onTagsChange} - isInRAGPipeline={dataSources.length > 0} + isInRAGPipeline={inRAGPipeline} + featuredPlugins={featuredPlugins} + featuredLoading={isFeaturedLoading} + showFeatured={enable_marketplace && !inRAGPipeline} + onFeaturedInstallSuccess={async () => { + invalidateBuiltInTools() + }} /> ) } diff --git a/web/app/components/workflow/block-selector/tool-picker.tsx b/web/app/components/workflow/block-selector/tool-picker.tsx index ae4b0d4f02..660cdad71e 100644 --- a/web/app/components/workflow/block-selector/tool-picker.tsx +++ b/web/app/components/workflow/block-selector/tool-picker.tsx @@ -23,7 +23,18 @@ import { } from '@/service/tools' import type { CustomCollectionBackend } from '@/app/components/tools/types' import Toast from '@/app/components/base/toast' -import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools, useInvalidateAllCustomTools } from '@/service/use-tools' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, + useInvalidateAllBuiltInTools, + useInvalidateAllCustomTools, + useInvalidateAllMCPTools, + useInvalidateAllWorkflowTools, +} from '@/service/use-tools' +import { useFeaturedToolsRecommendations } from '@/service/use-plugins' +import { useGlobalPublicStore } from '@/context/global-public-context' import cn from '@/utils/classnames' type Props = { @@ -61,11 +72,20 @@ const ToolPicker: FC<Props> = ({ const [searchText, setSearchText] = useState('') const [tags, setTags] = useState<string[]>([]) + const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) const { data: buildInTools } = useAllBuiltInTools() const { data: customTools } = useAllCustomTools() const invalidateCustomTools = useInvalidateAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() const { data: mcpTools } = useAllMCPTools() + const invalidateBuiltInTools = useInvalidateAllBuiltInTools() + const invalidateWorkflowTools = useInvalidateAllWorkflowTools() + const invalidateMcpTools = useInvalidateAllMCPTools() + + const { + plugins: featuredPlugins = [], + isLoading: isFeaturedLoading, + } = useFeaturedToolsRecommendations(enable_marketplace) const { builtinToolList, customToolList, workflowToolList } = useMemo(() => { if (scope === 'plugins') { @@ -179,6 +199,15 @@ const ToolPicker: FC<Props> = ({ selectedTools={selectedTools} canChooseMCPTool={canChooseMCPTool} onTagsChange={setTags} + featuredPlugins={featuredPlugins} + featuredLoading={isFeaturedLoading} + showFeatured={scope === 'all' && enable_marketplace} + onFeaturedInstallSuccess={async () => { + invalidateBuiltInTools() + invalidateCustomTools() + invalidateWorkflowTools() + invalidateMcpTools() + }} /> </div> </PortalToFollowElemContent> diff --git a/web/app/components/workflow/block-selector/tool/action-item.tsx b/web/app/components/workflow/block-selector/tool/action-item.tsx index 1005758d43..01c319327a 100644 --- a/web/app/components/workflow/block-selector/tool/action-item.tsx +++ b/web/app/components/workflow/block-selector/tool/action-item.tsx @@ -10,13 +10,20 @@ import { useGetLanguage } from '@/context/i18n' import BlockIcon from '../../block-icon' import cn from '@/utils/classnames' import { useTranslation } from 'react-i18next' +import { basePath } from '@/utils/var' + +const normalizeProviderIcon = (icon: ToolWithProvider['icon']) => { + if (typeof icon === 'string' && basePath && icon.startsWith('/') && !icon.startsWith(`${basePath}/`)) + return `${basePath}${icon}` + return icon +} type Props = { provider: ToolWithProvider payload: Tool disabled?: boolean isAdded?: boolean - onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void } const ToolItem: FC<Props> = ({ @@ -64,6 +71,9 @@ const ToolItem: FC<Props> = ({ provider_id: provider.id, provider_type: provider.type, provider_name: provider.name, + plugin_id: provider.plugin_id, + plugin_unique_identifier: provider.plugin_unique_identifier, + provider_icon: normalizeProviderIcon(provider.icon), tool_name: payload.name, tool_label: payload.label[language], tool_description: payload.description[language], diff --git a/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx b/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx index ca462c082e..510d6f2f4b 100644 --- a/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx +++ b/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx @@ -13,7 +13,7 @@ type Props = { isShowLetterIndex: boolean indexBar: React.ReactNode hasSearchText: boolean - onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void canNotSelectMultiple?: boolean onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void letters: string[] diff --git a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/item.tsx b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/item.tsx index ac0955da0b..a2833646f3 100644 --- a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/item.tsx +++ b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/item.tsx @@ -11,7 +11,7 @@ type Props = { groupName: string toolList: ToolWithProvider[] hasSearchText: boolean - onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void canNotSelectMultiple?: boolean onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void selectedTools?: ToolValue[] diff --git a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx index d85d1ea682..162b816069 100644 --- a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx +++ b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx @@ -11,7 +11,7 @@ import { AGENT_GROUP_NAME, CUSTOM_GROUP_NAME, WORKFLOW_GROUP_NAME } from '../../ type Props = { payload: Record<string, ToolWithProvider[]> hasSearchText: boolean - onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void canNotSelectMultiple?: boolean onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void selectedTools?: ToolValue[] diff --git a/web/app/components/workflow/block-selector/tool/tool.tsx b/web/app/components/workflow/block-selector/tool/tool.tsx index 30d3e218d2..38be8d19d6 100644 --- a/web/app/components/workflow/block-selector/tool/tool.tsx +++ b/web/app/components/workflow/block-selector/tool/tool.tsx @@ -16,17 +16,25 @@ import { useTranslation } from 'react-i18next' import { useHover } from 'ahooks' import McpToolNotSupportTooltip from '../../nodes/_base/components/mcp-tool-not-support-tooltip' import { Mcp } from '@/app/components/base/icons/src/vender/other' +import { basePath } from '@/utils/var' + +const normalizeProviderIcon = (icon: ToolWithProvider['icon']) => { + if (typeof icon === 'string' && basePath && icon.startsWith('/') && !icon.startsWith(`${basePath}/`)) + return `${basePath}${icon}` + return icon +} type Props = { className?: string payload: ToolWithProvider viewType: ViewType hasSearchText: boolean - onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void canNotSelectMultiple?: boolean onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void selectedTools?: ToolValue[] canChooseMCPTool?: boolean + isShowLetterIndex?: boolean } const Tool: FC<Props> = ({ @@ -85,6 +93,9 @@ const Tool: FC<Props> = ({ provider_id: payload.id, provider_type: payload.type, provider_name: payload.name, + plugin_id: payload.plugin_id, + plugin_unique_identifier: payload.plugin_unique_identifier, + provider_icon: normalizeProviderIcon(payload.icon), tool_name: tool.name, tool_label: tool.label[language], tool_description: tool.description[language], @@ -164,6 +175,9 @@ const Tool: FC<Props> = ({ provider_id: payload.id, provider_type: payload.type, provider_name: payload.name, + plugin_id: payload.plugin_id, + plugin_unique_identifier: payload.plugin_unique_identifier, + provider_icon: normalizeProviderIcon(payload.icon), tool_name: tool.name, tool_label: tool.label[language], tool_description: tool.description[language], diff --git a/web/app/components/workflow/block-selector/tools.tsx b/web/app/components/workflow/block-selector/tools.tsx index 71ed4092a3..c62f6a67f9 100644 --- a/web/app/components/workflow/block-selector/tools.tsx +++ b/web/app/components/workflow/block-selector/tools.tsx @@ -1,9 +1,4 @@ -import { - memo, - useMemo, - useRef, -} from 'react' -import { useTranslation } from 'react-i18next' +import { memo, useMemo, useRef } from 'react' import type { BlockEnum, ToolWithProvider } from '../types' import IndexBar, { groupItems } from './index-bar' import type { ToolDefaultValue, ToolValue } from './types' @@ -16,7 +11,7 @@ import ToolListFlatView from './tool/tool-list-flat-view/list' import classNames from '@/utils/classnames' type ToolsProps = { - onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void canNotSelectMultiple?: boolean onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void tools: ToolWithProvider[] @@ -28,7 +23,6 @@ type ToolsProps = { indexBarClassName?: string selectedTools?: ToolValue[] canChooseMCPTool?: boolean - isShowRAGRecommendations?: boolean } const Tools = ({ onSelect, @@ -43,10 +37,8 @@ const Tools = ({ indexBarClassName, selectedTools, canChooseMCPTool, - isShowRAGRecommendations = false, }: ToolsProps) => { // const tools: any = [] - const { t } = useTranslation() const language = useGetLanguage() const isFlatView = viewType === ViewType.flat const isShowLetterIndex = isFlatView && tools.length > 10 @@ -100,21 +92,11 @@ const Tools = ({ return ( <div className={classNames('max-w-[100%] p-1', className)}> - { - !tools.length && hasSearchText && ( - <div className='mt-2 flex h-[22px] items-center px-3 text-xs font-medium text-text-secondary'>{t('workflow.tabs.noResult')}</div> - ) - } {!tools.length && !hasSearchText && ( <div className='py-10'> <Empty type={toolType!} isAgent={isAgent} /> </div> )} - {!!tools.length && isShowRAGRecommendations && ( - <div className='system-xs-medium px-3 pb-0.5 pt-1 text-text-tertiary'> - {t('tools.allTools')} - </div> - )} {!!tools.length && ( isFlatView ? ( <ToolListFlatView diff --git a/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx b/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx new file mode 100644 index 0000000000..d2bdda8a82 --- /dev/null +++ b/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx @@ -0,0 +1,90 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import type { TriggerWithProvider } from '../types' +import type { Event } from '@/app/components/tools/types' +import { BlockEnum } from '../../types' +import type { TriggerDefaultValue } from '../types' +import Tooltip from '@/app/components/base/tooltip' +import { useGetLanguage } from '@/context/i18n' +import BlockIcon from '../../block-icon' +import cn from '@/utils/classnames' +import { useTranslation } from 'react-i18next' + +type Props = { + provider: TriggerWithProvider + payload: Event + disabled?: boolean + isAdded?: boolean + onSelect: (type: BlockEnum, trigger?: TriggerDefaultValue) => void +} + +const TriggerPluginActionItem: FC<Props> = ({ + provider, + payload, + onSelect, + disabled, + isAdded, +}) => { + const { t } = useTranslation() + const language = useGetLanguage() + + return ( + <Tooltip + key={payload.name} + position='right' + needsDelay={false} + popupClassName='!p-0 !px-3 !py-2.5 !w-[224px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !rounded-xl !shadow-lg' + popupContent={( + <div> + <BlockIcon + size='md' + className='mb-2' + type={BlockEnum.TriggerPlugin} + toolIcon={provider.icon} + /> + <div className='mb-1 text-sm leading-5 text-text-primary'>{payload.label[language]}</div> + <div className='text-xs leading-[18px] text-text-secondary'>{payload.description[language]}</div> + </div> + )} + > + <div + key={payload.name} + className='flex cursor-pointer items-center justify-between rounded-lg pl-[21px] pr-1 hover:bg-state-base-hover' + onClick={() => { + if (disabled) return + const params: Record<string, string> = {} + if (payload.parameters) { + payload.parameters.forEach((item: any) => { + params[item.name] = '' + }) + } + onSelect(BlockEnum.TriggerPlugin, { + plugin_id: provider.plugin_id, + provider_id: provider.name, + provider_type: provider.type as string, + provider_name: provider.name, + event_name: payload.name, + event_label: payload.label[language], + event_description: payload.description[language], + plugin_unique_identifier: provider.plugin_unique_identifier, + title: payload.label[language], + is_team_authorization: provider.is_team_authorization, + output_schema: payload.output_schema || {}, + paramSchemas: payload.parameters, + params, + meta: provider.meta, + }) + }} + > + <div className={cn('system-sm-medium h-8 truncate border-l-2 border-divider-subtle pl-4 leading-8 text-text-secondary')}> + <span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span> + </div> + {isAdded && ( + <div className='system-xs-regular mr-4 text-text-tertiary'>{t('tools.addToolModal.added')}</div> + )} + </div> + </Tooltip > + ) +} +export default React.memo(TriggerPluginActionItem) diff --git a/web/app/components/workflow/block-selector/trigger-plugin/item.tsx b/web/app/components/workflow/block-selector/trigger-plugin/item.tsx new file mode 100644 index 0000000000..702d3603fb --- /dev/null +++ b/web/app/components/workflow/block-selector/trigger-plugin/item.tsx @@ -0,0 +1,133 @@ +'use client' +import { useGetLanguage } from '@/context/i18n' +import cn from '@/utils/classnames' +import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react' +import type { FC } from 'react' +import React, { useEffect, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { CollectionType } from '@/app/components/tools/types' +import BlockIcon from '@/app/components/workflow/block-icon' +import { BlockEnum } from '@/app/components/workflow/types' +import type { TriggerDefaultValue, TriggerWithProvider } from '@/app/components/workflow/block-selector/types' +import TriggerPluginActionItem from './action-item' + +type Props = { + className?: string + payload: TriggerWithProvider + hasSearchText: boolean + onSelect: (type: BlockEnum, trigger?: TriggerDefaultValue) => void +} + +const TriggerPluginItem: FC<Props> = ({ + className, + payload, + hasSearchText, + onSelect, +}) => { + const { t } = useTranslation() + const language = useGetLanguage() + const notShowProvider = payload.type === CollectionType.workflow + const actions = payload.events + const hasAction = !notShowProvider + const [isFold, setFold] = React.useState<boolean>(true) + const ref = useRef(null) + + useEffect(() => { + if (hasSearchText && isFold) { + setFold(false) + return + } + if (!hasSearchText && !isFold) + setFold(true) + }, [hasSearchText]) + + const FoldIcon = isFold ? RiArrowRightSLine : RiArrowDownSLine + + const groupName = useMemo(() => { + if (payload.type === CollectionType.builtIn) + return payload.author + + if (payload.type === CollectionType.custom) + return t('workflow.tabs.customTool') + + if (payload.type === CollectionType.workflow) + return t('workflow.tabs.workflowTool') + + return payload.author || '' + }, [payload.author, payload.type, t]) + + return ( + <div + key={payload.id} + className={cn('mb-1 last-of-type:mb-0')} + ref={ref} + > + <div className={cn(className)}> + <div + className='group/item flex w-full cursor-pointer select-none items-center justify-between rounded-lg pl-3 pr-1 hover:bg-state-base-hover' + onClick={() => { + if (hasAction) { + setFold(!isFold) + return + } + + const event = actions[0] + const params: Record<string, string> = {} + if (event.parameters) { + event.parameters.forEach((item: any) => { + params[item.name] = '' + }) + } + onSelect(BlockEnum.TriggerPlugin, { + plugin_id: payload.plugin_id, + provider_id: payload.name, + provider_type: payload.type, + provider_name: payload.name, + event_name: event.name, + event_label: event.label[language], + event_description: event.description[language], + title: event.label[language], + plugin_unique_identifier: payload.plugin_unique_identifier, + is_team_authorization: payload.is_team_authorization, + output_schema: event.output_schema || {}, + paramSchemas: event.parameters, + params, + }) + }} + > + <div className='flex h-8 grow items-center'> + <BlockIcon + className='shrink-0' + type={BlockEnum.TriggerPlugin} + toolIcon={payload.icon} + /> + <div className='ml-2 flex min-w-0 flex-1 items-center text-sm text-text-primary'> + <span className='max-w-[200px] truncate'>{notShowProvider ? actions[0]?.label[language] : payload.label[language]}</span> + <span className='system-xs-regular ml-2 truncate text-text-quaternary'>{groupName}</span> + </div> + </div> + + <div className='ml-2 flex items-center'> + {hasAction && ( + <FoldIcon className={cn('h-4 w-4 shrink-0 text-text-tertiary group-hover/item:text-text-tertiary', isFold && 'text-text-quaternary')} /> + )} + </div> + </div> + + {!notShowProvider && hasAction && !isFold && ( + actions.map(action => ( + <TriggerPluginActionItem + key={action.name} + provider={payload} + payload={action} + onSelect={onSelect} + disabled={false} + isAdded={false} + /> + )) + )} + </div> + </div> + ) +} +export default React.memo(TriggerPluginItem) diff --git a/web/app/components/workflow/block-selector/trigger-plugin/list.tsx b/web/app/components/workflow/block-selector/trigger-plugin/list.tsx new file mode 100644 index 0000000000..3caf1149dd --- /dev/null +++ b/web/app/components/workflow/block-selector/trigger-plugin/list.tsx @@ -0,0 +1,105 @@ +'use client' +import { memo, useEffect, useMemo } from 'react' +import { useAllTriggerPlugins } from '@/service/use-triggers' +import TriggerPluginItem from './item' +import type { BlockEnum } from '../../types' +import type { TriggerDefaultValue, TriggerWithProvider } from '../types' +import { useGetLanguage } from '@/context/i18n' + +type TriggerPluginListProps = { + onSelect: (type: BlockEnum, trigger?: TriggerDefaultValue) => void + searchText: string + onContentStateChange?: (hasContent: boolean) => void + tags?: string[] +} + +const TriggerPluginList = ({ + onSelect, + searchText, + onContentStateChange, +}: TriggerPluginListProps) => { + const { data: triggerPluginsData } = useAllTriggerPlugins() + const language = useGetLanguage() + + const normalizedSearch = searchText.trim().toLowerCase() + const triggerPlugins = useMemo(() => { + const plugins = triggerPluginsData || [] + const getLocalizedText = (text?: Record<string, string> | null) => { + if (!text) + return '' + + if (text[language]) + return text[language] + + if (text['en-US']) + return text['en-US'] + + const firstValue = Object.values(text).find(Boolean) + return (typeof firstValue === 'string') ? firstValue : '' + } + const getSearchableTexts = (name: string, label?: Record<string, string> | null) => { + const localized = getLocalizedText(label) + const values = [localized, name].filter(Boolean) + return values.length > 0 ? values : [''] + } + const isMatchingKeywords = (value: string) => value.toLowerCase().includes(normalizedSearch) + + if (!normalizedSearch) + return plugins.filter(triggerWithProvider => triggerWithProvider.events.length > 0) + + return plugins.reduce<TriggerWithProvider[]>((acc, triggerWithProvider) => { + if (triggerWithProvider.events.length === 0) + return acc + + const providerMatches = getSearchableTexts( + triggerWithProvider.name, + triggerWithProvider.label, + ).some(text => isMatchingKeywords(text)) + + if (providerMatches) { + acc.push(triggerWithProvider) + return acc + } + + const matchedEvents = triggerWithProvider.events.filter((event) => { + return getSearchableTexts( + event.name, + event.label, + ).some(text => isMatchingKeywords(text)) + }) + + if (matchedEvents.length > 0) { + acc.push({ + ...triggerWithProvider, + events: matchedEvents, + }) + } + + return acc + }, []) + }, [triggerPluginsData, normalizedSearch, language]) + + const hasContent = triggerPlugins.length > 0 + + useEffect(() => { + onContentStateChange?.(hasContent) + }, [hasContent, onContentStateChange]) + + if (!hasContent) + return null + + return ( + <div className="p-1"> + {triggerPlugins.map(plugin => ( + <TriggerPluginItem + key={plugin.id} + payload={plugin} + onSelect={onSelect} + hasSearchText={!!searchText} + /> + ))} + </div> + ) +} + +export default memo(TriggerPluginList) diff --git a/web/app/components/workflow/block-selector/types.ts b/web/app/components/workflow/block-selector/types.ts index 48fbf6a500..b69453e937 100644 --- a/web/app/components/workflow/block-selector/types.ts +++ b/web/app/components/workflow/block-selector/types.ts @@ -1,8 +1,9 @@ -import type { PluginMeta } from '../../plugins/types' - import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { ParametersSchema, PluginMeta, PluginTriggerSubscriptionConstructor, SupportedCreationMethods, TriggerEvent } from '../../plugins/types' +import type { Collection, Event } from '../../tools/types' export enum TabsEnum { + Start = 'start', Blocks = 'blocks', Tools = 'tools', Sources = 'sources', @@ -24,10 +25,28 @@ export enum BlockClassificationEnum { Utilities = 'utilities', } -export type ToolDefaultValue = { +type PluginCommonDefaultValue = { provider_id: string provider_type: string provider_name: string +} + +export type TriggerDefaultValue = PluginCommonDefaultValue & { + plugin_id?: string + event_name: string + event_label: string + event_description: string + title: string + plugin_unique_identifier: string + is_team_authorization: boolean + params: Record<string, any> + paramSchemas: Record<string, any>[] + output_schema: Record<string, any> + subscription_id?: string + meta?: PluginMeta +} + +export type ToolDefaultValue = PluginCommonDefaultValue & { tool_name: string tool_label: string tool_description: string @@ -35,12 +54,15 @@ export type ToolDefaultValue = { is_team_authorization: boolean params: Record<string, any> paramSchemas: Record<string, any>[] + output_schema?: Record<string, any> credential_id?: string meta?: PluginMeta - output_schema?: Record<string, any> + plugin_id?: string + provider_icon?: Collection['icon'] + plugin_unique_identifier?: string } -export type DataSourceDefaultValue = { +export type DataSourceDefaultValue = Omit<PluginCommonDefaultValue, 'provider_id'> & { plugin_id: string provider_type: string provider_name: string @@ -48,8 +70,11 @@ export type DataSourceDefaultValue = { datasource_label: string title: string fileExtensions?: string[] + plugin_unique_identifier?: string } +export type PluginDefaultValue = ToolDefaultValue | DataSourceDefaultValue | TriggerDefaultValue + export type ToolValue = { provider_name: string provider_show_name?: string @@ -96,3 +121,218 @@ export type DataSourceItem = { } is_authorized: boolean } + +// Backend API types - exact match with Python definitions +export type TriggerParameter = { + multiple: boolean + name: string + label: TypeWithI18N + description?: TypeWithI18N + type: 'string' | 'number' | 'boolean' | 'select' | 'file' | 'files' + | 'model-selector' | 'app-selector' | 'object' | 'array' | 'dynamic-select' + auto_generate?: { + type: string + value?: any + } | null + template?: { + type: string + value?: any + } | null + scope?: string | null + required?: boolean + default?: any + min?: number | null + max?: number | null + precision?: number | null + options?: Array<{ + value: string + label: TypeWithI18N + icon?: string | null + }> | null +} + +export type TriggerCredentialField = { + type: 'secret-input' | 'text-input' | 'select' | 'boolean' + | 'app-selector' | 'model-selector' | 'tools-selector' + name: string + scope?: string | null + required: boolean + default?: string | number | boolean | Array<any> | null + options?: Array<{ + value: string + label: TypeWithI18N + }> | null + label: TypeWithI18N + help?: TypeWithI18N + url?: string | null + placeholder?: TypeWithI18N +} + +export type TriggerSubscriptionSchema = { + parameters_schema: TriggerParameter[] + properties_schema: TriggerCredentialField[] +} + +export type TriggerIdentity = { + author: string + name: string + label: TypeWithI18N + provider: string +} + +export type TriggerDescription = { + human: TypeWithI18N + llm: TypeWithI18N +} + +export type TriggerApiEntity = { + name: string + identity: TriggerIdentity + description: TypeWithI18N + parameters: TriggerParameter[] + output_schema?: Record<string, any> +} + +export type TriggerProviderApiEntity = { + author: string + name: string + label: TypeWithI18N + description: TypeWithI18N + icon?: string + icon_dark?: string + tags: string[] + plugin_id?: string + plugin_unique_identifier: string + supported_creation_methods: SupportedCreationMethods[] + credentials_schema?: TriggerCredentialField[] + subscription_constructor?: PluginTriggerSubscriptionConstructor | null + subscription_schema: ParametersSchema[] + events: TriggerEvent[] +} + +// Frontend types - compatible with ToolWithProvider +export type TriggerWithProvider = Collection & { + events: Event[] + meta: PluginMeta + plugin_unique_identifier: string + credentials_schema?: TriggerCredentialField[] + subscription_constructor?: PluginTriggerSubscriptionConstructor | null + subscription_schema?: ParametersSchema[] + supported_creation_methods: SupportedCreationMethods[] +} + +// ===== API Service Types ===== + +// Trigger subscription instance types + +export enum TriggerCredentialTypeEnum { + ApiKey = 'api-key', + Oauth2 = 'oauth2', + Unauthorized = 'unauthorized', +} + +type TriggerSubscriptionStructure = { + id: string + name: string + provider: string + credential_type: TriggerCredentialTypeEnum + credentials: TriggerSubCredentials + endpoint: string + parameters: TriggerSubParameters + properties: TriggerSubProperties + workflows_in_use: number +} + +export type TriggerSubscription = TriggerSubscriptionStructure + +export type TriggerSubCredentials = { + access_tokens: string +} + +export type TriggerSubParameters = { + repository: string + webhook_secret?: string +} + +export type TriggerSubProperties = { + active: boolean + events: string[] + external_id: string + repository: string + webhook_secret?: string +} + +export type TriggerSubscriptionBuilder = TriggerSubscriptionStructure + +// OAuth configuration types +export type TriggerOAuthConfig = { + configured: boolean + custom_configured: boolean + custom_enabled: boolean + redirect_uri: string + oauth_client_schema: ParametersSchema[] + params: { + client_id: string + client_secret: string + [key: string]: any + } + system_configured: boolean +} + +export type TriggerOAuthClientParams = { + client_id: string + client_secret: string + authorization_url?: string + token_url?: string + scope?: string +} + +export type TriggerOAuthResponse = { + authorization_url: string + subscription_builder: TriggerSubscriptionBuilder +} + +export type TriggerLogEntity = { + id: string + endpoint: string + request: LogRequest + response: LogResponse + created_at: string +} + +export type LogRequest = { + method: string + url: string + headers: LogRequestHeaders + data: string +} + +export type LogRequestHeaders = { + 'Host': string + 'User-Agent': string + 'Content-Length': string + 'Accept': string + 'Content-Type': string + 'X-Forwarded-For': string + 'X-Forwarded-Host': string + 'X-Forwarded-Proto': string + 'X-Github-Delivery': string + 'X-Github-Event': string + 'X-Github-Hook-Id': string + 'X-Github-Hook-Installation-Target-Id': string + 'X-Github-Hook-Installation-Target-Type': string + 'Accept-Encoding': string + [key: string]: string +} + +export type LogResponse = { + status_code: number + headers: LogResponseHeaders + data: string +} + +export type LogResponseHeaders = { + 'Content-Type': string + 'Content-Length': string + [key: string]: string +} diff --git a/web/app/components/workflow/block-selector/utils.ts b/web/app/components/workflow/block-selector/utils.ts index 9b7a5fc076..4272e61644 100644 --- a/web/app/components/workflow/block-selector/utils.ts +++ b/web/app/components/workflow/block-selector/utils.ts @@ -17,6 +17,7 @@ export const transformDataSourceToTool = (dataSourceItem: DataSourceItem) => { is_authorized: dataSourceItem.is_authorized, labels: dataSourceItem.declaration.identity.tags || [], plugin_id: dataSourceItem.plugin_id, + plugin_unique_identifier: dataSourceItem.plugin_unique_identifier, tools: dataSourceItem.declaration.datasources.map((datasource) => { return { name: datasource.identity.name, diff --git a/web/app/components/workflow/candidate-node.tsx b/web/app/components/workflow/candidate-node.tsx index 6f2389aad2..54daf13ebc 100644 --- a/web/app/components/workflow/candidate-node.tsx +++ b/web/app/components/workflow/candidate-node.tsx @@ -12,7 +12,7 @@ import { useStore, useWorkflowStore, } from './store' -import { WorkflowHistoryEvent, useNodesInteractions, useWorkflowHistory } from './hooks' +import { WorkflowHistoryEvent, useAutoGenerateWebhookUrl, useNodesInteractions, useNodesSyncDraft, useWorkflowHistory } from './hooks' import { CUSTOM_NODE } from './constants' import { getIterationStartNode, getLoopStartNode } from './utils' import CustomNode from './nodes' @@ -29,6 +29,8 @@ const CandidateNode = () => { const { zoom } = useViewport() const { handleNodeSelect } = useNodesInteractions() const { saveStateToHistory } = useWorkflowHistory() + const { handleSyncWorkflowDraft } = useNodesSyncDraft() + const autoGenerateWebhookUrl = useAutoGenerateWebhookUrl() useEventListener('click', (e) => { const { candidateNode, mousePosition } = workflowStore.getState() @@ -70,6 +72,12 @@ const CandidateNode = () => { if (candidateNode.type === CUSTOM_NOTE_NODE) handleNodeSelect(candidateNode.id) + + if (candidateNode.data.type === BlockEnum.TriggerWebhook) { + handleSyncWorkflowDraft(true, true, { + onSuccess: () => autoGenerateWebhookUrl(candidateNode.id), + }) + } } }) diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts index a8c6a458fc..ad498ff65b 100644 --- a/web/app/components/workflow/constants.ts +++ b/web/app/components/workflow/constants.ts @@ -35,6 +35,54 @@ export const NODE_LAYOUT_HORIZONTAL_PADDING = 60 export const NODE_LAYOUT_VERTICAL_PADDING = 60 export const NODE_LAYOUT_MIN_DISTANCE = 100 +export const isInWorkflowPage = () => { + const pathname = globalThis.location.pathname + return /^\/app\/[^/]+\/workflow$/.test(pathname) || /^\/workflow\/[^/]+$/.test(pathname) +} +export const getGlobalVars = (isChatMode: boolean): Var[] => { + const isInWorkflow = isInWorkflowPage() + const vars: Var[] = [ + ...(isChatMode ? [ + { + variable: 'sys.dialogue_count', + type: VarType.number, + }, + { + variable: 'sys.conversation_id', + type: VarType.string, + }, + ] : []), + { + variable: 'sys.user_id', + type: VarType.string, + }, + { + variable: 'sys.app_id', + type: VarType.string, + }, + { + variable: 'sys.workflow_id', + type: VarType.string, + }, + { + variable: 'sys.workflow_run_id', + type: VarType.string, + }, + ...((isInWorkflow && !isChatMode) ? [ + { + variable: 'sys.timestamp', + type: VarType.number, + }, + ] : []), + ] + return vars +} + +export const VAR_SHOW_NAME_MAP: Record<string, string> = { + 'sys.query': 'query', + 'sys.files': 'files', +} + export const RETRIEVAL_OUTPUT_STRUCT = `{ "content": "", "title": "", @@ -56,7 +104,7 @@ export const RETRIEVAL_OUTPUT_STRUCT = `{ }` export const SUPPORT_OUTPUT_VARS_NODE = [ - BlockEnum.Start, BlockEnum.LLM, BlockEnum.KnowledgeRetrieval, BlockEnum.Code, BlockEnum.TemplateTransform, + BlockEnum.Start, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin, BlockEnum.LLM, BlockEnum.KnowledgeRetrieval, BlockEnum.Code, BlockEnum.TemplateTransform, BlockEnum.HttpRequest, BlockEnum.Tool, BlockEnum.VariableAssigner, BlockEnum.VariableAggregator, BlockEnum.QuestionClassifier, BlockEnum.ParameterExtractor, BlockEnum.Iteration, BlockEnum.Loop, BlockEnum.DocExtractor, BlockEnum.ListFilter, diff --git a/web/app/components/workflow/custom-edge.tsx b/web/app/components/workflow/custom-edge.tsx index c38b0ef47d..d4cbc9199d 100644 --- a/web/app/components/workflow/custom-edge.tsx +++ b/web/app/components/workflow/custom-edge.tsx @@ -83,11 +83,11 @@ const CustomEdge = ({ setOpen(v) }, []) - const handleInsert = useCallback<OnSelectBlock>((nodeType, toolDefaultValue) => { + const handleInsert = useCallback<OnSelectBlock>((nodeType, pluginDefaultValue) => { handleNodeAdd( { nodeType, - toolDefaultValue, + pluginDefaultValue, }, { prevNodeId: source, diff --git a/web/app/components/workflow/header/chat-variable-button.tsx b/web/app/components/workflow/header/chat-variable-button.tsx index 36c4a640c4..aa68182c23 100644 --- a/web/app/components/workflow/header/chat-variable-button.tsx +++ b/web/app/components/workflow/header/chat-variable-button.tsx @@ -7,13 +7,16 @@ import cn from '@/utils/classnames' const ChatVariableButton = ({ disabled }: { disabled: boolean }) => { const { theme } = useTheme() + const showChatVariablePanel = useStore(s => s.showChatVariablePanel) const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel) const setShowEnvPanel = useStore(s => s.setShowEnvPanel) + const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel) const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel) const handleClick = () => { setShowChatVariablePanel(true) setShowEnvPanel(false) + setShowGlobalVariablePanel(false) setShowDebugAndPreviewPanel(false) } @@ -21,10 +24,11 @@ const ChatVariableButton = ({ disabled }: { disabled: boolean }) => { <Button className={cn( 'p-2', - theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm', + theme === 'dark' && showChatVariablePanel && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm', )} disabled={disabled} onClick={handleClick} + variant='ghost' > <BubbleX className='h-4 w-4 text-components-button-secondary-text' /> </Button> diff --git a/web/app/components/workflow/header/checklist.tsx b/web/app/components/workflow/header/checklist.tsx index 9da16c59c6..794a8997a9 100644 --- a/web/app/components/workflow/header/checklist.tsx +++ b/web/app/components/workflow/header/checklist.tsx @@ -16,6 +16,7 @@ import { useChecklist, useNodesInteractions, } from '../hooks' +import type { ChecklistItem } from '../hooks/use-checklist' import type { CommonEdgeType, CommonNodeType, @@ -29,7 +30,9 @@ import { import { ChecklistSquare, } from '@/app/components/base/icons/src/vender/line/general' -import { AlertTriangle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback' +import { Warning } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback' +import { IconR } from '@/app/components/base/icons/src/vender/line/arrows' +import type { BlockEnum } from '../types' type WorkflowChecklistProps = { disabled: boolean @@ -44,6 +47,13 @@ const WorkflowChecklist = ({ const needWarningNodes = useChecklist(nodes, edges) const { handleNodeSelect } = useNodesInteractions() + const handleChecklistItemClick = (item: ChecklistItem) => { + if (!item.canNavigate) + return + handleNodeSelect(item.id) + setOpen(false) + } + return ( <PortalToFollowElem placement='bottom-end' @@ -93,38 +103,53 @@ const WorkflowChecklist = ({ <RiCloseLine className='h-4 w-4 text-text-tertiary' /> </div> </div> - <div className='py-2'> + <div className='pb-2'> { !!needWarningNodes.length && ( <> - <div className='px-4 text-xs text-text-tertiary'>{t('workflow.panel.checklistTip')}</div> + <div className='px-4 pt-1 text-xs text-text-tertiary'>{t('workflow.panel.checklistTip')}</div> <div className='px-4 py-2'> { needWarningNodes.map(node => ( <div key={node.id} - className='mb-2 cursor-pointer rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs last-of-type:mb-0' - onClick={() => { - handleNodeSelect(node.id) - setOpen(false) - }} + className={cn( + 'group mb-2 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs last-of-type:mb-0', + node.canNavigate ? 'cursor-pointer' : 'cursor-default opacity-80', + )} + onClick={() => handleChecklistItemClick(node)} > <div className='flex h-9 items-center p-2 text-xs font-medium text-text-secondary'> <BlockIcon - type={node.type} + type={node.type as BlockEnum} className='mr-1.5' toolIcon={node.toolIcon} /> <span className='grow truncate'> {node.title} </span> + { + node.canNavigate && ( + <div className='flex h-4 w-[60px] shrink-0 items-center justify-center gap-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100'> + <span className='whitespace-nowrap text-xs font-medium leading-4 text-primary-600'> + {t('workflow.panel.goTo')} + </span> + <IconR className='h-3.5 w-3.5 text-primary-600' /> + </div> + ) + } </div> - <div className='border-t-[0.5px] border-divider-regular'> + <div + className={cn( + 'rounded-b-lg border-t-[0.5px] border-divider-regular', + (node.unConnected || node.errorMessage) && 'bg-gradient-to-r from-components-badge-bg-orange-soft to-transparent', + )} + > { node.unConnected && ( - <div className='px-3 py-2 last:rounded-b-lg'> - <div className='flex text-xs leading-[18px] text-text-tertiary'> - <AlertTriangle className='mr-2 mt-[3px] h-3 w-3 text-[#F79009]' /> + <div className='px-3 py-1 first:pt-1.5 last:pb-1.5'> + <div className='flex text-xs leading-4 text-text-tertiary'> + <Warning className='mr-2 mt-[2px] h-3 w-3 text-[#F79009]' /> {t('workflow.common.needConnectTip')} </div> </div> @@ -132,9 +157,9 @@ const WorkflowChecklist = ({ } { node.errorMessage && ( - <div className='px-3 py-2 last:rounded-b-lg'> - <div className='flex text-xs leading-[18px] text-text-tertiary'> - <AlertTriangle className='mr-2 mt-[3px] h-3 w-3 text-[#F79009]' /> + <div className='px-3 py-1 first:pt-1.5 last:pb-1.5'> + <div className='flex text-xs leading-4 text-text-tertiary'> + <Warning className='mr-2 mt-[2px] h-3 w-3 text-[#F79009]' /> {node.errorMessage} </div> </div> diff --git a/web/app/components/workflow/header/editing-title.tsx b/web/app/components/workflow/header/editing-title.tsx index 32cfd36b4f..81249b05bd 100644 --- a/web/app/components/workflow/header/editing-title.tsx +++ b/web/app/components/workflow/header/editing-title.tsx @@ -11,9 +11,10 @@ const EditingTitle = () => { const draftUpdatedAt = useStore(state => state.draftUpdatedAt) const publishedAt = useStore(state => state.publishedAt) const isSyncingWorkflowDraft = useStore(s => s.isSyncingWorkflowDraft) + const maximizeCanvas = useStore(s => s.maximizeCanvas) return ( - <div className='system-xs-regular flex h-[18px] items-center text-text-tertiary'> + <div className={`system-xs-regular flex h-[18px] min-w-[300px] items-center whitespace-nowrap text-text-tertiary ${maximizeCanvas ? 'ml-2' : ''}`}> { !!draftUpdatedAt && ( <> diff --git a/web/app/components/workflow/header/env-button.tsx b/web/app/components/workflow/header/env-button.tsx index fbb664fbf5..26723305f1 100644 --- a/web/app/components/workflow/header/env-button.tsx +++ b/web/app/components/workflow/header/env-button.tsx @@ -9,13 +9,16 @@ import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks' const EnvButton = ({ disabled }: { disabled: boolean }) => { const { theme } = useTheme() const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel) + const showEnvPanel = useStore(s => s.showEnvPanel) const setShowEnvPanel = useStore(s => s.setShowEnvPanel) + const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel) const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel) const { closeAllInputFieldPanels } = useInputFieldPanel() const handleClick = () => { setShowEnvPanel(true) setShowChatVariablePanel(false) + setShowGlobalVariablePanel(false) setShowDebugAndPreviewPanel(false) closeAllInputFieldPanels() } @@ -24,8 +27,9 @@ const EnvButton = ({ disabled }: { disabled: boolean }) => { <Button className={cn( 'p-2', - theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm', + theme === 'dark' && showEnvPanel && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm', )} + variant='ghost' disabled={disabled} onClick={handleClick} > diff --git a/web/app/components/workflow/header/global-variable-button.tsx b/web/app/components/workflow/header/global-variable-button.tsx index 597c91651e..a133cdeda5 100644 --- a/web/app/components/workflow/header/global-variable-button.tsx +++ b/web/app/components/workflow/header/global-variable-button.tsx @@ -2,16 +2,37 @@ import { memo } from 'react' import Button from '@/app/components/base/button' import { GlobalVariable } from '@/app/components/base/icons/src/vender/line/others' import { useStore } from '@/app/components/workflow/store' +import useTheme from '@/hooks/use-theme' +import cn from '@/utils/classnames' +import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks' const GlobalVariableButton = ({ disabled }: { disabled: boolean }) => { - const setShowPanel = useStore(s => s.setShowGlobalVariablePanel) + const { theme } = useTheme() + const showGlobalVariablePanel = useStore(s => s.showGlobalVariablePanel) + const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel) + const setShowEnvPanel = useStore(s => s.setShowEnvPanel) + const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel) + const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel) + const { closeAllInputFieldPanels } = useInputFieldPanel() const handleClick = () => { - setShowPanel(true) + setShowGlobalVariablePanel(true) + setShowEnvPanel(false) + setShowChatVariablePanel(false) + setShowDebugAndPreviewPanel(false) + closeAllInputFieldPanels() } return ( - <Button className='p-2' disabled={disabled} onClick={handleClick}> + <Button + className={cn( + 'p-2', + theme === 'dark' && showGlobalVariablePanel && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm', + )} + disabled={disabled} + onClick={handleClick} + variant='ghost' + > <GlobalVariable className='h-4 w-4 text-components-button-secondary-text' /> </Button> ) diff --git a/web/app/components/workflow/header/header-in-normal.tsx b/web/app/components/workflow/header/header-in-normal.tsx index 1c3a442422..20fdafaff5 100644 --- a/web/app/components/workflow/header/header-in-normal.tsx +++ b/web/app/components/workflow/header/header-in-normal.tsx @@ -19,11 +19,14 @@ import EditingTitle from './editing-title' import EnvButton from './env-button' import VersionHistoryButton from './version-history-button' import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks' +import ScrollToSelectedNodeButton from './scroll-to-selected-node-button' +import GlobalVariableButton from './global-variable-button' export type HeaderInNormalProps = { components?: { left?: React.ReactNode middle?: React.ReactNode + chatVariableTrigger?: React.ReactNode } runAndHistoryProps?: RunAndHistoryProps } @@ -39,6 +42,7 @@ const HeaderInNormal = ({ const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel) const setShowVariableInspectPanel = useStore(s => s.setShowVariableInspectPanel) const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel) + const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel) const nodes = useNodes<StartNodeType>() const selectedNode = nodes.find(node => node.data.selected) const { handleBackupDraft } = useWorkflowRun() @@ -55,23 +59,31 @@ const HeaderInNormal = ({ setShowDebugAndPreviewPanel(false) setShowVariableInspectPanel(false) setShowChatVariablePanel(false) + setShowGlobalVariablePanel(false) closeAllInputFieldPanels() - }, [workflowStore, handleBackupDraft, selectedNode, handleNodeSelect, setShowWorkflowVersionHistoryPanel, setShowEnvPanel, setShowDebugAndPreviewPanel, setShowVariableInspectPanel, setShowChatVariablePanel]) + }, [workflowStore, handleBackupDraft, selectedNode, handleNodeSelect, setShowWorkflowVersionHistoryPanel, setShowEnvPanel, setShowDebugAndPreviewPanel, setShowVariableInspectPanel, setShowChatVariablePanel, setShowGlobalVariablePanel]) return ( - <> + <div className='flex w-full items-center justify-between'> <div> <EditingTitle /> </div> + <div> + <ScrollToSelectedNodeButton /> + </div> <div className='flex items-center gap-2'> {components?.left} - <EnvButton disabled={nodesReadOnly} /> <Divider type='vertical' className='mx-auto h-3.5' /> <RunAndHistory {...runAndHistoryProps} /> + <div className='shrink-0 cursor-pointer rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs backdrop-blur-[10px]'> + {components?.chatVariableTrigger} + <EnvButton disabled={nodesReadOnly} /> + <GlobalVariableButton disabled={nodesReadOnly} /> + </div> {components?.middle} <VersionHistoryButton onClick={onStartRestoring} /> </div> - </> + </div> ) } diff --git a/web/app/components/workflow/header/run-mode.tsx b/web/app/components/workflow/header/run-mode.tsx index d1fd3510cc..7a1d444d30 100644 --- a/web/app/components/workflow/header/run-mode.tsx +++ b/web/app/components/workflow/header/run-mode.tsx @@ -1,6 +1,6 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { useWorkflowRun, useWorkflowStartRun } from '@/app/components/workflow/hooks' +import { useWorkflowRun, useWorkflowRunValidation, useWorkflowStartRun } from '@/app/components/workflow/hooks' import { useStore } from '@/app/components/workflow/store' import { WorkflowRunningStatus } from '@/app/components/workflow/types' import { useEventEmitterContextContext } from '@/context/event-emitter' @@ -9,6 +9,9 @@ import { getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils' import cn from '@/utils/classnames' import { RiLoader2Line, RiPlayLargeLine } from '@remixicon/react' import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' +import { useDynamicTestRunOptions } from '../hooks/use-dynamic-test-run-options' +import TestRunMenu, { type TestRunMenuRef, type TriggerOption, TriggerType } from './test-run-menu' +import { useToastContext } from '@/app/components/base/toast' type RunModeProps = { text?: string @@ -18,16 +21,84 @@ const RunMode = ({ text, }: RunModeProps) => { const { t } = useTranslation() - const { handleWorkflowStartRunInWorkflow } = useWorkflowStartRun() + const { + handleWorkflowStartRunInWorkflow, + handleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow, + } = useWorkflowStartRun() const { handleStopRun } = useWorkflowRun() + const { validateBeforeRun, warningNodes } = useWorkflowRunValidation() const workflowRunningData = useStore(s => s.workflowRunningData) + const isListening = useStore(s => s.isListening) - const isRunning = workflowRunningData?.result.status === WorkflowRunningStatus.Running + const status = workflowRunningData?.result.status + const isRunning = status === WorkflowRunningStatus.Running || isListening + + const dynamicOptions = useDynamicTestRunOptions() + const testRunMenuRef = useRef<TestRunMenuRef>(null) + const { notify } = useToastContext() + + useEffect(() => { + // @ts-expect-error - Dynamic property for backward compatibility with keyboard shortcuts + window._toggleTestRunDropdown = () => { + testRunMenuRef.current?.toggle() + } + return () => { + // @ts-expect-error - Dynamic property cleanup + delete window._toggleTestRunDropdown + } + }, []) const handleStop = useCallback(() => { handleStopRun(workflowRunningData?.task_id || '') }, [handleStopRun, workflowRunningData?.task_id]) + const handleTriggerSelect = useCallback((option: TriggerOption) => { + // Validate checklist before running any workflow + let isValid: boolean = true + warningNodes.forEach((node) => { + if (node.id === option.nodeId) + isValid = false + }) + if (!isValid) { + notify({ type: 'error', message: t('workflow.panel.checklistTip') }) + return + } + + if (option.type === TriggerType.UserInput) { + handleWorkflowStartRunInWorkflow() + } + else if (option.type === TriggerType.Schedule) { + handleWorkflowTriggerScheduleRunInWorkflow(option.nodeId) + } + else if (option.type === TriggerType.Webhook) { + if (option.nodeId) + handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: option.nodeId }) + } + else if (option.type === TriggerType.Plugin) { + if (option.nodeId) + handleWorkflowTriggerPluginRunInWorkflow(option.nodeId) + } + else if (option.type === TriggerType.All) { + const targetNodeIds = option.relatedNodeIds?.filter(Boolean) + if (targetNodeIds && targetNodeIds.length > 0) + handleWorkflowRunAllTriggersInWorkflow(targetNodeIds) + } + else { + // Placeholder for trigger-specific execution logic for schedule, webhook, plugin types + console.log('TODO: Handle trigger execution for type:', option.type, 'nodeId:', option.nodeId) + } + }, [ + validateBeforeRun, + handleWorkflowStartRunInWorkflow, + handleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow, + ]) + const { eventEmitter } = useEventEmitterContextContext() eventEmitter?.useSubscription((v: any) => { if (v.type === EVENT_WORKFLOW_STOP) @@ -36,46 +107,46 @@ const RunMode = ({ return ( <div className='flex items-center gap-x-px'> - <button - type='button' - className={cn( - 'system-xs-medium flex h-7 items-center gap-x-1 px-1.5 text-text-accent hover:bg-state-accent-hover', - isRunning && 'cursor-not-allowed bg-state-accent-hover', - isRunning ? 'rounded-l-md' : 'rounded-md', - )} - onClick={() => { - handleWorkflowStartRunInWorkflow() - }} - disabled={isRunning} - > - { - isRunning - ? ( - <> - <RiLoader2Line className='mr-1 size-4 animate-spin' /> - {t('workflow.common.running')} - </> - ) - : ( - <> + { + isRunning + ? ( + <button + type='button' + className={cn( + 'system-xs-medium flex h-7 cursor-not-allowed items-center gap-x-1 rounded-l-md bg-state-accent-hover px-1.5 text-text-accent', + )} + disabled={true} + > + <RiLoader2Line className='mr-1 size-4 animate-spin' /> + {isListening ? t('workflow.common.listening') : t('workflow.common.running')} + </button> + ) + : ( + <TestRunMenu + ref={testRunMenuRef} + options={dynamicOptions} + onSelect={handleTriggerSelect} + > + <div + className={cn( + 'system-xs-medium flex h-7 cursor-pointer items-center gap-x-1 rounded-md px-1.5 text-text-accent hover:bg-state-accent-hover', + )} + style={{ userSelect: 'none' }} + > <RiPlayLargeLine className='mr-1 size-4' /> {text ?? t('workflow.common.run')} - </> - ) - } - { - !isRunning && ( - <div className='system-kbd flex items-center gap-x-0.5 text-text-tertiary'> - <div className='flex size-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray'> - {getKeyboardKeyNameBySystem('alt')} + <div className='system-kbd flex items-center gap-x-0.5 text-text-tertiary'> + <div className='flex size-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray'> + {getKeyboardKeyNameBySystem('alt')} + </div> + <div className='flex size-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray'> + R + </div> + </div> </div> - <div className='flex size-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray'> - R - </div> - </div> + </TestRunMenu> ) - } - </button> + } { isRunning && ( <button diff --git a/web/app/components/workflow/header/scroll-to-selected-node-button.tsx b/web/app/components/workflow/header/scroll-to-selected-node-button.tsx new file mode 100644 index 0000000000..d3e7248d9a --- /dev/null +++ b/web/app/components/workflow/header/scroll-to-selected-node-button.tsx @@ -0,0 +1,34 @@ +import type { FC } from 'react' +import { useCallback } from 'react' +import { useNodes } from 'reactflow' +import { useTranslation } from 'react-i18next' +import type { CommonNodeType } from '../types' +import { scrollToWorkflowNode } from '../utils/node-navigation' +import cn from '@/utils/classnames' + +const ScrollToSelectedNodeButton: FC = () => { + const { t } = useTranslation() + const nodes = useNodes<CommonNodeType>() + const selectedNode = nodes.find(node => node.data.selected) + + const handleScrollToSelectedNode = useCallback(() => { + if (!selectedNode) return + scrollToWorkflowNode(selectedNode.id) + }, [selectedNode]) + + if (!selectedNode) + return null + + return ( + <div + className={cn( + 'system-xs-medium flex h-6 cursor-pointer items-center justify-center whitespace-nowrap rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-3 text-text-tertiary shadow-lg backdrop-blur-sm transition-colors duration-200 hover:text-text-accent', + )} + onClick={handleScrollToSelectedNode} + > + {t('workflow.panel.scrollToSelectedNode')} + </div> + ) +} + +export default ScrollToSelectedNodeButton diff --git a/web/app/components/workflow/header/test-run-menu.tsx b/web/app/components/workflow/header/test-run-menu.tsx new file mode 100644 index 0000000000..40aabab6f8 --- /dev/null +++ b/web/app/components/workflow/header/test-run-menu.tsx @@ -0,0 +1,251 @@ +import { + type MouseEvent, + type MouseEventHandler, + type ReactElement, + cloneElement, + forwardRef, + isValidElement, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' +import ShortcutsName from '../shortcuts-name' + +export enum TriggerType { + UserInput = 'user_input', + Schedule = 'schedule', + Webhook = 'webhook', + Plugin = 'plugin', + All = 'all', +} + +export type TriggerOption = { + id: string + type: TriggerType + name: string + icon: React.ReactNode + nodeId?: string + relatedNodeIds?: string[] + enabled: boolean +} + +export type TestRunOptions = { + userInput?: TriggerOption + triggers: TriggerOption[] + runAll?: TriggerOption +} + +type TestRunMenuProps = { + options: TestRunOptions + onSelect: (option: TriggerOption) => void + children: React.ReactNode +} + +export type TestRunMenuRef = { + toggle: () => void +} + +type ShortcutMapping = { + option: TriggerOption + shortcutKey: string +} + +const buildShortcutMappings = (options: TestRunOptions): ShortcutMapping[] => { + const mappings: ShortcutMapping[] = [] + + if (options.userInput && options.userInput.enabled !== false) + mappings.push({ option: options.userInput, shortcutKey: '~' }) + + let numericShortcut = 0 + + if (options.runAll && options.runAll.enabled !== false) + mappings.push({ option: options.runAll, shortcutKey: String(numericShortcut++) }) + + options.triggers.forEach((trigger) => { + if (trigger.enabled !== false) + mappings.push({ option: trigger, shortcutKey: String(numericShortcut++) }) + }) + + return mappings +} + +const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({ + options, + onSelect, + children, +}, ref) => { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const shortcutMappings = useMemo(() => buildShortcutMappings(options), [options]) + const shortcutKeyById = useMemo(() => { + const map = new Map<string, string>() + shortcutMappings.forEach(({ option, shortcutKey }) => { + map.set(option.id, shortcutKey) + }) + return map + }, [shortcutMappings]) + + const handleSelect = useCallback((option: TriggerOption) => { + onSelect(option) + setOpen(false) + }, [onSelect]) + + const enabledOptions = useMemo(() => { + const flattened: TriggerOption[] = [] + + if (options.userInput) + flattened.push(options.userInput) + if (options.runAll) + flattened.push(options.runAll) + flattened.push(...options.triggers) + + return flattened.filter(option => option.enabled !== false) + }, [options]) + + const hasSingleEnabledOption = enabledOptions.length === 1 + const soleEnabledOption = hasSingleEnabledOption ? enabledOptions[0] : undefined + + const runSoleOption = useCallback(() => { + if (soleEnabledOption) + handleSelect(soleEnabledOption) + }, [handleSelect, soleEnabledOption]) + + useImperativeHandle(ref, () => ({ + toggle: () => { + if (hasSingleEnabledOption) { + runSoleOption() + return + } + + setOpen(prev => !prev) + }, + }), [hasSingleEnabledOption, runSoleOption]) + + useEffect(() => { + if (!open) + return + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey) + return + + const normalizedKey = event.key === '`' ? '~' : event.key + const mapping = shortcutMappings.find(({ shortcutKey }) => shortcutKey === normalizedKey) + + if (mapping) { + event.preventDefault() + handleSelect(mapping.option) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => { + window.removeEventListener('keydown', handleKeyDown) + } + }, [handleSelect, open, shortcutMappings]) + + const renderOption = (option: TriggerOption) => { + const shortcutKey = shortcutKeyById.get(option.id) + + return ( + <div + key={option.id} + className='system-md-regular flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' + onClick={() => handleSelect(option)} + > + <div className='flex min-w-0 flex-1 items-center'> + <div className='flex h-6 w-6 shrink-0 items-center justify-center'> + {option.icon} + </div> + <span className='ml-2 truncate'>{option.name}</span> + </div> + {shortcutKey && ( + <ShortcutsName keys={[shortcutKey]} className="ml-2" textColor="secondary" /> + )} + </div> + ) + } + + const hasUserInput = !!options.userInput && options.userInput.enabled !== false + const hasTriggers = options.triggers.some(trigger => trigger.enabled !== false) + const hasRunAll = !!options.runAll && options.runAll.enabled !== false + + if (hasSingleEnabledOption && soleEnabledOption) { + const handleRunClick = (event?: MouseEvent<HTMLElement>) => { + if (event?.defaultPrevented) + return + + runSoleOption() + } + + if (isValidElement(children)) { + const childElement = children as ReactElement<{ onClick?: MouseEventHandler<HTMLElement> }> + const originalOnClick = childElement.props?.onClick + + return cloneElement(childElement, { + onClick: (event: MouseEvent<HTMLElement>) => { + if (typeof originalOnClick === 'function') + originalOnClick(event) + + if (event?.defaultPrevented) + return + + runSoleOption() + }, + }) + } + + return ( + <span onClick={handleRunClick}> + {children} + </span> + ) + } + + return ( + <PortalToFollowElem + open={open} + onOpenChange={setOpen} + placement='bottom-start' + offset={{ mainAxis: 8, crossAxis: -4 }} + > + <PortalToFollowElemTrigger asChild onClick={() => setOpen(!open)}> + <div style={{ userSelect: 'none' }}> + {children} + </div> + </PortalToFollowElemTrigger> + <PortalToFollowElemContent className='z-[12]'> + <div className='w-[284px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg'> + <div className='mb-2 px-3 pt-2 text-sm font-medium text-text-primary'> + {t('workflow.common.chooseStartNodeToRun')} + </div> + <div> + {hasUserInput && renderOption(options.userInput!)} + + {(hasTriggers || hasRunAll) && hasUserInput && ( + <div className='mx-3 my-1 h-px bg-divider-subtle' /> + )} + + {hasRunAll && renderOption(options.runAll!)} + + {hasTriggers && options.triggers + .filter(trigger => trigger.enabled !== false) + .map(trigger => renderOption(trigger))} + </div> + </div> + </PortalToFollowElemContent> + </PortalToFollowElem> + ) +}) + +TestRunMenu.displayName = 'TestRunMenu' + +export default TestRunMenu diff --git a/web/app/components/workflow/hooks-store/store.ts b/web/app/components/workflow/hooks-store/store.ts index bd09bc501b..3e205f9521 100644 --- a/web/app/components/workflow/hooks-store/store.ts +++ b/web/app/components/workflow/hooks-store/store.ts @@ -40,11 +40,15 @@ export type CommonHooksFnMap = { handleBackupDraft: () => void handleLoadBackupDraft: () => void handleRestoreFromPublishedWorkflow: (...args: any[]) => void - handleRun: (params: any, callback?: IOtherOptions) => void + handleRun: (params: any, callback?: IOtherOptions, options?: any) => void handleStopRun: (...args: any[]) => void handleStartWorkflowRun: () => void handleWorkflowStartRunInWorkflow: () => void handleWorkflowStartRunInChatflow: () => void + handleWorkflowTriggerScheduleRunInWorkflow: (nodeId?: string) => void + handleWorkflowTriggerWebhookRunInWorkflow: (params: { nodeId: string }) => void + handleWorkflowTriggerPluginRunInWorkflow: (nodeId?: string) => void + handleWorkflowRunAllTriggersInWorkflow: (nodeIds: string[]) => void availableNodesMetaData?: AvailableNodesMetaData getWorkflowRunAndTraceUrl: (runId?: string) => { runUrl: string; traceUrl: string } exportCheck?: () => Promise<void> @@ -87,6 +91,10 @@ export const createHooksStore = ({ handleStartWorkflowRun = noop, handleWorkflowStartRunInWorkflow = noop, handleWorkflowStartRunInChatflow = noop, + handleWorkflowTriggerScheduleRunInWorkflow = noop, + handleWorkflowTriggerWebhookRunInWorkflow = noop, + handleWorkflowTriggerPluginRunInWorkflow = noop, + handleWorkflowRunAllTriggersInWorkflow = noop, availableNodesMetaData = { nodes: [], }, @@ -125,6 +133,10 @@ export const createHooksStore = ({ handleStartWorkflowRun, handleWorkflowStartRunInWorkflow, handleWorkflowStartRunInChatflow, + handleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow, availableNodesMetaData, getWorkflowRunAndTraceUrl, exportCheck, diff --git a/web/app/components/workflow/hooks/index.ts b/web/app/components/workflow/hooks/index.ts index 1dbba6b0e2..1131836b35 100644 --- a/web/app/components/workflow/hooks/index.ts +++ b/web/app/components/workflow/hooks/index.ts @@ -22,3 +22,5 @@ export * from './use-DSL' export * from './use-inspect-vars-crud' export * from './use-set-workflow-vars-with-value' export * from './use-workflow-search' +export * from './use-auto-generate-webhook-url' +export * from './use-serial-async-callback' diff --git a/web/app/components/workflow/hooks/use-auto-generate-webhook-url.ts b/web/app/components/workflow/hooks/use-auto-generate-webhook-url.ts new file mode 100644 index 0000000000..d7d66e31ef --- /dev/null +++ b/web/app/components/workflow/hooks/use-auto-generate-webhook-url.ts @@ -0,0 +1,48 @@ +import { useCallback } from 'react' +import { produce } from 'immer' +import { useStoreApi } from 'reactflow' +import { useStore as useAppStore } from '@/app/components/app/store' +import { BlockEnum } from '@/app/components/workflow/types' +import { fetchWebhookUrl } from '@/service/apps' + +export const useAutoGenerateWebhookUrl = () => { + const reactFlowStore = useStoreApi() + + return useCallback(async (nodeId: string) => { + const appId = useAppStore.getState().appDetail?.id + if (!appId) + return + + const { getNodes } = reactFlowStore.getState() + const node = getNodes().find(n => n.id === nodeId) + if (!node || node.data.type !== BlockEnum.TriggerWebhook) + return + + if (node.data.webhook_url && node.data.webhook_url.length > 0) + return + + try { + const response = await fetchWebhookUrl({ appId, nodeId }) + const { getNodes: getLatestNodes, setNodes } = reactFlowStore.getState() + let hasUpdated = false + const updatedNodes = produce(getLatestNodes(), (draft) => { + const targetNode = draft.find(n => n.id === nodeId) + if (!targetNode || targetNode.data.type !== BlockEnum.TriggerWebhook) + return + + targetNode.data = { + ...targetNode.data, + webhook_url: response.webhook_url, + webhook_debug_url: response.webhook_debug_url, + } + hasUpdated = true + }) + + if (hasUpdated) + setNodes(updatedNodes) + } + catch (error: unknown) { + console.error('Failed to auto-generate webhook URL:', error) + } + }, [reactFlowStore]) +} diff --git a/web/app/components/workflow/hooks/use-available-blocks.ts b/web/app/components/workflow/hooks/use-available-blocks.ts index b4e037d29f..e1a1919afd 100644 --- a/web/app/components/workflow/hooks/use-available-blocks.ts +++ b/web/app/components/workflow/hooks/use-available-blocks.ts @@ -21,7 +21,9 @@ export const useAvailableBlocks = (nodeType?: BlockEnum, inContainer?: boolean) } = useNodesMetaData() const availableNodesType = useMemo(() => availableNodes.map(node => node.metaData.type), [availableNodes]) const availablePrevBlocks = useMemo(() => { - if (!nodeType || nodeType === BlockEnum.Start || nodeType === BlockEnum.DataSource) + if (!nodeType || nodeType === BlockEnum.Start || nodeType === BlockEnum.DataSource + || nodeType === BlockEnum.TriggerPlugin || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerSchedule) return [] return availableNodesType diff --git a/web/app/components/workflow/hooks/use-checklist.ts b/web/app/components/workflow/hooks/use-checklist.ts index d29827f273..8c4ec7299e 100644 --- a/web/app/components/workflow/hooks/use-checklist.ts +++ b/web/app/components/workflow/hooks/use-checklist.ts @@ -4,8 +4,9 @@ import { useRef, } from 'react' import { useTranslation } from 'react-i18next' -import { useStoreApi } from 'reactflow' +import { useEdges, useNodes, useStoreApi } from 'reactflow' import type { + CommonEdgeType, CommonNodeType, Edge, Node, @@ -21,20 +22,22 @@ import { getToolCheckParams, getValidTreeNodes, } from '../utils' +import { getTriggerCheckParams } from '../utils/trigger' import { CUSTOM_NODE, } from '../constants' import { useGetToolIcon, - useWorkflow, + useNodesMetaData, } from '../hooks' import type { ToolNodeType } from '../nodes/tool/types' import type { DataSourceNodeType } from '../nodes/data-source/types' -import { useNodesMetaData } from './use-nodes-meta-data' +import type { PluginTriggerNodeType } from '../nodes/trigger-plugin/types' import { useToastContext } from '@/app/components/base/toast' import { useGetLanguage } from '@/context/i18n' import type { AgentNodeType } from '../nodes/agent/types' import { useStrategyProviders } from '@/service/use-strategy' +import { useAllTriggerPlugins } from '@/service/use-triggers' import { useDatasetsDetailStore } from '../datasets-detail-store/store' import type { KnowledgeRetrievalNodeType } from '../nodes/knowledge-retrieval/types' import type { DataSet } from '@/models/datasets' @@ -42,6 +45,7 @@ import { fetchDatasets } from '@/service/datasets' import { MAX_TREE_DEPTH } from '@/config' import useNodesAvailableVarList, { useGetNodesAvailableVarList } from './use-nodes-available-var-list' import { getNodeUsedVars, isSpecialVar } from '../nodes/_base/components/variable/utils' +import type { Emoji } from '@/app/components/tools/types' import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { KnowledgeBaseNodeType } from '../nodes/knowledge-base/types' @@ -50,6 +54,25 @@ import { useAllCustomTools, useAllWorkflowTools, } from '@/service/use-tools' +import { useStore as useAppStore } from '@/app/components/app/store' +import { AppModeEnum } from '@/types/app' + +export type ChecklistItem = { + id: string + type: BlockEnum | string + title: string + toolIcon?: string | Emoji + unConnected?: boolean + errorMessage?: string + canNavigate: boolean +} + +const START_NODE_TYPES: BlockEnum[] = [ + BlockEnum.Start, + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, +] export const useChecklist = (nodes: Node[], edges: Edge[]) => { const { t } = useTranslation() @@ -60,9 +83,11 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { const { data: workflowTools } = useAllWorkflowTools() const dataSourceList = useStore(s => s.dataSourceList) const { data: strategyProviders } = useStrategyProviders() + const { data: triggerPlugins } = useAllTriggerPlugins() const datasetsDetail = useDatasetsDetailStore(s => s.datasetsDetail) - const { getStartNodes } = useWorkflow() const getToolIcon = useGetToolIcon() + const appMode = useAppStore.getState().appDetail?.mode + const shouldCheckStartNode = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT const map = useNodesAvailableVarList(nodes) const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding) @@ -92,16 +117,10 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { return checkData }, [datasetsDetail, embeddingModelList, rerankModelList]) - const needWarningNodes = useMemo(() => { - const list = [] + const needWarningNodes = useMemo<ChecklistItem[]>(() => { + const list: ChecklistItem[] = [] const filteredNodes = nodes.filter(node => node.type === CUSTOM_NODE) - const startNodes = getStartNodes(filteredNodes) - const validNodesFlattened = startNodes.map(startNode => getValidTreeNodes(startNode, filteredNodes, edges)) - const validNodes = validNodesFlattened.reduce((acc, curr) => { - if (curr.validNodes) - acc.push(...curr.validNodes) - return acc - }, [] as Node[]) + const { validNodes } = getValidTreeNodes(filteredNodes, edges) for (let i = 0; i < filteredNodes.length; i++) { const node = filteredNodes[i] @@ -114,6 +133,9 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { if (node.data.type === BlockEnum.DataSource) moreDataForCheckValid = getDataSourceCheckParams(node.data as DataSourceNodeType, dataSourceList || [], language) + if (node.data.type === BlockEnum.TriggerPlugin) + moreDataForCheckValid = getTriggerCheckParams(node.data as PluginTriggerNodeType, triggerPlugins, language) + const toolIcon = getToolIcon(node.data) if (node.data.type === BlockEnum.Agent) { const data = node.data as AgentNodeType @@ -133,7 +155,8 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { if (node.type === CUSTOM_NODE) { const checkData = getCheckData(node.data) - let { errorMessage } = nodesExtraData![node.data.type].checkValid(checkData, t, moreDataForCheckValid) + const validator = nodesExtraData?.[node.data.type as BlockEnum]?.checkValid + let errorMessage = validator ? validator(checkData, t, moreDataForCheckValid).errorMessage : undefined if (!errorMessage) { const availableVars = map[node.id].availableVars @@ -153,19 +176,43 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { } } } - if (errorMessage || !validNodes.find(n => n.id === node.id)) { + + // Start nodes and Trigger nodes should not show unConnected error if they have validation errors + // or if they are valid start nodes (even without incoming connections) + const isStartNodeMeta = nodesExtraData?.[node.data.type as BlockEnum]?.metaData.isStart ?? false + const canSkipConnectionCheck = shouldCheckStartNode ? isStartNodeMeta : true + + const isUnconnected = !validNodes.find(n => n.id === node.id) + const shouldShowError = errorMessage || (isUnconnected && !canSkipConnectionCheck) + + if (shouldShowError) { list.push({ id: node.id, type: node.data.type, title: node.data.title, toolIcon, - unConnected: !validNodes.find(n => n.id === node.id), + unConnected: isUnconnected && !canSkipConnectionCheck, errorMessage, + canNavigate: true, }) } } } + // Check for start nodes (including triggers) + if (shouldCheckStartNode) { + const startNodesFiltered = nodes.filter(node => START_NODE_TYPES.includes(node.data.type as BlockEnum)) + if (startNodesFiltered.length === 0) { + list.push({ + id: 'start-node-required', + type: BlockEnum.Start, + title: t('workflow.panel.startNode'), + errorMessage: t('workflow.common.needStartNode'), + canNavigate: false, + }) + } + } + const isRequiredNodesType = Object.keys(nodesExtraData!).filter((key: any) => (nodesExtraData as any)[key].metaData.isRequired) isRequiredNodesType.forEach((type: string) => { @@ -175,12 +222,13 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { type, title: t(`workflow.blocks.${type}`), errorMessage: t('workflow.common.needAdd', { node: t(`workflow.blocks.${type}`) }), + canNavigate: false, }) } }) return list - }, [nodes, getStartNodes, nodesExtraData, edges, buildInTools, customTools, workflowTools, language, dataSourceList, getToolIcon, strategyProviders, getCheckData, t, map]) + }, [nodes, nodesExtraData, edges, buildInTools, customTools, workflowTools, language, dataSourceList, getToolIcon, strategyProviders, getCheckData, t, map, shouldCheckStartNode]) return needWarningNodes } @@ -194,7 +242,6 @@ export const useChecklistBeforePublish = () => { const { data: strategyProviders } = useStrategyProviders() const updateDatasetsDetail = useDatasetsDetailStore(s => s.updateDatasetsDetail) const updateTime = useRef(0) - const { getStartNodes } = useWorkflow() const workflowStore = useWorkflowStore() const { getNodesAvailableVarList } = useGetNodesAvailableVarList() const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding) @@ -241,20 +288,11 @@ export const useChecklistBeforePublish = () => { } = workflowStore.getState() const nodes = getNodes() const filteredNodes = nodes.filter(node => node.type === CUSTOM_NODE) - const startNodes = getStartNodes(filteredNodes) - const validNodesFlattened = startNodes.map(startNode => getValidTreeNodes(startNode, filteredNodes, edges)) - const validNodes = validNodesFlattened.reduce((acc, curr) => { - if (curr.validNodes) - acc.push(...curr.validNodes) - return acc - }, [] as Node[]) - const maxDepthArr = validNodesFlattened.map(item => item.maxDepth) + const { validNodes, maxDepth } = getValidTreeNodes(filteredNodes, edges) - for (let i = 0; i < maxDepthArr.length; i++) { - if (maxDepthArr[i] > MAX_TREE_DEPTH) { - notify({ type: 'error', message: t('workflow.common.maxTreeDepth', { depth: MAX_TREE_DEPTH }) }) - return false - } + if (maxDepth > MAX_TREE_DEPTH) { + notify({ type: 'error', message: t('workflow.common.maxTreeDepth', { depth: MAX_TREE_DEPTH }) }) + return false } // Before publish, we need to fetch datasets detail, in case of the settings of datasets have been changed const knowledgeRetrievalNodes = filteredNodes.filter(node => node.data.type === BlockEnum.KnowledgeRetrieval) @@ -334,10 +372,18 @@ export const useChecklistBeforePublish = () => { } } + const startNodesFiltered = nodes.filter(node => START_NODE_TYPES.includes(node.data.type as BlockEnum)) + + if (startNodesFiltered.length === 0) { + notify({ type: 'error', message: t('workflow.common.needStartNode') }) + return false + } + const isRequiredNodesType = Object.keys(nodesExtraData!).filter((key: any) => (nodesExtraData as any)[key].metaData.isRequired) for (let i = 0; i < isRequiredNodesType.length; i++) { const type = isRequiredNodesType[i] + if (!filteredNodes.find(node => node.data.type === type)) { notify({ type: 'error', message: t('workflow.common.needAdd', { node: t(`workflow.blocks.${type}`) }) }) return false @@ -345,9 +391,31 @@ export const useChecklistBeforePublish = () => { } return true - }, [store, notify, t, language, nodesExtraData, strategyProviders, updateDatasetsDetail, getCheckData, getStartNodes, workflowStore, buildInTools, customTools, workflowTools]) + }, [store, notify, t, language, nodesExtraData, strategyProviders, updateDatasetsDetail, getCheckData, workflowStore, buildInTools, customTools, workflowTools]) return { handleCheckBeforePublish, } } + +export const useWorkflowRunValidation = () => { + const { t } = useTranslation() + const nodes = useNodes<CommonNodeType>() + const edges = useEdges<CommonEdgeType>() + const needWarningNodes = useChecklist(nodes, edges) + const { notify } = useToastContext() + + const validateBeforeRun = useCallback(() => { + if (needWarningNodes.length > 0) { + notify({ type: 'error', message: t('workflow.panel.checklistTip') }) + return false + } + return true + }, [needWarningNodes, notify, t]) + + return { + validateBeforeRun, + hasValidationErrors: needWarningNodes.length > 0, + warningNodes: needWarningNodes, + } +} diff --git a/web/app/components/workflow/hooks/use-dynamic-test-run-options.tsx b/web/app/components/workflow/hooks/use-dynamic-test-run-options.tsx new file mode 100644 index 0000000000..3e35ff0168 --- /dev/null +++ b/web/app/components/workflow/hooks/use-dynamic-test-run-options.tsx @@ -0,0 +1,144 @@ +import { useMemo } from 'react' +import { useNodes } from 'reactflow' +import { useTranslation } from 'react-i18next' +import { BlockEnum, type CommonNodeType } from '../types' +import { getWorkflowEntryNode } from '../utils/workflow-entry' +import { type TestRunOptions, type TriggerOption, TriggerType } from '../header/test-run-menu' +import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow' +import BlockIcon from '../block-icon' +import { useStore } from '../store' +import { useAllTriggerPlugins } from '@/service/use-triggers' + +export const useDynamicTestRunOptions = (): TestRunOptions => { + const { t } = useTranslation() + const nodes = useNodes() + const buildInTools = useStore(s => s.buildInTools) + const customTools = useStore(s => s.customTools) + const workflowTools = useStore(s => s.workflowTools) + const mcpTools = useStore(s => s.mcpTools) + const { data: triggerPlugins } = useAllTriggerPlugins() + + return useMemo(() => { + const allTriggers: TriggerOption[] = [] + let userInput: TriggerOption | undefined + + for (const node of nodes) { + const nodeData = node.data as CommonNodeType + + if (!nodeData?.type) continue + + if (nodeData.type === BlockEnum.Start) { + userInput = { + id: node.id, + type: TriggerType.UserInput, + name: nodeData.title || t('workflow.blocks.start'), + icon: ( + <BlockIcon + type={BlockEnum.Start} + size='md' + /> + ), + nodeId: node.id, + enabled: true, + } + } + else if (nodeData.type === BlockEnum.TriggerSchedule) { + allTriggers.push({ + id: node.id, + type: TriggerType.Schedule, + name: nodeData.title || t('workflow.blocks.trigger-schedule'), + icon: ( + <BlockIcon + type={BlockEnum.TriggerSchedule} + size='md' + /> + ), + nodeId: node.id, + enabled: true, + }) + } + else if (nodeData.type === BlockEnum.TriggerWebhook) { + allTriggers.push({ + id: node.id, + type: TriggerType.Webhook, + name: nodeData.title || t('workflow.blocks.trigger-webhook'), + icon: ( + <BlockIcon + type={BlockEnum.TriggerWebhook} + size='md' + /> + ), + nodeId: node.id, + enabled: true, + }) + } + else if (nodeData.type === BlockEnum.TriggerPlugin) { + let triggerIcon: string | any + + if (nodeData.provider_id) { + const targetTriggers = triggerPlugins || [] + triggerIcon = targetTriggers.find(toolWithProvider => toolWithProvider.name === nodeData.provider_id)?.icon + } + + const icon = ( + <BlockIcon + type={BlockEnum.TriggerPlugin} + size='md' + toolIcon={triggerIcon} + /> + ) + + allTriggers.push({ + id: node.id, + type: TriggerType.Plugin, + name: nodeData.title || (nodeData as any).plugin_name || t('workflow.blocks.trigger-plugin'), + icon, + nodeId: node.id, + enabled: true, + }) + } + } + + if (!userInput) { + const startNode = getWorkflowEntryNode(nodes as any[]) + if (startNode && startNode.data?.type === BlockEnum.Start) { + userInput = { + id: startNode.id, + type: TriggerType.UserInput, + name: (startNode.data as CommonNodeType)?.title || t('workflow.blocks.start'), + icon: ( + <BlockIcon + type={BlockEnum.Start} + size='md' + /> + ), + nodeId: startNode.id, + enabled: true, + } + } + } + + const triggerNodeIds = allTriggers + .map(trigger => trigger.nodeId) + .filter((nodeId): nodeId is string => Boolean(nodeId)) + + const runAll: TriggerOption | undefined = triggerNodeIds.length > 1 ? { + id: 'run-all', + type: TriggerType.All, + name: t('workflow.common.runAllTriggers'), + icon: ( + <div className="flex h-6 w-6 items-center justify-center rounded-lg border-[0.5px] border-white/2 bg-util-colors-purple-purple-500 text-white shadow-md"> + <TriggerAll className="h-4.5 w-4.5" /> + </div> + ), + relatedNodeIds: triggerNodeIds, + enabled: true, + } : undefined + + return { + userInput, + triggers: allTriggers, + runAll, + } + }, [nodes, buildInTools, customTools, workflowTools, mcpTools, triggerPlugins, t]) +} diff --git a/web/app/components/workflow/hooks/use-helpline.ts b/web/app/components/workflow/hooks/use-helpline.ts index 2eed71a807..55979904fb 100644 --- a/web/app/components/workflow/hooks/use-helpline.ts +++ b/web/app/components/workflow/hooks/use-helpline.ts @@ -1,12 +1,40 @@ import { useCallback } from 'react' import { useStoreApi } from 'reactflow' import type { Node } from '../types' +import { BlockEnum, isTriggerNode } from '../types' import { useWorkflowStore } from '../store' +// Entry node (Start/Trigger) wrapper offsets +// The EntryNodeContainer adds a wrapper with status indicator above the actual node +// These offsets ensure alignment happens on the inner node, not the wrapper +const ENTRY_NODE_WRAPPER_OFFSET = { + x: 0, // No horizontal padding on wrapper (px-0) + y: 21, // Actual measured: pt-0.5 (2px) + status bar height (~19px) +} as const + export const useHelpline = () => { const store = useStoreApi() const workflowStore = useWorkflowStore() + // Check if a node is an entry node (Start or Trigger) + const isEntryNode = useCallback((node: Node): boolean => { + return isTriggerNode(node.data.type as any) || node.data.type === BlockEnum.Start + }, []) + + // Get the actual alignment position of a node (accounting for wrapper offset) + const getNodeAlignPosition = useCallback((node: Node) => { + if (isEntryNode(node)) { + return { + x: node.position.x + ENTRY_NODE_WRAPPER_OFFSET.x, + y: node.position.y + ENTRY_NODE_WRAPPER_OFFSET.y, + } + } + return { + x: node.position.x, + y: node.position.y, + } + }, [isEntryNode]) + const handleSetHelpline = useCallback((node: Node) => { const { getNodes } = store.getState() const nodes = getNodes() @@ -29,6 +57,9 @@ export const useHelpline = () => { } } + // Get the actual alignment position for the dragging node + const nodeAlignPos = getNodeAlignPosition(node) + const showHorizontalHelpLineNodes = nodes.filter((n) => { if (n.id === node.id) return false @@ -39,33 +70,52 @@ export const useHelpline = () => { if (n.data.isInLoop) return false - const nY = Math.ceil(n.position.y) - const nodeY = Math.ceil(node.position.y) + // Get actual alignment position for comparison node + const nAlignPos = getNodeAlignPosition(n) + const nY = Math.ceil(nAlignPos.y) + const nodeY = Math.ceil(nodeAlignPos.y) if (nY - nodeY < 5 && nY - nodeY > -5) return true return false - }).sort((a, b) => a.position.x - b.position.x) + }).sort((a, b) => { + const aPos = getNodeAlignPosition(a) + const bPos = getNodeAlignPosition(b) + return aPos.x - bPos.x + }) const showHorizontalHelpLineNodesLength = showHorizontalHelpLineNodes.length if (showHorizontalHelpLineNodesLength > 0) { const first = showHorizontalHelpLineNodes[0] const last = showHorizontalHelpLineNodes[showHorizontalHelpLineNodesLength - 1] + // Use actual alignment positions for help line rendering + const firstPos = getNodeAlignPosition(first) + const lastPos = getNodeAlignPosition(last) + + // For entry nodes, we need to subtract the offset from width since lastPos already includes it + const lastIsEntryNode = isEntryNode(last) + const lastNodeWidth = lastIsEntryNode ? last.width! - ENTRY_NODE_WRAPPER_OFFSET.x : last.width! + const helpLine = { - top: first.position.y, - left: first.position.x, - width: last.position.x + last.width! - first.position.x, + top: firstPos.y, + left: firstPos.x, + width: lastPos.x + lastNodeWidth - firstPos.x, } - if (node.position.x < first.position.x) { - helpLine.left = node.position.x - helpLine.width = first.position.x + first.width! - node.position.x + if (nodeAlignPos.x < firstPos.x) { + const firstIsEntryNode = isEntryNode(first) + const firstNodeWidth = firstIsEntryNode ? first.width! - ENTRY_NODE_WRAPPER_OFFSET.x : first.width! + helpLine.left = nodeAlignPos.x + helpLine.width = firstPos.x + firstNodeWidth - nodeAlignPos.x } - if (node.position.x > last.position.x) - helpLine.width = node.position.x + node.width! - first.position.x + if (nodeAlignPos.x > lastPos.x) { + const nodeIsEntryNode = isEntryNode(node) + const nodeWidth = nodeIsEntryNode ? node.width! - ENTRY_NODE_WRAPPER_OFFSET.x : node.width! + helpLine.width = nodeAlignPos.x + nodeWidth - firstPos.x + } setHelpLineHorizontal(helpLine) } @@ -81,33 +131,52 @@ export const useHelpline = () => { if (n.data.isInLoop) return false - const nX = Math.ceil(n.position.x) - const nodeX = Math.ceil(node.position.x) + // Get actual alignment position for comparison node + const nAlignPos = getNodeAlignPosition(n) + const nX = Math.ceil(nAlignPos.x) + const nodeX = Math.ceil(nodeAlignPos.x) if (nX - nodeX < 5 && nX - nodeX > -5) return true return false - }).sort((a, b) => a.position.x - b.position.x) + }).sort((a, b) => { + const aPos = getNodeAlignPosition(a) + const bPos = getNodeAlignPosition(b) + return aPos.x - bPos.x + }) const showVerticalHelpLineNodesLength = showVerticalHelpLineNodes.length if (showVerticalHelpLineNodesLength > 0) { const first = showVerticalHelpLineNodes[0] const last = showVerticalHelpLineNodes[showVerticalHelpLineNodesLength - 1] + // Use actual alignment positions for help line rendering + const firstPos = getNodeAlignPosition(first) + const lastPos = getNodeAlignPosition(last) + + // For entry nodes, we need to subtract the offset from height since lastPos already includes it + const lastIsEntryNode = isEntryNode(last) + const lastNodeHeight = lastIsEntryNode ? last.height! - ENTRY_NODE_WRAPPER_OFFSET.y : last.height! + const helpLine = { - top: first.position.y, - left: first.position.x, - height: last.position.y + last.height! - first.position.y, + top: firstPos.y, + left: firstPos.x, + height: lastPos.y + lastNodeHeight - firstPos.y, } - if (node.position.y < first.position.y) { - helpLine.top = node.position.y - helpLine.height = first.position.y + first.height! - node.position.y + if (nodeAlignPos.y < firstPos.y) { + const firstIsEntryNode = isEntryNode(first) + const firstNodeHeight = firstIsEntryNode ? first.height! - ENTRY_NODE_WRAPPER_OFFSET.y : first.height! + helpLine.top = nodeAlignPos.y + helpLine.height = firstPos.y + firstNodeHeight - nodeAlignPos.y } - if (node.position.y > last.position.y) - helpLine.height = node.position.y + node.height! - first.position.y + if (nodeAlignPos.y > lastPos.y) { + const nodeIsEntryNode = isEntryNode(node) + const nodeHeight = nodeIsEntryNode ? node.height! - ENTRY_NODE_WRAPPER_OFFSET.y : node.height! + helpLine.height = nodeAlignPos.y + nodeHeight - firstPos.y + } setHelpLineVertical(helpLine) } @@ -119,7 +188,7 @@ export const useHelpline = () => { showHorizontalHelpLineNodes, showVerticalHelpLineNodes, } - }, [store, workflowStore]) + }, [store, workflowStore, getNodeAlignPosition]) return { handleSetHelpline, diff --git a/web/app/components/workflow/hooks/use-inspect-vars-crud.ts b/web/app/components/workflow/hooks/use-inspect-vars-crud.ts index c922192267..0f58cf8be2 100644 --- a/web/app/components/workflow/hooks/use-inspect-vars-crud.ts +++ b/web/app/components/workflow/hooks/use-inspect-vars-crud.ts @@ -5,13 +5,35 @@ import { useSysVarValues, } from '@/service/use-workflow' import { FlowType } from '@/types/common' +import { produce } from 'immer' +import { BlockEnum } from '../types' +const varsAppendStartNodeKeys = ['query', 'files'] const useInspectVarsCrud = () => { - const nodesWithInspectVars = useStore(s => s.nodesWithInspectVars) + const partOfNodesWithInspectVars = useStore(s => s.nodesWithInspectVars) const configsMap = useHooksStore(s => s.configsMap) const isRagPipeline = configsMap?.flowType === FlowType.ragPipeline const { data: conversationVars } = useConversationVarValues(configsMap?.flowType, !isRagPipeline ? configsMap?.flowId : '') - const { data: systemVars } = useSysVarValues(configsMap?.flowType, !isRagPipeline ? configsMap?.flowId : '') + const { data: allSystemVars } = useSysVarValues(configsMap?.flowType, !isRagPipeline ? configsMap?.flowId : '') + const { varsAppendStartNode, systemVars } = (() => { + if(allSystemVars?.length === 0) + return { varsAppendStartNode: [], systemVars: [] } + const varsAppendStartNode = allSystemVars?.filter(({ name }) => varsAppendStartNodeKeys.includes(name)) || [] + const systemVars = allSystemVars?.filter(({ name }) => !varsAppendStartNodeKeys.includes(name)) || [] + return { varsAppendStartNode, systemVars } + })() + const nodesWithInspectVars = (() => { + if(!partOfNodesWithInspectVars || partOfNodesWithInspectVars.length === 0) + return [] + + const nodesWithInspectVars = produce(partOfNodesWithInspectVars, (draft) => { + draft.forEach((nodeWithVars) => { + if(nodeWithVars.nodeType === BlockEnum.Start) + nodeWithVars.vars = [...nodeWithVars.vars, ...varsAppendStartNode] + }) + }) + return nodesWithInspectVars + })() const hasNodeInspectVars = useHooksStore(s => s.hasNodeInspectVars) const hasSetInspectVar = useHooksStore(s => s.hasSetInspectVar) const fetchInspectVarValue = useHooksStore(s => s.fetchInspectVarValue) diff --git a/web/app/components/workflow/hooks/use-node-data-update.ts b/web/app/components/workflow/hooks/use-node-data-update.ts index edacc31a7c..ac7dca9e4c 100644 --- a/web/app/components/workflow/hooks/use-node-data-update.ts +++ b/web/app/components/workflow/hooks/use-node-data-update.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react' import { produce } from 'immer' import { useStoreApi } from 'reactflow' +import type { SyncCallback } from './use-nodes-sync-draft' import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useNodesReadOnly } from './use-workflow' @@ -28,12 +29,19 @@ export const useNodeDataUpdate = () => { setNodes(newNodes) }, [store]) - const handleNodeDataUpdateWithSyncDraft = useCallback((payload: NodeDataUpdatePayload) => { + const handleNodeDataUpdateWithSyncDraft = useCallback(( + payload: NodeDataUpdatePayload, + options?: { + sync?: boolean + notRefreshWhenSyncError?: boolean + callback?: SyncCallback + }, + ) => { if (getNodesReadOnly()) return handleNodeDataUpdate(payload) - handleSyncWorkflowDraft() + handleSyncWorkflowDraft(options?.sync, options?.notRefreshWhenSyncError, options?.callback) }, [handleSyncWorkflowDraft, handleNodeDataUpdate, getNodesReadOnly]) return { diff --git a/web/app/components/workflow/hooks/use-node-plugin-installation.ts b/web/app/components/workflow/hooks/use-node-plugin-installation.ts new file mode 100644 index 0000000000..96e3919e67 --- /dev/null +++ b/web/app/components/workflow/hooks/use-node-plugin-installation.ts @@ -0,0 +1,218 @@ +import { useCallback, useMemo } from 'react' +import { BlockEnum, type CommonNodeType } from '../types' +import type { ToolNodeType } from '../nodes/tool/types' +import type { PluginTriggerNodeType } from '../nodes/trigger-plugin/types' +import type { DataSourceNodeType } from '../nodes/data-source/types' +import { CollectionType } from '@/app/components/tools/types' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, + useInvalidToolsByType, +} from '@/service/use-tools' +import { + useAllTriggerPlugins, + useInvalidateAllTriggerPlugins, +} from '@/service/use-triggers' +import { useInvalidDataSourceList } from '@/service/use-pipeline' +import { useStore } from '../store' +import { canFindTool } from '@/utils' + +type InstallationState = { + isChecking: boolean + isMissing: boolean + uniqueIdentifier?: string + canInstall: boolean + onInstallSuccess: () => void + shouldDim: boolean +} + +const useToolInstallation = (data: ToolNodeType): InstallationState => { + const builtInQuery = useAllBuiltInTools() + const customQuery = useAllCustomTools() + const workflowQuery = useAllWorkflowTools() + const mcpQuery = useAllMCPTools() + const invalidateTools = useInvalidToolsByType(data.provider_type) + + const collectionInfo = useMemo(() => { + switch (data.provider_type) { + case CollectionType.builtIn: + return { + list: builtInQuery.data, + isLoading: builtInQuery.isLoading, + } + case CollectionType.custom: + return { + list: customQuery.data, + isLoading: customQuery.isLoading, + } + case CollectionType.workflow: + return { + list: workflowQuery.data, + isLoading: workflowQuery.isLoading, + } + case CollectionType.mcp: + return { + list: mcpQuery.data, + isLoading: mcpQuery.isLoading, + } + default: + return undefined + } + }, [ + builtInQuery.data, + builtInQuery.isLoading, + customQuery.data, + customQuery.isLoading, + data.provider_type, + mcpQuery.data, + mcpQuery.isLoading, + workflowQuery.data, + workflowQuery.isLoading, + ]) + + const collection = collectionInfo?.list + const isLoading = collectionInfo?.isLoading ?? false + const isResolved = !!collectionInfo && !isLoading + + const matchedCollection = useMemo(() => { + if (!collection || !collection.length) + return undefined + + return collection.find((toolWithProvider) => { + if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id) + return true + if (canFindTool(toolWithProvider.id, data.provider_id)) + return true + if (toolWithProvider.name === data.provider_name) + return true + return false + }) + }, [collection, data.plugin_id, data.provider_id, data.provider_name]) + + const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id || data.provider_id + const canInstall = Boolean(data.plugin_unique_identifier) + + const onInstallSuccess = useCallback(() => { + if (invalidateTools) + invalidateTools() + }, [invalidateTools]) + + const shouldDim = (!!collectionInfo && !isResolved) || (isResolved && !matchedCollection) + + return { + isChecking: !!collectionInfo && !isResolved, + isMissing: isResolved && !matchedCollection, + uniqueIdentifier, + canInstall, + onInstallSuccess, + shouldDim, + } +} + +const useTriggerInstallation = (data: PluginTriggerNodeType): InstallationState => { + const triggerPluginsQuery = useAllTriggerPlugins() + const invalidateTriggers = useInvalidateAllTriggerPlugins() + + const triggerProviders = triggerPluginsQuery.data + const isLoading = triggerPluginsQuery.isLoading + + const matchedProvider = useMemo(() => { + if (!triggerProviders || !triggerProviders.length) + return undefined + + return triggerProviders.find(provider => + provider.name === data.provider_name + || provider.id === data.provider_id + || (data.plugin_id && provider.plugin_id === data.plugin_id), + ) + }, [ + data.plugin_id, + data.provider_id, + data.provider_name, + triggerProviders, + ]) + + const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id || data.provider_id + const canInstall = Boolean(data.plugin_unique_identifier) + + const onInstallSuccess = useCallback(() => { + invalidateTriggers() + }, [invalidateTriggers]) + + const shouldDim = isLoading || (!isLoading && !!triggerProviders && !matchedProvider) + + return { + isChecking: isLoading, + isMissing: !isLoading && !!triggerProviders && !matchedProvider, + uniqueIdentifier, + canInstall, + onInstallSuccess, + shouldDim, + } +} + +const useDataSourceInstallation = (data: DataSourceNodeType): InstallationState => { + const dataSourceList = useStore(s => s.dataSourceList) + const invalidateDataSourceList = useInvalidDataSourceList() + + const matchedPlugin = useMemo(() => { + if (!dataSourceList || !dataSourceList.length) + return undefined + + return dataSourceList.find((item) => { + if (data.plugin_unique_identifier && item.plugin_unique_identifier === data.plugin_unique_identifier) + return true + if (data.plugin_id && item.plugin_id === data.plugin_id) + return true + if (data.provider_name && item.provider === data.provider_name) + return true + return false + }) + }, [data.plugin_id, data.plugin_unique_identifier, data.provider_name, dataSourceList]) + + const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id + const canInstall = Boolean(data.plugin_unique_identifier) + + const onInstallSuccess = useCallback(() => { + invalidateDataSourceList() + }, [invalidateDataSourceList]) + + const hasLoadedList = dataSourceList !== undefined + + const shouldDim = !hasLoadedList || (hasLoadedList && !matchedPlugin) + + return { + isChecking: !hasLoadedList, + isMissing: hasLoadedList && !matchedPlugin, + uniqueIdentifier, + canInstall, + onInstallSuccess, + shouldDim, + } +} + +export const useNodePluginInstallation = (data: CommonNodeType): InstallationState => { + const toolInstallation = useToolInstallation(data as ToolNodeType) + const triggerInstallation = useTriggerInstallation(data as PluginTriggerNodeType) + const dataSourceInstallation = useDataSourceInstallation(data as DataSourceNodeType) + + switch (data.type as BlockEnum) { + case BlockEnum.Tool: + return toolInstallation + case BlockEnum.TriggerPlugin: + return triggerInstallation + case BlockEnum.DataSource: + return dataSourceInstallation + default: + return { + isChecking: false, + isMissing: false, + uniqueIdentifier: undefined, + canInstall: false, + onInstallSuccess: () => undefined, + shouldDim: false, + } + } +} diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 4de53c431c..3cbdf08e43 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -16,9 +16,9 @@ import { useReactFlow, useStoreApi, } from 'reactflow' -import type { DataSourceDefaultValue, ToolDefaultValue } from '../block-selector/types' +import type { PluginDefaultValue } from '../block-selector/types' import type { Edge, Node, OnNodeAdd } from '../types' -import { BlockEnum } from '../types' +import { BlockEnum, isTriggerNode } from '../types' import { useWorkflowStore } from '../store' import { CUSTOM_EDGE, @@ -63,6 +63,15 @@ import type { RAGPipelineVariables } from '@/models/pipeline' import useInspectVarsCrud from './use-inspect-vars-crud' import { getNodeUsedVars } from '../nodes/_base/components/variable/utils' +// Entry node deletion restriction has been removed to allow empty workflows + +// Entry node (Start/Trigger) wrapper offsets for alignment +// Must match the values in use-helpline.ts +const ENTRY_NODE_WRAPPER_OFFSET = { + x: 0, + y: 21, // Adjusted based on visual testing feedback +} as const + export const useNodesInteractions = () => { const { t } = useTranslation() const store = useStoreApi() @@ -138,21 +147,51 @@ export const useNodesInteractions = () => { const newNodes = produce(nodes, (draft) => { const currentNode = draft.find(n => n.id === node.id)! - if (showVerticalHelpLineNodesLength > 0) - currentNode.position.x = showVerticalHelpLineNodes[0].position.x - else if (restrictPosition.x !== undefined) - currentNode.position.x = restrictPosition.x - else if (restrictLoopPosition.x !== undefined) - currentNode.position.x = restrictLoopPosition.x - else currentNode.position.x = node.position.x + // Check if current dragging node is an entry node + const isCurrentEntryNode = isTriggerNode(node.data.type as any) || node.data.type === BlockEnum.Start - if (showHorizontalHelpLineNodesLength > 0) - currentNode.position.y = showHorizontalHelpLineNodes[0].position.y - else if (restrictPosition.y !== undefined) + // X-axis alignment with offset consideration + if (showVerticalHelpLineNodesLength > 0) { + const targetNode = showVerticalHelpLineNodes[0] + const isTargetEntryNode = isTriggerNode(targetNode.data.type as any) || targetNode.data.type === BlockEnum.Start + + // Calculate the wrapper position needed to align the inner nodes + // Target inner position = target.position + target.offset + // Current inner position should equal target inner position + // So: current.position + current.offset = target.position + target.offset + // Therefore: current.position = target.position + target.offset - current.offset + const targetOffset = isTargetEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.x : 0 + const currentOffset = isCurrentEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.x : 0 + currentNode.position.x = targetNode.position.x + targetOffset - currentOffset + } + else if (restrictPosition.x !== undefined) { + currentNode.position.x = restrictPosition.x + } + else if (restrictLoopPosition.x !== undefined) { + currentNode.position.x = restrictLoopPosition.x + } + else { + currentNode.position.x = node.position.x + } + + // Y-axis alignment with offset consideration + if (showHorizontalHelpLineNodesLength > 0) { + const targetNode = showHorizontalHelpLineNodes[0] + const isTargetEntryNode = isTriggerNode(targetNode.data.type as any) || targetNode.data.type === BlockEnum.Start + + const targetOffset = isTargetEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.y : 0 + const currentOffset = isCurrentEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.y : 0 + currentNode.position.y = targetNode.position.y + targetOffset - currentOffset + } + else if (restrictPosition.y !== undefined) { currentNode.position.y = restrictPosition.y - else if (restrictLoopPosition.y !== undefined) + } + else if (restrictLoopPosition.y !== undefined) { currentNode.position.y = restrictLoopPosition.y - else currentNode.position.y = node.position.y + } + else { + currentNode.position.y = node.position.y + } }) setNodes(newNodes) }, @@ -357,6 +396,7 @@ export const useNodesInteractions = () => { if (node.type === CUSTOM_ITERATION_START_NODE) return if (node.type === CUSTOM_LOOP_START_NODE) return if (node.data.type === BlockEnum.DataSourceEmpty) return + if (node.data._pluginInstallLocked) return handleNodeSelect(node.id) }, [handleNodeSelect], @@ -735,7 +775,7 @@ export const useNodesInteractions = () => { nodeType, sourceHandle = 'source', targetHandle = 'target', - toolDefaultValue, + pluginDefaultValue, }, { prevNodeId, prevNodeSourceHandle, nextNodeId, nextNodeTargetHandle }, ) => { @@ -756,7 +796,7 @@ export const useNodesInteractions = () => { nodesWithSameType.length > 0 ? `${defaultValue.title} ${nodesWithSameType.length + 1}` : defaultValue.title, - ...toolDefaultValue, + ...pluginDefaultValue, selected: true, _showAddVariablePopup: (nodeType === BlockEnum.VariableAssigner @@ -1286,7 +1326,7 @@ export const useNodesInteractions = () => { currentNodeId: string, nodeType: BlockEnum, sourceHandle: string, - toolDefaultValue?: ToolDefaultValue | DataSourceDefaultValue, + pluginDefaultValue?: PluginDefaultValue, ) => { if (getNodesReadOnly()) return @@ -1310,7 +1350,7 @@ export const useNodesInteractions = () => { nodesWithSameType.length > 0 ? `${defaultValue.title} ${nodesWithSameType.length + 1}` : defaultValue.title, - ...toolDefaultValue, + ...pluginDefaultValue, _connectedSourceHandleIds: [], _connectedTargetHandleIds: [], selected: currentNode.data.selected, @@ -1656,7 +1696,7 @@ export const useNodesInteractions = () => { const nodes = getNodes() const bundledNodes = nodes.filter( - node => node.data._isBundled && node.data.type !== BlockEnum.Start, + node => node.data._isBundled, ) if (bundledNodes.length) { @@ -1669,7 +1709,7 @@ export const useNodesInteractions = () => { if (edgeSelected) return const selectedNode = nodes.find( - node => node.data.selected && node.data.type !== BlockEnum.Start, + node => node.data.selected, ) if (selectedNode) handleNodeDelete(selectedNode.id) diff --git a/web/app/components/workflow/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow/hooks/use-nodes-sync-draft.ts index e6cc3a97e3..a4c9a45542 100644 --- a/web/app/components/workflow/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow/hooks/use-nodes-sync-draft.ts @@ -1,12 +1,14 @@ import { useCallback } from 'react' -import { - useStore, -} from '../store' -import { - useNodesReadOnly, -} from './use-workflow' +import { useStore } from '../store' +import { useNodesReadOnly } from './use-workflow' import { useHooksStore } from '@/app/components/workflow/hooks-store' +export type SyncCallback = { + onSuccess?: () => void + onError?: () => void + onSettled?: () => void +} + export const useNodesSyncDraft = () => { const { getNodesReadOnly } = useNodesReadOnly() const debouncedSyncWorkflowDraft = useStore(s => s.debouncedSyncWorkflowDraft) @@ -16,11 +18,7 @@ export const useNodesSyncDraft = () => { const handleSyncWorkflowDraft = useCallback(( sync?: boolean, notRefreshWhenSyncError?: boolean, - callback?: { - onSuccess?: () => void - onError?: () => void - onSettled?: () => void - }, + callback?: SyncCallback, ) => { if (getNodesReadOnly()) return diff --git a/web/app/components/workflow/hooks/use-serial-async-callback.ts b/web/app/components/workflow/hooks/use-serial-async-callback.ts new file mode 100644 index 0000000000..c36409a776 --- /dev/null +++ b/web/app/components/workflow/hooks/use-serial-async-callback.ts @@ -0,0 +1,22 @@ +import { + useCallback, + useRef, +} from 'react' + +export const useSerialAsyncCallback = <Args extends any[], Result = void>( + fn: (...args: Args) => Promise<Result> | Result, + shouldSkip?: () => boolean, +) => { + const queueRef = useRef<Promise<unknown>>(Promise.resolve()) + + return useCallback((...args: Args) => { + if (shouldSkip?.()) + return Promise.resolve(undefined as Result) + + const lastPromise = queueRef.current.catch(() => undefined) + const nextPromise = lastPromise.then(() => fn(...args)) + queueRef.current = nextPromise + + return nextPromise + }, [fn, shouldSkip]) +} diff --git a/web/app/components/workflow/hooks/use-shortcuts.ts b/web/app/components/workflow/hooks/use-shortcuts.ts index a744fefd50..fa9b019011 100644 --- a/web/app/components/workflow/hooks/use-shortcuts.ts +++ b/web/app/components/workflow/hooks/use-shortcuts.ts @@ -14,7 +14,6 @@ import { useWorkflowCanvasMaximize, useWorkflowMoveMode, useWorkflowOrganize, - useWorkflowStartRun, } from '.' export const useShortcuts = (): void => { @@ -28,7 +27,6 @@ export const useShortcuts = (): void => { dimOtherNodes, undimAllNodes, } = useNodesInteractions() - const { handleStartWorkflowRun } = useWorkflowStartRun() const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore() const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { handleEdgeDelete } = useEdgesInteractions() @@ -61,9 +59,8 @@ export const useShortcuts = (): void => { } const shouldHandleShortcut = useCallback((e: KeyboardEvent) => { - const { showFeaturesPanel } = workflowStore.getState() - return !showFeaturesPanel && !isEventTargetInputArea(e.target as HTMLElement) - }, [workflowStore]) + return !isEventTargetInputArea(e.target as HTMLElement) + }, []) useKeyPress(['delete', 'backspace'], (e) => { if (shouldHandleShortcut(e)) { @@ -99,7 +96,11 @@ export const useShortcuts = (): void => { useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, (e) => { if (shouldHandleShortcut(e)) { e.preventDefault() - handleStartWorkflowRun() + // @ts-expect-error - Dynamic property added by run-and-history component + if (window._toggleTestRunDropdown) { + // @ts-expect-error - Dynamic property added by run-and-history component + window._toggleTestRunDropdown() + } } }, { exactMatch: true, useCapture: true }) diff --git a/web/app/components/workflow/hooks/use-tool-icon.ts b/web/app/components/workflow/hooks/use-tool-icon.ts index 32d65365db..8276989ee3 100644 --- a/web/app/components/workflow/hooks/use-tool-icon.ts +++ b/web/app/components/workflow/hooks/use-tool-icon.ts @@ -1,17 +1,7 @@ -import { - useCallback, - useMemo, -} from 'react' -import type { - Node, -} from '../types' -import { - BlockEnum, -} from '../types' -import { - useStore, - useWorkflowStore, -} from '../store' +import { useCallback, useMemo } from 'react' +import type { Node, ToolWithProvider } from '../types' +import { BlockEnum } from '../types' +import { useStore, useWorkflowStore } from '../store' import { CollectionType } from '@/app/components/tools/types' import { canFindTool } from '@/utils' import { @@ -20,6 +10,32 @@ import { useAllMCPTools, useAllWorkflowTools, } from '@/service/use-tools' +import { useAllTriggerPlugins } from '@/service/use-triggers' +import type { PluginTriggerNodeType } from '../nodes/trigger-plugin/types' +import type { ToolNodeType } from '../nodes/tool/types' +import type { DataSourceNodeType } from '../nodes/data-source/types' +import type { TriggerWithProvider } from '../block-selector/types' + +const isTriggerPluginNode = (data: Node['data']): data is PluginTriggerNodeType => data.type === BlockEnum.TriggerPlugin + +const isToolNode = (data: Node['data']): data is ToolNodeType => data.type === BlockEnum.Tool + +const isDataSourceNode = (data: Node['data']): data is DataSourceNodeType => data.type === BlockEnum.DataSource + +const findTriggerPluginIcon = ( + identifiers: (string | undefined)[], + triggers: TriggerWithProvider[] | undefined, +) => { + const targetTriggers = triggers || [] + for (const identifier of identifiers) { + if (!identifier) + continue + const matched = targetTriggers.find(trigger => trigger.id === identifier || canFindTool(trigger.id, identifier)) + if (matched?.icon) + return matched.icon + } + return undefined +} export const useToolIcon = (data?: Node['data']) => { const { data: buildInTools } = useAllBuiltInTools() @@ -27,26 +43,78 @@ export const useToolIcon = (data?: Node['data']) => { const { data: workflowTools } = useAllWorkflowTools() const { data: mcpTools } = useAllMCPTools() const dataSourceList = useStore(s => s.dataSourceList) - // const a = useStore(s => s.data) + const { data: triggerPlugins } = useAllTriggerPlugins() + const toolIcon = useMemo(() => { if (!data) return '' - if (data.type === BlockEnum.Tool) { - // eslint-disable-next-line sonarjs/no-dead-store - let targetTools = buildInTools || [] - if (data.provider_type === CollectionType.builtIn) - targetTools = buildInTools || [] - else if (data.provider_type === CollectionType.custom) - targetTools = customTools || [] - else if (data.provider_type === CollectionType.mcp) - targetTools = mcpTools || [] - else - targetTools = workflowTools || [] - return targetTools.find(toolWithProvider => canFindTool(toolWithProvider.id, data.provider_id))?.icon + + if (isTriggerPluginNode(data)) { + const icon = findTriggerPluginIcon( + [ + data.plugin_id, + data.provider_id, + data.provider_name, + ], + triggerPlugins, + ) + if (icon) + return icon } - if (data.type === BlockEnum.DataSource) - return dataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon - }, [data, dataSourceList, buildInTools, customTools, mcpTools, workflowTools]) + + if (isToolNode(data)) { + let primaryCollection: ToolWithProvider[] | undefined + switch (data.provider_type) { + case CollectionType.custom: + primaryCollection = customTools + break + case CollectionType.mcp: + primaryCollection = mcpTools + break + case CollectionType.workflow: + primaryCollection = workflowTools + break + case CollectionType.builtIn: + default: + primaryCollection = buildInTools + break + } + + const collectionsToSearch = [ + primaryCollection, + buildInTools, + customTools, + workflowTools, + mcpTools, + ] as Array<ToolWithProvider[] | undefined> + + const seen = new Set<ToolWithProvider[]>() + for (const collection of collectionsToSearch) { + if (!collection || seen.has(collection)) + continue + seen.add(collection) + const matched = collection.find((toolWithProvider) => { + if (canFindTool(toolWithProvider.id, data.provider_id)) + return true + if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id) + return true + return data.provider_name === toolWithProvider.name + }) + if (matched?.icon) + return matched.icon + } + + if (data.provider_icon) + return data.provider_icon + + return '' + } + + if (isDataSourceNode(data)) + return dataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon || '' + + return '' + }, [data, dataSourceList, buildInTools, customTools, workflowTools, mcpTools, triggerPlugins]) return toolIcon } @@ -55,27 +123,80 @@ export const useGetToolIcon = () => { const { data: buildInTools } = useAllBuiltInTools() const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() + const { data: triggerPlugins } = useAllTriggerPlugins() const workflowStore = useWorkflowStore() + const getToolIcon = useCallback((data: Node['data']) => { const { + buildInTools: storeBuiltInTools, + customTools: storeCustomTools, + workflowTools: storeWorkflowTools, + mcpTools: storeMcpTools, dataSourceList, } = workflowStore.getState() - if (data.type === BlockEnum.Tool) { - // eslint-disable-next-line sonarjs/no-dead-store - let targetTools = buildInTools || [] - if (data.provider_type === CollectionType.builtIn) - targetTools = buildInTools || [] - else if (data.provider_type === CollectionType.custom) - targetTools = customTools || [] - else - targetTools = workflowTools || [] - return targetTools.find(toolWithProvider => canFindTool(toolWithProvider.id, data.provider_id))?.icon + if (isTriggerPluginNode(data)) { + return findTriggerPluginIcon( + [ + data.plugin_id, + data.provider_id, + data.provider_name, + ], + triggerPlugins, + ) } - if (data.type === BlockEnum.DataSource) + if (isToolNode(data)) { + const primaryCollection = (() => { + switch (data.provider_type) { + case CollectionType.custom: + return storeCustomTools ?? customTools + case CollectionType.mcp: + return storeMcpTools ?? mcpTools + case CollectionType.workflow: + return storeWorkflowTools ?? workflowTools + case CollectionType.builtIn: + default: + return storeBuiltInTools ?? buildInTools + } + })() + + const collectionsToSearch = [ + primaryCollection, + storeBuiltInTools ?? buildInTools, + storeCustomTools ?? customTools, + storeWorkflowTools ?? workflowTools, + storeMcpTools ?? mcpTools, + ] as Array<ToolWithProvider[] | undefined> + + const seen = new Set<ToolWithProvider[]>() + for (const collection of collectionsToSearch) { + if (!collection || seen.has(collection)) + continue + seen.add(collection) + const matched = collection.find((toolWithProvider) => { + if (canFindTool(toolWithProvider.id, data.provider_id)) + return true + if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id) + return true + return data.provider_name === toolWithProvider.name + }) + if (matched?.icon) + return matched.icon + } + + if (data.provider_icon) + return data.provider_icon + + return undefined + } + + if (isDataSourceNode(data)) return dataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon - }, [workflowStore]) + + return undefined + }, [workflowStore, triggerPlugins, buildInTools, customTools, workflowTools, mcpTools]) return getToolIcon } diff --git a/web/app/components/workflow/hooks/use-workflow-interactions.ts b/web/app/components/workflow/hooks/use-workflow-interactions.ts index c080d6279e..e56c39d51e 100644 --- a/web/app/components/workflow/hooks/use-workflow-interactions.ts +++ b/web/app/components/workflow/hooks/use-workflow-interactions.ts @@ -316,7 +316,10 @@ export const useWorkflowUpdate = () => { edges: initialEdges(edges, nodes), }, } as any) - setViewport(viewport) + + // Only set viewport if it exists and is valid + if (viewport && typeof viewport.x === 'number' && typeof viewport.y === 'number' && typeof viewport.zoom === 'number') + setViewport(viewport) }, [eventEmitter, reactflow]) return { diff --git a/web/app/components/workflow/hooks/use-workflow-start-run.tsx b/web/app/components/workflow/hooks/use-workflow-start-run.tsx index 0f4e68fe95..46fe5649c8 100644 --- a/web/app/components/workflow/hooks/use-workflow-start-run.tsx +++ b/web/app/components/workflow/hooks/use-workflow-start-run.tsx @@ -4,10 +4,17 @@ export const useWorkflowStartRun = () => { const handleStartWorkflowRun = useHooksStore(s => s.handleStartWorkflowRun) const handleWorkflowStartRunInWorkflow = useHooksStore(s => s.handleWorkflowStartRunInWorkflow) const handleWorkflowStartRunInChatflow = useHooksStore(s => s.handleWorkflowStartRunInChatflow) - + const handleWorkflowTriggerScheduleRunInWorkflow = useHooksStore(s => s.handleWorkflowTriggerScheduleRunInWorkflow) + const handleWorkflowTriggerWebhookRunInWorkflow = useHooksStore(s => s.handleWorkflowTriggerWebhookRunInWorkflow) + const handleWorkflowTriggerPluginRunInWorkflow = useHooksStore(s => s.handleWorkflowTriggerPluginRunInWorkflow) + const handleWorkflowRunAllTriggersInWorkflow = useHooksStore(s => s.handleWorkflowRunAllTriggersInWorkflow) return { handleStartWorkflowRun, handleWorkflowStartRunInWorkflow, handleWorkflowStartRunInChatflow, + handleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow, } } diff --git a/web/app/components/workflow/hooks/use-workflow.ts b/web/app/components/workflow/hooks/use-workflow.ts index 66c499dc59..e6746085b8 100644 --- a/web/app/components/workflow/hooks/use-workflow.ts +++ b/web/app/components/workflow/hooks/use-workflow.ts @@ -23,6 +23,10 @@ import { useStore, useWorkflowStore, } from '../store' +import { + getWorkflowEntryNode, + isWorkflowEntryNode, +} from '../utils/workflow-entry' import { SUPPORT_OUTPUT_VARS_NODE, } from '../constants' @@ -36,11 +40,12 @@ import { useStore as useAppStore } from '@/app/components/app/store' import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants' import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants' import { useNodesMetaData } from '.' +import { AppModeEnum } from '@/types/app' export const useIsChatMode = () => { const appDetail = useAppStore(s => s.appDetail) - return appDetail?.mode === 'advanced-chat' + return appDetail?.mode === AppModeEnum.ADVANCED_CHAT } export const useWorkflow = () => { @@ -63,6 +68,7 @@ export const useWorkflow = () => { edges, } = store.getState() const nodes = getNodes() + // let startNode = getWorkflowEntryNode(nodes) const currentNode = nodes.find(node => node.id === nodeId) let startNodes = nodes.filter(node => nodesMap?.[node.data.type as BlockEnum]?.metaData.isStart) || [] @@ -232,6 +238,33 @@ export const useWorkflow = () => { return nodes.filter(node => node.parentId === nodeId) }, [store]) + const isFromStartNode = useCallback((nodeId: string) => { + const { getNodes } = store.getState() + const nodes = getNodes() + const currentNode = nodes.find(node => node.id === nodeId) + + if (!currentNode) + return false + + if (isWorkflowEntryNode(currentNode.data.type)) + return true + + const checkPreviousNodes = (node: Node) => { + const previousNodes = getBeforeNodeById(node.id) + + for (const prevNode of previousNodes) { + if (isWorkflowEntryNode(prevNode.data.type)) + return true + if (checkPreviousNodes(prevNode)) + return true + } + + return false + } + + return checkPreviousNodes(currentNode) + }, [store, getBeforeNodeById]) + const handleOutVarRenameChange = useCallback((nodeId: string, oldValeSelector: ValueSelector, newVarSelector: ValueSelector) => { const { getNodes, setNodes } = store.getState() const allNodes = getNodes() @@ -391,6 +424,13 @@ export const useWorkflow = () => { return !hasCycle(targetNode) }, [store, getAvailableBlocks]) + const getNode = useCallback((nodeId?: string) => { + const { getNodes } = store.getState() + const nodes = getNodes() + + return nodes.find(node => node.id === nodeId) || getWorkflowEntryNode(nodes) + }, [store]) + return { getNodeById, getTreeLeafNodes, @@ -407,6 +447,8 @@ export const useWorkflow = () => { getLoopNodeChildren, getRootNodesById, getStartNodes, + isFromStartNode, + getNode, } } @@ -430,14 +472,14 @@ export const useNodesReadOnly = () => { const historyWorkflowData = useStore(s => s.historyWorkflowData) const isRestoring = useStore(s => s.isRestoring) - const getNodesReadOnly = useCallback(() => { + const getNodesReadOnly = useCallback((): boolean => { const { workflowRunningData, historyWorkflowData, isRestoring, } = workflowStore.getState() - return workflowRunningData?.result.status === WorkflowRunningStatus.Running || historyWorkflowData || isRestoring + return !!(workflowRunningData?.result.status === WorkflowRunningStatus.Running || historyWorkflowData || isRestoring) }, [workflowStore]) return { diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx index 0c24dcfd2c..fe6266dea3 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx @@ -12,7 +12,7 @@ import SearchInput from '@/app/components/base/search-input' import Tools from '../../../block-selector/tools' import { useTranslation } from 'react-i18next' import { useStrategyProviders } from '@/service/use-strategy' -import { PluginType, type StrategyPluginDetail } from '@/app/components/plugins/types' +import { PluginCategoryEnum, type StrategyPluginDetail } from '@/app/components/plugins/types' import type { ToolWithProvider } from '../../../types' import { CollectionType } from '@/app/components/tools/types' import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon' @@ -140,7 +140,7 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) => if (query) { fetchPlugins({ query, - category: PluginType.agent, + category: PluginCategoryEnum.agent, }) } }, [query]) diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx index b447c3f70e..4b15e57d5c 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx @@ -22,6 +22,7 @@ import type { Node } from 'reactflow' import type { PluginMeta } from '@/app/components/plugins/types' import { noop } from 'lodash-es' import { useDocLink } from '@/context/i18n' +import { AppModeEnum } from '@/types/app' export type Strategy = { agent_strategy_provider_name: string @@ -99,7 +100,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { modelConfig={ defaultModel.data ? { - mode: 'chat', + mode: AppModeEnum.CHAT, name: defaultModel.data.model, provider: defaultModel.data.provider.provider, completion_params: {}, diff --git a/web/app/components/workflow/nodes/_base/components/code-generator-button.tsx b/web/app/components/workflow/nodes/_base/components/code-generator-button.tsx index a62ffeb55f..21b1cf0595 100644 --- a/web/app/components/workflow/nodes/_base/components/code-generator-button.tsx +++ b/web/app/components/workflow/nodes/_base/components/code-generator-button.tsx @@ -6,7 +6,7 @@ import cn from 'classnames' import type { CodeLanguage } from '../../code/types' import { Generator } from '@/app/components/base/icons/src/vender/other' import { ActionButton } from '@/app/components/base/action-button' -import { AppType } from '@/types/app' +import { AppModeEnum } from '@/types/app' import type { GenRes } from '@/service/debug' import { GetCodeGeneratorResModal } from '@/app/components/app/configuration/config/code-generator/get-code-generator-res' import { useHooksStore } from '../../../hooks-store' @@ -42,7 +42,7 @@ const CodeGenerateBtn: FC<Props> = ({ </ActionButton> {showAutomatic && ( <GetCodeGeneratorResModal - mode={AppType.chat} + mode={AppModeEnum.CHAT} isShow={showAutomatic} codeLanguages={codeLanguages} onClose={showAutomaticFalse} diff --git a/web/app/components/workflow/nodes/_base/components/entry-node-container.tsx b/web/app/components/workflow/nodes/_base/components/entry-node-container.tsx new file mode 100644 index 0000000000..b0cecdd0ae --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/entry-node-container.tsx @@ -0,0 +1,40 @@ +import type { FC, ReactNode } from 'react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +export enum StartNodeTypeEnum { + Start = 'start', + Trigger = 'trigger', +} + +type EntryNodeContainerProps = { + children: ReactNode + customLabel?: string + nodeType?: StartNodeTypeEnum +} + +const EntryNodeContainer: FC<EntryNodeContainerProps> = ({ + children, + customLabel, + nodeType = StartNodeTypeEnum.Trigger, +}) => { + const { t } = useTranslation() + + const label = useMemo(() => { + const translationKey = nodeType === StartNodeTypeEnum.Start ? 'entryNodeStatus' : 'triggerStatus' + return customLabel || t(`workflow.${translationKey}.enabled`) + }, [customLabel, nodeType, t]) + + return ( + <div className="w-fit min-w-[242px] rounded-2xl bg-workflow-block-wrapper-bg-1 px-0 pb-0 pt-0.5"> + <div className="mb-0.5 flex items-center px-1.5 pt-0.5"> + <span className="text-2xs font-semibold uppercase text-text-tertiary"> + {label} + </span> + </div> + {children} + </div> + ) +} + +export default EntryNodeContainer diff --git a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx index 70212a8581..14a0f19317 100644 --- a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx @@ -1,38 +1,50 @@ 'use client' import type { FC } from 'react' -import type { ToolVarInputs } from '@/app/components/workflow/nodes/tool/types' -import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { useEffect, useMemo, useState } from 'react' +import { type ResourceVarInputs, VarKindType } from '../types' +import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' import { VarType } from '@/app/components/workflow/types' +import { useFetchDynamicOptions } from '@/service/use-plugins' +import { useTriggerPluginDynamicOptions } from '@/service/use-triggers' import type { ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types' +import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' +import type { Tool } from '@/app/components/tools/types' import FormInputTypeSwitch from './form-input-type-switch' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import Input from '@/app/components/base/input' import { SimpleSelect } from '@/app/components/base/select' import MixedVariableTextInput from '@/app/components/workflow/nodes/tool/components/mixed-variable-text-input' -import FormInputBoolean from './form-input-boolean' import AppSelector from '@/app/components/plugins/plugin-detail-panel/app-selector' import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector' import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import cn from '@/utils/classnames' -import type { Tool } from '@/app/components/tools/types' +import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react' +import { ChevronDownIcon } from '@heroicons/react/20/solid' +import { RiCheckLine, RiLoader4Line } from '@remixicon/react' +import type { Event } from '@/app/components/tools/types' +import { PluginCategoryEnum } from '@/app/components/plugins/types' +import CheckboxList from '@/app/components/base/checkbox-list' +import FormInputBoolean from './form-input-boolean' type Props = { readOnly: boolean nodeId: string schema: CredentialFormSchema - value: ToolVarInputs + value: ResourceVarInputs onChange: (value: any) => void inPanel?: boolean - currentTool?: Tool - currentProvider?: ToolWithProvider + currentTool?: Tool | Event + currentProvider?: ToolWithProvider | TriggerWithProvider showManageInputField?: boolean onManageInputField?: () => void + extraParams?: Record<string, any> + providerType?: string + disableVariableInsertion?: boolean } const FormInputItem: FC<Props> = ({ @@ -46,15 +58,22 @@ const FormInputItem: FC<Props> = ({ currentProvider, showManageInputField, onManageInputField, + extraParams, + providerType, + disableVariableInsertion = false, }) => { const language = useLanguage() + const [toolsOptions, setToolsOptions] = useState<FormOption[] | null>(null) + const [isLoadingToolsOptions, setIsLoadingToolsOptions] = useState(false) const { placeholder, variable, type, + _type, default: defaultValue, options, + multiple, scope, } = schema as any const varInput = value[variable] @@ -64,13 +83,16 @@ const FormInputItem: FC<Props> = ({ const isArray = type === FormTypeEnum.array const isShowJSONEditor = isObject || isArray const isFile = type === FormTypeEnum.file || type === FormTypeEnum.files - const isBoolean = type === FormTypeEnum.boolean - const isSelect = type === FormTypeEnum.select || type === FormTypeEnum.dynamicSelect + const isBoolean = _type === FormTypeEnum.boolean + const isCheckbox = _type === FormTypeEnum.checkbox + const isSelect = type === FormTypeEnum.select + const isDynamicSelect = type === FormTypeEnum.dynamicSelect const isAppSelector = type === FormTypeEnum.appSelector const isModelSelector = type === FormTypeEnum.modelSelector const showTypeSwitch = isNumber || isBoolean || isObject || isArray || isSelect const isConstant = varInput?.type === VarKindType.constant || !varInput?.type const showVariableSelector = isFile || varInput?.type === VarKindType.variable + const isMultipleSelect = multiple && (isSelect || isDynamicSelect) const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, { onlyLeafNodeVar: false, @@ -123,12 +145,71 @@ const FormInputItem: FC<Props> = ({ const getVarKindType = () => { if (isFile) return VarKindType.variable - if (isSelect || isBoolean || isNumber || isArray || isObject) + if (isSelect || isDynamicSelect || isBoolean || isNumber || isArray || isObject) return VarKindType.constant if (isString) return VarKindType.mixed } + // Fetch dynamic options hook for tools + const { mutateAsync: fetchDynamicOptions } = useFetchDynamicOptions( + currentProvider?.plugin_id || '', + currentProvider?.name || '', + currentTool?.name || '', + variable || '', + providerType, + extraParams, + ) + + // Fetch dynamic options hook for triggers + const { data: triggerDynamicOptions, isLoading: isTriggerOptionsLoading } = useTriggerPluginDynamicOptions({ + plugin_id: currentProvider?.plugin_id || '', + provider: currentProvider?.name || '', + action: currentTool?.name || '', + parameter: variable || '', + extra: extraParams, + credential_id: currentProvider?.credential_id || '', + }, isDynamicSelect && providerType === PluginCategoryEnum.trigger && !!currentTool && !!currentProvider) + + // Computed values for dynamic options (unified for triggers and tools) + const triggerOptions = triggerDynamicOptions?.options + const dynamicOptions = providerType === PluginCategoryEnum.trigger + ? triggerOptions ?? toolsOptions + : toolsOptions + const isLoadingOptions = providerType === PluginCategoryEnum.trigger + ? (isTriggerOptionsLoading || isLoadingToolsOptions) + : isLoadingToolsOptions + + // Fetch dynamic options for tools only (triggers use hook directly) + useEffect(() => { + const fetchPanelDynamicOptions = async () => { + if (isDynamicSelect && currentTool && currentProvider && (providerType === PluginCategoryEnum.tool || providerType === PluginCategoryEnum.trigger)) { + setIsLoadingToolsOptions(true) + try { + const data = await fetchDynamicOptions() + setToolsOptions(data?.options || []) + } + catch (error) { + console.error('Failed to fetch dynamic options:', error) + setToolsOptions([]) + } + finally { + setIsLoadingToolsOptions(false) + } + } + } + + fetchPanelDynamicOptions() + }, [ + isDynamicSelect, + currentTool?.name, + currentProvider?.name, + variable, + extraParams, + providerType, + fetchDynamicOptions, + ]) + const handleTypeChange = (newType: string) => { if (newType === VarKindType.variable) { onChange({ @@ -163,6 +244,24 @@ const FormInputItem: FC<Props> = ({ }) } + const getSelectedLabels = (selectedValues: any[]) => { + if (!selectedValues || selectedValues.length === 0) + return '' + + const optionsList = isDynamicSelect ? (dynamicOptions || options || []) : (options || []) + const selectedOptions = optionsList.filter((opt: any) => + selectedValues.includes(opt.value), + ) + + if (selectedOptions.length <= 2) { + return selectedOptions + .map((opt: any) => opt.label?.[language] || opt.label?.en_US || opt.value) + .join(', ') + } + + return `${selectedOptions.length} selected` + } + const handleAppOrModelSelect = (newValue: any) => { onChange({ ...value, @@ -184,6 +283,45 @@ const FormInputItem: FC<Props> = ({ }) } + const availableCheckboxOptions = useMemo(() => ( + (options || []).filter((option: { show_on?: Array<{ variable: string; value: any }> }) => { + if (option.show_on?.length) + return option.show_on.every(showOnItem => value[showOnItem.variable]?.value === showOnItem.value || value[showOnItem.variable] === showOnItem.value) + return true + }) + ), [options, value]) + + const checkboxListOptions = useMemo(() => ( + availableCheckboxOptions.map((option: { value: string; label: Record<string, string> }) => ({ + value: option.value, + label: option.label?.[language] || option.label?.en_US || option.value, + })) + ), [availableCheckboxOptions, language]) + + const checkboxListValue = useMemo(() => { + let current: string[] = [] + if (Array.isArray(varInput?.value)) + current = varInput.value as string[] + else if (typeof varInput?.value === 'string') + current = [varInput.value as string] + else if (Array.isArray(defaultValue)) + current = defaultValue as string[] + + const allowedValues = new Set(availableCheckboxOptions.map((option: { value: string }) => option.value)) + return current.filter(item => allowedValues.has(item)) + }, [varInput?.value, defaultValue, availableCheckboxOptions]) + + const handleCheckboxListChange = (selected: string[]) => { + onChange({ + ...value, + [variable]: { + ...varInput, + type: VarKindType.constant, + value: selected, + }, + }) + } + return ( <div className={cn('gap-1', !(isShowJSONEditor && isConstant) && 'flex')}> {showTypeSwitch && ( @@ -198,6 +336,7 @@ const FormInputItem: FC<Props> = ({ availableNodes={availableNodesWithParent} showManageInputField={showManageInputField} onManageInputField={onManageInputField} + disableVariableInsertion={disableVariableInsertion} /> )} {isNumber && isConstant && ( @@ -209,13 +348,23 @@ const FormInputItem: FC<Props> = ({ placeholder={placeholder?.[language] || placeholder?.en_US} /> )} + {isCheckbox && isConstant && ( + <CheckboxList + title={schema.label?.[language] || schema.label?.en_US || variable} + value={checkboxListValue} + onChange={handleCheckboxListChange} + options={checkboxListOptions} + disabled={readOnly} + maxHeight='200px' + /> + )} {isBoolean && isConstant && ( <FormInputBoolean value={varInput?.value as boolean} onChange={handleValueChange} /> )} - {isSelect && isConstant && ( + {isSelect && isConstant && !isMultipleSelect && ( <SimpleSelect wrapperClassName='h-8 grow' disabled={readOnly} @@ -225,11 +374,175 @@ const FormInputItem: FC<Props> = ({ return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value) return true - }).map((option: { value: any; label: { [x: string]: any; en_US: any } }) => ({ value: option.value, name: option.label[language] || option.label.en_US }))} + }).map((option: { value: any; label: { [x: string]: any; en_US: any }; icon?: string }) => ({ + value: option.value, + name: option.label[language] || option.label.en_US, + icon: option.icon, + }))} onSelect={item => handleValueChange(item.value as string)} placeholder={placeholder?.[language] || placeholder?.en_US} + renderOption={options.some((opt: any) => opt.icon) ? ({ item }) => ( + <div className="flex items-center"> + {item.icon && ( + <img src={item.icon} alt="" className="mr-2 h-4 w-4" /> + )} + <span>{item.name}</span> + </div> + ) : undefined} /> )} + {isSelect && isConstant && isMultipleSelect && ( + <Listbox + multiple + value={varInput?.value || []} + onChange={handleValueChange} + disabled={readOnly} + > + <div className="group/simple-select relative h-8 grow"> + <ListboxButton className="flex h-full w-full cursor-pointer items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6"> + <span className={cn('system-sm-regular block truncate text-left', + varInput?.value?.length > 0 ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder', + )}> + {getSelectedLabels(varInput?.value) || placeholder?.[language] || placeholder?.en_US || 'Select options'} + </span> + <span className="absolute inset-y-0 right-0 flex items-center pr-2"> + <ChevronDownIcon + className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary" + aria-hidden="true" + /> + </span> + </ListboxButton> + <ListboxOptions className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm"> + {options.filter((option: { show_on: any[] }) => { + if (option.show_on?.length) + return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value) + return true + }).map((option: { value: any; label: { [x: string]: any; en_US: any }; icon?: string }) => ( + <ListboxOption + key={option.value} + value={option.value} + className={({ focus }) => + cn('relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover', + focus && 'bg-state-base-hover', + ) + } + > + {({ selected }) => ( + <> + <div className="flex items-center"> + {option.icon && ( + <img src={option.icon} alt="" className="mr-2 h-4 w-4" /> + )} + <span className={cn('block truncate', selected && 'font-normal')}> + {option.label[language] || option.label.en_US} + </span> + </div> + {selected && ( + <span className="absolute inset-y-0 right-0 flex items-center pr-2 text-text-accent"> + <RiCheckLine className="h-4 w-4" aria-hidden="true" /> + </span> + )} + </> + )} + </ListboxOption> + ))} + </ListboxOptions> + </div> + </Listbox> + )} + {isDynamicSelect && !isMultipleSelect && ( + <SimpleSelect + wrapperClassName='h-8 grow' + disabled={readOnly || isLoadingOptions} + defaultValue={varInput?.value} + items={(dynamicOptions || options || []).filter((option: { show_on?: any[] }) => { + if (option.show_on?.length) + return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value) + + return true + }).map((option: { value: any; label: { [x: string]: any; en_US: any }; icon?: string }) => ({ + value: option.value, + name: option.label[language] || option.label.en_US, + icon: option.icon, + }))} + onSelect={item => handleValueChange(item.value as string)} + placeholder={isLoadingOptions ? 'Loading...' : (placeholder?.[language] || placeholder?.en_US)} + renderOption={({ item }) => ( + <div className="flex items-center"> + {item.icon && ( + <img src={item.icon} alt="" className="mr-2 h-4 w-4" /> + )} + <span>{item.name}</span> + </div> + )} + /> + )} + {isDynamicSelect && isMultipleSelect && ( + <Listbox + multiple + value={varInput?.value || []} + onChange={handleValueChange} + disabled={readOnly || isLoadingOptions} + > + <div className="group/simple-select relative h-8 grow"> + <ListboxButton className="flex h-full w-full cursor-pointer items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6"> + <span className={cn('system-sm-regular block truncate text-left', + isLoadingOptions ? 'text-components-input-text-placeholder' + : varInput?.value?.length > 0 ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder', + )}> + {isLoadingOptions + ? 'Loading...' + : getSelectedLabels(varInput?.value) || placeholder?.[language] || placeholder?.en_US || 'Select options'} + </span> + <span className="absolute inset-y-0 right-0 flex items-center pr-2"> + {isLoadingOptions ? ( + <RiLoader4Line className='h-3.5 w-3.5 animate-spin text-text-secondary' /> + ) : ( + <ChevronDownIcon + className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary" + aria-hidden="true" + /> + )} + </span> + </ListboxButton> + <ListboxOptions className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm"> + {(dynamicOptions || options || []).filter((option: { show_on?: any[] }) => { + if (option.show_on?.length) + return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value) + return true + }).map((option: { value: any; label: { [x: string]: any; en_US: any }; icon?: string }) => ( + <ListboxOption + key={option.value} + value={option.value} + className={({ focus }) => + cn('relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover', + focus && 'bg-state-base-hover', + ) + } + > + {({ selected }) => ( + <> + <div className="flex items-center"> + {option.icon && ( + <img src={option.icon} alt="" className="mr-2 h-4 w-4" /> + )} + <span className={cn('block truncate', selected && 'font-normal')}> + {option.label[language] || option.label.en_US} + </span> + </div> + {selected && ( + <span className="absolute inset-y-0 right-0 flex items-center pr-2 text-text-accent"> + <RiCheckLine className="h-4 w-4" aria-hidden="true" /> + </span> + )} + </> + )} + </ListboxOption> + ))} + </ListboxOptions> + </div> + </Listbox> + )} {isShowJSONEditor && isConstant && ( <div className='mt-1 w-full'> <CodeEditor diff --git a/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx b/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx index 23119f0213..b0d878d53d 100644 --- a/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx +++ b/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx @@ -1,37 +1,96 @@ import Button from '@/app/components/base/button' import { RiInstallLine, RiLoader2Line } from '@remixicon/react' import type { ComponentProps, MouseEventHandler } from 'react' +import { useState } from 'react' import classNames from '@/utils/classnames' import { useTranslation } from 'react-i18next' +import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status' +import { TaskStatus } from '@/app/components/plugins/types' import { useCheckInstalled, useInstallPackageFromMarketPlace } from '@/service/use-plugins' type InstallPluginButtonProps = Omit<ComponentProps<typeof Button>, 'children' | 'loading'> & { uniqueIdentifier: string + extraIdentifiers?: string[] onSuccess?: () => void } export const InstallPluginButton = (props: InstallPluginButtonProps) => { - const { className, uniqueIdentifier, onSuccess, ...rest } = props + const { + className, + uniqueIdentifier, + extraIdentifiers = [], + onSuccess, + ...rest + } = props const { t } = useTranslation() + const identifiers = Array.from(new Set( + [uniqueIdentifier, ...extraIdentifiers].filter((item): item is string => Boolean(item)), + )) const manifest = useCheckInstalled({ - pluginIds: [uniqueIdentifier], - enabled: !!uniqueIdentifier, + pluginIds: identifiers, + enabled: identifiers.length > 0, }) const install = useInstallPackageFromMarketPlace() - const isLoading = manifest.isLoading || install.isPending - // await for refetch to get the new installed plugin, when manifest refetch, this component will unmount - || install.isSuccess + const [isTracking, setIsTracking] = useState(false) + const isLoading = manifest.isLoading || install.isPending || isTracking const handleInstall: MouseEventHandler = (e) => { e.stopPropagation() + if (isLoading) + return + setIsTracking(true) install.mutate(uniqueIdentifier, { - onSuccess: async () => { - await manifest.refetch() - onSuccess?.() + onSuccess: async (response) => { + const finish = async () => { + await manifest.refetch() + onSuccess?.() + setIsTracking(false) + install.reset() + } + + if (!response) { + await finish() + return + } + + if (response.all_installed) { + await finish() + return + } + + const { check } = checkTaskStatus() + try { + const { status } = await check({ + taskId: response.task_id, + pluginUniqueIdentifier: uniqueIdentifier, + }) + + if (status === TaskStatus.failed) { + setIsTracking(false) + install.reset() + return + } + + await finish() + } + catch { + setIsTracking(false) + install.reset() + } + }, + onError: () => { + setIsTracking(false) + install.reset() }, }) } if (!manifest.data) return null - if (manifest.data.plugins.some(plugin => plugin.id === uniqueIdentifier)) return null + const identifierSet = new Set(identifiers) + const isInstalled = manifest.data.plugins.some(plugin => ( + identifierSet.has(plugin.id) + || (plugin.plugin_unique_identifier && identifierSet.has(plugin.plugin_unique_identifier)) + || (plugin.plugin_id && identifierSet.has(plugin.plugin_id)) + )) + if (isInstalled) return null return <Button variant={'secondary'} disabled={isLoading} diff --git a/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/index.tsx new file mode 100644 index 0000000000..6680c8ebb6 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/index.tsx @@ -0,0 +1,62 @@ +import { + memo, +} from 'react' +import { useTranslation } from 'react-i18next' +import PromptEditor from '@/app/components/base/prompt-editor' +import Placeholder from './placeholder' +import type { + Node, + NodeOutPutVar, +} from '@/app/components/workflow/types' +import { BlockEnum } from '@/app/components/workflow/types' +import cn from '@/utils/classnames' + +type MixedVariableTextInputProps = { + readOnly?: boolean + nodesOutputVars?: NodeOutPutVar[] + availableNodes?: Node[] + value?: string + onChange?: (text: string) => void +} +const MixedVariableTextInput = ({ + readOnly = false, + nodesOutputVars, + availableNodes = [], + value = '', + onChange, +}: MixedVariableTextInputProps) => { + const { t } = useTranslation() + return ( + <PromptEditor + wrapperClassName={cn( + 'w-full rounded-lg border border-transparent bg-components-input-bg-normal px-2 py-1', + 'hover:border-components-input-border-hover hover:bg-components-input-bg-hover', + 'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs', + )} + className='caret:text-text-accent' + editable={!readOnly} + value={value} + workflowVariableBlock={{ + show: true, + variables: nodesOutputVars || [], + workflowNodesMap: availableNodes.reduce((acc, node) => { + acc[node.id] = { + title: node.data.title, + type: node.data.type, + } + if (node.data.type === BlockEnum.Start) { + acc.sys = { + title: t('workflow.blocks.start'), + type: BlockEnum.Start, + } + } + return acc + }, {} as any), + }} + placeholder={<Placeholder />} + onChange={onChange} + /> + ) +} + +export default memo(MixedVariableTextInput) diff --git a/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/placeholder.tsx b/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/placeholder.tsx new file mode 100644 index 0000000000..75d4c91996 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/placeholder.tsx @@ -0,0 +1,52 @@ +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { FOCUS_COMMAND } from 'lexical' +import { $insertNodes } from 'lexical' +import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node' +import Badge from '@/app/components/base/badge' + +const Placeholder = () => { + const { t } = useTranslation() + const [editor] = useLexicalComposerContext() + + const handleInsert = useCallback((text: string) => { + editor.update(() => { + const textNode = new CustomTextNode(text) + $insertNodes([textNode]) + }) + editor.dispatchCommand(FOCUS_COMMAND, undefined as any) + }, [editor]) + + return ( + <div + className='pointer-events-auto flex h-full w-full cursor-text items-center px-2' + onClick={(e) => { + e.stopPropagation() + handleInsert('') + }} + > + <div className='flex grow items-center'> + {t('workflow.nodes.tool.insertPlaceholder1')} + <div className='system-kbd mx-0.5 flex h-4 w-4 items-center justify-center rounded bg-components-kbd-bg-gray text-text-placeholder'>/</div> + <div + className='system-sm-regular cursor-pointer text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary' + onMouseDown={((e) => { + e.preventDefault() + e.stopPropagation() + handleInsert('/') + })} + > + {t('workflow.nodes.tool.insertPlaceholder2')} + </div> + </div> + <Badge + className='shrink-0' + text='String' + uppercase={false} + /> + </div> + ) +} + +export default Placeholder diff --git a/web/app/components/workflow/nodes/_base/components/next-step/add.tsx b/web/app/components/workflow/nodes/_base/components/next-step/add.tsx index 601bc8ea75..3001274c31 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/add.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/add.tsx @@ -39,11 +39,11 @@ const Add = ({ const { nodesReadOnly } = useNodesReadOnly() const { availableNextBlocks } = useAvailableBlocks(nodeData.type, nodeData.isInIteration || nodeData.isInLoop) - const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => { + const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { handleNodeAdd( { nodeType: type, - toolDefaultValue, + pluginDefaultValue, }, { prevNodeId: nodeId, diff --git a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx index c54a63d8ad..7143e6fe43 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx @@ -38,8 +38,8 @@ const ChangeItem = ({ availableNextBlocks, } = useAvailableBlocks(data.type, data.isInIteration || data.isInLoop) - const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => { - handleNodeChange(nodeId, type, sourceHandle, toolDefaultValue) + const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { + handleNodeChange(nodeId, type, sourceHandle, pluginDefaultValue) }, [nodeId, sourceHandle, handleNodeChange]) const renderTrigger = useCallback(() => { diff --git a/web/app/components/workflow/nodes/_base/components/node-control.tsx b/web/app/components/workflow/nodes/_base/components/node-control.tsx index 0e3f54f108..544e595ecf 100644 --- a/web/app/components/workflow/nodes/_base/components/node-control.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-control.tsx @@ -9,7 +9,6 @@ import { RiPlayLargeLine, } from '@remixicon/react' import { - useNodeDataUpdate, useNodesInteractions, } from '../../../hooks' import { type Node, NodeRunningStatus } from '../../../types' @@ -19,6 +18,9 @@ import { Stop, } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' import Tooltip from '@/app/components/base/tooltip' +import { useWorkflowStore } from '@/app/components/workflow/store' +import { useWorkflowRunValidation } from '@/app/components/workflow/hooks/use-checklist' +import Toast from '@/app/components/base/toast' type NodeControlProps = Pick<Node, 'id' | 'data'> const NodeControl: FC<NodeControlProps> = ({ @@ -27,9 +29,11 @@ const NodeControl: FC<NodeControlProps> = ({ }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) - const { handleNodeDataUpdate } = useNodeDataUpdate() const { handleNodeSelect } = useNodesInteractions() + const workflowStore = useWorkflowStore() const isSingleRunning = data._singleRunningStatus === NodeRunningStatus.Running + const { warningNodes } = useWorkflowRunValidation() + const warningForNode = warningNodes.find(item => item.id === id) const handleOpenChange = useCallback((newOpen: boolean) => { setOpen(newOpen) }, []) @@ -38,7 +42,8 @@ const NodeControl: FC<NodeControlProps> = ({ return ( <div className={` - absolute -top-7 right-0 hidden h-7 pb-1 group-hover:flex + absolute -top-7 right-0 hidden h-7 pb-1 + ${!data._pluginInstallLocked && 'group-hover:flex'} ${data.selected && '!flex'} ${open && '!flex'} `} @@ -50,17 +55,20 @@ const NodeControl: FC<NodeControlProps> = ({ { canRunBySingle(data.type, isChildNode) && ( <div - className='flex h-5 w-5 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover' + className={`flex h-5 w-5 items-center justify-center rounded-md ${isSingleRunning ? 'cursor-pointer hover:bg-state-base-hover' : warningForNode ? 'cursor-not-allowed text-text-disabled' : 'cursor-pointer hover:bg-state-base-hover'}`} onClick={() => { - const nextData: Record<string, any> = { - _isSingleRun: !isSingleRunning, + const action = isSingleRunning ? 'stop' : 'run' + if (!isSingleRunning && warningForNode) { + const message = warningForNode.errorMessage || t('workflow.panel.checklistTip') + Toast.notify({ type: 'error', message }) + return } - if(isSingleRunning) - nextData._singleRunningStatus = undefined - handleNodeDataUpdate({ - id, - data: nextData, + const store = workflowStore.getState() + store.setInitShowLastRunTab(true) + store.setPendingSingleRun({ + nodeId: id, + action, }) handleNodeSelect(id) }} @@ -70,7 +78,7 @@ const NodeControl: FC<NodeControlProps> = ({ ? <Stop className='h-3 w-3' /> : ( <Tooltip - popupContent={t('workflow.panel.runThisStep')} + popupContent={warningForNode ? warningForNode.errorMessage || t('workflow.panel.checklistTip') : t('workflow.panel.runThisStep')} asChild={false} > <RiPlayLargeLine className='h-3 w-3' /> diff --git a/web/app/components/workflow/nodes/_base/components/node-handle.tsx b/web/app/components/workflow/nodes/_base/components/node-handle.tsx index 90968a4580..6cfa7a7b9e 100644 --- a/web/app/components/workflow/nodes/_base/components/node-handle.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-handle.tsx @@ -16,7 +16,7 @@ import { } from '../../../types' import type { Node } from '../../../types' import BlockSelector from '../../../block-selector' -import type { DataSourceDefaultValue, ToolDefaultValue } from '../../../block-selector/types' +import type { PluginDefaultValue } from '../../../block-selector/types' import { useAvailableBlocks, useIsChatMode, @@ -25,6 +25,7 @@ import { } from '../../../hooks' import { useStore, + useWorkflowStore, } from '../../../store' import cn from '@/utils/classnames' @@ -57,11 +58,11 @@ export const NodeTargetHandle = memo(({ if (!connected) setOpen(v => !v) }, [connected]) - const handleSelect = useCallback((type: BlockEnum, toolDefaultValue?: ToolDefaultValue | DataSourceDefaultValue) => { + const handleSelect = useCallback((type: BlockEnum, pluginDefaultValue?: PluginDefaultValue) => { handleNodeAdd( { nodeType: type, - toolDefaultValue, + pluginDefaultValue, }, { nextNodeId: id, @@ -84,7 +85,10 @@ export const NodeTargetHandle = memo(({ data._runningStatus === NodeRunningStatus.Failed && 'after:bg-workflow-link-line-error-handle', data._runningStatus === NodeRunningStatus.Exception && 'after:bg-workflow-link-line-failure-handle', !connected && 'after:opacity-0', - data.type === BlockEnum.Start && 'opacity-0', + (data.type === BlockEnum.Start + || data.type === BlockEnum.TriggerWebhook + || data.type === BlockEnum.TriggerSchedule + || data.type === BlockEnum.TriggerPlugin) && 'opacity-0', handleClassName, )} isConnectable={isConnectable} @@ -124,7 +128,10 @@ export const NodeSourceHandle = memo(({ showExceptionStatus, }: NodeHandleProps) => { const { t } = useTranslation() - const notInitialWorkflow = useStore(s => s.notInitialWorkflow) + const shouldAutoOpenStartNodeSelector = useStore(s => s.shouldAutoOpenStartNodeSelector) + const setShouldAutoOpenStartNodeSelector = useStore(s => s.setShouldAutoOpenStartNodeSelector) + const setHasSelectedStartNode = useStore(s => s.setHasSelectedStartNode) + const workflowStoreApi = useWorkflowStore() const [open, setOpen] = useState(false) const { handleNodeAdd } = useNodesInteractions() const { getNodesReadOnly } = useNodesReadOnly() @@ -140,11 +147,11 @@ export const NodeSourceHandle = memo(({ e.stopPropagation() setOpen(v => !v) }, []) - const handleSelect = useCallback((type: BlockEnum, toolDefaultValue?: ToolDefaultValue | DataSourceDefaultValue) => { + const handleSelect = useCallback((type: BlockEnum, pluginDefaultValue?: PluginDefaultValue) => { handleNodeAdd( { nodeType: type, - toolDefaultValue, + pluginDefaultValue, }, { prevNodeId: id, @@ -154,9 +161,27 @@ export const NodeSourceHandle = memo(({ }, [handleNodeAdd, id, handleId]) useEffect(() => { - if (notInitialWorkflow && data.type === BlockEnum.Start && !isChatMode) + if (!shouldAutoOpenStartNodeSelector) + return + + if (isChatMode) { + setShouldAutoOpenStartNodeSelector?.(false) + return + } + + if (data.type === BlockEnum.Start || data.type === BlockEnum.TriggerSchedule || data.type === BlockEnum.TriggerWebhook || data.type === BlockEnum.TriggerPlugin) { setOpen(true) - }, [notInitialWorkflow, data.type, isChatMode]) + if (setShouldAutoOpenStartNodeSelector) + setShouldAutoOpenStartNodeSelector(false) + else + workflowStoreApi?.setState?.({ shouldAutoOpenStartNodeSelector: false }) + + if (setHasSelectedStartNode) + setHasSelectedStartNode(false) + else + workflowStoreApi?.setState?.({ hasSelectedStartNode: false }) + } + }, [shouldAutoOpenStartNodeSelector, data.type, isChatMode, setShouldAutoOpenStartNodeSelector, setHasSelectedStartNode, workflowStoreApi]) return ( <Handle diff --git a/web/app/components/workflow/nodes/_base/components/node-position.tsx b/web/app/components/workflow/nodes/_base/components/node-position.tsx deleted file mode 100644 index e844726b4f..0000000000 --- a/web/app/components/workflow/nodes/_base/components/node-position.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { memo } from 'react' -import { useTranslation } from 'react-i18next' -import { useShallow } from 'zustand/react/shallow' -import { RiCrosshairLine } from '@remixicon/react' -import { useReactFlow, useStore } from 'reactflow' -import TooltipPlus from '@/app/components/base/tooltip' -import { useNodesSyncDraft } from '@/app/components/workflow-app/hooks' - -type NodePositionProps = { - nodeId: string -} -const NodePosition = ({ - nodeId, -}: NodePositionProps) => { - const { t } = useTranslation() - const reactflow = useReactFlow() - const { doSyncWorkflowDraft } = useNodesSyncDraft() - const { - nodePosition, - nodeWidth, - nodeHeight, - } = useStore(useShallow((s) => { - const nodes = s.getNodes() - const currentNode = nodes.find(node => node.id === nodeId)! - - return { - nodePosition: currentNode.position, - nodeWidth: currentNode.width, - nodeHeight: currentNode.height, - } - })) - const transform = useStore(s => s.transform) - - if (!nodePosition || !nodeWidth || !nodeHeight) return null - - const workflowContainer = document.getElementById('workflow-container') - const zoom = transform[2] - - const { clientWidth, clientHeight } = workflowContainer! - const { setViewport } = reactflow - - return ( - <TooltipPlus - popupContent={t('workflow.panel.moveToThisNode')} - > - <div - className='mr-1 flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover' - onClick={() => { - setViewport({ - x: (clientWidth - 400 - nodeWidth * zoom) / 2 - nodePosition.x * zoom, - y: (clientHeight - nodeHeight * zoom) / 2 - nodePosition.y * zoom, - zoom: transform[2], - }) - doSyncWorkflowDraft() - }} - > - <RiCrosshairLine className='h-4 w-4 text-text-tertiary' /> - </div> - </TooltipPlus> - ) -} - -export default memo(NodePosition) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx index d7b2188ed5..8b6d137127 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx +++ b/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx @@ -8,12 +8,17 @@ import { intersection } from 'lodash-es' import BlockSelector from '@/app/components/workflow/block-selector' import { useAvailableBlocks, + useIsChatMode, useNodesInteractions, } from '@/app/components/workflow/hooks' +import { useHooksStore } from '@/app/components/workflow/hooks-store' import type { Node, OnSelectBlock, } from '@/app/components/workflow/types' +import { BlockEnum, isTriggerNode } from '@/app/components/workflow/types' + +import { FlowType } from '@/types/common' type ChangeBlockProps = { nodeId: string @@ -31,6 +36,14 @@ const ChangeBlock = ({ availablePrevBlocks, availableNextBlocks, } = useAvailableBlocks(nodeData.type, nodeData.isInIteration || nodeData.isInLoop) + const isChatMode = useIsChatMode() + const flowType = useHooksStore(s => s.configsMap?.flowType) + const showStartTab = flowType !== FlowType.ragPipeline && !isChatMode + const ignoreNodeIds = useMemo(() => { + if (isTriggerNode(nodeData.type as BlockEnum)) + return [nodeId] + return undefined + }, [nodeData.type, nodeId]) const availableNodes = useMemo(() => { if (availablePrevBlocks.length && availableNextBlocks.length) @@ -41,8 +54,8 @@ const ChangeBlock = ({ return availableNextBlocks }, [availablePrevBlocks, availableNextBlocks]) - const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => { - handleNodeChange(nodeId, type, sourceHandle, toolDefaultValue) + const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { + handleNodeChange(nodeId, type, sourceHandle, pluginDefaultValue) }, [handleNodeChange, nodeId, sourceHandle]) const renderTrigger = useCallback(() => { @@ -64,6 +77,9 @@ const ChangeBlock = ({ trigger={renderTrigger} popupClassName='min-w-[240px]' availableBlocksTypes={availableNodes} + showStartTab={showStartTab} + ignoreNodeIds={ignoreNodeIds} + forceEnableStartTab={nodeData.type === BlockEnum.Start} /> ) } diff --git a/web/app/components/workflow/nodes/_base/components/variable-tag.tsx b/web/app/components/workflow/nodes/_base/components/variable-tag.tsx index 4d3dfe217c..71af3ad4fd 100644 --- a/web/app/components/workflow/nodes/_base/components/variable-tag.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable-tag.tsx @@ -8,7 +8,7 @@ import type { VarType, } from '@/app/components/workflow/types' import { BlockEnum } from '@/app/components/workflow/types' -import { getNodeInfoById, isConversationVar, isENV, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' +import { getNodeInfoById, isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' import { isExceptionVariable } from '@/app/components/workflow/utils' import { VariableLabelInSelect, @@ -39,7 +39,8 @@ const VariableTag = ({ const isEnv = isENV(valueSelector) const isChatVar = isConversationVar(valueSelector) - const isValid = Boolean(node) || isEnv || isChatVar || isRagVar + const isGlobal = isGlobalVar(valueSelector) + const isValid = Boolean(node) || isEnv || isChatVar || isRagVar || isGlobal const variableName = isSystemVar(valueSelector) ? valueSelector.slice(0).join('.') : valueSelector.slice(1).join('.') const isException = isExceptionVariable(variableName, node?.data.type) diff --git a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field.tsx b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field.tsx index 7862dc824c..62133f3212 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field.tsx @@ -1,14 +1,14 @@ 'use client' +import cn from '@/utils/classnames' +import { RiArrowDropDownLine } from '@remixicon/react' +import { useBoolean } from 'ahooks' import type { FC } from 'react' import React from 'react' +import { useTranslation } from 'react-i18next' +import type { Field as FieldType } from '../../../../../llm/types' import { Type } from '../../../../../llm/types' import { getFieldType } from '../../../../../llm/utils' -import type { Field as FieldType } from '../../../../../llm/types' -import cn from '@/utils/classnames' import TreeIndentLine from '../tree-indent-line' -import { useTranslation } from 'react-i18next' -import { useBoolean } from 'ahooks' -import { RiArrowDropDownLine } from '@remixicon/react' type Props = { name: string, @@ -28,6 +28,7 @@ const Field: FC<Props> = ({ const { t } = useTranslation() const isRoot = depth === 1 const hasChildren = payload.type === Type.object && payload.properties + const hasEnum = payload.enum && payload.enum.length > 0 const [fold, { toggle: toggleFold, }] = useBoolean(false) @@ -44,7 +45,10 @@ const Field: FC<Props> = ({ /> )} <div className={cn('system-sm-medium ml-[7px] h-6 truncate leading-6 text-text-secondary', isRoot && rootClassName)}>{name}</div> - <div className='system-xs-regular ml-3 shrink-0 leading-6 text-text-tertiary'>{getFieldType(payload)}{(payload.schemaType && payload.schemaType !== 'file' && ` (${payload.schemaType})`)}</div> + <div className='system-xs-regular ml-3 shrink-0 leading-6 text-text-tertiary'> + {getFieldType(payload)} + {(payload.schemaType && payload.schemaType !== 'file' && ` (${payload.schemaType})`)} + </div> {required && <div className='system-2xs-medium-uppercase ml-3 leading-6 text-text-warning'>{t('app.structOutput.required')}</div>} </div> {payload.description && ( @@ -52,6 +56,18 @@ const Field: FC<Props> = ({ <div className='system-xs-regular w-0 grow truncate text-text-tertiary'>{payload.description}</div> </div> )} + {hasEnum && ( + <div className='ml-[7px] flex'> + <div className='system-xs-regular w-0 grow text-text-quaternary'> + {payload.enum!.map((value, index) => ( + <span key={index}> + {typeof value === 'string' ? `"${value}"` : value} + {index < payload.enum!.length - 1 && ' | '} + </span> + ))} + </div> + </div> + )} </div> </div> diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts index 715551cbff..3bd43bd29a 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -39,6 +39,9 @@ import type { import type { VariableAssignerNodeType } from '@/app/components/workflow/nodes/variable-assigner/types' import type { Field as StructField } from '@/app/components/workflow/nodes/llm/types' import type { RAGPipelineVariable } from '@/models/pipeline' +import type { WebhookTriggerNodeType } from '@/app/components/workflow/nodes/trigger-webhook/types' +import type { PluginTriggerNodeType } from '@/app/components/workflow/nodes/trigger-plugin/types' +import PluginTriggerNodeDefault from '@/app/components/workflow/nodes/trigger-plugin/default' import { AGENT_OUTPUT_STRUCT, @@ -51,6 +54,7 @@ import { SUPPORT_OUTPUT_VARS_NODE, TEMPLATE_TRANSFORM_OUTPUT_STRUCT, TOOL_OUTPUT_STRUCT, + getGlobalVars, } from '@/app/components/workflow/constants' import ToolNodeDefault from '@/app/components/workflow/nodes/tool/default' import DataSourceNodeDefault from '@/app/components/workflow/nodes/data-source/default' @@ -59,11 +63,21 @@ import type { PromptItem } from '@/models/debug' import { VAR_REGEX } from '@/config' import type { AgentNodeType } from '../../../agent/types' import type { SchemaTypeDefinition } from '@/service/use-common' +import { AppModeEnum } from '@/types/app' export const isSystemVar = (valueSelector: ValueSelector) => { return valueSelector[0] === 'sys' || valueSelector[1] === 'sys' } +export const isGlobalVar = (valueSelector: ValueSelector) => { + if(!isSystemVar(valueSelector)) return false + const second = valueSelector[1] + + if(['query', 'files'].includes(second)) + return false + return true +} + export const isENV = (valueSelector: ValueSelector) => { return valueSelector[0] === 'env' } @@ -348,34 +362,29 @@ const formatItem = ( variable: 'sys.query', type: VarType.string, }) - res.vars.push({ - variable: 'sys.dialogue_count', - type: VarType.number, - }) - res.vars.push({ - variable: 'sys.conversation_id', - type: VarType.string, - }) } - res.vars.push({ - variable: 'sys.user_id', - type: VarType.string, - }) res.vars.push({ variable: 'sys.files', type: VarType.arrayFile, }) - res.vars.push({ - variable: 'sys.app_id', - type: VarType.string, - }) - res.vars.push({ - variable: 'sys.workflow_id', - type: VarType.string, - }) - res.vars.push({ - variable: 'sys.workflow_run_id', - type: VarType.string, + break + } + + case BlockEnum.TriggerWebhook: { + const { + variables = [], + } = data as WebhookTriggerNodeType + res.vars = variables.map((v) => { + const type = v.value_type || VarType.string + const varRes: Var = { + variable: v.variable, + type, + isParagraph: false, + isSelect: false, + options: v.options, + required: v.required, + } + return varRes }) break @@ -612,6 +621,17 @@ const formatItem = ( break } + case BlockEnum.TriggerPlugin: { + const outputSchema = PluginTriggerNodeDefault.getOutputVars?.( + data as PluginTriggerNodeType, + allPluginInfoList, + [], + { schemaTypeDefinitions }, + ) || [] + res.vars = outputSchema + break + } + case 'env': { res.vars = data.envList.map((env: EnvironmentVariable) => { return { @@ -634,6 +654,11 @@ const formatItem = ( break } + case 'global': { + res.vars = data.globalVarList + break + } + case 'rag': { res.vars = data.ragVariables.map((ragVar: RAGPipelineVariable) => { return { @@ -774,6 +799,15 @@ export const toNodeOutputVars = ( chatVarList: conversationVariables, }, } + // GLOBAL_VAR_NODE data format + const GLOBAL_VAR_NODE = { + id: 'global', + data: { + title: 'SYSTEM', + type: 'global', + globalVarList: getGlobalVars(isChatMode), + }, + } // RAG_PIPELINE_NODE data format const RAG_PIPELINE_NODE = { id: 'rag', @@ -793,6 +827,8 @@ export const toNodeOutputVars = ( if (b.data.type === 'env') return -1 if (a.data.type === 'conversation') return 1 if (b.data.type === 'conversation') return -1 + if (a.data.type === 'global') return 1 + if (b.data.type === 'global') return -1 // sort nodes by x position return (b.position?.x || 0) - (a.position?.x || 0) }) @@ -803,6 +839,7 @@ export const toNodeOutputVars = ( ), ...(environmentVariables.length > 0 ? [ENV_NODE] : []), ...(isChatMode && conversationVariables.length > 0 ? [CHAT_VAR_NODE] : []), + GLOBAL_VAR_NODE, ...(RAG_PIPELINE_NODE.data.ragVariables.length > 0 ? [RAG_PIPELINE_NODE] : []), @@ -1026,7 +1063,8 @@ export const getVarType = ({ if (valueSelector[1] === 'index') return VarType.number } - const isSystem = isSystemVar(valueSelector) + const isGlobal = isGlobalVar(valueSelector) + const isInStartNodeSysVar = isSystemVar(valueSelector) && !isGlobal const isEnv = isENV(valueSelector) const isChatVar = isConversationVar(valueSelector) const isSharedRagVariable @@ -1039,7 +1077,8 @@ export const getVarType = ({ }) const targetVarNodeId = (() => { - if (isSystem) return startNode?.id + if (isInStartNodeSysVar) return startNode?.id + if (isGlobal) return 'global' if (isInNodeRagVariable) return valueSelector[1] return valueSelector[0] })() @@ -1052,7 +1091,7 @@ export const getVarType = ({ let type: VarType = VarType.string let curr: any = targetVar.vars - if (isSystem || isEnv || isChatVar || isSharedRagVariable) { + if (isInStartNodeSysVar || isEnv || isChatVar || isSharedRagVariable || isGlobal) { return curr.find( (v: any) => v.variable === (valueSelector as ValueSelector).join('.'), )?.type @@ -1242,7 +1281,7 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { } case BlockEnum.LLM: { const payload = data as LLMNodeType - const isChatModel = payload.model?.mode === 'chat' + const isChatModel = payload.model?.mode === AppModeEnum.CHAT let prompts: string[] = [] if (isChatModel) { prompts @@ -1545,7 +1584,7 @@ export const updateNodeVars = ( } case BlockEnum.LLM: { const payload = data as LLMNodeType - const isChatModel = payload.model?.mode === 'chat' + const isChatModel = payload.model?.mode === AppModeEnum.CHAT if (isChatModel) { payload.prompt_template = ( payload.prompt_template as PromptItem[] diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx index 85424cdaf4..82c2dfd470 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx @@ -18,10 +18,11 @@ import { import RemoveButton from '../remove-button' import useAvailableVarList from '../../hooks/use-available-var-list' import VarReferencePopup from './var-reference-popup' -import { getNodeInfoById, isConversationVar, isENV, isRagVariableVar, isSystemVar, removeFileVars, varTypeToStructType } from './utils' +import { getNodeInfoById, isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar, removeFileVars, varTypeToStructType } from './utils' import ConstantField from './constant-field' import cn from '@/utils/classnames' import type { CommonNodeType, Node, NodeOutPutVar, ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types' +import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' import type { CredentialFormSchemaSelect } from '@/app/components/header/account-setting/model-provider-page/declarations' import { type CredentialFormSchema, type FormOption, FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { BlockEnum } from '@/app/components/workflow/types' @@ -38,6 +39,7 @@ import { useWorkflowVariables, } from '@/app/components/workflow/hooks' import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' +// import type { BaseResource, BaseResourceProvider } from '@/app/components/workflow/nodes/_base/types' import TypeSelector from '@/app/components/workflow/nodes/_base/components/selector' import AddButton from '@/app/components/base/button/add-button' import Badge from '@/app/components/base/badge' @@ -45,9 +47,10 @@ import Tooltip from '@/app/components/base/tooltip' import { isExceptionVariable } from '@/app/components/workflow/utils' import VarFullPathPanel from './var-full-path-panel' import { noop } from 'lodash-es' -import { useFetchDynamicOptions } from '@/service/use-plugins' import type { Tool } from '@/app/components/tools/types' +import { useFetchDynamicOptions } from '@/service/use-plugins' import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' +import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants' const TRIGGER_DEFAULT_WIDTH = 227 @@ -78,7 +81,7 @@ type Props = { popupFor?: 'assigned' | 'toAssigned' zIndex?: number currentTool?: Tool - currentProvider?: ToolWithProvider + currentProvider?: ToolWithProvider | TriggerWithProvider preferSchemaType?: boolean } @@ -203,6 +206,9 @@ const VarReferencePicker: FC<Props> = ({ const varName = useMemo(() => { if (!hasValue) return '' + const showName = VAR_SHOW_NAME_MAP[(value as ValueSelector).join('.')] + if(showName) + return showName const isSystem = isSystemVar(value as ValueSelector) const varName = Array.isArray(value) ? value[(value as ValueSelector).length - 1] : '' @@ -291,15 +297,17 @@ const VarReferencePicker: FC<Props> = ({ preferSchemaType, }) - const { isEnv, isChatVar, isRagVar, isValidVar, isException } = useMemo(() => { + const { isEnv, isChatVar, isGlobal, isRagVar, isValidVar, isException } = useMemo(() => { const isEnv = isENV(value as ValueSelector) const isChatVar = isConversationVar(value as ValueSelector) + const isGlobal = isGlobalVar(value as ValueSelector) const isRagVar = isRagVariableVar(value as ValueSelector) - const isValidVar = Boolean(outputVarNode) || isEnv || isChatVar || isRagVar + const isValidVar = Boolean(outputVarNode) || isEnv || isChatVar || isGlobal || isRagVar const isException = isExceptionVariable(varName, outputVarNode?.type) return { isEnv, isChatVar, + isGlobal, isRagVar, isValidVar, isException, @@ -392,10 +400,11 @@ const VarReferencePicker: FC<Props> = ({ const variableCategory = useMemo(() => { if (isEnv) return 'environment' if (isChatVar) return 'conversation' + if (isGlobal) return 'global' if (isLoopVar) return 'loop' if (isRagVar) return 'rag' return 'system' - }, [isEnv, isChatVar, isLoopVar, isRagVar]) + }, [isEnv, isChatVar, isGlobal, isLoopVar, isRagVar]) return ( <div className={cn(className, !readonly && 'cursor-pointer')}> @@ -473,7 +482,7 @@ const VarReferencePicker: FC<Props> = ({ {hasValue ? ( <> - {isShowNodeName && !isEnv && !isChatVar && !isRagVar && ( + {isShowNodeName && !isEnv && !isChatVar && !isGlobal && !isRagVar && ( <div className='flex items-center' onClick={(e) => { if (e.metaKey || e.ctrlKey) { e.stopPropagation() @@ -501,10 +510,11 @@ const VarReferencePicker: FC<Props> = ({ <div className='flex items-center text-text-accent'> {isLoading && <RiLoader4Line className='h-3.5 w-3.5 animate-spin text-text-secondary' />} <VariableIconWithColor + variables={value as ValueSelector} variableCategory={variableCategory} isExceptionVariable={isException} /> - <div className={cn('ml-0.5 truncate text-xs font-medium', isEnv && '!text-text-secondary', isChatVar && 'text-util-colors-teal-teal-700', isException && 'text-text-warning')} title={varName} style={{ + <div className={cn('ml-0.5 truncate text-xs font-medium', isEnv && '!text-text-secondary', isChatVar && 'text-util-colors-teal-teal-700', isException && 'text-text-warning', isGlobal && 'text-util-colors-orange-orange-600')} title={varName} style={{ maxWidth: maxVarNameWidth, }}>{varName}</div> </div> diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index e70cfed97c..ced4b7c65f 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -23,6 +23,7 @@ import { CodeAssistant, MagicEdit } from '@/app/components/base/icons/src/vender import ManageInputField from './manage-input-field' import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' +import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants' type ItemProps = { nodeId: string @@ -82,10 +83,14 @@ const Item: FC<ItemProps> = ({ }, [isFlat, isInCodeGeneratorInstructionEditor, itemData.variable]) const varName = useMemo(() => { + if(VAR_SHOW_NAME_MAP[itemData.variable]) + return VAR_SHOW_NAME_MAP[itemData.variable] + if (!isFlat) return itemData.variable if (itemData.variable === 'current') return isInCodeGeneratorInstructionEditor ? 'current_code' : 'current_prompt' + return itemData.variable }, [isFlat, isInCodeGeneratorInstructionEditor, itemData.variable]) @@ -182,6 +187,7 @@ const Item: FC<ItemProps> = ({ > <div className='flex w-0 grow items-center'> {!isFlat && <VariableIconWithColor + variables={itemData.variable.split('.')} variableCategory={variableCategory} isExceptionVariable={isException} />} diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx b/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx index 99f080f545..a8acda7e2c 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx @@ -11,6 +11,7 @@ import VariableIcon from './variable-icon' import VariableName from './variable-name' import cn from '@/utils/classnames' import Tooltip from '@/app/components/base/tooltip' +import { isConversationVar, isENV, isGlobalVar, isRagVariableVar } from '../../utils' const VariableLabel = ({ nodeType, @@ -26,6 +27,7 @@ const VariableLabel = ({ rightSlot, }: VariablePayload) => { const varColorClassName = useVarColor(variables, isExceptionVariable) + const isHideNodeLabel = !(isENV(variables) || isConversationVar(variables) || isGlobalVar(variables) || isRagVariableVar(variables)) return ( <div className={cn( @@ -35,10 +37,12 @@ const VariableLabel = ({ onClick={onClick} ref={ref} > - <VariableNodeLabel - nodeType={nodeType} - nodeTitle={nodeTitle} - /> + { isHideNodeLabel && ( + <VariableNodeLabel + nodeType={nodeType} + nodeTitle={nodeTitle} + /> + )} { notShowFullPath && ( <> diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/hooks.ts b/web/app/components/workflow/nodes/_base/components/variable/variable-label/hooks.ts index fef6d8c396..bb388d429a 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/variable-label/hooks.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/hooks.ts @@ -1,15 +1,17 @@ import { useMemo } from 'react' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' -import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others' +import { BubbleX, Env, GlobalVariable } from '@/app/components/base/icons/src/vender/line/others' import { Loop } from '@/app/components/base/icons/src/vender/workflow' import { InputField } from '@/app/components/base/icons/src/vender/pipeline' import { isConversationVar, isENV, + isGlobalVar, isRagVariableVar, isSystemVar, } from '../utils' import { VarInInspectType } from '@/types/workflow' +import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants' export const useVarIcon = (variables: string[], variableCategory?: VarInInspectType | string) => { if (variableCategory === 'loop') @@ -24,6 +26,9 @@ export const useVarIcon = (variables: string[], variableCategory?: VarInInspectT if (isConversationVar(variables) || variableCategory === VarInInspectType.conversation || variableCategory === 'conversation') return BubbleX + if (isGlobalVar(variables) || variableCategory === VarInInspectType.system) + return GlobalVariable + return Variable02 } @@ -41,13 +46,22 @@ export const useVarColor = (variables: string[], isExceptionVariable?: boolean, if (isConversationVar(variables) || variableCategory === VarInInspectType.conversation || variableCategory === 'conversation') return 'text-util-colors-teal-teal-700' + if (isGlobalVar(variables) || variableCategory === VarInInspectType.system) + return 'text-util-colors-orange-orange-600' + return 'text-text-accent' }, [variables, isExceptionVariable, variableCategory]) } export const useVarName = (variables: string[], notShowFullPath?: boolean) => { + const showName = VAR_SHOW_NAME_MAP[variables.join('.')] + let variableFullPathName = variables.slice(1).join('.') + + if (isRagVariableVar(variables)) + variableFullPathName = variables.slice(2).join('.') + const varName = useMemo(() => { - let variableFullPathName = variables.slice(1).join('.') + variableFullPathName = variables.slice(1).join('.') if (isRagVariableVar(variables)) variableFullPathName = variables.slice(2).join('.') @@ -58,6 +72,8 @@ export const useVarName = (variables: string[], notShowFullPath?: boolean) => { return `${isSystem ? 'sys.' : ''}${varName}` }, [variables, notShowFullPath]) + if (showName) + return showName return varName } diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index 29aebd4fd5..eaafab550e 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -1,36 +1,18 @@ -import type { - FC, - ReactNode, -} from 'react' -import React, { - cloneElement, - memo, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react' +import { useStore as useAppStore } from '@/app/components/app/store' +import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' +import Tooltip from '@/app/components/base/tooltip' +import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { - RiCloseLine, - RiPlayLargeLine, -} from '@remixicon/react' -import { useShallow } from 'zustand/react/shallow' -import { useTranslation } from 'react-i18next' -import NextStep from '../next-step' -import PanelOperator from '../panel-operator' -import NodePosition from '@/app/components/workflow/nodes/_base/components/node-position' -import HelpLink from '../help-link' -import { - DescriptionInput, - TitleInput, -} from '../title-description-input' -import ErrorHandleOnPanel from '../error-handle/error-handle-on-panel' -import RetryOnPanel from '../retry/retry-on-panel' -import { useResizePanel } from '../../hooks/use-resize-panel' -import cn from '@/utils/classnames' + AuthCategory, + AuthorizedInDataSourceNode, + AuthorizedInNode, + PluginAuth, + PluginAuthInDataSourceNode, +} from '@/app/components/plugins/plugin-auth' +import { usePluginStore } from '@/app/components/plugins/plugin-detail-panel/store' +import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list' +import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' import BlockIcon from '@/app/components/workflow/block-icon' -import Split from '@/app/components/workflow/nodes/_base/components/split' import { WorkflowHistoryEvent, useAvailableBlocks, @@ -41,41 +23,59 @@ import { useToolIcon, useWorkflowHistory, } from '@/app/components/workflow/hooks' +import { useHooksStore } from '@/app/components/workflow/hooks-store' +import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' +import Split from '@/app/components/workflow/nodes/_base/components/split' +import DataSourceBeforeRunForm from '@/app/components/workflow/nodes/data-source/before-run-form' +import type { CustomRunFormProps } from '@/app/components/workflow/nodes/data-source/types' +import { DataSourceClassification } from '@/app/components/workflow/nodes/data-source/types' +import { useLogs } from '@/app/components/workflow/run/hooks' +import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel' +import { useStore } from '@/app/components/workflow/store' +import { BlockEnum, type Node, NodeRunningStatus } from '@/app/components/workflow/types' import { canRunBySingle, hasErrorHandleNode, hasRetryNode, isSupportCustomRunForm, } from '@/app/components/workflow/utils' -import Tooltip from '@/app/components/base/tooltip' -import { BlockEnum, type Node, NodeRunningStatus } from '@/app/components/workflow/types' -import { useStore as useAppStore } from '@/app/components/app/store' -import { useStore } from '@/app/components/workflow/store' -import Tab, { TabType } from './tab' +import { useModalContext } from '@/context/modal-context' +import { useAllBuiltInTools } from '@/service/use-tools' +import { useAllTriggerPlugins } from '@/service/use-triggers' +import { FlowType } from '@/types/common' +import { canFindTool } from '@/utils' +import cn from '@/utils/classnames' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' +import { + RiCloseLine, + RiPlayLargeLine, +} from '@remixicon/react' +import { debounce } from 'lodash-es' +import type { FC, ReactNode } from 'react' +import React, { + cloneElement, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import { useShallow } from 'zustand/react/shallow' +import { useResizePanel } from '../../hooks/use-resize-panel' +import BeforeRunForm from '../before-run-form' +import PanelWrap from '../before-run-form/panel-wrap' +import ErrorHandleOnPanel from '../error-handle/error-handle-on-panel' +import HelpLink from '../help-link' +import NextStep from '../next-step' +import PanelOperator from '../panel-operator' +import RetryOnPanel from '../retry/retry-on-panel' +import { DescriptionInput, TitleInput } from '../title-description-input' import LastRun from './last-run' import useLastRun from './last-run/use-last-run' -import BeforeRunForm from '../before-run-form' -import { debounce } from 'lodash-es' -import { useLogs } from '@/app/components/workflow/run/hooks' -import PanelWrap from '../before-run-form/panel-wrap' -import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel' -import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' -import { useHooksStore } from '@/app/components/workflow/hooks-store' -import { FlowType } from '@/types/common' -import { - AuthorizedInDataSourceNode, - AuthorizedInNode, - PluginAuth, - PluginAuthInDataSourceNode, -} from '@/app/components/plugins/plugin-auth' -import { AuthCategory } from '@/app/components/plugins/plugin-auth' -import { canFindTool } from '@/utils' -import type { CustomRunFormProps } from '@/app/components/workflow/nodes/data-source/types' -import { DataSourceClassification } from '@/app/components/workflow/nodes/data-source/types' -import { useModalContext } from '@/context/modal-context' -import DataSourceBeforeRunForm from '@/app/components/workflow/nodes/data-source/before-run-form' -import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' -import { useAllBuiltInTools } from '@/service/use-tools' +import Tab, { TabType } from './tab' +import { TriggerSubscription } from './trigger-subscription' const getCustomRunForm = (params: CustomRunFormProps): React.JSX.Element => { const nodeType = params.payload.type @@ -86,6 +86,7 @@ const getCustomRunForm = (params: CustomRunFormProps): React.JSX.Element => { return <div>Custom Run Form: {nodeType} not found</div> } } + type BasePanelProps = { children: ReactNode id: Node['id'] @@ -98,6 +99,7 @@ const BasePanel: FC<BasePanelProps> = ({ children, }) => { const { t } = useTranslation() + const language = useLanguage() const { showMessageLogModal } = useAppStore(useShallow(state => ({ showMessageLogModal: state.showMessageLogModal, }))) @@ -108,6 +110,13 @@ const BasePanel: FC<BasePanelProps> = ({ const nodePanelWidth = useStore(s => s.nodePanelWidth) const otherPanelWidth = useStore(s => s.otherPanelWidth) const setNodePanelWidth = useStore(s => s.setNodePanelWidth) + const { + pendingSingleRun, + setPendingSingleRun, + } = useStore(s => ({ + pendingSingleRun: s.pendingSingleRun, + setPendingSingleRun: s.setPendingSingleRun, + })) const reservedCanvasWidth = 400 // Reserve the minimum visible width for the canvas @@ -212,6 +221,7 @@ const BasePanel: FC<BasePanelProps> = ({ useEffect(() => { hasClickRunning.current = false }, [id]) + const { nodesMap, } = useNodesMetaData() @@ -235,6 +245,7 @@ const BasePanel: FC<BasePanelProps> = ({ singleRunParams, nodeInfo, setRunInputData, + handleStop, handleSingleRun, handleRunWithParams, getExistVarValuesInForms, @@ -252,26 +263,65 @@ const BasePanel: FC<BasePanelProps> = ({ setIsPaused(false) }, [tabType]) + useEffect(() => { + if (!pendingSingleRun || pendingSingleRun.nodeId !== id) + return + + if (pendingSingleRun.action === 'run') + handleSingleRun() + else + handleStop() + + setPendingSingleRun(undefined) + }, [pendingSingleRun, id, handleSingleRun, handleStop, setPendingSingleRun]) + const logParams = useLogs() - const passedLogParams = (() => { - if ([BlockEnum.Tool, BlockEnum.Agent, BlockEnum.Iteration, BlockEnum.Loop].includes(data.type)) - return logParams - - return {} - })() + const passedLogParams = useMemo(() => [BlockEnum.Tool, BlockEnum.Agent, BlockEnum.Iteration, BlockEnum.Loop].includes(data.type) ? logParams : {}, [data.type, logParams]) + const storeBuildInTools = useStore(s => s.buildInTools) const { data: buildInTools } = useAllBuiltInTools() - const currCollection = useMemo(() => { - return buildInTools?.find(item => canFindTool(item.id, data.provider_id)) - }, [buildInTools, data.provider_id]) - const showPluginAuth = useMemo(() => { - return data.type === BlockEnum.Tool && currCollection?.allow_delete - }, [currCollection, data.type]) + const currToolCollection = useMemo(() => { + const candidates = buildInTools ?? storeBuildInTools + return candidates?.find(item => canFindTool(item.id, data.provider_id)) + }, [buildInTools, storeBuildInTools, data.provider_id]) + const needsToolAuth = useMemo(() => { + return data.type === BlockEnum.Tool && currToolCollection?.allow_delete + }, [data.type, currToolCollection?.allow_delete]) + + // only fetch trigger plugins when the node is a trigger plugin + const { data: triggerPlugins = [] } = useAllTriggerPlugins(data.type === BlockEnum.TriggerPlugin) + const currentTriggerPlugin = useMemo(() => { + if (data.type !== BlockEnum.TriggerPlugin || !data.plugin_id || !triggerPlugins?.length) + return undefined + return triggerPlugins?.find(p => p.plugin_id === data.plugin_id) + }, [data.type, data.plugin_id, triggerPlugins]) + const { setDetail } = usePluginStore() + + useEffect(() => { + if (currentTriggerPlugin?.subscription_constructor) { + setDetail({ + name: currentTriggerPlugin.label[language], + plugin_id: currentTriggerPlugin.plugin_id || '', + plugin_unique_identifier: currentTriggerPlugin.plugin_unique_identifier || '', + id: currentTriggerPlugin.id, + provider: currentTriggerPlugin.name, + declaration: { + trigger: { + subscription_schema: currentTriggerPlugin.subscription_schema || [], + subscription_constructor: currentTriggerPlugin.subscription_constructor, + }, + }, + }) + } + }, [currentTriggerPlugin, language, setDetail]) + const dataSourceList = useStore(s => s.dataSourceList) + const currentDataSource = useMemo(() => { if (data.type === BlockEnum.DataSource && data.provider_type !== DataSourceClassification.localFile) return dataSourceList?.find(item => item.plugin_id === data.plugin_id) - }, [dataSourceList, data.plugin_id, data.type, data.provider_type]) + }, [dataSourceList, data.provider_id, data.type, data.provider_type]) + const handleAuthorizationItemClick = useCallback((credential_id: string) => { handleNodeDataUpdateWithSyncDraft({ id, @@ -280,15 +330,46 @@ const BasePanel: FC<BasePanelProps> = ({ }, }) }, [handleNodeDataUpdateWithSyncDraft, id]) + const { setShowAccountSettingModal } = useModalContext() + const handleJumpToDataSourcePage = useCallback(() => { - setShowAccountSettingModal({ payload: 'data-source' }) + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE }) }, [setShowAccountSettingModal]) const { appendNodeInspectVars, } = useInspectVarsCrud() + const handleSubscriptionChange = useCallback((v: SimpleSubscription, callback?: () => void) => { + handleNodeDataUpdateWithSyncDraft( + { id, data: { subscription_id: v.id } }, + { + sync: true, + callback: { onSettled: callback }, + }, + ) + }, [handleNodeDataUpdateWithSyncDraft, id]) + + const readmeEntranceComponent = useMemo(() => { + let pluginDetail + switch (data.type) { + case BlockEnum.Tool: + pluginDetail = currToolCollection + break + case BlockEnum.DataSource: + pluginDetail = currentDataSource + break + case BlockEnum.TriggerPlugin: + pluginDetail = currentTriggerPlugin + break + + default: + break + } + return !pluginDetail ? null : <ReadmeEntrance pluginDetail={pluginDetail as any} className='mt-auto' /> + }, [data.type, currToolCollection, currentDataSource, currentTriggerPlugin]) + if (logParams.showSpecialResultPanel) { return ( <div className={cn( @@ -405,18 +486,10 @@ const BasePanel: FC<BasePanelProps> = ({ <div className='mr-1 flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover' onClick={() => { - if (isSingleRunning) { - handleNodeDataUpdate({ - id, - data: { - _isSingleRun: false, - _singleRunningStatus: undefined, - }, - }) - } - else { + if (isSingleRunning) + handleStop() + else handleSingleRun() - } }} > { @@ -427,7 +500,6 @@ const BasePanel: FC<BasePanelProps> = ({ </Tooltip> ) } - <NodePosition nodeId={id}></NodePosition> <HelpLink nodeType={data.type} /> <PanelOperator id={id} data={data} showHelpLink={false} /> <div className='mx-3 h-3.5 w-[1px] bg-divider-regular' /> @@ -446,13 +518,14 @@ const BasePanel: FC<BasePanelProps> = ({ /> </div> { - showPluginAuth && ( + needsToolAuth && ( <PluginAuth className='px-4 pb-2' pluginPayload={{ - provider: currCollection?.name || '', - providerType: currCollection?.type || '', + provider: currToolCollection?.name || '', + providerType: currToolCollection?.type || '', category: AuthCategory.tool, + detail: currToolCollection as any, }} > <div className='flex items-center justify-between pl-4 pr-3'> @@ -462,9 +535,10 @@ const BasePanel: FC<BasePanelProps> = ({ /> <AuthorizedInNode pluginPayload={{ - provider: currCollection?.name || '', - providerType: currCollection?.type || '', + provider: currToolCollection?.name || '', + providerType: currToolCollection?.type || '', category: AuthCategory.tool, + detail: currToolCollection as any, }} onAuthorizationItemClick={handleAuthorizationItemClick} credentialId={data.credential_id} @@ -493,7 +567,20 @@ const BasePanel: FC<BasePanelProps> = ({ ) } { - !showPluginAuth && !currentDataSource && ( + currentTriggerPlugin && ( + <TriggerSubscription + subscriptionIdSelected={data.subscription_id} + onSubscriptionChange={handleSubscriptionChange} + > + <Tab + value={tabType} + onChange={setTabType} + /> + </TriggerSubscription> + ) + } + { + !needsToolAuth && !currentDataSource && !currentTriggerPlugin && ( <div className='flex items-center justify-between pl-4 pr-3'> <Tab value={tabType} @@ -505,7 +592,7 @@ const BasePanel: FC<BasePanelProps> = ({ <Split /> </div> {tabType === TabType.settings && ( - <div className='flex-1 overflow-y-auto'> + <div className='flex flex-1 flex-col overflow-y-auto'> <div> {cloneElement(children as any, { id, @@ -550,6 +637,7 @@ const BasePanel: FC<BasePanelProps> = ({ </div> ) } + {readmeEntranceComponent} </div> )} @@ -568,6 +656,7 @@ const BasePanel: FC<BasePanelProps> = ({ {...passedLogParams} /> )} + </div> </div> ) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/index.tsx index b26dd74714..43dab49ed8 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/index.tsx @@ -60,6 +60,19 @@ const LastRun: FC<Props> = ({ const noLastRun = (error as any)?.status === 404 const runResult = (canRunLastRun ? lastRunResult : singleRunResult) || lastRunResult || {} + const resolvedStatus = useMemo(() => { + if (isPaused) + return NodeRunningStatus.Stopped + + if (oneStepRunRunningStatus === NodeRunningStatus.Stopped) + return NodeRunningStatus.Stopped + + if (oneStepRunRunningStatus === NodeRunningStatus.Listening) + return NodeRunningStatus.Listening + + return (runResult as any).status || otherResultPanelProps.status + }, [isPaused, oneStepRunRunningStatus, runResult, otherResultPanelProps.status]) + const resetHidePageStatus = useCallback(() => { setPageHasHide(false) setPageShowed(false) @@ -104,18 +117,18 @@ const LastRun: FC<Props> = ({ if (isRunning) return <ResultPanel status='running' showSteps={false} /> - if (!isPaused && (noLastRun || !runResult)) { return ( <NoData canSingleRun={canSingleRun} onSingleRun={onSingleRunClicked} /> ) } + return ( <div> <ResultPanel {...runResult as any} {...otherResultPanelProps} - status={isPaused ? NodeRunningStatus.Stopped : ((runResult as any).status || otherResultPanelProps.status)} + status={resolvedStatus} total_tokens={(runResult as any)?.execution_metadata?.total_tokens || otherResultPanelProps?.total_tokens} created_by={(runResult as any)?.created_by_account?.created_by || otherResultPanelProps?.created_by} nodeInfo={runResult as NodeTracing} diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts index 21462de939..ac9f2051c3 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts @@ -22,6 +22,7 @@ import useVariableAssignerSingleRunFormParams from '@/app/components/workflow/no import useKnowledgeBaseSingleRunFormParams from '@/app/components/workflow/nodes/knowledge-base/use-single-run-form-params' import useToolGetDataForCheckMore from '@/app/components/workflow/nodes/tool/use-get-data-for-check-more' +import useTriggerPluginGetDataForCheckMore from '@/app/components/workflow/nodes/trigger-plugin/use-check-params' import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config' // import @@ -30,10 +31,12 @@ import { BlockEnum } from '@/app/components/workflow/types' import { useNodesSyncDraft, } from '@/app/components/workflow/hooks' +import { useWorkflowRunValidation } from '@/app/components/workflow/hooks/use-checklist' import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' import { useInvalidLastRun } from '@/service/use-workflow' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { isSupportCustomRunForm } from '@/app/components/workflow/utils' +import Toast from '@/app/components/base/toast' const singleRunFormParamsHooks: Record<BlockEnum, any> = { [BlockEnum.LLM]: useLLMSingleRunFormParams, @@ -62,6 +65,9 @@ const singleRunFormParamsHooks: Record<BlockEnum, any> = { [BlockEnum.LoopEnd]: undefined, [BlockEnum.DataSource]: undefined, [BlockEnum.DataSourceEmpty]: undefined, + [BlockEnum.TriggerWebhook]: undefined, + [BlockEnum.TriggerSchedule]: undefined, + [BlockEnum.TriggerPlugin]: undefined, } const useSingleRunFormParamsHooks = (nodeType: BlockEnum) => { @@ -97,6 +103,9 @@ const getDataForCheckMoreHooks: Record<BlockEnum, any> = { [BlockEnum.DataSource]: undefined, [BlockEnum.DataSourceEmpty]: undefined, [BlockEnum.KnowledgeBase]: undefined, + [BlockEnum.TriggerWebhook]: undefined, + [BlockEnum.TriggerSchedule]: undefined, + [BlockEnum.TriggerPlugin]: useTriggerPluginGetDataForCheckMore, } const useGetDataForCheckMoreHooks = <T>(nodeType: BlockEnum) => { @@ -139,6 +148,17 @@ const useLastRun = <T>({ isRunAfterSingleRun, }) + const { warningNodes } = useWorkflowRunValidation() + const blockIfChecklistFailed = useCallback(() => { + const warningForNode = warningNodes.find(item => item.id === id) + if (!warningForNode) + return false + + const message = warningForNode.errorMessage || 'This node has unresolved checklist issues' + Toast.notify({ type: 'error', message }) + return true + }, [warningNodes, id]) + const { hideSingleRun, handleRun: doCallRunApi, @@ -199,7 +219,7 @@ const useLastRun = <T>({ }) } const workflowStore = useWorkflowStore() - const { setInitShowLastRunTab } = workflowStore.getState() + const { setInitShowLastRunTab, setShowVariableInspectPanel } = workflowStore.getState() const initShowLastRunTab = useStore(s => s.initShowLastRunTab) const [tabType, setTabType] = useState<TabType>(initShowLastRunTab ? TabType.lastRun : TabType.settings) useEffect(() => { @@ -211,6 +231,8 @@ const useLastRun = <T>({ const invalidLastRun = useInvalidLastRun(flowType, flowId, id) const handleRunWithParams = async (data: Record<string, any>) => { + if (blockIfChecklistFailed()) + return const { isValid } = checkValid() if (!isValid) return @@ -309,9 +331,13 @@ const useLastRun = <T>({ } const handleSingleRun = () => { + if (blockIfChecklistFailed()) + return const { isValid } = checkValid() if (!isValid) return + if (blockType === BlockEnum.TriggerWebhook || blockType === BlockEnum.TriggerPlugin || blockType === BlockEnum.TriggerSchedule) + setShowVariableInspectPanel(true) if (isCustomRunNode) { showSingleRun() return diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/trigger-subscription.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/trigger-subscription.tsx new file mode 100644 index 0000000000..811516df3d --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/trigger-subscription.tsx @@ -0,0 +1,26 @@ +import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list' +import { CreateButtonType, CreateSubscriptionButton } from '@/app/components/plugins/plugin-detail-panel/subscription-list/create' +import { SubscriptionSelectorEntry } from '@/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry' +import { useSubscriptionList } from '@/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list' +import cn from '@/utils/classnames' +import type { FC } from 'react' + +type TriggerSubscriptionProps = { + subscriptionIdSelected?: string + onSubscriptionChange: (v: SimpleSubscription, callback?: () => void) => void + children: React.ReactNode +} + +export const TriggerSubscription: FC<TriggerSubscriptionProps> = ({ subscriptionIdSelected, onSubscriptionChange, children }) => { + const { subscriptions } = useSubscriptionList() + const subscriptionCount = subscriptions?.length || 0 + + return <div className={cn('px-4', subscriptionCount > 0 && 'flex items-center justify-between pr-3')}> + {!subscriptionCount && <CreateSubscriptionButton buttonType={CreateButtonType.FULL_BUTTON} />} + {children} + {subscriptionCount > 0 && <SubscriptionSelectorEntry + selectedId={subscriptionIdSelected} + onSelect={onSubscriptionChange} + />} + </div> +} diff --git a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts index 93602f9751..77d75ccc4f 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts @@ -10,7 +10,15 @@ import { import { getNodeInfoById, isConversationVar, isENV, isSystemVar, toNodeOutputVars } from '@/app/components/workflow/nodes/_base/components/variable/utils' import type { CommonNodeType, InputVar, ValueSelector, Var, Variable } from '@/app/components/workflow/types' -import { BlockEnum, InputVarType, NodeRunningStatus, VarType } from '@/app/components/workflow/types' +import { + BlockEnum, + InputVarType, + NodeRunningStatus, + VarType, + WorkflowRunningStatus, +} from '@/app/components/workflow/types' +import type { TriggerNodeType } from '@/app/components/workflow/types' +import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { fetchNodeInspectVars, getIterationSingleNodeRunUrl, getLoopSingleNodeRunUrl, singleNodeRun } from '@/service/workflow' import Toast from '@/app/components/base/toast' @@ -28,7 +36,7 @@ import ParameterExtractorDefault from '@/app/components/workflow/nodes/parameter import IterationDefault from '@/app/components/workflow/nodes/iteration/default' import DocumentExtractorDefault from '@/app/components/workflow/nodes/document-extractor/default' import LoopDefault from '@/app/components/workflow/nodes/loop/default' -import { ssePost } from '@/service/base' +import { post, ssePost } from '@/service/base' import { noop } from 'lodash-es' import { getInputVars as doGetInputVars } from '@/app/components/base/prompt-editor/constants' import type { NodeRunResult, NodeTracing } from '@/types/workflow' @@ -50,9 +58,10 @@ import { useStoreApi, } from 'reactflow' import { useInvalidLastRun } from '@/service/use-workflow' -import useInspectVarsCrud from '../../../hooks/use-inspect-vars-crud' +import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' import type { FlowType } from '@/types/common' import useMatchSchemaType from '../components/variable/use-match-schema-type' +import { useEventEmitterContextContext } from '@/context/event-emitter' import { useAllBuiltInTools, useAllCustomTools, @@ -61,7 +70,7 @@ import { } from '@/service/use-tools' // eslint-disable-next-line ts/no-unsafe-function-type -const checkValidFns: Record<BlockEnum, Function> = { +const checkValidFns: Partial<Record<BlockEnum, Function>> = { [BlockEnum.LLM]: checkLLMValid, [BlockEnum.KnowledgeRetrieval]: checkKnowledgeRetrievalValid, [BlockEnum.IfElse]: checkIfElseValid, @@ -76,7 +85,12 @@ const checkValidFns: Record<BlockEnum, Function> = { [BlockEnum.Iteration]: checkIterationValid, [BlockEnum.DocExtractor]: checkDocumentExtractorValid, [BlockEnum.Loop]: checkLoopValid, -} as any +} + +type RequestError = { + message: string + status: string +} export type Params<T> = { id: string @@ -198,7 +212,52 @@ const useOneStepRun = <T>({ const store = useStoreApi() const { setShowSingleRunPanel, + setIsListening, + setListeningTriggerType, + setListeningTriggerNodeId, + setListeningTriggerNodeIds, + setListeningTriggerIsAll, + setShowVariableInspectPanel, } = workflowStore.getState() + const updateNodeInspectRunningState = useCallback((nodeId: string, isRunning: boolean) => { + const { + nodesWithInspectVars, + setNodesWithInspectVars, + } = workflowStore.getState() + + let hasChanges = false + const nodes = produce(nodesWithInspectVars, (draft) => { + const index = draft.findIndex(node => node.nodeId === nodeId) + if (index !== -1) { + const targetNode = draft[index] + if (targetNode.isSingRunRunning !== isRunning) { + targetNode.isSingRunRunning = isRunning + if (isRunning) + targetNode.isValueFetched = false + hasChanges = true + } + } + else if (isRunning) { + const { getNodes } = store.getState() + const target = getNodes().find(node => node.id === nodeId) + if (target) { + draft.unshift({ + nodeId, + nodeType: target.data.type, + title: target.data.title, + vars: [], + nodePayload: target.data, + isSingRunRunning: true, + isValueFetched: false, + }) + hasChanges = true + } + } + }) + + if (hasChanges) + setNodesWithInspectVars(nodes) + }, [workflowStore, store]) const invalidLastRun = useInvalidLastRun(flowType, flowId!, id) const [runResult, doSetRunResult] = useState<NodeRunResult | null>(null) const { @@ -207,10 +266,26 @@ const useOneStepRun = <T>({ invalidateConversationVarValues, } = useInspectVarsCrud() const runningStatus = data._singleRunningStatus || NodeRunningStatus.NotStart + const webhookSingleRunActiveRef = useRef(false) + const webhookSingleRunAbortRef = useRef<AbortController | null>(null) + const webhookSingleRunTimeoutRef = useRef<number | undefined>(undefined) + const webhookSingleRunTokenRef = useRef(0) + const webhookSingleRunDelayResolveRef = useRef<(() => void) | null>(null) + const pluginSingleRunActiveRef = useRef(false) + const pluginSingleRunAbortRef = useRef<AbortController | null>(null) + const pluginSingleRunTimeoutRef = useRef<number | undefined>(undefined) + const pluginSingleRunTokenRef = useRef(0) + const pluginSingleRunDelayResolveRef = useRef<(() => void) | null>(null) const isPausedRef = useRef(isPaused) useEffect(() => { isPausedRef.current = isPaused }, [isPaused]) + const { eventEmitter } = useEventEmitterContextContext() + + const isScheduleTriggerNode = data.type === BlockEnum.TriggerSchedule + const isWebhookTriggerNode = data.type === BlockEnum.TriggerWebhook + const isPluginTriggerNode = data.type === BlockEnum.TriggerPlugin + const isTriggerNode = isWebhookTriggerNode || isPluginTriggerNode || isScheduleTriggerNode const setRunResult = useCallback(async (data: NodeRunResult | null) => { const isPaused = isPausedRef.current @@ -230,13 +305,27 @@ const useOneStepRun = <T>({ const { getNodes } = store.getState() const nodes = getNodes() appendNodeInspectVars(id, vars, nodes) + updateNodeInspectRunningState(id, false) if (data?.status === NodeRunningStatus.Succeeded) { invalidLastRun() - if (isStartNode) + if (isStartNode || isTriggerNode) invalidateSysVarValues() invalidateConversationVarValues() // loop, iteration, variable assigner node can update the conversation variables, but to simple the logic(some nodes may also can update in the future), all nodes refresh. } - }, [isRunAfterSingleRun, runningStatus, flowId, id, store, appendNodeInspectVars, invalidLastRun, isStartNode, invalidateSysVarValues, invalidateConversationVarValues]) + }, [ + isRunAfterSingleRun, + runningStatus, + flowId, + id, + store, + appendNodeInspectVars, + updateNodeInspectRunningState, + invalidLastRun, + isStartNode, + isTriggerNode, + invalidateSysVarValues, + invalidateConversationVarValues, + ]) const { handleNodeDataUpdate }: { handleNodeDataUpdate: (data: any) => void } = useNodeDataUpdate() const setNodeRunning = () => { @@ -248,6 +337,299 @@ const useOneStepRun = <T>({ }, }) } + + const cancelWebhookSingleRun = useCallback(() => { + webhookSingleRunActiveRef.current = false + webhookSingleRunTokenRef.current += 1 + if (webhookSingleRunAbortRef.current) + webhookSingleRunAbortRef.current.abort() + webhookSingleRunAbortRef.current = null + if (webhookSingleRunTimeoutRef.current !== undefined) { + window.clearTimeout(webhookSingleRunTimeoutRef.current) + webhookSingleRunTimeoutRef.current = undefined + } + if (webhookSingleRunDelayResolveRef.current) { + webhookSingleRunDelayResolveRef.current() + webhookSingleRunDelayResolveRef.current = null + } + }, []) + + const cancelPluginSingleRun = useCallback(() => { + pluginSingleRunActiveRef.current = false + pluginSingleRunTokenRef.current += 1 + if (pluginSingleRunAbortRef.current) + pluginSingleRunAbortRef.current.abort() + pluginSingleRunAbortRef.current = null + if (pluginSingleRunTimeoutRef.current !== undefined) { + window.clearTimeout(pluginSingleRunTimeoutRef.current) + pluginSingleRunTimeoutRef.current = undefined + } + if (pluginSingleRunDelayResolveRef.current) { + pluginSingleRunDelayResolveRef.current() + pluginSingleRunDelayResolveRef.current = null + } + }, []) + + const startTriggerListening = useCallback(() => { + if (!isTriggerNode) + return + + setIsListening(true) + setShowVariableInspectPanel(true) + setListeningTriggerType(data.type as TriggerNodeType) + setListeningTriggerNodeId(id) + setListeningTriggerNodeIds([id]) + setListeningTriggerIsAll(false) + }, [ + isTriggerNode, + setIsListening, + setShowVariableInspectPanel, + setListeningTriggerType, + data.type, + setListeningTriggerNodeId, + id, + setListeningTriggerNodeIds, + setListeningTriggerIsAll, + ]) + + const stopTriggerListening = useCallback(() => { + if (!isTriggerNode) + return + + setIsListening(false) + setListeningTriggerType(null) + setListeningTriggerNodeId(null) + setListeningTriggerNodeIds([]) + setListeningTriggerIsAll(false) + }, [ + isTriggerNode, + setIsListening, + setListeningTriggerType, + setListeningTriggerNodeId, + setListeningTriggerNodeIds, + setListeningTriggerIsAll, + ]) + + const runScheduleSingleRun = useCallback(async (): Promise<NodeRunResult | null> => { + const urlPath = `/apps/${flowId}/workflows/draft/nodes/${id}/trigger/run` + + try { + const response: any = await post(urlPath, { + body: JSON.stringify({}), + }) + + if (!response) { + const message = 'Schedule trigger run failed' + Toast.notify({ type: 'error', message }) + throw new Error(message) + } + + if (response?.status === 'error') { + const message = response?.message || 'Schedule trigger run failed' + Toast.notify({ type: 'error', message }) + throw new Error(message) + } + + handleNodeDataUpdate({ + id, + data: { + ...data, + _isSingleRun: false, + _singleRunningStatus: NodeRunningStatus.Succeeded, + }, + }) + + return response as NodeRunResult + } + catch (error) { + console.error('handleRun: schedule trigger single run error', error) + handleNodeDataUpdate({ + id, + data: { + ...data, + _isSingleRun: false, + _singleRunningStatus: NodeRunningStatus.Failed, + }, + }) + Toast.notify({ type: 'error', message: 'Schedule trigger run failed' }) + throw error + } + }, [flowId, id, handleNodeDataUpdate, data]) + + const runWebhookSingleRun = useCallback(async (): Promise<any | null> => { + const urlPath = `/apps/${flowId}/workflows/draft/nodes/${id}/trigger/run` + + webhookSingleRunActiveRef.current = true + const token = ++webhookSingleRunTokenRef.current + + while (webhookSingleRunActiveRef.current && token === webhookSingleRunTokenRef.current) { + const controller = new AbortController() + webhookSingleRunAbortRef.current = controller + + try { + const response: any = await post(urlPath, { + body: JSON.stringify({}), + signal: controller.signal, + }) + + if (!webhookSingleRunActiveRef.current || token !== webhookSingleRunTokenRef.current) + return null + + if (!response) { + const message = response?.message || 'Webhook debug failed' + Toast.notify({ type: 'error', message }) + cancelWebhookSingleRun() + throw new Error(message) + } + + if (response?.status === 'waiting') { + const delay = Number(response.retry_in) || 2000 + webhookSingleRunAbortRef.current = null + if (!webhookSingleRunActiveRef.current || token !== webhookSingleRunTokenRef.current) + return null + + await new Promise<void>((resolve) => { + const timeoutId = window.setTimeout(resolve, delay) + webhookSingleRunTimeoutRef.current = timeoutId + webhookSingleRunDelayResolveRef.current = resolve + controller.signal.addEventListener('abort', () => { + window.clearTimeout(timeoutId) + resolve() + }, { once: true }) + }) + + webhookSingleRunTimeoutRef.current = undefined + webhookSingleRunDelayResolveRef.current = null + continue + } + + if (response?.status === 'error') { + const message = response.message || 'Webhook debug failed' + Toast.notify({ type: 'error', message }) + cancelWebhookSingleRun() + throw new Error(message) + } + + handleNodeDataUpdate({ + id, + data: { + ...data, + _isSingleRun: false, + _singleRunningStatus: NodeRunningStatus.Listening, + }, + }) + + cancelWebhookSingleRun() + return response + } + catch (error) { + if (controller.signal.aborted && (!webhookSingleRunActiveRef.current || token !== webhookSingleRunTokenRef.current)) + return null + if (controller.signal.aborted) + return null + + Toast.notify({ type: 'error', message: 'Webhook debug request failed' }) + cancelWebhookSingleRun() + if (error instanceof Error) + throw error + throw new Error(String(error)) + } + finally { + webhookSingleRunAbortRef.current = null + } + } + + return null + }, [flowId, id, data, handleNodeDataUpdate, cancelWebhookSingleRun]) + + const runPluginSingleRun = useCallback(async (): Promise<any | null> => { + const urlPath = `/apps/${flowId}/workflows/draft/nodes/${id}/trigger/run` + + pluginSingleRunActiveRef.current = true + const token = ++pluginSingleRunTokenRef.current + + while (pluginSingleRunActiveRef.current && token === pluginSingleRunTokenRef.current) { + const controller = new AbortController() + pluginSingleRunAbortRef.current = controller + + let requestError: RequestError | undefined + const response: any = await post(urlPath, { + body: JSON.stringify({}), + signal: controller.signal, + }).catch(async (error: Response) => { + const data = await error.clone().json() as Record<string, any> + const { error: respError, status } = data || {} + requestError = { + message: respError, + status, + } + return null + }).finally(() => { + pluginSingleRunAbortRef.current = null + }) + + if (!pluginSingleRunActiveRef.current || token !== pluginSingleRunTokenRef.current) + return null + + if (requestError) { + if (controller.signal.aborted) + return null + + Toast.notify({ type: 'error', message: requestError.message }) + cancelPluginSingleRun() + throw requestError + } + + if (!response) { + const message = 'Plugin debug failed' + Toast.notify({ type: 'error', message }) + cancelPluginSingleRun() + throw new Error(message) + } + + if (response?.status === 'waiting') { + const delay = Number(response.retry_in) || 2000 + if (!pluginSingleRunActiveRef.current || token !== pluginSingleRunTokenRef.current) + return null + + await new Promise<void>((resolve) => { + const timeoutId = window.setTimeout(resolve, delay) + pluginSingleRunTimeoutRef.current = timeoutId + pluginSingleRunDelayResolveRef.current = resolve + controller.signal.addEventListener('abort', () => { + window.clearTimeout(timeoutId) + resolve() + }, { once: true }) + }) + + pluginSingleRunTimeoutRef.current = undefined + pluginSingleRunDelayResolveRef.current = null + continue + } + + if (response?.status === 'error') { + const message = response.message || 'Plugin debug failed' + Toast.notify({ type: 'error', message }) + cancelPluginSingleRun() + throw new Error(message) + } + + handleNodeDataUpdate({ + id, + data: { + ...data, + _isSingleRun: false, + _singleRunningStatus: NodeRunningStatus.Listening, + }, + }) + + cancelPluginSingleRun() + return response + } + + return null + }, [flowId, id, data, handleNodeDataUpdate, cancelPluginSingleRun]) + const checkValidWrap = () => { if (!checkValid) return { isValid: true, errorMessage: '' } @@ -262,7 +644,7 @@ const useOneStepRun = <T>({ }) Toast.notify({ type: 'error', - message: res.errorMessage, + message: res.errorMessage || '', }) } return res @@ -309,33 +691,84 @@ const useOneStepRun = <T>({ const isCompleted = runningStatus === NodeRunningStatus.Succeeded || runningStatus === NodeRunningStatus.Failed const handleRun = async (submitData: Record<string, any>) => { + if (isWebhookTriggerNode) + cancelWebhookSingleRun() + if (isPluginTriggerNode) + cancelPluginSingleRun() + + updateNodeInspectRunningState(id, true) + + if (isTriggerNode) + startTriggerListening() + else + stopTriggerListening() + handleNodeDataUpdate({ id, data: { ...data, _isSingleRun: false, - _singleRunningStatus: NodeRunningStatus.Running, + _singleRunningStatus: isTriggerNode + ? NodeRunningStatus.Listening + : NodeRunningStatus.Running, }, }) let res: any let hasError = false try { if (!isIteration && !isLoop) { - const isStartNode = data.type === BlockEnum.Start - const postData: Record<string, any> = {} - if (isStartNode) { - const { '#sys.query#': query, '#sys.files#': files, ...inputs } = submitData - if (isChatMode) - postData.conversation_id = '' - - postData.inputs = inputs - postData.query = query - postData.files = files || [] + if (isScheduleTriggerNode) { + res = await runScheduleSingleRun() + } + else if (isWebhookTriggerNode) { + res = await runWebhookSingleRun() + if (!res) { + if (webhookSingleRunActiveRef.current) { + handleNodeDataUpdate({ + id, + data: { + ...data, + _isSingleRun: false, + _singleRunningStatus: NodeRunningStatus.Stopped, + }, + }) + } + return false + } + } + else if (isPluginTriggerNode) { + res = await runPluginSingleRun() + if (!res) { + if (pluginSingleRunActiveRef.current) { + handleNodeDataUpdate({ + id, + data: { + ...data, + _isSingleRun: false, + _singleRunningStatus: NodeRunningStatus.Stopped, + }, + }) + } + return false + } } else { - postData.inputs = submitData + const isStartNode = data.type === BlockEnum.Start + const postData: Record<string, any> = {} + if (isStartNode) { + const { '#sys.query#': query, '#sys.files#': files, ...inputs } = submitData + if (isChatMode) + postData.conversation_id = '' + + postData.inputs = inputs + postData.query = query + postData.files = files || [] + } + else { + postData.inputs = submitData + } + res = await singleNodeRun(flowType, flowId!, id, postData) as any } - res = await singleNodeRun(flowType, flowId!, id, postData) as any } else if (isIteration) { setIterationRunResult([]) @@ -566,6 +999,14 @@ const useOneStepRun = <T>({ } } finally { + if (isWebhookTriggerNode) + cancelWebhookSingleRun() + if (isPluginTriggerNode) + cancelPluginSingleRun() + if (isTriggerNode) + stopTriggerListening() + if (!isIteration && !isLoop) + updateNodeInspectRunningState(id, false) if (!isPausedRef.current && !isIteration && !isLoop && res) { setRunResult({ ...res, @@ -591,15 +1032,55 @@ const useOneStepRun = <T>({ } } - const handleStop = () => { + const handleStop = useCallback(() => { + if (isTriggerNode) { + const isTriggerActive = runningStatus === NodeRunningStatus.Listening + || webhookSingleRunActiveRef.current + || pluginSingleRunActiveRef.current + if (!isTriggerActive) + return + } + else if (runningStatus !== NodeRunningStatus.Running) { + return + } + + cancelWebhookSingleRun() + cancelPluginSingleRun() handleNodeDataUpdate({ id, data: { - ...data, - _singleRunningStatus: NodeRunningStatus.NotStart, + _isSingleRun: false, + _singleRunningStatus: NodeRunningStatus.Stopped, }, }) - } + stopTriggerListening() + updateNodeInspectRunningState(id, false) + const { + workflowRunningData, + setWorkflowRunningData, + nodesWithInspectVars, + deleteNodeInspectVars, + } = workflowStore.getState() + if (workflowRunningData) { + setWorkflowRunningData(produce(workflowRunningData, (draft) => { + draft.result.status = WorkflowRunningStatus.Stopped + })) + } + + const inspectNode = nodesWithInspectVars.find(node => node.nodeId === id) + if (inspectNode && !inspectNode.isValueFetched && (!inspectNode.vars || inspectNode.vars.length === 0)) + deleteNodeInspectVars(id) + }, [ + isTriggerNode, + runningStatus, + cancelWebhookSingleRun, + cancelPluginSingleRun, + handleNodeDataUpdate, + id, + stopTriggerListening, + updateNodeInspectRunningState, + workflowStore, + ]) const toVarInputs = (variables: Variable[]): InputVar[] => { if (!variables) @@ -662,6 +1143,11 @@ const useOneStepRun = <T>({ }) } + eventEmitter?.useSubscription((v: any) => { + if (v.type === EVENT_WORKFLOW_STOP) + handleStop() + }) + return { isShowSingleRun, hideSingleRun, diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx index 4725f86ad5..73f78401ac 100644 --- a/web/app/components/workflow/nodes/_base/node.tsx +++ b/web/app/components/workflow/nodes/_base/node.tsx @@ -16,23 +16,18 @@ import { RiLoader2Line, } from '@remixicon/react' import { useTranslation } from 'react-i18next' -import type { NodeProps } from '../../types' +import type { NodeProps } from '@/app/components/workflow/types' import { BlockEnum, NodeRunningStatus, -} from '../../types' -import { - useNodesReadOnly, - useToolIcon, -} from '../../hooks' -import { - hasErrorHandleNode, - hasRetryNode, -} from '../../utils' -import { useNodeIterationInteractions } from '../iteration/use-interactions' -import { useNodeLoopInteractions } from '../loop/use-interactions' -import type { IterationNodeType } from '../iteration/types' -import CopyID from '../tool/components/copy-id' + isTriggerNode, +} from '@/app/components/workflow/types' +import { useNodesReadOnly, useToolIcon } from '@/app/components/workflow/hooks' +import { hasErrorHandleNode, hasRetryNode } from '@/app/components/workflow/utils' +import { useNodeIterationInteractions } from '@/app/components/workflow/nodes/iteration/use-interactions' +import { useNodeLoopInteractions } from '@/app/components/workflow/nodes/loop/use-interactions' +import type { IterationNodeType } from '@/app/components/workflow/nodes/iteration/types' +import CopyID from '@/app/components/workflow/nodes/tool/components/copy-id' import { NodeSourceHandle, NodeTargetHandle, @@ -42,11 +37,12 @@ import NodeControl from './components/node-control' import ErrorHandleOnNode from './components/error-handle/error-handle-on-node' import RetryOnNode from './components/retry/retry-on-node' import AddVariablePopupWithPosition from './components/add-variable-popup-with-position' +import EntryNodeContainer, { StartNodeTypeEnum } from './components/entry-node-container' import cn from '@/utils/classnames' import BlockIcon from '@/app/components/workflow/block-icon' import Tooltip from '@/app/components/base/tooltip' -import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud' -import { ToolTypeEnum } from '../../block-selector/types' +import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' +import { ToolTypeEnum } from '@/app/components/workflow/block-selector/types' type NodeChildProps = { id: string @@ -67,6 +63,7 @@ const BaseNode: FC<BaseNodeProps> = ({ const { t } = useTranslation() const nodeRef = useRef<HTMLDivElement>(null) const { nodesReadOnly } = useNodesReadOnly() + const { handleNodeIterationChildSizeChange } = useNodeIterationInteractions() const { handleNodeLoopChildSizeChange } = useNodeLoopInteractions() const toolIcon = useToolIcon(data) @@ -141,13 +138,13 @@ const BaseNode: FC<BaseNodeProps> = ({ return null }, [data._loopIndex, data._runningStatus, t]) - return ( + const nodeContent = ( <div className={cn( 'relative flex rounded-2xl border', showSelectedBorder ? 'border-components-option-card-option-selected-border' : 'border-transparent', data._waitingRun && 'opacity-70', - data._dimmed && 'opacity-30', + data._pluginInstallLocked && 'cursor-not-allowed', )} ref={nodeRef} style={{ @@ -155,6 +152,17 @@ const BaseNode: FC<BaseNodeProps> = ({ height: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.height : 'auto', }} > + {(data._dimmed || data._pluginInstallLocked) && ( + <div + className={cn( + 'absolute inset-0 rounded-2xl transition-opacity', + data._pluginInstallLocked + ? 'pointer-events-auto z-30 bg-workflow-block-parma-bg opacity-80 backdrop-blur-[2px]' + : 'pointer-events-none z-20 bg-workflow-block-parma-bg opacity-50', + )} + data-testid='workflow-node-install-overlay' + /> + )} { data.type === BlockEnum.DataSource && ( <div className='absolute inset-[-2px] top-[-22px] z-[-1] rounded-[18px] bg-node-data-source-bg p-0.5 backdrop-blur-[6px]'> @@ -297,13 +305,13 @@ const BaseNode: FC<BaseNodeProps> = ({ </div> { data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && ( - cloneElement(children, { id, data }) + cloneElement(children, { id, data } as any) ) } { (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && ( <div className='grow pb-1 pl-1 pr-1'> - {cloneElement(children, { id, data })} + {cloneElement(children, { id, data } as any)} </div> ) } @@ -338,6 +346,17 @@ const BaseNode: FC<BaseNodeProps> = ({ </div> </div> ) + + const isStartNode = data.type === BlockEnum.Start + const isEntryNode = isTriggerNode(data.type as any) || isStartNode + + return isEntryNode ? ( + <EntryNodeContainer + nodeType={isStartNode ? StartNodeTypeEnum.Start : StartNodeTypeEnum.Trigger} + > + {nodeContent} + </EntryNodeContainer> + ) : nodeContent } export default memo(BaseNode) diff --git a/web/app/components/workflow/nodes/_base/types.ts b/web/app/components/workflow/nodes/_base/types.ts new file mode 100644 index 0000000000..18ad9c4e71 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/types.ts @@ -0,0 +1,27 @@ +import type { ValueSelector } from '@/app/components/workflow/types' + +// Generic variable types for all resource forms +export enum VarKindType { + variable = 'variable', + constant = 'constant', + mixed = 'mixed', +} + +// Generic resource variable inputs +export type ResourceVarInputs = Record<string, { + type: VarKindType + value?: string | ValueSelector | any +}> + +// Base resource interface +export type BaseResource = { + name: string + [key: string]: any +} + +// Base resource provider interface +export type BaseResourceProvider = { + plugin_id?: string + name: string + [key: string]: any +} diff --git a/web/app/components/workflow/nodes/assigner/components/var-list/index.tsx b/web/app/components/workflow/nodes/assigner/components/var-list/index.tsx index bfb48d4eb2..b81277d740 100644 --- a/web/app/components/workflow/nodes/assigner/components/var-list/index.tsx +++ b/web/app/components/workflow/nodes/assigner/components/var-list/index.tsx @@ -68,7 +68,7 @@ const VarList: FC<Props> = ({ draft[index].value = '' // Clear value when operation changes if (item.value === WriteMode.set || item.value === WriteMode.increment || item.value === WriteMode.decrement || item.value === WriteMode.multiply || item.value === WriteMode.divide) { - if(varType === VarType.boolean) + if (varType === VarType.boolean) draft[index].value = false draft[index].input_type = AssignerNodeInputType.constant } diff --git a/web/app/components/workflow/nodes/components.ts b/web/app/components/workflow/nodes/components.ts index cdf3a21598..d8da8b9dae 100644 --- a/web/app/components/workflow/nodes/components.ts +++ b/web/app/components/workflow/nodes/components.ts @@ -42,6 +42,12 @@ import DataSourceNode from './data-source/node' import DataSourcePanel from './data-source/panel' import KnowledgeBaseNode from './knowledge-base/node' import KnowledgeBasePanel from './knowledge-base/panel' +import TriggerScheduleNode from './trigger-schedule/node' +import TriggerSchedulePanel from './trigger-schedule/panel' +import TriggerWebhookNode from './trigger-webhook/node' +import TriggerWebhookPanel from './trigger-webhook/panel' +import TriggerPluginNode from './trigger-plugin/node' +import TriggerPluginPanel from './trigger-plugin/panel' export const NodeComponentMap: Record<string, ComponentType<any>> = { [BlockEnum.Start]: StartNode, @@ -66,6 +72,9 @@ export const NodeComponentMap: Record<string, ComponentType<any>> = { [BlockEnum.Agent]: AgentNode, [BlockEnum.DataSource]: DataSourceNode, [BlockEnum.KnowledgeBase]: KnowledgeBaseNode, + [BlockEnum.TriggerSchedule]: TriggerScheduleNode, + [BlockEnum.TriggerWebhook]: TriggerWebhookNode, + [BlockEnum.TriggerPlugin]: TriggerPluginNode, } export const PanelComponentMap: Record<string, ComponentType<any>> = { @@ -91,4 +100,7 @@ export const PanelComponentMap: Record<string, ComponentType<any>> = { [BlockEnum.Agent]: AgentPanel, [BlockEnum.DataSource]: DataSourcePanel, [BlockEnum.KnowledgeBase]: KnowledgeBasePanel, + [BlockEnum.TriggerSchedule]: TriggerSchedulePanel, + [BlockEnum.TriggerWebhook]: TriggerWebhookPanel, + [BlockEnum.TriggerPlugin]: TriggerPluginPanel, } diff --git a/web/app/components/workflow/nodes/constants.ts b/web/app/components/workflow/nodes/constants.ts index 78684577f2..b09b27343a 100644 --- a/web/app/components/workflow/nodes/constants.ts +++ b/web/app/components/workflow/nodes/constants.ts @@ -1,5 +1,7 @@ import { TransferMethod } from '@/types/app' +export const CUSTOM_NODE_TYPE = 'custom' + export const FILE_TYPE_OPTIONS = [ { value: 'image', i18nKey: 'image' }, { value: 'document', i18nKey: 'doc' }, diff --git a/web/app/components/workflow/nodes/data-source-empty/hooks.ts b/web/app/components/workflow/nodes/data-source-empty/hooks.ts index e22e87485c..a17f0b2acb 100644 --- a/web/app/components/workflow/nodes/data-source-empty/hooks.ts +++ b/web/app/components/workflow/nodes/data-source-empty/hooks.ts @@ -11,7 +11,7 @@ export const useReplaceDataSourceNode = (id: string) => { const handleReplaceNode = useCallback<OnSelectBlock>(( type, - toolDefaultValue, + pluginDefaultValue, ) => { const { getNodes, @@ -28,7 +28,7 @@ export const useReplaceDataSourceNode = (id: string) => { const { newNode } = generateNewNode({ data: { ...(defaultValue as any), - ...toolDefaultValue, + ...pluginDefaultValue, }, position: { x: emptyNode.position.x, diff --git a/web/app/components/workflow/nodes/data-source/node.tsx b/web/app/components/workflow/nodes/data-source/node.tsx index f97098e52f..b490aea2a9 100644 --- a/web/app/components/workflow/nodes/data-source/node.tsx +++ b/web/app/components/workflow/nodes/data-source/node.tsx @@ -1,10 +1,57 @@ import type { FC } from 'react' -import { memo } from 'react' -import type { DataSourceNodeType } from './types' +import { memo, useEffect } from 'react' import type { NodeProps } from '@/app/components/workflow/types' -const Node: FC<NodeProps<DataSourceNodeType>> = () => { +import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' +import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation' +import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update' +import type { DataSourceNodeType } from './types' + +const Node: FC<NodeProps<DataSourceNodeType>> = ({ + id, + data, +}) => { + const { + isChecking, + isMissing, + uniqueIdentifier, + canInstall, + onInstallSuccess, + shouldDim, + } = useNodePluginInstallation(data) + const { handleNodeDataUpdate } = useNodeDataUpdate() + const shouldLock = !isChecking && isMissing && canInstall && Boolean(uniqueIdentifier) + + useEffect(() => { + if (data._pluginInstallLocked === shouldLock && data._dimmed === shouldDim) + return + handleNodeDataUpdate({ + id, + data: { + _pluginInstallLocked: shouldLock, + _dimmed: shouldDim, + }, + }) + }, [data._pluginInstallLocked, data._dimmed, handleNodeDataUpdate, id, shouldDim, shouldLock]) + + const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier + + if (!showInstallButton) + return null + return ( - <div> + <div className='relative mb-1 px-3 py-1'> + <div className='pointer-events-auto absolute right-3 top-[-32px] z-40'> + <InstallPluginButton + size='small' + extraIdentifiers={[ + data.plugin_id, + data.provider_name, + ].filter(Boolean) as string[]} + className='!font-medium !text-text-accent' + uniqueIdentifier={uniqueIdentifier!} + onSuccess={onInstallSuccess} + /> + </div> </div> ) } diff --git a/web/app/components/workflow/nodes/data-source/types.ts b/web/app/components/workflow/nodes/data-source/types.ts index da887244b8..d0bc034b89 100644 --- a/web/app/components/workflow/nodes/data-source/types.ts +++ b/web/app/components/workflow/nodes/data-source/types.ts @@ -1,13 +1,9 @@ -import type { CommonNodeType, Node, ValueSelector } from '@/app/components/workflow/types' +import type { CommonNodeType, Node } from '@/app/components/workflow/types' import type { FlowType } from '@/types/common' import type { NodeRunResult, VarInInspect } from '@/types/workflow' import type { Dispatch, SetStateAction } from 'react' - -export enum VarType { - variable = 'variable', - constant = 'constant', - mixed = 'mixed', -} +import type { ResourceVarInputs } from '../_base/types' +export { VarKindType as VarType } from '../_base/types' export enum DataSourceClassification { localFile = 'local_file', @@ -16,10 +12,7 @@ export enum DataSourceClassification { onlineDrive = 'online_drive', } -export type ToolVarInputs = Record<string, { - type: VarType - value?: string | ValueSelector | any -}> +export type ToolVarInputs = ResourceVarInputs export type DataSourceNodeType = CommonNodeType & { fileExtensions?: string[] @@ -30,6 +23,7 @@ export type DataSourceNodeType = CommonNodeType & { datasource_label: string datasource_parameters: ToolVarInputs datasource_configurations: Record<string, any> + plugin_unique_identifier?: string } export type CustomRunFormProps = { diff --git a/web/app/components/workflow/nodes/end/default.ts b/web/app/components/workflow/nodes/end/default.ts index cadb580c34..881c16986b 100644 --- a/web/app/components/workflow/nodes/end/default.ts +++ b/web/app/components/workflow/nodes/end/default.ts @@ -6,17 +6,34 @@ import { BlockEnum } from '@/app/components/workflow/types' const metaData = genNodeMetaData({ sort: 2.1, type: BlockEnum.End, - isRequired: true, + isRequired: false, }) const nodeDefault: NodeDefault<EndNodeType> = { metaData, defaultValue: { outputs: [], }, - checkValid() { + checkValid(payload: EndNodeType, t: any) { + const outputs = payload.outputs || [] + + let errorMessage = '' + if (!outputs.length) { + errorMessage = t('workflow.errorMsg.fieldRequired', { field: t('workflow.nodes.end.output.variable') }) + } + else { + const invalidOutput = outputs.find((output) => { + const variableName = output.variable?.trim() + const hasSelector = Array.isArray(output.value_selector) && output.value_selector.length > 0 + return !variableName || !hasSelector + }) + + if (invalidOutput) + errorMessage = t('workflow.errorMsg.fieldRequired', { field: t('workflow.nodes.end.output.variable') }) + } + return { - isValid: true, - errorMessage: '', + isValid: !errorMessage, + errorMessage, } }, } diff --git a/web/app/components/workflow/nodes/end/panel.tsx b/web/app/components/workflow/nodes/end/panel.tsx index 2ad90ff5ac..420280d7c5 100644 --- a/web/app/components/workflow/nodes/end/panel.tsx +++ b/web/app/components/workflow/nodes/end/panel.tsx @@ -30,6 +30,7 @@ const Panel: FC<NodePanelProps<EndNodeType>> = ({ <Field title={t(`${i18nPrefix}.output.variable`)} + required operations={ !readOnly ? <AddButton onClick={handleAddVariable} /> : undefined } diff --git a/web/app/components/workflow/nodes/iteration/add-block.tsx b/web/app/components/workflow/nodes/iteration/add-block.tsx index 10aa8bb3e2..05d69caef4 100644 --- a/web/app/components/workflow/nodes/iteration/add-block.tsx +++ b/web/app/components/workflow/nodes/iteration/add-block.tsx @@ -33,11 +33,11 @@ const AddBlock = ({ const { handleNodeAdd } = useNodesInteractions() const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, true) - const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => { + const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { handleNodeAdd( { nodeType: type, - toolDefaultValue, + pluginDefaultValue, }, { prevNodeId: iterationNodeData.start_node_id, diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx index 8ea313dd26..1c6158a60e 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx @@ -84,7 +84,6 @@ const MetadataFilter = ({ popupClassName='!w-[387px]' isInWorkflow isAdvancedMode={true} - mode={metadataModelConfig?.mode || 'chat'} provider={metadataModelConfig?.provider || ''} completionParams={metadataModelConfig?.completion_params || { temperature: 0.7 }} modelId={metadataModelConfig?.name || ''} diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/use-config.ts b/web/app/components/workflow/nodes/knowledge-retrieval/use-config.ts index 60789e6863..73d1c15872 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/use-config.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/use-config.ts @@ -32,7 +32,7 @@ import { getMultipleRetrievalConfig, getSelectedDatasetsMode, } from './utils' -import { RETRIEVE_TYPE } from '@/types/app' +import { AppModeEnum, RETRIEVE_TYPE } from '@/types/app' import { DATASET_DEFAULT } from '@/config' import type { DataSet } from '@/models/datasets' import { fetchDatasets } from '@/service/datasets' @@ -344,7 +344,7 @@ const useConfig = (id: string, payload: KnowledgeRetrievalNodeType) => { draft.metadata_model_config = { provider: model.provider, name: model.modelId, - mode: model.mode || 'chat', + mode: model.mode || AppModeEnum.CHAT, completion_params: draft.metadata_model_config?.completion_params || { temperature: 0.7 }, } }) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx index 9387813ee5..62d156253a 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx @@ -65,7 +65,6 @@ const PromptEditor: FC<PromptEditorProps> = ({ portalToFollowElemContentClassName='z-[1000]' isAdvancedMode={true} provider={model.provider} - mode={model.mode} completionParams={model.completion_params} modelId={model.name} setModel={onModelChange} diff --git a/web/app/components/workflow/nodes/llm/components/prompt-generator-btn.tsx b/web/app/components/workflow/nodes/llm/components/prompt-generator-btn.tsx index c3c0483bec..a2b96535fa 100644 --- a/web/app/components/workflow/nodes/llm/components/prompt-generator-btn.tsx +++ b/web/app/components/workflow/nodes/llm/components/prompt-generator-btn.tsx @@ -6,7 +6,7 @@ import cn from 'classnames' import { Generator } from '@/app/components/base/icons/src/vender/other' import { ActionButton } from '@/app/components/base/action-button' import GetAutomaticResModal from '@/app/components/app/configuration/config/automatic/get-automatic-res' -import { AppType } from '@/types/app' +import { AppModeEnum } from '@/types/app' import type { GenRes } from '@/service/debug' import type { ModelConfig } from '@/app/components/workflow/types' import { useHooksStore } from '../../../hooks-store' @@ -42,7 +42,7 @@ const PromptGeneratorBtn: FC<Props> = ({ </ActionButton> {showAutomatic && ( <GetAutomaticResModal - mode={AppType.chat} + mode={AppModeEnum.CHAT} isShow={showAutomatic} onClose={showAutomaticFalse} onFinished={handleAutomaticRes} diff --git a/web/app/components/workflow/nodes/llm/default.ts b/web/app/components/workflow/nodes/llm/default.ts index a4ea0ef683..57033d26a1 100644 --- a/web/app/components/workflow/nodes/llm/default.ts +++ b/web/app/components/workflow/nodes/llm/default.ts @@ -1,4 +1,5 @@ // import { RETRIEVAL_OUTPUT_STRUCT } from '../../constants' +import { AppModeEnum } from '@/types/app' import { BlockEnum, EditionType } from '../../types' import { type NodeDefault, type PromptItem, PromptRole } from '../../types' import type { LLMNodeType } from './types' @@ -36,7 +37,7 @@ const nodeDefault: NodeDefault<LLMNodeType> = { model: { provider: '', name: '', - mode: 'chat', + mode: AppModeEnum.CHAT, completion_params: { temperature: 0.7, }, @@ -63,7 +64,7 @@ const nodeDefault: NodeDefault<LLMNodeType> = { errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.model`) }) if (!errorMessages && !payload.memory) { - const isChatModel = payload.model.mode === 'chat' + const isChatModel = payload.model.mode === AppModeEnum.CHAT const isPromptEmpty = isChatModel ? !(payload.prompt_template as PromptItem[]).some((t) => { if (t.edition_type === EditionType.jinja2) @@ -77,14 +78,14 @@ const nodeDefault: NodeDefault<LLMNodeType> = { } if (!errorMessages && !!payload.memory) { - const isChatModel = payload.model.mode === 'chat' + const isChatModel = payload.model.mode === AppModeEnum.CHAT // payload.memory.query_prompt_template not pass is default: {{#sys.query#}} if (isChatModel && !!payload.memory.query_prompt_template && !payload.memory.query_prompt_template.includes('{{#sys.query#}}')) errorMessages = t('workflow.nodes.llm.sysQueryInUser') } if (!errorMessages) { - const isChatModel = payload.model.mode === 'chat' + const isChatModel = payload.model.mode === AppModeEnum.CHAT const isShowVars = (() => { if (isChatModel) return (payload.prompt_template as PromptItem[]).some(item => item.edition_type === EditionType.jinja2) diff --git a/web/app/components/workflow/nodes/llm/panel.tsx b/web/app/components/workflow/nodes/llm/panel.tsx index cd79b9f3d9..bb893b0da7 100644 --- a/web/app/components/workflow/nodes/llm/panel.tsx +++ b/web/app/components/workflow/nodes/llm/panel.tsx @@ -94,7 +94,6 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({ } })() }, [inputs.model.completion_params]) - return ( <div className='mt-2'> <div className='space-y-4 px-4 pb-4'> @@ -106,7 +105,6 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({ popupClassName='!w-[387px]' isInWorkflow isAdvancedMode={true} - mode={model?.mode} provider={model?.provider} completionParams={model?.completion_params} modelId={model?.name} diff --git a/web/app/components/workflow/nodes/llm/types.ts b/web/app/components/workflow/nodes/llm/types.ts index 987fb75fef..70dc4d9cc7 100644 --- a/web/app/components/workflow/nodes/llm/types.ts +++ b/web/app/components/workflow/nodes/llm/types.ts @@ -30,6 +30,7 @@ export enum Type { arrayNumber = 'array[number]', arrayObject = 'array[object]', file = 'file', + enumType = 'enum', } export enum ArrayType { diff --git a/web/app/components/workflow/nodes/llm/use-config.ts b/web/app/components/workflow/nodes/llm/use-config.ts index c0608865b8..d9b811bb85 100644 --- a/web/app/components/workflow/nodes/llm/use-config.ts +++ b/web/app/components/workflow/nodes/llm/use-config.ts @@ -18,6 +18,7 @@ import { import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { checkHasContextBlock, checkHasHistoryBlock, checkHasQueryBlock } from '@/app/components/base/prompt-editor/constants' import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' +import { AppModeEnum } from '@/types/app' const useConfig = (id: string, payload: LLMNodeType) => { const { nodesReadOnly: readOnly } = useNodesReadOnly() @@ -49,7 +50,7 @@ const useConfig = (id: string, payload: LLMNodeType) => { // model const model = inputs.model const modelMode = inputs.model?.mode - const isChatModel = modelMode === 'chat' + const isChatModel = modelMode === AppModeEnum.CHAT const isCompletionModel = !isChatModel @@ -134,7 +135,7 @@ const useConfig = (id: string, payload: LLMNodeType) => { draft.model.mode = model.mode! const isModeChange = model.mode !== inputRef.current.model.mode if (isModeChange && defaultConfig && Object.keys(defaultConfig).length > 0) - appendDefaultPromptConfig(draft, defaultConfig, model.mode === 'chat') + appendDefaultPromptConfig(draft, defaultConfig, model.mode === AppModeEnum.CHAT) }) setInputs(newInputs) setModelChanged(true) diff --git a/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts b/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts index aaa12be0c2..8d539dfc15 100644 --- a/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts @@ -12,6 +12,7 @@ import useConfigVision from '../../hooks/use-config-vision' import { noop } from 'lodash-es' import { findVariableWhenOnLLMVision } from '../utils' import useAvailableVarList from '../_base/hooks/use-available-var-list' +import { AppModeEnum } from '@/types/app' const i18nPrefix = 'workflow.nodes.llm' type Params = { @@ -56,7 +57,7 @@ const useSingleRunFormParams = ({ // model const model = inputs.model const modelMode = inputs.model?.mode - const isChatModel = modelMode === 'chat' + const isChatModel = modelMode === AppModeEnum.CHAT const { isVisionModel, } = useConfigVision(model, { diff --git a/web/app/components/workflow/nodes/llm/utils.ts b/web/app/components/workflow/nodes/llm/utils.ts index 10c287f86b..1652d511d0 100644 --- a/web/app/components/workflow/nodes/llm/utils.ts +++ b/web/app/components/workflow/nodes/llm/utils.ts @@ -9,8 +9,9 @@ export const checkNodeValid = (_payload: LLMNodeType) => { } export const getFieldType = (field: Field) => { - const { type, items } = field - if(field.schemaType === 'file') return Type.file + const { type, items, enum: enums } = field + if (field.schemaType === 'file') return Type.file + if (enums && enums.length > 0) return Type.enumType if (type !== Type.array || !items) return type diff --git a/web/app/components/workflow/nodes/loop/add-block.tsx b/web/app/components/workflow/nodes/loop/add-block.tsx index a9c1429269..9e2fa5b555 100644 --- a/web/app/components/workflow/nodes/loop/add-block.tsx +++ b/web/app/components/workflow/nodes/loop/add-block.tsx @@ -34,11 +34,11 @@ const AddBlock = ({ const { handleNodeAdd } = useNodesInteractions() const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, true) - const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => { + const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { handleNodeAdd( { nodeType: type, - toolDefaultValue, + pluginDefaultValue, }, { prevNodeId: loopNodeData.start_node_id, diff --git a/web/app/components/workflow/nodes/loop/insert-block.tsx b/web/app/components/workflow/nodes/loop/insert-block.tsx index c4f4348d8e..66d51956ba 100644 --- a/web/app/components/workflow/nodes/loop/insert-block.tsx +++ b/web/app/components/workflow/nodes/loop/insert-block.tsx @@ -25,11 +25,11 @@ const InsertBlock = ({ const handleOpenChange = useCallback((v: boolean) => { setOpen(v) }, []) - const handleInsert = useCallback<OnSelectBlock>((nodeType, toolDefaultValue) => { + const handleInsert = useCallback<OnSelectBlock>((nodeType, pluginDefaultValue) => { handleNodeAdd( { nodeType, - toolDefaultValue, + pluginDefaultValue, }, { nextNodeId: startNodeId, diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx index 9392f28736..7b8354f6d5 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx @@ -9,7 +9,7 @@ import BlockSelector from '../../../../block-selector' import type { Param, ParamType } from '../../types' import cn from '@/utils/classnames' import type { - DataSourceDefaultValue, + PluginDefaultValue, ToolDefaultValue, } from '@/app/components/workflow/block-selector/types' import type { ToolParameter } from '@/app/components/tools/types' @@ -50,11 +50,11 @@ const ImportFromTool: FC<Props> = ({ const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() - const handleSelectTool = useCallback((_type: BlockEnum, toolInfo?: ToolDefaultValue | DataSourceDefaultValue) => { - if (!toolInfo || 'datasource_name' in toolInfo) + const handleSelectTool = useCallback((_type: BlockEnum, toolInfo?: PluginDefaultValue) => { + if (!toolInfo || 'datasource_name' in toolInfo || !('tool_name' in toolInfo)) return - const { provider_id, provider_type, tool_name } = toolInfo + const { provider_id, provider_type, tool_name } = toolInfo as ToolDefaultValue const currentTools = (() => { switch (provider_type) { case CollectionType.builtIn: diff --git a/web/app/components/workflow/nodes/parameter-extractor/default.ts b/web/app/components/workflow/nodes/parameter-extractor/default.ts index a65306249d..5d2010122d 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/default.ts +++ b/web/app/components/workflow/nodes/parameter-extractor/default.ts @@ -3,6 +3,7 @@ import { type ParameterExtractorNodeType, ReasoningModeType } from './types' import { genNodeMetaData } from '@/app/components/workflow/utils' import { BlockEnum } from '@/app/components/workflow/types' import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types' +import { AppModeEnum } from '@/types/app' const i18nPrefix = 'workflow' const metaData = genNodeMetaData({ @@ -17,7 +18,7 @@ const nodeDefault: NodeDefault<ParameterExtractorNodeType> = { model: { provider: '', name: '', - mode: 'chat', + mode: AppModeEnum.CHAT, completion_params: { temperature: 0.7, }, diff --git a/web/app/components/workflow/nodes/parameter-extractor/panel.tsx b/web/app/components/workflow/nodes/parameter-extractor/panel.tsx index a169217609..8faebfa547 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/panel.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/panel.tsx @@ -67,7 +67,6 @@ const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({ popupClassName='!w-[387px]' isInWorkflow isAdvancedMode={true} - mode={model?.mode} provider={model?.provider} completionParams={model?.completion_params} modelId={model?.name} diff --git a/web/app/components/workflow/nodes/parameter-extractor/use-config.ts b/web/app/components/workflow/nodes/parameter-extractor/use-config.ts index 81dace1014..676d631a8a 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/use-config.ts +++ b/web/app/components/workflow/nodes/parameter-extractor/use-config.ts @@ -17,6 +17,7 @@ import { checkHasQueryBlock } from '@/app/components/base/prompt-editor/constant import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import { supportFunctionCall } from '@/utils/tool-call' import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud' +import { AppModeEnum } from '@/types/app' const useConfig = (id: string, payload: ParameterExtractorNodeType) => { const { @@ -86,13 +87,13 @@ const useConfig = (id: string, payload: ParameterExtractorNodeType) => { const model = inputs.model || { provider: '', name: '', - mode: 'chat', + mode: AppModeEnum.CHAT, completion_params: { temperature: 0.7, }, } const modelMode = inputs.model?.mode - const isChatModel = modelMode === 'chat' + const isChatModel = modelMode === AppModeEnum.CHAT const isCompletionModel = !isChatModel const { @@ -133,7 +134,7 @@ const useConfig = (id: string, payload: ParameterExtractorNodeType) => { draft.model.mode = model.mode! const isModeChange = model.mode !== inputRef.current.model?.mode if (isModeChange && defaultConfig && Object.keys(defaultConfig).length > 0) - appendDefaultPromptConfig(draft, defaultConfig, model.mode === 'chat') + appendDefaultPromptConfig(draft, defaultConfig, model.mode === AppModeEnum.CHAT) }) setInputs(newInputs) setModelChanged(true) diff --git a/web/app/components/workflow/nodes/question-classifier/default.ts b/web/app/components/workflow/nodes/question-classifier/default.ts index d34c854916..90ae3fd586 100644 --- a/web/app/components/workflow/nodes/question-classifier/default.ts +++ b/web/app/components/workflow/nodes/question-classifier/default.ts @@ -3,6 +3,7 @@ import type { QuestionClassifierNodeType } from './types' import { genNodeMetaData } from '@/app/components/workflow/utils' import { BlockEnum } from '@/app/components/workflow/types' import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types' +import { AppModeEnum } from '@/types/app' const i18nPrefix = 'workflow' @@ -18,7 +19,7 @@ const nodeDefault: NodeDefault<QuestionClassifierNodeType> = { model: { provider: '', name: '', - mode: 'chat', + mode: AppModeEnum.CHAT, completion_params: { temperature: 0.7, }, diff --git a/web/app/components/workflow/nodes/question-classifier/panel.tsx b/web/app/components/workflow/nodes/question-classifier/panel.tsx index 8e27f5dceb..8b6bc533f2 100644 --- a/web/app/components/workflow/nodes/question-classifier/panel.tsx +++ b/web/app/components/workflow/nodes/question-classifier/panel.tsx @@ -56,7 +56,6 @@ const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({ popupClassName='!w-[387px]' isInWorkflow isAdvancedMode={true} - mode={model?.mode} provider={model?.provider} completionParams={model.completion_params} modelId={model.name} diff --git a/web/app/components/workflow/nodes/question-classifier/use-config.ts b/web/app/components/workflow/nodes/question-classifier/use-config.ts index dc197a079e..28a6fa0314 100644 --- a/web/app/components/workflow/nodes/question-classifier/use-config.ts +++ b/web/app/components/workflow/nodes/question-classifier/use-config.ts @@ -15,6 +15,7 @@ import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/com import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { checkHasQueryBlock } from '@/app/components/base/prompt-editor/constants' import { useUpdateNodeInternals } from 'reactflow' +import { AppModeEnum } from '@/types/app' const useConfig = (id: string, payload: QuestionClassifierNodeType) => { const updateNodeInternals = useUpdateNodeInternals() @@ -38,7 +39,7 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => { const model = inputs.model const modelMode = inputs.model?.mode - const isChatModel = modelMode === 'chat' + const isChatModel = modelMode === AppModeEnum.CHAT const { isVisionModel, diff --git a/web/app/components/workflow/nodes/start/default.ts b/web/app/components/workflow/nodes/start/default.ts index 3b98b57a73..60584b5144 100644 --- a/web/app/components/workflow/nodes/start/default.ts +++ b/web/app/components/workflow/nodes/start/default.ts @@ -7,10 +7,10 @@ const metaData = genNodeMetaData({ sort: 0.1, type: BlockEnum.Start, isStart: true, - isRequired: true, - isUndeletable: true, + isRequired: false, isSingleton: true, - isTypeFixed: true, + isTypeFixed: false, // support node type change for start node(user input) + helpLinkUri: 'user-input', }) const nodeDefault: NodeDefault<StartNodeType> = { metaData, diff --git a/web/app/components/workflow/nodes/start/panel.tsx b/web/app/components/workflow/nodes/start/panel.tsx index 0a1efd444f..a560bd2e63 100644 --- a/web/app/components/workflow/nodes/start/panel.tsx +++ b/web/app/components/workflow/nodes/start/panel.tsx @@ -62,7 +62,7 @@ const Panel: FC<NodePanelProps<StartNodeType>> = ({ <VarItem readonly payload={{ - variable: 'sys.query', + variable: 'userinput.query', } as any} rightContent={ <div className='text-xs font-normal text-text-tertiary'> @@ -76,7 +76,7 @@ const Panel: FC<NodePanelProps<StartNodeType>> = ({ readonly showLegacyBadge={!isChatMode} payload={{ - variable: 'sys.files', + variable: 'userinput.files', } as any} rightContent={ <div className='text-xs font-normal text-text-tertiary'> @@ -84,80 +84,7 @@ const Panel: FC<NodePanelProps<StartNodeType>> = ({ </div> } /> - { - isChatMode && ( - <> - <VarItem - readonly - payload={{ - variable: 'sys.dialogue_count', - } as any} - rightContent={ - <div className='text-xs font-normal text-text-tertiary'> - Number - </div> - } - /> - <VarItem - readonly - payload={{ - variable: 'sys.conversation_id', - } as any} - rightContent={ - <div className='text-xs font-normal text-text-tertiary'> - String - </div> - } - /> - </> - ) - } - <VarItem - readonly - payload={{ - variable: 'sys.user_id', - } as any} - rightContent={ - <div className='text-xs font-normal text-text-tertiary'> - String - </div> - } - /> - <VarItem - readonly - payload={{ - variable: 'sys.app_id', - } as any} - rightContent={ - <div className='text-xs font-normal text-text-tertiary'> - String - </div> - } - /> - <VarItem - readonly - payload={{ - variable: 'sys.workflow_id', - } as any} - rightContent={ - <div className='text-xs font-normal text-text-tertiary'> - String - </div> - } - /> - <VarItem - readonly - payload={{ - variable: 'sys.workflow_run_id', - } as any} - rightContent={ - <div className='text-xs font-normal text-text-tertiary'> - String - </div> - } - /> </div> - </> </Field> </div> diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index ec35f9a60a..fa50727123 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -20,6 +20,7 @@ type MixedVariableTextInputProps = { onChange?: (text: string) => void showManageInputField?: boolean onManageInputField?: () => void + disableVariableInsertion?: boolean } const MixedVariableTextInput = ({ readOnly = false, @@ -29,6 +30,7 @@ const MixedVariableTextInput = ({ onChange, showManageInputField, onManageInputField, + disableVariableInsertion = false, }: MixedVariableTextInputProps) => { const { t } = useTranslation() const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey) @@ -37,7 +39,7 @@ const MixedVariableTextInput = ({ <PromptEditor key={controlPromptEditorRerenderKey} wrapperClassName={cn( - 'w-full rounded-lg border border-transparent bg-components-input-bg-normal px-2 py-1', + 'min-h-8 w-full rounded-lg border border-transparent bg-components-input-bg-normal px-2 py-1', 'hover:border-components-input-border-hover hover:bg-components-input-bg-hover', 'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs', )} @@ -45,7 +47,7 @@ const MixedVariableTextInput = ({ editable={!readOnly} value={value} workflowVariableBlock={{ - show: true, + show: !disableVariableInsertion, variables: nodesOutputVars || [], workflowNodesMap: availableNodes.reduce((acc, node) => { acc[node.id] = { @@ -63,7 +65,7 @@ const MixedVariableTextInput = ({ showManageInputField, onManageInputField, }} - placeholder={<Placeholder />} + placeholder={<Placeholder disableVariableInsertion={disableVariableInsertion} />} onChange={onChange} /> ) diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx index 75d4c91996..d6e0bbc059 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx @@ -6,7 +6,11 @@ import { $insertNodes } from 'lexical' import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node' import Badge from '@/app/components/base/badge' -const Placeholder = () => { +type PlaceholderProps = { + disableVariableInsertion?: boolean +} + +const Placeholder = ({ disableVariableInsertion = false }: PlaceholderProps) => { const { t } = useTranslation() const [editor] = useLexicalComposerContext() @@ -28,17 +32,21 @@ const Placeholder = () => { > <div className='flex grow items-center'> {t('workflow.nodes.tool.insertPlaceholder1')} - <div className='system-kbd mx-0.5 flex h-4 w-4 items-center justify-center rounded bg-components-kbd-bg-gray text-text-placeholder'>/</div> - <div - className='system-sm-regular cursor-pointer text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary' - onMouseDown={((e) => { - e.preventDefault() - e.stopPropagation() - handleInsert('/') - })} - > - {t('workflow.nodes.tool.insertPlaceholder2')} - </div> + {(!disableVariableInsertion) && ( + <> + <div className='system-kbd mx-0.5 flex h-4 w-4 items-center justify-center rounded bg-components-kbd-bg-gray text-text-placeholder'>/</div> + <div + className='system-sm-regular cursor-pointer text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary' + onMouseDown={((e) => { + e.preventDefault() + e.stopPropagation() + handleInsert('/') + })} + > + {t('workflow.nodes.tool.insertPlaceholder2')} + </div> + </> + )} </div> <Badge className='shrink-0' diff --git a/web/app/components/workflow/nodes/tool/components/tool-form/index.tsx b/web/app/components/workflow/nodes/tool/components/tool-form/index.tsx index 747790ac58..ade29beddb 100644 --- a/web/app/components/workflow/nodes/tool/components/tool-form/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/tool-form/index.tsx @@ -18,6 +18,7 @@ type Props = { currentProvider?: ToolWithProvider showManageInputField?: boolean onManageInputField?: () => void + extraParams?: Record<string, any> } const ToolForm: FC<Props> = ({ @@ -31,6 +32,7 @@ const ToolForm: FC<Props> = ({ currentProvider, showManageInputField, onManageInputField, + extraParams, }) => { return ( <div className='space-y-1'> @@ -48,6 +50,8 @@ const ToolForm: FC<Props> = ({ currentProvider={currentProvider} showManageInputField={showManageInputField} onManageInputField={onManageInputField} + extraParams={extraParams} + providerType='tool' /> )) } diff --git a/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx b/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx index c70a039b5b..567266abde 100644 --- a/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx +++ b/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx @@ -26,6 +26,8 @@ type Props = { currentProvider?: ToolWithProvider showManageInputField?: boolean onManageInputField?: () => void + extraParams?: Record<string, any> + providerType?: 'tool' | 'trigger' } const ToolFormItem: FC<Props> = ({ @@ -39,6 +41,8 @@ const ToolFormItem: FC<Props> = ({ currentProvider, showManageInputField, onManageInputField, + extraParams, + providerType = 'tool', }) => { const language = useLanguage() const { name, label, type, required, tooltip, input_schema } = schema @@ -95,6 +99,8 @@ const ToolFormItem: FC<Props> = ({ currentProvider={currentProvider} showManageInputField={showManageInputField} onManageInputField={onManageInputField} + extraParams={extraParams} + providerType={providerType} /> {isShowSchema && ( diff --git a/web/app/components/workflow/nodes/tool/node.tsx b/web/app/components/workflow/nodes/tool/node.tsx index 8cc3ec580d..6aa483e8b0 100644 --- a/web/app/components/workflow/nodes/tool/node.tsx +++ b/web/app/components/workflow/nodes/tool/node.tsx @@ -1,46 +1,90 @@ import type { FC } from 'react' -import React from 'react' -import type { ToolNodeType } from './types' +import React, { useEffect } from 'react' import type { NodeProps } from '@/app/components/workflow/types' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' +import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation' +import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update' +import type { ToolNodeType } from './types' const Node: FC<NodeProps<ToolNodeType>> = ({ + id, data, }) => { const { tool_configurations, paramSchemas } = data const toolConfigs = Object.keys(tool_configurations || {}) + const { + isChecking, + isMissing, + uniqueIdentifier, + canInstall, + onInstallSuccess, + shouldDim, + } = useNodePluginInstallation(data) + const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier + const { handleNodeDataUpdate } = useNodeDataUpdate() + const shouldLock = !isChecking && isMissing && canInstall && Boolean(uniqueIdentifier) - if (!toolConfigs.length) + useEffect(() => { + if (data._pluginInstallLocked === shouldLock && data._dimmed === shouldDim) + return + handleNodeDataUpdate({ + id, + data: { + _pluginInstallLocked: shouldLock, + _dimmed: shouldDim, + }, + }) + }, [data._pluginInstallLocked, data._dimmed, handleNodeDataUpdate, id, shouldDim, shouldLock]) + + const hasConfigs = toolConfigs.length > 0 + + if (!showInstallButton && !hasConfigs) return null return ( - <div className='mb-1 px-3 py-1'> - <div className='space-y-0.5'> - {toolConfigs.map((key, index) => ( - <div key={index} className='flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary'> - <div title={key} className='max-w-[100px] shrink-0 truncate text-xs font-medium uppercase text-text-tertiary'> - {key} + <div className='relative mb-1 px-3 py-1'> + {showInstallButton && ( + <div className='pointer-events-auto absolute right-3 top-[-32px] z-40'> + <InstallPluginButton + size='small' + className='!font-medium !text-text-accent' + extraIdentifiers={[ + data.plugin_id, + data.provider_id, + data.provider_name, + ].filter(Boolean) as string[]} + uniqueIdentifier={uniqueIdentifier!} + onSuccess={onInstallSuccess} + /> + </div> + )} + {hasConfigs && ( + <div className='space-y-0.5' aria-disabled={shouldDim}> + {toolConfigs.map((key, index) => ( + <div key={index} className='flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary'> + <div title={key} className='max-w-[100px] shrink-0 truncate text-xs font-medium uppercase text-text-tertiary'> + {key} + </div> + {typeof tool_configurations[key].value === 'string' && ( + <div title={tool_configurations[key].value} className='w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary'> + {paramSchemas?.find(i => i.name === key)?.type === FormTypeEnum.secretInput ? '********' : tool_configurations[key].value} + </div> + )} + {typeof tool_configurations[key].value === 'number' && ( + <div title={Number.isNaN(tool_configurations[key].value) ? '' : tool_configurations[key].value} className='w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary'> + {Number.isNaN(tool_configurations[key].value) ? '' : tool_configurations[key].value} + </div> + )} + {typeof tool_configurations[key] !== 'string' && tool_configurations[key]?.type === FormTypeEnum.modelSelector && ( + <div title={tool_configurations[key].model} className='w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary'> + {tool_configurations[key].model} + </div> + )} </div> - {typeof tool_configurations[key].value === 'string' && ( - <div title={tool_configurations[key].value} className='w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary'> - {paramSchemas?.find(i => i.name === key)?.type === FormTypeEnum.secretInput ? '********' : tool_configurations[key].value} - </div> - )} - {typeof tool_configurations[key].value === 'number' && ( - <div title={Number.isNaN(tool_configurations[key].value) ? '' : tool_configurations[key].value} className='w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary'> - {Number.isNaN(tool_configurations[key].value) ? '' : tool_configurations[key].value} - </div> - )} - {typeof tool_configurations[key] !== 'string' && tool_configurations[key]?.type === FormTypeEnum.modelSelector && ( - <div title={tool_configurations[key].model} className='w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary'> - {tool_configurations[key].model} - </div> - )} - </div> - - ))} - - </div> + ))} + </div> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/tool/types.ts b/web/app/components/workflow/nodes/tool/types.ts index 8bed5076d3..6e6ef858dc 100644 --- a/web/app/components/workflow/nodes/tool/types.ts +++ b/web/app/components/workflow/nodes/tool/types.ts @@ -1,16 +1,10 @@ -import type { CollectionType } from '@/app/components/tools/types' -import type { CommonNodeType, ValueSelector } from '@/app/components/workflow/types' +import type { Collection, CollectionType } from '@/app/components/tools/types' +import type { CommonNodeType } from '@/app/components/workflow/types' +import type { ResourceVarInputs } from '../_base/types' -export enum VarType { - variable = 'variable', - constant = 'constant', - mixed = 'mixed', -} - -export type ToolVarInputs = Record<string, { - type: VarType - value?: string | ValueSelector | any -}> +// Use base types directly +export { VarKindType as VarType } from '../_base/types' +export type ToolVarInputs = ResourceVarInputs export type ToolNodeType = CommonNodeType & { provider_id: string @@ -26,4 +20,7 @@ export type ToolNodeType = CommonNodeType & { tool_description?: string is_team_authorization?: boolean params?: Record<string, any> + plugin_id?: string + provider_icon?: Collection['icon'] + plugin_unique_identifier?: string } diff --git a/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/index.tsx b/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/index.tsx new file mode 100644 index 0000000000..93bf788c34 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/index.tsx @@ -0,0 +1,57 @@ +'use client' +import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { Event } from '@/app/components/tools/types' +import type { FC } from 'react' +import type { PluginTriggerVarInputs } from '@/app/components/workflow/nodes/trigger-plugin/types' +import TriggerFormItem from './item' +import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' + +type Props = { + readOnly: boolean + nodeId: string + schema: CredentialFormSchema[] + value: PluginTriggerVarInputs + onChange: (value: PluginTriggerVarInputs) => void + onOpen?: (index: number) => void + inPanel?: boolean + currentEvent?: Event + currentProvider?: TriggerWithProvider + extraParams?: Record<string, any> + disableVariableInsertion?: boolean +} + +const TriggerForm: FC<Props> = ({ + readOnly, + nodeId, + schema, + value, + onChange, + inPanel, + currentEvent, + currentProvider, + extraParams, + disableVariableInsertion = false, +}) => { + return ( + <div className='space-y-1'> + { + schema.map((schema, index) => ( + <TriggerFormItem + key={index} + readOnly={readOnly} + nodeId={nodeId} + schema={schema} + value={value} + onChange={onChange} + inPanel={inPanel} + currentEvent={currentEvent} + currentProvider={currentProvider} + extraParams={extraParams} + disableVariableInsertion={disableVariableInsertion} + /> + )) + } + </div> + ) +} +export default TriggerForm diff --git a/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx b/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx new file mode 100644 index 0000000000..678c12f02a --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx @@ -0,0 +1,112 @@ +'use client' +import type { FC } from 'react' +import { + RiBracesLine, +} from '@remixicon/react' +import type { PluginTriggerVarInputs } from '@/app/components/workflow/nodes/trigger-plugin/types' +import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' +import Button from '@/app/components/base/button' +import Tooltip from '@/app/components/base/tooltip' +import FormInputItem from '@/app/components/workflow/nodes/_base/components/form-input-item' +import { useBoolean } from 'ahooks' +import SchemaModal from '@/app/components/plugins/plugin-detail-panel/tool-selector/schema-modal' +import type { Event } from '@/app/components/tools/types' +import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' + +type Props = { + readOnly: boolean + nodeId: string + schema: CredentialFormSchema + value: PluginTriggerVarInputs + onChange: (value: PluginTriggerVarInputs) => void + inPanel?: boolean + currentEvent?: Event + currentProvider?: TriggerWithProvider + extraParams?: Record<string, any> + disableVariableInsertion?: boolean +} + +const TriggerFormItem: FC<Props> = ({ + readOnly, + nodeId, + schema, + value, + onChange, + inPanel, + currentEvent, + currentProvider, + extraParams, + disableVariableInsertion = false, +}) => { + const language = useLanguage() + const { name, label, type, required, tooltip, input_schema } = schema + const showSchemaButton = type === FormTypeEnum.object || type === FormTypeEnum.array + const showDescription = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput + const [isShowSchema, { + setTrue: showSchema, + setFalse: hideSchema, + }] = useBoolean(false) + return ( + <div className='space-y-0.5 py-1'> + <div> + <div className='flex h-6 items-center'> + <div className='system-sm-medium text-text-secondary'>{label[language] || label.en_US}</div> + {required && ( + <div className='system-xs-regular ml-1 text-text-destructive-secondary'>*</div> + )} + {!showDescription && tooltip && ( + <Tooltip + popupContent={<div className='w-[200px]'> + {tooltip[language] || tooltip.en_US} + </div>} + triggerClassName='ml-1 w-4 h-4' + asChild={false} + /> + )} + {showSchemaButton && ( + <> + <div className='system-xs-regular ml-1 mr-0.5 text-text-quaternary'>·</div> + <Button + variant='ghost' + size='small' + onClick={showSchema} + className='system-xs-regular px-1 text-text-tertiary' + > + <RiBracesLine className='mr-1 size-3.5' /> + <span>JSON Schema</span> + </Button> + </> + )} + </div> + {showDescription && tooltip && ( + <div className='body-xs-regular pb-0.5 text-text-tertiary'>{tooltip[language] || tooltip.en_US}</div> + )} + </div> + <FormInputItem + readOnly={readOnly} + nodeId={nodeId} + schema={schema} + value={value} + onChange={onChange} + inPanel={inPanel} + currentTool={currentEvent} + currentProvider={currentProvider} + providerType='trigger' + extraParams={extraParams} + disableVariableInsertion={disableVariableInsertion} + /> + + {isShowSchema && ( + <SchemaModal + isShow + onClose={hideSchema} + rootName={name} + schema={input_schema!} + /> + )} + </div> + ) +} +export default TriggerFormItem diff --git a/web/app/components/workflow/nodes/trigger-plugin/default.ts b/web/app/components/workflow/nodes/trigger-plugin/default.ts new file mode 100644 index 0000000000..928534e07c --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/default.ts @@ -0,0 +1,297 @@ +import type { SchemaTypeDefinition } from '@/service/use-common' +import type { NodeDefault, Var } from '../../types' +import { BlockEnum, VarType } from '../../types' +import { genNodeMetaData } from '../../utils' +import { VarKindType } from '../_base/types' +import { type Field, type StructuredOutput, Type } from '../llm/types' +import type { PluginTriggerNodeType } from './types' + +const normalizeJsonSchemaType = (schema: any): string | undefined => { + if (!schema) return undefined + const { type, properties, items, oneOf, anyOf, allOf } = schema + + if (Array.isArray(type)) + return type.find((item: string | null) => item && item !== 'null') || type[0] + + if (typeof type === 'string') + return type + + const compositeCandidates = [oneOf, anyOf, allOf] + .filter((entry): entry is any[] => Array.isArray(entry)) + .flat() + + for (const candidate of compositeCandidates) { + const normalized = normalizeJsonSchemaType(candidate) + if (normalized) + return normalized + } + + if (properties) + return 'object' + + if (items) + return 'array' + + return undefined +} + +const pickItemSchema = (schema: any) => { + if (!schema || !schema.items) + return undefined + return Array.isArray(schema.items) ? schema.items[0] : schema.items +} + +const extractSchemaType = (schema: any, _schemaTypeDefinitions?: SchemaTypeDefinition[]): string | undefined => { + if (!schema) + return undefined + + const schemaTypeFromSchema = schema.schema_type || schema.schemaType + if (typeof schemaTypeFromSchema === 'string' && schemaTypeFromSchema.trim().length > 0) + return schemaTypeFromSchema + + return undefined +} + +const resolveVarType = ( + schema: any, + schemaTypeDefinitions?: SchemaTypeDefinition[], +): { type: VarType; schemaType?: string } => { + const schemaType = extractSchemaType(schema, schemaTypeDefinitions) + const normalizedType = normalizeJsonSchemaType(schema) + + switch (normalizedType) { + case 'string': + return { type: VarType.string, schemaType } + case 'number': + return { type: VarType.number, schemaType } + case 'integer': + return { type: VarType.integer, schemaType } + case 'boolean': + return { type: VarType.boolean, schemaType } + case 'object': + return { type: VarType.object, schemaType } + case 'array': { + const itemSchema = pickItemSchema(schema) + if (!itemSchema) + return { type: VarType.array, schemaType } + + const { type: itemType, schemaType: itemSchemaType } = resolveVarType(itemSchema, schemaTypeDefinitions) + const resolvedSchemaType = schemaType || itemSchemaType + + if (itemSchemaType === 'file') + return { type: VarType.arrayFile, schemaType: resolvedSchemaType } + + switch (itemType) { + case VarType.string: + return { type: VarType.arrayString, schemaType: resolvedSchemaType } + case VarType.number: + case VarType.integer: + return { type: VarType.arrayNumber, schemaType: resolvedSchemaType } + case VarType.boolean: + return { type: VarType.arrayBoolean, schemaType: resolvedSchemaType } + case VarType.object: + return { type: VarType.arrayObject, schemaType: resolvedSchemaType } + case VarType.file: + return { type: VarType.arrayFile, schemaType: resolvedSchemaType } + default: + return { type: VarType.array, schemaType: resolvedSchemaType } + } + } + default: + return { type: VarType.any, schemaType } + } +} + +const toFieldType = (normalizedType: string | undefined, schemaType?: string): Type => { + if (schemaType === 'file') + return normalizedType === 'array' ? Type.array : Type.file + + switch (normalizedType) { + case 'number': + case 'integer': + return Type.number + case 'boolean': + return Type.boolean + case 'object': + return Type.object + case 'array': + return Type.array + case 'string': + default: + return Type.string + } +} + +const toArrayItemType = (type: Type): Exclude<Type, Type.array> => { + if (type === Type.array) + return Type.object + return type as Exclude<Type, Type.array> +} + +const convertJsonSchemaToField = (schema: any, schemaTypeDefinitions?: SchemaTypeDefinition[]): Field => { + const schemaType = extractSchemaType(schema, schemaTypeDefinitions) + const normalizedType = normalizeJsonSchemaType(schema) + const fieldType = toFieldType(normalizedType, schemaType) + + const field: Field = { + type: fieldType, + } + + if (schema?.description) + field.description = schema.description + + if (schemaType) + field.schemaType = schemaType + + if (Array.isArray(schema?.enum)) + field.enum = schema.enum + + if (fieldType === Type.object) { + const properties = schema?.properties || {} + field.properties = Object.entries(properties).reduce((acc, [key, value]) => { + acc[key] = convertJsonSchemaToField(value, schemaTypeDefinitions) + return acc + }, {} as Record<string, Field>) + + const required = Array.isArray(schema?.required) ? schema.required.filter(Boolean) : undefined + field.required = required && required.length > 0 ? required : undefined + field.additionalProperties = false + } + + if (fieldType === Type.array) { + const itemSchema = pickItemSchema(schema) + if (itemSchema) { + const itemField = convertJsonSchemaToField(itemSchema, schemaTypeDefinitions) + const { type, ...rest } = itemField + field.items = { + ...rest, + type: toArrayItemType(type), + } + } + } + + return field +} + +const buildOutputVars = (schema: Record<string, any>, schemaTypeDefinitions?: SchemaTypeDefinition[]): Var[] => { + if (!schema || typeof schema !== 'object') + return [] + + const properties = schema.properties as Record<string, any> | undefined + if (!properties) + return [] + + return Object.entries(properties).map(([name, propertySchema]) => { + const { type, schemaType } = resolveVarType(propertySchema, schemaTypeDefinitions) + const normalizedType = normalizeJsonSchemaType(propertySchema) + + const varItem: Var = { + variable: name, + type, + des: propertySchema?.description, + ...(schemaType ? { schemaType } : {}), + } + + if (normalizedType === 'object') { + const childProperties = propertySchema?.properties + ? Object.entries(propertySchema.properties).reduce((acc, [key, value]) => { + acc[key] = convertJsonSchemaToField(value, schemaTypeDefinitions) + return acc + }, {} as Record<string, Field>) + : {} + + const required = Array.isArray(propertySchema?.required) ? propertySchema.required.filter(Boolean) : undefined + + varItem.children = { + schema: { + type: Type.object, + properties: childProperties, + required: required && required.length > 0 ? required : undefined, + additionalProperties: false, + }, + } as StructuredOutput + } + + return varItem + }) +} + +const metaData = genNodeMetaData({ + sort: 1, + type: BlockEnum.TriggerPlugin, + helpLinkUri: 'plugin-trigger', + isStart: true, +}) + +const nodeDefault: NodeDefault<PluginTriggerNodeType> = { + metaData, + defaultValue: { + plugin_id: '', + event_name: '', + event_parameters: {}, + // event_type: '', + config: {}, + }, + checkValid(payload: PluginTriggerNodeType, t: any, moreDataForCheckValid: { + triggerInputsSchema?: Array<{ + variable: string + label: string + required?: boolean + }> + isReadyForCheckValid?: boolean + } = {}) { + let errorMessage = '' + + if (!payload.subscription_id) + errorMessage = t('workflow.nodes.triggerPlugin.subscriptionRequired') + + const { + triggerInputsSchema = [], + isReadyForCheckValid = true, + } = moreDataForCheckValid || {} + + if (!errorMessage && isReadyForCheckValid) { + triggerInputsSchema.filter(field => field.required).forEach((field) => { + if (errorMessage) + return + + const rawParam = payload.event_parameters?.[field.variable] + ?? (payload.config as Record<string, any> | undefined)?.[field.variable] + if (!rawParam) { + errorMessage = t('workflow.errorMsg.fieldRequired', { field: field.label }) + return + } + + const targetParam = typeof rawParam === 'object' && rawParam !== null && 'type' in rawParam + ? rawParam as { type: VarKindType; value: any } + : { type: VarKindType.constant, value: rawParam } + + const { type, value } = targetParam + if (type === VarKindType.variable) { + if (!value || (Array.isArray(value) && value.length === 0)) + errorMessage = t('workflow.errorMsg.fieldRequired', { field: field.label }) + } + else { + if ( + value === undefined + || value === null + || value === '' + || (Array.isArray(value) && value.length === 0) + ) + errorMessage = t('workflow.errorMsg.fieldRequired', { field: field.label }) + } + }) + } + + return { + isValid: !errorMessage, + errorMessage, + } + }, + getOutputVars(payload, _allPluginInfoList, _ragVars, { schemaTypeDefinitions } = { schemaTypeDefinitions: [] }) { + const schema = payload.output_schema || {} + return buildOutputVars(schema, schemaTypeDefinitions) + }, +} + +export default nodeDefault diff --git a/web/app/components/workflow/nodes/trigger-plugin/hooks/use-trigger-auth-flow.ts b/web/app/components/workflow/nodes/trigger-plugin/hooks/use-trigger-auth-flow.ts new file mode 100644 index 0000000000..983b8512de --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/hooks/use-trigger-auth-flow.ts @@ -0,0 +1,162 @@ +import { useCallback, useState } from 'react' +import { + useBuildTriggerSubscription, + useCreateTriggerSubscriptionBuilder, + useUpdateTriggerSubscriptionBuilder, + useVerifyTriggerSubscriptionBuilder, +} from '@/service/use-triggers' +import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' + +// Helper function to serialize complex values to strings for backend encryption +const serializeFormValues = (values: Record<string, any>): Record<string, string> => { + const result: Record<string, string> = {} + + for (const [key, value] of Object.entries(values)) { + if (value === null || value === undefined) + result[key] = '' + else if (typeof value === 'object') + result[key] = JSON.stringify(value) + else + result[key] = String(value) + } + + return result +} + +export type AuthFlowStep = 'auth' | 'params' | 'complete' + +export type AuthFlowState = { + step: AuthFlowStep + builderId: string + isLoading: boolean + error: string | null +} + +export type AuthFlowActions = { + startAuth: () => Promise<void> + verifyAuth: (credentials: Record<string, any>) => Promise<void> + completeConfig: (parameters: Record<string, any>, properties?: Record<string, any>, name?: string) => Promise<void> + reset: () => void +} + +export const useTriggerAuthFlow = (provider: TriggerWithProvider): AuthFlowState & AuthFlowActions => { + const [step, setStep] = useState<AuthFlowStep>('auth') + const [builderId, setBuilderId] = useState<string>('') + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState<string | null>(null) + + const createBuilder = useCreateTriggerSubscriptionBuilder() + const updateBuilder = useUpdateTriggerSubscriptionBuilder() + const verifyBuilder = useVerifyTriggerSubscriptionBuilder() + const buildSubscription = useBuildTriggerSubscription() + + const startAuth = useCallback(async () => { + if (builderId) return // Prevent multiple calls if already started + + setIsLoading(true) + setError(null) + + try { + const response = await createBuilder.mutateAsync({ + provider: provider.name, + }) + setBuilderId(response.subscription_builder.id) + setStep('auth') + } + catch (err: any) { + setError(err.message || 'Failed to start authentication flow') + throw err + } + finally { + setIsLoading(false) + } + }, [provider.name, createBuilder, builderId]) + + const verifyAuth = useCallback(async (credentials: Record<string, any>) => { + if (!builderId) { + setError('No builder ID available') + return + } + + setIsLoading(true) + setError(null) + + try { + await updateBuilder.mutateAsync({ + provider: provider.name, + subscriptionBuilderId: builderId, + credentials: serializeFormValues(credentials), + }) + + await verifyBuilder.mutateAsync({ + provider: provider.name, + subscriptionBuilderId: builderId, + }) + + setStep('params') + } + catch (err: any) { + setError(err.message || 'Authentication verification failed') + throw err + } + finally { + setIsLoading(false) + } + }, [provider.name, builderId, updateBuilder, verifyBuilder]) + + const completeConfig = useCallback(async ( + parameters: Record<string, any>, + properties: Record<string, any> = {}, + name?: string, + ) => { + if (!builderId) { + setError('No builder ID available') + return + } + + setIsLoading(true) + setError(null) + + try { + await updateBuilder.mutateAsync({ + provider: provider.name, + subscriptionBuilderId: builderId, + parameters: serializeFormValues(parameters), + properties: serializeFormValues(properties), + name, + }) + + await buildSubscription.mutateAsync({ + provider: provider.name, + subscriptionBuilderId: builderId, + }) + + setStep('complete') + } + catch (err: any) { + setError(err.message || 'Configuration failed') + throw err + } + finally { + setIsLoading(false) + } + }, [provider.name, builderId, updateBuilder, buildSubscription]) + + const reset = useCallback(() => { + setStep('auth') + setBuilderId('') + setIsLoading(false) + setError(null) + }, []) + + return { + step, + builderId, + isLoading, + error, + startAuth, + verifyAuth, + completeConfig, + reset, + } +} diff --git a/web/app/components/workflow/nodes/trigger-plugin/node.tsx b/web/app/components/workflow/nodes/trigger-plugin/node.tsx new file mode 100644 index 0000000000..0eee4cb8b4 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/node.tsx @@ -0,0 +1,126 @@ +import NodeStatus, { NodeStatusEnum } from '@/app/components/base/node-status' +import type { NodeProps } from '@/app/components/workflow/types' +import type { FC } from 'react' +import React, { useEffect, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' +import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation' +import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update' +import type { PluginTriggerNodeType } from './types' +import useConfig from './use-config' + +const formatConfigValue = (rawValue: any): string => { + if (rawValue === null || rawValue === undefined) + return '' + + if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean') + return String(rawValue) + + if (Array.isArray(rawValue)) + return rawValue.join('.') + + if (typeof rawValue === 'object') { + const { value } = rawValue as { value?: any } + if (value === null || value === undefined) + return '' + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') + return String(value) + if (Array.isArray(value)) + return value.join('.') + try { + return JSON.stringify(value) + } + catch { + return '' + } + } + + return '' +} + +const Node: FC<NodeProps<PluginTriggerNodeType>> = ({ + id, + data, +}) => { + const { subscriptions } = useConfig(id, data) + const { config = {}, subscription_id } = data + const configKeys = Object.keys(config) + const { + isChecking, + isMissing, + uniqueIdentifier, + canInstall, + onInstallSuccess, + shouldDim, + } = useNodePluginInstallation(data) + const { handleNodeDataUpdate } = useNodeDataUpdate() + const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier + const shouldLock = !isChecking && isMissing && canInstall && Boolean(uniqueIdentifier) + + useEffect(() => { + if (data._pluginInstallLocked === shouldLock && data._dimmed === shouldDim) + return + handleNodeDataUpdate({ + id, + data: { + _pluginInstallLocked: shouldLock, + _dimmed: shouldDim, + }, + }) + }, [data._pluginInstallLocked, data._dimmed, handleNodeDataUpdate, id, shouldDim, shouldLock]) + + const { t } = useTranslation() + + const isValidSubscription = useMemo(() => { + return subscription_id && subscriptions?.some(sub => sub.id === subscription_id) + }, [subscription_id, subscriptions]) + + return ( + <div className="relative mb-1 px-3 py-1"> + {showInstallButton && ( + <div className="pointer-events-auto absolute right-3 top-[-32px] z-40"> + <InstallPluginButton + size="small" + extraIdentifiers={[ + data.plugin_id, + data.provider_id, + data.provider_name, + ].filter(Boolean) as string[]} + className="!font-medium !text-text-accent" + uniqueIdentifier={uniqueIdentifier!} + onSuccess={onInstallSuccess} + /> + </div> + )} + <div className="space-y-0.5" aria-disabled={shouldDim}> + {!isValidSubscription && <NodeStatus status={NodeStatusEnum.warning} message={t('pluginTrigger.node.status.warning')} />} + {isValidSubscription && configKeys.map((key, index) => ( + <div + key={index} + className="flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary" + > + <div + title={key} + className="max-w-[100px] shrink-0 truncate text-xs font-medium uppercase text-text-tertiary" + > + {key} + </div> + <div + title={formatConfigValue(config[key])} + className="w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary" + > + {(() => { + const displayValue = formatConfigValue(config[key]) + if (displayValue.includes('secret')) + return '********' + return displayValue + })()} + </div> + </div> + ))} + </div> + </div> + ) +} + +export default React.memo(Node) diff --git a/web/app/components/workflow/nodes/trigger-plugin/panel.tsx b/web/app/components/workflow/nodes/trigger-plugin/panel.tsx new file mode 100644 index 0000000000..9b4d8058b1 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/panel.tsx @@ -0,0 +1,94 @@ +import type { FC } from 'react' +import React from 'react' +import type { PluginTriggerNodeType } from './types' +import Split from '@/app/components/workflow/nodes/_base/components/split' +import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars' +import type { NodePanelProps } from '@/app/components/workflow/types' +import useConfig from './use-config' +import TriggerForm from './components/trigger-form' +import StructureOutputItem from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show' +import { Type } from '../llm/types' +import { BlockEnum } from '@/app/components/workflow/types' + +const Panel: FC<NodePanelProps<PluginTriggerNodeType>> = ({ + id, + data, +}) => { + const { + readOnly, + triggerParameterSchema, + triggerParameterValue, + setTriggerParameterValue, + outputSchema, + hasObjectOutput, + currentProvider, + currentEvent, + subscriptionSelected, + } = useConfig(id, data) + const disableVariableInsertion = data.type === BlockEnum.TriggerPlugin + + // Convert output schema to VarItem format + const outputVars = Object.entries(outputSchema.properties || {}).map(([name, schema]: [string, any]) => ({ + name, + type: schema.type || 'string', + description: schema.description || '', + })) + + return ( + <div className='mt-2'> + {/* Dynamic Parameters Form - Only show when authenticated */} + {triggerParameterSchema.length > 0 && subscriptionSelected && ( + <> + <div className='px-4 pb-4'> + <TriggerForm + readOnly={readOnly} + nodeId={id} + schema={triggerParameterSchema as any} + value={triggerParameterValue} + onChange={setTriggerParameterValue} + currentProvider={currentProvider} + currentEvent={currentEvent} + disableVariableInsertion={disableVariableInsertion} + /> + </div> + <Split /> + </> + )} + + {/* Output Variables - Always show */} + <OutputVars> + <> + {outputVars.map(varItem => ( + <VarItem + key={varItem.name} + name={varItem.name} + type={varItem.type} + description={varItem.description} + isIndent={hasObjectOutput} + /> + ))} + {Object.entries(outputSchema.properties || {}).map(([name, schema]: [string, any]) => ( + <div key={name}> + {schema.type === 'object' ? ( + <StructureOutputItem + rootClassName='code-sm-semibold text-text-secondary' + payload={{ + schema: { + type: Type.object, + properties: { + [name]: schema, + }, + additionalProperties: false, + }, + }} + /> + ) : null} + </div> + ))} + </> + </OutputVars> + </div> + ) +} + +export default React.memo(Panel) diff --git a/web/app/components/workflow/nodes/trigger-plugin/types.ts b/web/app/components/workflow/nodes/trigger-plugin/types.ts new file mode 100644 index 0000000000..6dba97d795 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/types.ts @@ -0,0 +1,24 @@ +import type { CommonNodeType } from '@/app/components/workflow/types' +import type { CollectionType } from '@/app/components/tools/types' +import type { ResourceVarInputs } from '../_base/types' + +export type PluginTriggerNodeType = CommonNodeType & { + provider_id: string + provider_type: CollectionType + provider_name: string + event_name: string + event_label: string + event_parameters: PluginTriggerVarInputs + event_configurations: Record<string, any> + output_schema: Record<string, any> + parameters_schema?: Record<string, any>[] + version?: string + event_node_version?: string + plugin_id?: string + config?: Record<string, any> + plugin_unique_identifier?: string +} + +// Use base types directly +export { VarKindType as PluginTriggerVarType } from '../_base/types' +export type PluginTriggerVarInputs = ResourceVarInputs diff --git a/web/app/components/workflow/nodes/trigger-plugin/use-check-params.ts b/web/app/components/workflow/nodes/trigger-plugin/use-check-params.ts new file mode 100644 index 0000000000..16b763f11a --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/use-check-params.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react' +import type { PluginTriggerNodeType } from './types' +import { useAllTriggerPlugins } from '@/service/use-triggers' +import { useGetLanguage } from '@/context/i18n' +import { getTriggerCheckParams } from '@/app/components/workflow/utils/trigger' + +type Params = { + id: string + payload: PluginTriggerNodeType +} + +const useGetDataForCheckMore = ({ + payload, +}: Params) => { + const { data: triggerPlugins } = useAllTriggerPlugins() + const language = useGetLanguage() + + const getData = useCallback(() => { + return getTriggerCheckParams(payload, triggerPlugins, language) + }, [payload, triggerPlugins, language]) + + return { + getData, + } +} + +export default useGetDataForCheckMore diff --git a/web/app/components/workflow/nodes/trigger-plugin/use-config.ts b/web/app/components/workflow/nodes/trigger-plugin/use-config.ts new file mode 100644 index 0000000000..cf66913e58 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/use-config.ts @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useMemo } from 'react' +import { produce } from 'immer' +import type { PluginTriggerNodeType } from './types' +import type { PluginTriggerVarInputs } from './types' +import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' +import { useNodesReadOnly } from '@/app/components/workflow/hooks' +import { + useAllTriggerPlugins, + useTriggerSubscriptions, +} from '@/service/use-triggers' +import { + getConfiguredValue, + toolParametersToFormSchemas, +} from '@/app/components/tools/utils/to-form-schema' +import type { InputVar } from '@/app/components/workflow/types' +import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' +import type { Event } from '@/app/components/tools/types' +import { VarKindType } from '../_base/types' + +const normalizeEventParameters = ( + params: PluginTriggerVarInputs | Record<string, unknown> | null | undefined, + { allowScalars = false }: { allowScalars?: boolean } = {}, +): PluginTriggerVarInputs => { + if (!params || typeof params !== 'object' || Array.isArray(params)) + return {} as PluginTriggerVarInputs + + return Object.entries(params).reduce((acc, [key, entry]) => { + if (!entry && entry !== 0 && entry !== false) + return acc + + if ( + typeof entry === 'object' + && !Array.isArray(entry) + && 'type' in entry + && 'value' in entry + ) { + const normalizedEntry = { ...(entry as PluginTriggerVarInputs[string]) } + if (normalizedEntry.type === VarKindType.mixed) + normalizedEntry.type = VarKindType.constant + acc[key] = normalizedEntry + return acc + } + + if (!allowScalars) + return acc + + if (typeof entry === 'string') { + acc[key] = { + type: VarKindType.constant, + value: entry, + } + return acc + } + + if (typeof entry === 'number' || typeof entry === 'boolean') { + acc[key] = { + type: VarKindType.constant, + value: entry, + } + return acc + } + + if (Array.isArray(entry) && entry.every(item => typeof item === 'string')) { + acc[key] = { + type: VarKindType.variable, + value: entry, + } + } + + return acc + }, {} as PluginTriggerVarInputs) +} + +const useConfig = (id: string, payload: PluginTriggerNodeType) => { + const { nodesReadOnly: readOnly } = useNodesReadOnly() + const { data: triggerPlugins = [] } = useAllTriggerPlugins() + + const { inputs, setInputs: doSetInputs } = useNodeCrud<PluginTriggerNodeType>( + id, + payload, + ) + + const { + provider_id, + provider_name, + event_name, + config = {}, + event_parameters: rawEventParameters = {}, + subscription_id, + } = inputs + + const event_parameters = useMemo( + () => normalizeEventParameters(rawEventParameters as PluginTriggerVarInputs), + [rawEventParameters], + ) + const legacy_config_parameters = useMemo( + () => normalizeEventParameters(config as PluginTriggerVarInputs, { allowScalars: true }), + [config], + ) + + const currentProvider = useMemo<TriggerWithProvider | undefined>(() => { + return triggerPlugins.find( + provider => + provider.name === provider_name + || provider.id === provider_id + || (provider_id && provider.plugin_id === provider_id), + ) + }, [triggerPlugins, provider_name, provider_id]) + + const { data: subscriptions = [] } = useTriggerSubscriptions(provider_id || '') + + const subscriptionSelected = useMemo(() => { + return subscriptions?.find(s => s.id === subscription_id) + }, [subscriptions, subscription_id]) + + const currentEvent = useMemo<Event | undefined>(() => { + return currentProvider?.events.find( + event => event.name === event_name, + ) + }, [currentProvider, event_name]) + + // Dynamic trigger parameters (from specific trigger.parameters) + const triggerSpecificParameterSchema = useMemo(() => { + if (!currentEvent) return [] + return toolParametersToFormSchemas(currentEvent.parameters) + }, [currentEvent]) + + // Combined parameter schema (subscription + trigger specific) + const triggerParameterSchema = useMemo(() => { + const schemaMap = new Map() + + triggerSpecificParameterSchema.forEach((schema) => { + schemaMap.set(schema.variable || schema.name, schema) + }) + + return Array.from(schemaMap.values()) + }, [triggerSpecificParameterSchema]) + + const triggerParameterValue = useMemo(() => { + if (!triggerParameterSchema.length) + return {} as PluginTriggerVarInputs + + const hasStoredParameters = event_parameters && Object.keys(event_parameters).length > 0 + const baseValue = hasStoredParameters ? event_parameters : legacy_config_parameters + + const configuredValue = getConfiguredValue(baseValue, triggerParameterSchema) as PluginTriggerVarInputs + return normalizeEventParameters(configuredValue) + }, [triggerParameterSchema, event_parameters, legacy_config_parameters]) + + useEffect(() => { + if (!triggerParameterSchema.length) + return + + if (event_parameters && Object.keys(event_parameters).length > 0) + return + + if (!triggerParameterValue || Object.keys(triggerParameterValue).length === 0) + return + + const newInputs = produce(inputs, (draft) => { + draft.event_parameters = triggerParameterValue + draft.config = triggerParameterValue + }) + doSetInputs(newInputs) + }, [ + doSetInputs, + event_parameters, + inputs, + triggerParameterSchema, + triggerParameterValue, + ]) + + const setTriggerParameterValue = useCallback( + (value: PluginTriggerVarInputs) => { + const sanitizedValue = normalizeEventParameters(value) + const newInputs = produce(inputs, (draft) => { + draft.event_parameters = sanitizedValue + draft.config = sanitizedValue + }) + doSetInputs(newInputs) + }, + [inputs, doSetInputs], + ) + + const setInputVar = useCallback( + (variable: InputVar, varDetail: InputVar) => { + const newInputs = produce(inputs, (draft) => { + const nextEventParameters = normalizeEventParameters({ + ...draft.event_parameters, + [variable.variable]: { + type: VarKindType.variable, + value: varDetail.variable, + }, + } as PluginTriggerVarInputs) + + draft.event_parameters = nextEventParameters + draft.config = nextEventParameters + }) + doSetInputs(newInputs) + }, + [inputs, doSetInputs], + ) + + // Get output schema + const outputSchema = useMemo(() => { + return currentEvent?.output_schema || {} + }, [currentEvent]) + + // Check if trigger has complex output structure + const hasObjectOutput = useMemo(() => { + const properties = outputSchema.properties || {} + return Object.values(properties).some( + (prop: any) => prop.type === 'object', + ) + }, [outputSchema]) + + return { + readOnly, + inputs, + currentProvider, + currentEvent, + triggerParameterSchema, + triggerParameterValue, + setTriggerParameterValue, + setInputVar, + outputSchema, + hasObjectOutput, + subscriptions, + subscriptionSelected, + } +} + +export default useConfig diff --git a/web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.test.ts b/web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.test.ts new file mode 100644 index 0000000000..c75ffc0a59 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.test.ts @@ -0,0 +1,308 @@ +import { deepSanitizeFormValues, findMissingRequiredField, sanitizeFormValues } from '../form-helpers' + +describe('Form Helpers', () => { + describe('sanitizeFormValues', () => { + it('should convert null values to empty strings', () => { + const input = { field1: null, field2: 'value', field3: undefined } + const result = sanitizeFormValues(input) + + expect(result).toEqual({ + field1: '', + field2: 'value', + field3: '', + }) + }) + + it('should convert undefined values to empty strings', () => { + const input = { field1: undefined, field2: 'test' } + const result = sanitizeFormValues(input) + + expect(result).toEqual({ + field1: '', + field2: 'test', + }) + }) + + it('should convert non-string values to strings', () => { + const input = { number: 123, boolean: true, string: 'test' } + const result = sanitizeFormValues(input) + + expect(result).toEqual({ + number: '123', + boolean: 'true', + string: 'test', + }) + }) + + it('should handle empty objects', () => { + const result = sanitizeFormValues({}) + expect(result).toEqual({}) + }) + + it('should handle objects with mixed value types', () => { + const input = { + null_field: null, + undefined_field: undefined, + zero: 0, + false_field: false, + empty_string: '', + valid_string: 'test', + } + const result = sanitizeFormValues(input) + + expect(result).toEqual({ + null_field: '', + undefined_field: '', + zero: '0', + false_field: 'false', + empty_string: '', + valid_string: 'test', + }) + }) + }) + + describe('deepSanitizeFormValues', () => { + it('should handle nested objects', () => { + const input = { + level1: { + field1: null, + field2: 'value', + level2: { + field3: undefined, + field4: 'nested', + }, + }, + simple: 'test', + } + const result = deepSanitizeFormValues(input) + + expect(result).toEqual({ + level1: { + field1: '', + field2: 'value', + level2: { + field3: '', + field4: 'nested', + }, + }, + simple: 'test', + }) + }) + + it('should handle arrays correctly', () => { + const input = { + array: [1, 2, 3], + nested: { + array: ['a', null, 'c'], + }, + } + const result = deepSanitizeFormValues(input) + + expect(result).toEqual({ + array: [1, 2, 3], + nested: { + array: ['a', null, 'c'], + }, + }) + }) + + it('should handle null and undefined at root level', () => { + const input = { + null_field: null, + undefined_field: undefined, + nested: { + null_nested: null, + }, + } + const result = deepSanitizeFormValues(input) + + expect(result).toEqual({ + null_field: '', + undefined_field: '', + nested: { + null_nested: '', + }, + }) + }) + + it('should handle deeply nested structures', () => { + const input = { + level1: { + level2: { + level3: { + field: null, + }, + }, + }, + } + const result = deepSanitizeFormValues(input) + + expect(result).toEqual({ + level1: { + level2: { + level3: { + field: '', + }, + }, + }, + }) + }) + + it('should preserve non-null values in nested structures', () => { + const input = { + config: { + client_id: 'valid_id', + client_secret: null, + options: { + timeout: 5000, + enabled: true, + message: undefined, + }, + }, + } + const result = deepSanitizeFormValues(input) + + expect(result).toEqual({ + config: { + client_id: 'valid_id', + client_secret: '', + options: { + timeout: 5000, + enabled: true, + message: '', + }, + }, + }) + }) + }) + + describe('findMissingRequiredField', () => { + const requiredFields = [ + { name: 'client_id', label: 'Client ID' }, + { name: 'client_secret', label: 'Client Secret' }, + { name: 'scope', label: 'Scope' }, + ] + + it('should return null when all required fields are present', () => { + const formData = { + client_id: 'test_id', + client_secret: 'test_secret', + scope: 'read', + optional_field: 'optional', + } + + const result = findMissingRequiredField(formData, requiredFields) + expect(result).toBeNull() + }) + + it('should return the first missing field', () => { + const formData = { + client_id: 'test_id', + scope: 'read', + } + + const result = findMissingRequiredField(formData, requiredFields) + expect(result).toEqual({ name: 'client_secret', label: 'Client Secret' }) + }) + + it('should treat empty strings as missing fields', () => { + const formData = { + client_id: '', + client_secret: 'test_secret', + scope: 'read', + } + + const result = findMissingRequiredField(formData, requiredFields) + expect(result).toEqual({ name: 'client_id', label: 'Client ID' }) + }) + + it('should treat null values as missing fields', () => { + const formData = { + client_id: 'test_id', + client_secret: null, + scope: 'read', + } + + const result = findMissingRequiredField(formData, requiredFields) + expect(result).toEqual({ name: 'client_secret', label: 'Client Secret' }) + }) + + it('should treat undefined values as missing fields', () => { + const formData = { + client_id: 'test_id', + client_secret: 'test_secret', + scope: undefined, + } + + const result = findMissingRequiredField(formData, requiredFields) + expect(result).toEqual({ name: 'scope', label: 'Scope' }) + }) + + it('should handle empty required fields array', () => { + const formData = { + client_id: 'test_id', + } + + const result = findMissingRequiredField(formData, []) + expect(result).toBeNull() + }) + + it('should handle empty form data', () => { + const result = findMissingRequiredField({}, requiredFields) + expect(result).toEqual({ name: 'client_id', label: 'Client ID' }) + }) + + it('should handle multilingual labels', () => { + const multilingualFields = [ + { name: 'field1', label: { en_US: 'Field 1 EN', zh_Hans: 'Field 1 CN' } }, + ] + const formData = {} + + const result = findMissingRequiredField(formData, multilingualFields) + expect(result).toEqual({ + name: 'field1', + label: { en_US: 'Field 1 EN', zh_Hans: 'Field 1 CN' }, + }) + }) + + it('should return null for form data with extra fields', () => { + const formData = { + client_id: 'test_id', + client_secret: 'test_secret', + scope: 'read', + extra_field1: 'extra1', + extra_field2: 'extra2', + } + + const result = findMissingRequiredField(formData, requiredFields) + expect(result).toBeNull() + }) + }) + + describe('Edge cases', () => { + it('should handle objects with non-string keys', () => { + const input = { [Symbol('test')]: 'value', regular: 'field' } as any + const result = sanitizeFormValues(input) + + expect(result.regular).toBe('field') + }) + + it('should handle objects with getter properties', () => { + const obj = {} + Object.defineProperty(obj, 'getter', { + get: () => 'computed_value', + enumerable: true, + }) + + const result = sanitizeFormValues(obj) + expect(result.getter).toBe('computed_value') + }) + + it('should handle circular references in deepSanitizeFormValues gracefully', () => { + const obj: any = { field: 'value' } + obj.circular = obj + + expect(() => deepSanitizeFormValues(obj)).not.toThrow() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-plugin/utils/form-helpers.ts b/web/app/components/workflow/nodes/trigger-plugin/utils/form-helpers.ts new file mode 100644 index 0000000000..36090d9771 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/utils/form-helpers.ts @@ -0,0 +1,55 @@ +/** + * Utility functions for form data handling in trigger plugin components + */ + +/** + * Sanitizes form values by converting null/undefined to empty strings + * This ensures React form inputs don't receive null values which can cause warnings + */ +export const sanitizeFormValues = (values: Record<string, any>): Record<string, string> => { + return Object.fromEntries( + Object.entries(values).map(([key, value]) => [ + key, + value === null || value === undefined ? '' : String(value), + ]), + ) +} + +/** + * Deep sanitizes form values while preserving nested objects structure + * Useful for complex form schemas with nested properties + */ +export const deepSanitizeFormValues = (values: Record<string, any>, visited = new WeakSet()): Record<string, any> => { + if (visited.has(values)) + return {} + + visited.add(values) + + const result: Record<string, any> = {} + + for (const [key, value] of Object.entries(values)) { + if (value === null || value === undefined) + result[key] = '' + else if (typeof value === 'object' && !Array.isArray(value)) + result[key] = deepSanitizeFormValues(value, visited) + else + result[key] = value + } + + return result +} + +/** + * Validates required fields in form data + * Returns the first missing required field or null if all are present + */ +export const findMissingRequiredField = ( + formData: Record<string, any>, + requiredFields: Array<{ name: string; label: any }>, +): { name: string; label: any } | null => { + for (const field of requiredFields) { + if (!formData[field.name] || formData[field.name] === '') + return field + } + return null +} diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx new file mode 100644 index 0000000000..d0de74a6ef --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx @@ -0,0 +1,38 @@ +import React, { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { SimpleSelect } from '@/app/components/base/select' +import type { ScheduleFrequency } from '../types' + +type FrequencySelectorProps = { + frequency: ScheduleFrequency + onChange: (frequency: ScheduleFrequency) => void +} + +const FrequencySelector = ({ frequency, onChange }: FrequencySelectorProps) => { + const { t } = useTranslation() + + const frequencies = useMemo(() => [ + { value: 'frequency-header', name: t('workflow.nodes.triggerSchedule.frequency.label'), isGroup: true }, + { value: 'hourly', name: t('workflow.nodes.triggerSchedule.frequency.hourly') }, + { value: 'daily', name: t('workflow.nodes.triggerSchedule.frequency.daily') }, + { value: 'weekly', name: t('workflow.nodes.triggerSchedule.frequency.weekly') }, + { value: 'monthly', name: t('workflow.nodes.triggerSchedule.frequency.monthly') }, + ], [t]) + + return ( + <SimpleSelect + key={`${frequency}-${frequencies[0]?.name}`} // Include translation in key to force re-render + items={frequencies} + defaultValue={frequency} + onSelect={item => onChange(item.value as ScheduleFrequency)} + placeholder={t('workflow.nodes.triggerSchedule.selectFrequency')} + className="w-full py-2" + wrapperClassName="h-auto" + optionWrapClassName="min-w-40" + notClearable={true} + allowSearch={false} + /> + ) +} + +export default FrequencySelector diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx new file mode 100644 index 0000000000..6dc88c85bf --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { RiCalendarLine, RiCodeLine } from '@remixicon/react' +import { SegmentedControl } from '@/app/components/base/segmented-control' +import type { ScheduleMode } from '../types' + +type ModeSwitcherProps = { + mode: ScheduleMode + onChange: (mode: ScheduleMode) => void +} + +const ModeSwitcher = ({ mode, onChange }: ModeSwitcherProps) => { + const { t } = useTranslation() + + const options = [ + { + Icon: RiCalendarLine, + text: t('workflow.nodes.triggerSchedule.mode.visual'), + value: 'visual' as const, + }, + { + Icon: RiCodeLine, + text: t('workflow.nodes.triggerSchedule.mode.cron'), + value: 'cron' as const, + }, + ] + + return ( + <SegmentedControl + options={options} + value={mode} + onChange={onChange} + /> + ) +} + +export default ModeSwitcher diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/mode-toggle.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/mode-toggle.tsx new file mode 100644 index 0000000000..6ae5d2cf67 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/mode-toggle.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { Asterisk, CalendarCheckLine } from '@/app/components/base/icons/src/vender/workflow' +import type { ScheduleMode } from '../types' + +type ModeToggleProps = { + mode: ScheduleMode + onChange: (mode: ScheduleMode) => void +} + +const ModeToggle = ({ mode, onChange }: ModeToggleProps) => { + const { t } = useTranslation() + + const handleToggle = () => { + const newMode = mode === 'visual' ? 'cron' : 'visual' + onChange(newMode) + } + + const currentText = mode === 'visual' + ? t('workflow.nodes.triggerSchedule.useCronExpression') + : t('workflow.nodes.triggerSchedule.useVisualPicker') + + const currentIcon = mode === 'visual' ? Asterisk : CalendarCheckLine + + return ( + <button + type="button" + onClick={handleToggle} + className="flex cursor-pointer items-center gap-1 rounded-lg px-2 py-1 text-sm text-text-secondary hover:bg-state-base-hover" + > + {React.createElement(currentIcon, { className: 'w-4 h-4' })} + <span>{currentText}</span> + </button> + ) +} + +export default ModeToggle diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx new file mode 100644 index 0000000000..d7cce79328 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx @@ -0,0 +1,90 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { RiQuestionLine } from '@remixicon/react' +import Tooltip from '@/app/components/base/tooltip' + +type MonthlyDaysSelectorProps = { + selectedDays: (number | 'last')[] + onChange: (days: (number | 'last')[]) => void +} + +const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProps) => { + const { t } = useTranslation() + + const handleDayClick = (day: number | 'last') => { + const current = selectedDays || [] + const newSelected = current.includes(day) + ? current.filter(d => d !== day) + : [...current, day] + // Ensure at least one day is selected (consistent with WeekdaySelector) + onChange(newSelected.length > 0 ? newSelected : [day]) + } + + const isDaySelected = (day: number | 'last') => selectedDays?.includes(day) || false + + const days = Array.from({ length: 31 }, (_, i) => i + 1) + const rows = [ + days.slice(0, 7), + days.slice(7, 14), + days.slice(14, 21), + days.slice(21, 28), + [29, 30, 31, 'last' as const], + ] + + return ( + <div className="space-y-2"> + <label className="mb-2 block text-xs font-medium text-text-tertiary"> + {t('workflow.nodes.triggerSchedule.days')} + </label> + + <div className="space-y-1.5"> + {rows.map((row, rowIndex) => ( + <div key={rowIndex} className="grid grid-cols-7 gap-1.5"> + {row.map(day => ( + <button + key={day} + type="button" + onClick={() => handleDayClick(day)} + className={`rounded-lg border bg-components-option-card-option-bg py-1 text-xs transition-colors ${ + day === 'last' ? 'col-span-2 min-w-0' : '' + } ${ + isDaySelected(day) + ? 'border-util-colors-blue-brand-blue-brand-600 text-text-secondary' + : 'border-divider-subtle text-text-tertiary hover:border-divider-regular hover:text-text-secondary' + }`} + > + {day === 'last' ? ( + <div className="flex items-center justify-center gap-1"> + <span>{t('workflow.nodes.triggerSchedule.lastDay')}</span> + <Tooltip + popupContent={t('workflow.nodes.triggerSchedule.lastDayTooltip')} + > + <RiQuestionLine className="h-3 w-3 text-text-quaternary" /> + </Tooltip> + </div> + ) : ( + day + )} + </button> + ))} + {/* Fill empty cells in the last row (Last day takes 2 cols, so need 1 less) */} + {rowIndex === rows.length - 1 && Array.from({ length: 7 - row.length - 1 }, (_, i) => ( + <div key={`empty-${i}`} className="invisible"></div> + ))} + </div> + ))} + </div> + + {/* Warning message for day 31 - aligned with grid */} + {selectedDays?.includes(31) && ( + <div className="mt-1.5 grid grid-cols-7 gap-1.5"> + <div className="col-span-7 text-xs text-gray-500"> + {t('workflow.nodes.triggerSchedule.lastDayTooltip')} + </div> + </div> + )} + </div> + ) +} + +export default MonthlyDaysSelector diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx new file mode 100644 index 0000000000..02e85e2724 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import type { ScheduleTriggerNodeType } from '../types' +import { getFormattedExecutionTimes } from '../utils/execution-time-calculator' + +type NextExecutionTimesProps = { + data: ScheduleTriggerNodeType +} + +const NextExecutionTimes = ({ data }: NextExecutionTimesProps) => { + const { t } = useTranslation() + + if (!data.frequency) + return null + + const executionTimes = getFormattedExecutionTimes(data, 5) + + if (executionTimes.length === 0) + return null + + return ( + <div className="space-y-2"> + <label className="block text-xs font-medium text-gray-500"> + {t('workflow.nodes.triggerSchedule.nextExecutionTimes')} + </label> + <div className="flex min-h-[80px] flex-col rounded-xl bg-components-input-bg-normal py-2"> + {executionTimes.map((time, index) => ( + <div key={index} className="flex items-baseline text-xs"> + <span className="w-6 select-none text-right font-mono font-normal leading-[150%] tracking-wider text-text-quaternary"> + {String(index + 1).padStart(2, '0')} + </span> + <span className="pl-2 pr-3 font-mono font-normal leading-[150%] tracking-wider text-text-secondary"> + {time} + </span> + </div> + ))} + </div> + </div> + ) +} + +export default NextExecutionTimes diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/on-minute-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/on-minute-selector.tsx new file mode 100644 index 0000000000..992a111d19 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/on-minute-selector.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import Slider from '@/app/components/base/slider' + +type OnMinuteSelectorProps = { + value?: number + onChange: (value: number) => void +} + +const OnMinuteSelector = ({ value = 0, onChange }: OnMinuteSelectorProps) => { + const { t } = useTranslation() + + return ( + <div> + <label className="mb-2 block text-xs font-medium text-gray-500"> + {t('workflow.nodes.triggerSchedule.onMinute')} + </label> + <div className="relative flex h-8 items-center rounded-lg bg-components-input-bg-normal"> + <div className="flex h-full w-12 shrink-0 items-center justify-center text-[13px] text-components-input-text-filled"> + {value} + </div> + <div className="absolute left-12 top-0 h-full w-px bg-components-panel-bg"></div> + <div className="flex h-full grow items-center pl-4 pr-3"> + <Slider + className="w-full" + value={value} + min={0} + max={59} + step={1} + onChange={onChange} + /> + </div> + </div> + </div> + ) +} + +export default OnMinuteSelector diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx new file mode 100644 index 0000000000..348fd53454 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx @@ -0,0 +1,57 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' + +type WeekdaySelectorProps = { + selectedDays: string[] + onChange: (days: string[]) => void +} + +const WeekdaySelector = ({ selectedDays, onChange }: WeekdaySelectorProps) => { + const { t } = useTranslation() + + const weekdays = [ + { key: 'sun', label: 'Sun' }, + { key: 'mon', label: 'Mon' }, + { key: 'tue', label: 'Tue' }, + { key: 'wed', label: 'Wed' }, + { key: 'thu', label: 'Thu' }, + { key: 'fri', label: 'Fri' }, + { key: 'sat', label: 'Sat' }, + ] + + const handleDaySelect = (dayKey: string) => { + const current = selectedDays || [] + const newSelected = current.includes(dayKey) + ? current.filter(d => d !== dayKey) + : [...current, dayKey] + onChange(newSelected.length > 0 ? newSelected : [dayKey]) + } + + const isDaySelected = (dayKey: string) => selectedDays.includes(dayKey) + + return ( + <div className="space-y-2"> + <label className="mb-2 block text-xs font-medium text-text-tertiary"> + {t('workflow.nodes.triggerSchedule.weekdays')} + </label> + <div className="flex gap-1.5"> + {weekdays.map(day => ( + <button + key={day.key} + type="button" + className={`flex-1 rounded-lg border bg-components-option-card-option-bg py-1 text-xs transition-colors ${ + isDaySelected(day.key) + ? 'border-util-colors-blue-brand-blue-brand-600 text-text-secondary' + : 'border-divider-subtle text-text-tertiary hover:border-divider-regular hover:text-text-secondary' + }`} + onClick={() => handleDaySelect(day.key)} + > + {day.label} + </button> + ))} + </div> + </div> + ) +} + +export default WeekdaySelector diff --git a/web/app/components/workflow/nodes/trigger-schedule/constants.ts b/web/app/components/workflow/nodes/trigger-schedule/constants.ts new file mode 100644 index 0000000000..ab6b8842bf --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/constants.ts @@ -0,0 +1,19 @@ +import type { ScheduleTriggerNodeType } from './types' + +export const getDefaultScheduleConfig = (): Partial<ScheduleTriggerNodeType> => ({ + mode: 'visual', + frequency: 'daily', + visual_config: { + time: '12:00 AM', + weekdays: ['sun'], + on_minute: 0, + monthly_days: [1], + }, +}) + +export const getDefaultVisualConfig = () => ({ + time: '12:00 AM', + weekdays: ['sun'], + on_minute: 0, + monthly_days: [1], +}) diff --git a/web/app/components/workflow/nodes/trigger-schedule/default.ts b/web/app/components/workflow/nodes/trigger-schedule/default.ts new file mode 100644 index 0000000000..69f93c33f4 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/default.ts @@ -0,0 +1,167 @@ +import { BlockEnum } from '../../types' +import type { NodeDefault } from '../../types' +import type { ScheduleTriggerNodeType } from './types' +import { isValidCronExpression } from './utils/cron-parser' +import { getNextExecutionTimes } from './utils/execution-time-calculator' +import { getDefaultScheduleConfig } from './constants' +import { genNodeMetaData } from '../../utils' + +const isValidTimeFormat = (time: string): boolean => { + const timeRegex = /^(0?\d|1[0-2]):[0-5]\d (AM|PM)$/ + if (!timeRegex.test(time)) return false + + const [timePart, period] = time.split(' ') + const [hour, minute] = timePart.split(':') + const hourNum = Number.parseInt(hour, 10) + const minuteNum = Number.parseInt(minute, 10) + + return hourNum >= 1 && hourNum <= 12 + && minuteNum >= 0 && minuteNum <= 59 + && ['AM', 'PM'].includes(period) +} + +const validateHourlyConfig = (config: any, t: any): string => { + if (config.on_minute === undefined || config.on_minute < 0 || config.on_minute > 59) + return t('workflow.nodes.triggerSchedule.invalidOnMinute') + + return '' +} + +const validateDailyConfig = (config: any, t: any): string => { + const i18nPrefix = 'workflow.errorMsg' + + if (!config.time) + return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.time') }) + + if (!isValidTimeFormat(config.time)) + return t('workflow.nodes.triggerSchedule.invalidTimeFormat') + + return '' +} + +const validateWeeklyConfig = (config: any, t: any): string => { + const dailyError = validateDailyConfig(config, t) + if (dailyError) return dailyError + + const i18nPrefix = 'workflow.errorMsg' + + if (!config.weekdays || config.weekdays.length === 0) + return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.weekdays') }) + + const validWeekdays = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] + for (const day of config.weekdays) { + if (!validWeekdays.includes(day)) + return t('workflow.nodes.triggerSchedule.invalidWeekday', { weekday: day }) + } + + return '' +} + +const validateMonthlyConfig = (config: any, t: any): string => { + const dailyError = validateDailyConfig(config, t) + if (dailyError) return dailyError + + const i18nPrefix = 'workflow.errorMsg' + + const getMonthlyDays = (): (number | 'last')[] => { + if (Array.isArray(config.monthly_days) && config.monthly_days.length > 0) + return config.monthly_days + + return [] + } + + const monthlyDays = getMonthlyDays() + + if (monthlyDays.length === 0) + return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.monthlyDay') }) + + for (const day of monthlyDays) { + if (day !== 'last' && (typeof day !== 'number' || day < 1 || day > 31)) + return t('workflow.nodes.triggerSchedule.invalidMonthlyDay') + } + + return '' +} + +const validateVisualConfig = (payload: ScheduleTriggerNodeType, t: any): string => { + const i18nPrefix = 'workflow.errorMsg' + const { visual_config } = payload + + if (!visual_config) + return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.visualConfig') }) + + switch (payload.frequency) { + case 'hourly': + return validateHourlyConfig(visual_config, t) + case 'daily': + return validateDailyConfig(visual_config, t) + case 'weekly': + return validateWeeklyConfig(visual_config, t) + case 'monthly': + return validateMonthlyConfig(visual_config, t) + default: + return t('workflow.nodes.triggerSchedule.invalidFrequency') + } +} + +const metaData = genNodeMetaData({ + sort: 2, + type: BlockEnum.TriggerSchedule, + helpLinkUri: 'schedule-trigger', + isStart: true, +}) + +const nodeDefault: NodeDefault<ScheduleTriggerNodeType> = { + metaData, + defaultValue: { + ...getDefaultScheduleConfig(), + cron_expression: '', + } as ScheduleTriggerNodeType, + checkValid(payload: ScheduleTriggerNodeType, t: any) { + const i18nPrefix = 'workflow.errorMsg' + let errorMessages = '' + if (!errorMessages && !payload.mode) + errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.mode') }) + + // Validate timezone format if provided (timezone will be auto-filled by use-config.ts if undefined) + if (!errorMessages && payload.timezone) { + try { + Intl.DateTimeFormat(undefined, { timeZone: payload.timezone }) + } + catch { + errorMessages = t('workflow.nodes.triggerSchedule.invalidTimezone') + } + } + if (!errorMessages) { + if (payload.mode === 'cron') { + if (!payload.cron_expression || payload.cron_expression.trim() === '') + errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.cronExpression') }) + else if (!isValidCronExpression(payload.cron_expression)) + errorMessages = t('workflow.nodes.triggerSchedule.invalidCronExpression') + } + else if (payload.mode === 'visual') { + if (!payload.frequency) + errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.frequency') }) + else + errorMessages = validateVisualConfig(payload, t) + } + } + if (!errorMessages) { + try { + const nextTimes = getNextExecutionTimes(payload, 1) + if (nextTimes.length === 0) + errorMessages = t('workflow.nodes.triggerSchedule.noValidExecutionTime') + } + catch { + errorMessages = t('workflow.nodes.triggerSchedule.executionTimeCalculationError') + } + } + + return { + isValid: !errorMessages, + errorMessage: errorMessages, + } + }, +} + +export default nodeDefault diff --git a/web/app/components/workflow/nodes/trigger-schedule/node.tsx b/web/app/components/workflow/nodes/trigger-schedule/node.tsx new file mode 100644 index 0000000000..9870ef211a --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/node.tsx @@ -0,0 +1,31 @@ +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import type { ScheduleTriggerNodeType } from './types' +import type { NodeProps } from '@/app/components/workflow/types' +import { getNextExecutionTime } from './utils/execution-time-calculator' + +const i18nPrefix = 'workflow.nodes.triggerSchedule' + +const Node: FC<NodeProps<ScheduleTriggerNodeType>> = ({ + data, +}) => { + const { t } = useTranslation() + + return ( + <div className="mb-1 px-3 py-1"> + <div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-text-tertiary"> + {t(`${i18nPrefix}.nextExecutionTime`)} + </div> + <div className="flex h-[26px] items-center rounded-md bg-workflow-block-parma-bg px-2 text-xs text-text-secondary"> + <div className="w-0 grow"> + <div className="truncate" title={getNextExecutionTime(data)}> + {getNextExecutionTime(data)} + </div> + </div> + </div> + </div> + ) +} + +export default React.memo(Node) diff --git a/web/app/components/workflow/nodes/trigger-schedule/panel.tsx b/web/app/components/workflow/nodes/trigger-schedule/panel.tsx new file mode 100644 index 0000000000..2a7c661339 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/panel.tsx @@ -0,0 +1,146 @@ +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import type { ScheduleTriggerNodeType } from './types' +import Field from '@/app/components/workflow/nodes/_base/components/field' +import type { NodePanelProps } from '@/app/components/workflow/types' +import ModeToggle from './components/mode-toggle' +import FrequencySelector from './components/frequency-selector' +import WeekdaySelector from './components/weekday-selector' +import TimePicker from '@/app/components/base/date-and-time-picker/time-picker' +import NextExecutionTimes from './components/next-execution-times' +import MonthlyDaysSelector from './components/monthly-days-selector' +import OnMinuteSelector from './components/on-minute-selector' +import Input from '@/app/components/base/input' +import useConfig from './use-config' + +const i18nPrefix = 'workflow.nodes.triggerSchedule' + +const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({ + id, + data, +}) => { + const { t } = useTranslation() + const { + inputs, + setInputs, + handleModeChange, + handleFrequencyChange, + handleCronExpressionChange, + handleWeekdaysChange, + handleTimeChange, + handleOnMinuteChange, + } = useConfig(id, data) + + return ( + <div className='mt-2'> + <div className='space-y-4 px-4 pb-3 pt-2'> + <Field + title={t(`${i18nPrefix}.title`)} + operations={ + <ModeToggle + mode={inputs.mode} + onChange={handleModeChange} + /> + } + > + <div className="space-y-3"> + + {inputs.mode === 'visual' && ( + <div className="space-y-3"> + <div className="grid grid-cols-3 gap-3"> + <div> + <label className="mb-2 block text-xs font-medium text-gray-500"> + {t('workflow.nodes.triggerSchedule.frequencyLabel')} + </label> + <FrequencySelector + frequency={inputs.frequency || 'daily'} + onChange={handleFrequencyChange} + /> + </div> + <div className="col-span-2"> + {inputs.frequency === 'hourly' ? ( + <OnMinuteSelector + value={inputs.visual_config?.on_minute} + onChange={handleOnMinuteChange} + /> + ) : ( + <> + <label className="mb-2 block text-xs font-medium text-gray-500"> + {t('workflow.nodes.triggerSchedule.time')} + </label> + <TimePicker + notClearable={true} + timezone={inputs.timezone} + value={inputs.visual_config?.time || '12:00 AM'} + triggerFullWidth={true} + onChange={(time) => { + if (time) { + const timeString = time.format('h:mm A') + handleTimeChange(timeString) + } + }} + onClear={() => { + handleTimeChange('12:00 AM') + }} + placeholder={t('workflow.nodes.triggerSchedule.selectTime')} + showTimezone={true} + /> + </> + )} + </div> + </div> + + {inputs.frequency === 'weekly' && ( + <WeekdaySelector + selectedDays={inputs.visual_config?.weekdays || []} + onChange={handleWeekdaysChange} + /> + )} + + {inputs.frequency === 'monthly' && ( + <MonthlyDaysSelector + selectedDays={inputs.visual_config?.monthly_days || [1]} + onChange={(days) => { + const newInputs = { + ...inputs, + visual_config: { + ...inputs.visual_config, + monthly_days: days, + }, + } + setInputs(newInputs) + }} + /> + )} + </div> + )} + + {inputs.mode === 'cron' && ( + <div className="space-y-2"> + <div> + <label className="mb-2 block text-xs font-medium text-gray-500"> + {t('workflow.nodes.triggerSchedule.cronExpression')} + </label> + <Input + value={inputs.cron_expression || ''} + onChange={e => handleCronExpressionChange(e.target.value)} + placeholder="0 0 * * *" + className="font-mono" + /> + </div> + </div> + )} + </div> + </Field> + + <div className="border-t border-divider-subtle"></div> + + <NextExecutionTimes data={inputs} /> + + </div> + </div> + ) +} + +export default React.memo(Panel) diff --git a/web/app/components/workflow/nodes/trigger-schedule/types.ts b/web/app/components/workflow/nodes/trigger-schedule/types.ts new file mode 100644 index 0000000000..3d82709199 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/types.ts @@ -0,0 +1,20 @@ +import type { CommonNodeType } from '@/app/components/workflow/types' + +export type ScheduleMode = 'visual' | 'cron' + +export type ScheduleFrequency = 'hourly' | 'daily' | 'weekly' | 'monthly' + +export type VisualConfig = { + time?: string + weekdays?: string[] + on_minute?: number + monthly_days?: (number | 'last')[] +} + +export type ScheduleTriggerNodeType = CommonNodeType & { + mode: ScheduleMode + frequency?: ScheduleFrequency + cron_expression?: string + visual_config?: VisualConfig + timezone?: string +} diff --git a/web/app/components/workflow/nodes/trigger-schedule/use-config.ts b/web/app/components/workflow/nodes/trigger-schedule/use-config.ts new file mode 100644 index 0000000000..06e29ccd84 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/use-config.ts @@ -0,0 +1,110 @@ +import { useCallback, useMemo } from 'react' +import type { ScheduleFrequency, ScheduleMode, ScheduleTriggerNodeType } from './types' +import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' +import { useNodesReadOnly } from '@/app/components/workflow/hooks' +import { useAppContext } from '@/context/app-context' +import { getDefaultVisualConfig } from './constants' + +const useConfig = (id: string, payload: ScheduleTriggerNodeType) => { + const { nodesReadOnly: readOnly } = useNodesReadOnly() + + const { userProfile } = useAppContext() + + const frontendPayload = useMemo(() => { + return { + ...payload, + mode: payload.mode || 'visual', + frequency: payload.frequency || 'daily', + timezone: payload.timezone || userProfile.timezone || 'UTC', + visual_config: { + ...getDefaultVisualConfig(), + ...payload.visual_config, + }, + } + }, [payload, userProfile.timezone]) + + const { inputs, setInputs } = useNodeCrud<ScheduleTriggerNodeType>(id, frontendPayload) + + const handleModeChange = useCallback((mode: ScheduleMode) => { + const newInputs = { + ...inputs, + mode, + } + setInputs(newInputs) + }, [inputs, setInputs]) + + const handleFrequencyChange = useCallback((frequency: ScheduleFrequency) => { + const newInputs = { + ...inputs, + frequency, + visual_config: { + ...inputs.visual_config, + ...(frequency === 'hourly') && { + on_minute: inputs.visual_config?.on_minute ?? 0, + }, + }, + cron_expression: undefined, + } + setInputs(newInputs) + }, [inputs, setInputs]) + + const handleCronExpressionChange = useCallback((value: string) => { + const newInputs = { + ...inputs, + cron_expression: value, + frequency: undefined, + visual_config: undefined, + } + setInputs(newInputs) + }, [inputs, setInputs]) + + const handleWeekdaysChange = useCallback((weekdays: string[]) => { + const newInputs = { + ...inputs, + visual_config: { + ...inputs.visual_config, + weekdays, + }, + cron_expression: undefined, + } + setInputs(newInputs) + }, [inputs, setInputs]) + + const handleTimeChange = useCallback((time: string) => { + const newInputs = { + ...inputs, + visual_config: { + ...inputs.visual_config, + time, + }, + cron_expression: undefined, + } + setInputs(newInputs) + }, [inputs, setInputs]) + + const handleOnMinuteChange = useCallback((on_minute: number) => { + const newInputs = { + ...inputs, + visual_config: { + ...inputs.visual_config, + on_minute, + }, + cron_expression: undefined, + } + setInputs(newInputs) + }, [inputs, setInputs]) + + return { + readOnly, + inputs, + setInputs, + handleModeChange, + handleFrequencyChange, + handleCronExpressionChange, + handleWeekdaysChange, + handleTimeChange, + handleOnMinuteChange, + } +} + +export default useConfig diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts new file mode 100644 index 0000000000..90f65db0aa --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts @@ -0,0 +1,84 @@ +import { CronExpressionParser } from 'cron-parser' + +// Convert a UTC date from cron-parser to user timezone representation +// This ensures consistency with other execution time calculations +const convertToUserTimezoneRepresentation = (utcDate: Date, timezone: string): Date => { + // Get the time string in the target timezone + const userTimeStr = utcDate.toLocaleString('en-CA', { + timeZone: timezone, + hour12: false, + }) + const [dateStr, timeStr] = userTimeStr.split(', ') + const [year, month, day] = dateStr.split('-').map(Number) + const [hour, minute, second] = timeStr.split(':').map(Number) + + // Create a new Date object representing this time as "local" time + // This matches the behavior expected by the execution-time-calculator + return new Date(year, month - 1, day, hour, minute, second) +} + +/** + * Parse a cron expression and return the next 5 execution times + * + * @param cronExpression - Standard 5-field cron expression (minute hour day month dayOfWeek) + * @param timezone - IANA timezone identifier (e.g., 'UTC', 'America/New_York') + * @returns Array of Date objects representing the next 5 execution times + */ +export const parseCronExpression = (cronExpression: string, timezone: string = 'UTC'): Date[] => { + if (!cronExpression || cronExpression.trim() === '') + return [] + + const parts = cronExpression.trim().split(/\s+/) + + // Support both 5-field format and predefined expressions + if (parts.length !== 5 && !cronExpression.startsWith('@')) + return [] + + try { + // Parse the cron expression with timezone support + // Use the actual current time for cron-parser to handle properly + const interval = CronExpressionParser.parse(cronExpression, { + tz: timezone, + }) + + // Get the next 5 execution times using the take() method + const nextCronDates = interval.take(5) + + // Convert CronDate objects to Date objects and ensure they represent + // the time in user timezone (consistent with execution-time-calculator.ts) + return nextCronDates.map((cronDate) => { + const utcDate = cronDate.toDate() + return convertToUserTimezoneRepresentation(utcDate, timezone) + }) + } + catch { + // Return empty array if parsing fails + return [] + } +} + +/** + * Validate a cron expression format and syntax + * + * @param cronExpression - Standard 5-field cron expression to validate + * @returns boolean indicating if the cron expression is valid + */ +export const isValidCronExpression = (cronExpression: string): boolean => { + if (!cronExpression || cronExpression.trim() === '') + return false + + const parts = cronExpression.trim().split(/\s+/) + + // Support both 5-field format and predefined expressions + if (parts.length !== 5 && !cronExpression.startsWith('@')) + return false + + try { + // Use cron-parser to validate the expression + CronExpressionParser.parse(cronExpression) + return true + } + catch { + return false + } +} diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts new file mode 100644 index 0000000000..aef122ba25 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts @@ -0,0 +1,295 @@ +import type { ScheduleTriggerNodeType } from '../types' +import { isValidCronExpression, parseCronExpression } from './cron-parser' +import { convertTimezoneToOffsetStr } from '@/app/components/base/date-and-time-picker/utils/dayjs' + +const DEFAULT_TIMEZONE = 'UTC' + +const resolveTimezone = (timezone?: string): string => { + if (timezone) + return timezone + + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || DEFAULT_TIMEZONE + } + catch { + return DEFAULT_TIMEZONE + } +} + +// Get current time completely in user timezone, no browser timezone involved +const getUserTimezoneCurrentTime = (timezone?: string): Date => { + const targetTimezone = resolveTimezone(timezone) + const now = new Date() + const userTimeStr = now.toLocaleString('en-CA', { + timeZone: targetTimezone, + hour12: false, + }) + const [dateStr, timeStr] = userTimeStr.split(', ') + const [year, month, day] = dateStr.split('-').map(Number) + const [hour, minute, second] = timeStr.split(':').map(Number) + return new Date(year, month - 1, day, hour, minute, second) +} + +// Format date that is already in user timezone, no timezone conversion +const formatUserTimezoneDate = (date: Date, timezone: string, includeWeekday: boolean = true, includeTimezone: boolean = true): string => { + const dateOptions: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'long', + day: 'numeric', + } + + if (includeWeekday) + dateOptions.weekday = 'long' // Changed from 'short' to 'long' for full weekday name + + const timeOptions: Intl.DateTimeFormatOptions = { + hour: 'numeric', + minute: '2-digit', + hour12: true, + } + + const dateStr = date.toLocaleDateString('en-US', dateOptions) + const timeStr = date.toLocaleTimeString('en-US', timeOptions) + + if (includeTimezone) { + const timezoneOffset = convertTimezoneToOffsetStr(timezone) + return `${dateStr}, ${timeStr} (${timezoneOffset})` + } + + return `${dateStr}, ${timeStr}` +} + +// Helper function to get default datetime - consistent with base DatePicker +export const getDefaultDateTime = (): Date => { + const defaultDate = new Date(2024, 0, 2, 11, 30, 0, 0) + return defaultDate +} + +export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): Date[] => { + const timezone = resolveTimezone(data.timezone) + + if (data.mode === 'cron') { + if (!data.cron_expression || !isValidCronExpression(data.cron_expression)) + return [] + return parseCronExpression(data.cron_expression, timezone).slice(0, count) + } + + const times: Date[] = [] + const defaultTime = data.visual_config?.time || '12:00 AM' + + // Get "today" in user's timezone for display purposes + const now = new Date() + const userTodayStr = now.toLocaleDateString('en-CA', { timeZone: timezone }) + const [year, month, day] = userTodayStr.split('-').map(Number) + const userToday = new Date(year, month - 1, day, 0, 0, 0, 0) + + if (data.frequency === 'hourly') { + const onMinute = data.visual_config?.on_minute ?? 0 + + // Get current time completely in user timezone + const userCurrentTime = getUserTimezoneCurrentTime(timezone) + + let hour = userCurrentTime.getHours() + if (userCurrentTime.getMinutes() >= onMinute) + hour += 1 // Start from next hour if current minute has passed + + for (let i = 0; i < count; i++) { + const execution = new Date(userToday) + execution.setHours(hour + i, onMinute, 0, 0) + // Handle day overflow + if (hour + i >= 24) { + execution.setDate(userToday.getDate() + Math.floor((hour + i) / 24)) + execution.setHours((hour + i) % 24, onMinute, 0, 0) + } + times.push(execution) + } + } + else if (data.frequency === 'daily') { + const [time, period] = defaultTime.split(' ') + const [hour, minute] = time.split(':') + let displayHour = Number.parseInt(hour) + if (period === 'PM' && displayHour !== 12) displayHour += 12 + if (period === 'AM' && displayHour === 12) displayHour = 0 + + // Check if today's configured time has already passed + const todayExecution = new Date(userToday) + todayExecution.setHours(displayHour, Number.parseInt(minute), 0, 0) + + const userCurrentTime = getUserTimezoneCurrentTime(timezone) + + const startOffset = todayExecution <= userCurrentTime ? 1 : 0 + + for (let i = 0; i < count; i++) { + const execution = new Date(userToday) + execution.setDate(userToday.getDate() + startOffset + i) + execution.setHours(displayHour, Number.parseInt(minute), 0, 0) + times.push(execution) + } + } + else if (data.frequency === 'weekly') { + const selectedDays = data.visual_config?.weekdays || ['sun'] + const dayMap = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 } + + const [time, period] = defaultTime.split(' ') + const [hour, minute] = time.split(':') + let displayHour = Number.parseInt(hour) + if (period === 'PM' && displayHour !== 12) displayHour += 12 + if (period === 'AM' && displayHour === 12) displayHour = 0 + + // Get current time completely in user timezone + const userCurrentTime = getUserTimezoneCurrentTime(timezone) + + let executionCount = 0 + let weekOffset = 0 + + while (executionCount < count) { + let hasValidDays = false + + for (const selectedDay of selectedDays) { + if (executionCount >= count) break + + const targetDay = dayMap[selectedDay as keyof typeof dayMap] + if (targetDay === undefined) continue + + hasValidDays = true + + const currentDayOfWeek = userToday.getDay() + const daysUntilTarget = (targetDay - currentDayOfWeek + 7) % 7 + + // Check if today's configured time has already passed + const todayAtTargetTime = new Date(userToday) + todayAtTargetTime.setHours(displayHour, Number.parseInt(minute), 0, 0) + + let adjustedDays = daysUntilTarget + if (daysUntilTarget === 0 && todayAtTargetTime <= userCurrentTime) + adjustedDays = 7 + + const execution = new Date(userToday) + execution.setDate(userToday.getDate() + adjustedDays + (weekOffset * 7)) + execution.setHours(displayHour, Number.parseInt(minute), 0, 0) + + // Only add if execution time is in the future + if (execution > userCurrentTime) { + times.push(execution) + executionCount++ + } + } + + if (!hasValidDays) break + weekOffset++ + } + + times.sort((a, b) => a.getTime() - b.getTime()) + } + else if (data.frequency === 'monthly') { + const getSelectedDays = (): (number | 'last')[] => { + if (data.visual_config?.monthly_days && data.visual_config.monthly_days.length > 0) + return data.visual_config.monthly_days + + return [1] + } + + const selectedDays = [...new Set(getSelectedDays())] + const [time, period] = defaultTime.split(' ') + const [hour, minute] = time.split(':') + let displayHour = Number.parseInt(hour) + if (period === 'PM' && displayHour !== 12) displayHour += 12 + if (period === 'AM' && displayHour === 12) displayHour = 0 + + // Get current time completely in user timezone + const userCurrentTime = getUserTimezoneCurrentTime(timezone) + + let executionCount = 0 + let monthOffset = 0 + + while (executionCount < count) { + const targetMonth = new Date(userToday.getFullYear(), userToday.getMonth() + monthOffset, 1) + const daysInMonth = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate() + + const monthlyExecutions: Date[] = [] + const processedDays = new Set<number>() + + for (const selectedDay of selectedDays) { + let targetDay: number + + if (selectedDay === 'last') { + targetDay = daysInMonth + } + else { + const dayNumber = selectedDay as number + if (dayNumber > daysInMonth) + continue + + targetDay = dayNumber + } + + if (processedDays.has(targetDay)) + continue + + processedDays.add(targetDay) + + const execution = new Date(targetMonth.getFullYear(), targetMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0) + + // Only add if execution time is in the future + if (execution > userCurrentTime) + monthlyExecutions.push(execution) + } + + monthlyExecutions.sort((a, b) => a.getTime() - b.getTime()) + + for (const execution of monthlyExecutions) { + if (executionCount >= count) break + times.push(execution) + executionCount++ + } + + monthOffset++ + } + } + else { + for (let i = 0; i < count; i++) { + const execution = new Date(userToday) + execution.setDate(userToday.getDate() + i) + times.push(execution) + } + } + + return times +} + +export const formatExecutionTime = (date: Date, timezone: string | undefined, includeWeekday: boolean = true, includeTimezone: boolean = true): string => { + const resolvedTimezone = resolveTimezone(timezone) + return formatUserTimezoneDate(date, resolvedTimezone, includeWeekday, includeTimezone) +} + +export const getFormattedExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): string[] => { + const timezone = resolveTimezone(data.timezone) + const times = getNextExecutionTimes(data, count) + + return times.map((date) => { + const includeWeekday = data.mode === 'visual' && data.frequency === 'weekly' + return formatExecutionTime(date, timezone, includeWeekday, true) // Panel shows timezone + }) +} + +export const getNextExecutionTime = (data: ScheduleTriggerNodeType): string => { + const timezone = resolveTimezone(data.timezone) + + // Return placeholder for cron mode with empty or invalid expression + if (data.mode === 'cron') { + if (!data.cron_expression || !isValidCronExpression(data.cron_expression)) + return '--' + } + + // Get Date objects (not formatted strings) + const times = getNextExecutionTimes(data, 1) + if (times.length === 0) { + const userCurrentTime = getUserTimezoneCurrentTime(timezone) + const fallbackDate = new Date(userCurrentTime.getFullYear(), userCurrentTime.getMonth(), userCurrentTime.getDate(), 12, 0, 0, 0) + const includeWeekday = data.mode === 'visual' && data.frequency === 'weekly' + return formatExecutionTime(fallbackDate, timezone, includeWeekday, false) // Node doesn't show timezone + } + + // Format the first execution time without timezone for node display + const includeWeekday = data.mode === 'visual' && data.frequency === 'weekly' + return formatExecutionTime(times[0], timezone, includeWeekday, false) // Node doesn't show timezone +} diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/integration.spec.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/integration.spec.ts new file mode 100644 index 0000000000..1b7d374d33 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/integration.spec.ts @@ -0,0 +1,349 @@ +import { isValidCronExpression, parseCronExpression } from './cron-parser' +import { getNextExecutionTime, getNextExecutionTimes } from './execution-time-calculator' +import type { ScheduleTriggerNodeType } from '../types' + +// Comprehensive integration tests for cron-parser and execution-time-calculator compatibility +describe('cron-parser + execution-time-calculator integration', () => { + beforeAll(() => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2024-01-15T10:00:00Z')) + }) + + afterAll(() => { + jest.useRealTimers() + }) + + const createCronData = (overrides: Partial<ScheduleTriggerNodeType> = {}): ScheduleTriggerNodeType => ({ + id: 'test-cron', + type: 'schedule-trigger', + mode: 'cron', + frequency: 'daily', + timezone: 'UTC', + ...overrides, + }) + + describe('backward compatibility validation', () => { + it('maintains exact behavior for legacy cron expressions', () => { + const legacyExpressions = [ + '15 10 1 * *', // Monthly 1st at 10:15 + '0 0 * * 0', // Weekly Sunday midnight + '*/5 * * * *', // Every 5 minutes + '0 9-17 * * 1-5', // Business hours weekdays + '30 14 * * 1', // Monday 14:30 + '0 0 1,15 * *', // 1st and 15th midnight + ] + + legacyExpressions.forEach((expression) => { + // Test direct cron-parser usage + const directResult = parseCronExpression(expression, 'UTC') + expect(directResult).toHaveLength(5) + expect(isValidCronExpression(expression)).toBe(true) + + // Test through execution-time-calculator + const data = createCronData({ cron_expression: expression }) + const calculatorResult = getNextExecutionTimes(data, 5) + + expect(calculatorResult).toHaveLength(5) + + // Results should be identical + directResult.forEach((directDate, index) => { + const calcDate = calculatorResult[index] + expect(calcDate.getTime()).toBe(directDate.getTime()) + expect(calcDate.getHours()).toBe(directDate.getHours()) + expect(calcDate.getMinutes()).toBe(directDate.getMinutes()) + }) + }) + }) + + it('validates timezone handling consistency', () => { + const timezones = ['UTC', 'America/New_York', 'Asia/Tokyo', 'Europe/London'] + const expression = '0 12 * * *' // Daily noon + + timezones.forEach((timezone) => { + // Direct cron-parser call + const directResult = parseCronExpression(expression, timezone) + + // Through execution-time-calculator + const data = createCronData({ cron_expression: expression, timezone }) + const calculatorResult = getNextExecutionTimes(data, 5) + + expect(directResult).toHaveLength(5) + expect(calculatorResult).toHaveLength(5) + + // All results should show noon (12:00) in their respective timezone + directResult.forEach(date => expect(date.getHours()).toBe(12)) + calculatorResult.forEach(date => expect(date.getHours()).toBe(12)) + + // Cross-validation: results should be identical + directResult.forEach((directDate, index) => { + expect(calculatorResult[index].getTime()).toBe(directDate.getTime()) + }) + }) + }) + + it('error handling consistency', () => { + const invalidExpressions = [ + '', // Empty string + ' ', // Whitespace only + '60 10 1 * *', // Invalid minute + '15 25 1 * *', // Invalid hour + '15 10 32 * *', // Invalid day + '15 10 1 13 *', // Invalid month + '15 10 1', // Too few fields + '15 10 1 * * *', // Too many fields + 'invalid expression', // Completely invalid + ] + + invalidExpressions.forEach((expression) => { + // Direct cron-parser calls + expect(isValidCronExpression(expression)).toBe(false) + expect(parseCronExpression(expression, 'UTC')).toEqual([]) + + // Through execution-time-calculator + const data = createCronData({ cron_expression: expression }) + const result = getNextExecutionTimes(data, 5) + expect(result).toEqual([]) + + // getNextExecutionTime should return '--' for invalid cron + const timeString = getNextExecutionTime(data) + expect(timeString).toBe('--') + }) + }) + }) + + describe('enhanced features integration', () => { + it('month and day abbreviations work end-to-end', () => { + const enhancedExpressions = [ + { expr: '0 9 1 JAN *', month: 0, day: 1, hour: 9 }, // January 1st 9 AM + { expr: '0 15 * * MON', weekday: 1, hour: 15 }, // Monday 3 PM + { expr: '30 10 15 JUN,DEC *', month: [5, 11], day: 15, hour: 10, minute: 30 }, // Jun/Dec 15th + { expr: '0 12 * JAN-MAR *', month: [0, 1, 2], hour: 12 }, // Q1 noon + ] + + enhancedExpressions.forEach(({ expr, month, day, weekday, hour, minute = 0 }) => { + // Validate through both paths + expect(isValidCronExpression(expr)).toBe(true) + + const directResult = parseCronExpression(expr, 'UTC') + const data = createCronData({ cron_expression: expr }) + const calculatorResult = getNextExecutionTimes(data, 3) + + expect(directResult.length).toBeGreaterThan(0) + expect(calculatorResult.length).toBeGreaterThan(0) + + // Validate expected properties + const validateDate = (date: Date) => { + expect(date.getHours()).toBe(hour) + expect(date.getMinutes()).toBe(minute) + + if (month !== undefined) { + if (Array.isArray(month)) + expect(month).toContain(date.getMonth()) + else + expect(date.getMonth()).toBe(month) + } + + if (day !== undefined) + expect(date.getDate()).toBe(day) + + if (weekday !== undefined) + expect(date.getDay()).toBe(weekday) + } + + directResult.forEach(validateDate) + calculatorResult.forEach(validateDate) + }) + }) + + it('predefined expressions work through execution-time-calculator', () => { + const predefExpressions = [ + { expr: '@daily', hour: 0, minute: 0 }, + { expr: '@weekly', hour: 0, minute: 0, weekday: 0 }, // Sunday + { expr: '@monthly', hour: 0, minute: 0, day: 1 }, // 1st of month + { expr: '@yearly', hour: 0, minute: 0, month: 0, day: 1 }, // Jan 1st + ] + + predefExpressions.forEach(({ expr, hour, minute, weekday, day, month }) => { + expect(isValidCronExpression(expr)).toBe(true) + + const data = createCronData({ cron_expression: expr }) + const result = getNextExecutionTimes(data, 3) + + expect(result.length).toBeGreaterThan(0) + + result.forEach((date) => { + expect(date.getHours()).toBe(hour) + expect(date.getMinutes()).toBe(minute) + + if (weekday !== undefined) expect(date.getDay()).toBe(weekday) + if (day !== undefined) expect(date.getDate()).toBe(day) + if (month !== undefined) expect(date.getMonth()).toBe(month) + }) + }) + }) + + it('special characters integration', () => { + const specialExpressions = [ + '0 9 ? * 1', // ? wildcard for day + '0 12 * * 7', // Sunday as 7 + '0 15 L * *', // Last day of month + ] + + specialExpressions.forEach((expr) => { + // Should validate and parse successfully + expect(isValidCronExpression(expr)).toBe(true) + + const directResult = parseCronExpression(expr, 'UTC') + const data = createCronData({ cron_expression: expr }) + const calculatorResult = getNextExecutionTimes(data, 2) + + expect(directResult.length).toBeGreaterThan(0) + expect(calculatorResult.length).toBeGreaterThan(0) + + // Results should be consistent + expect(calculatorResult[0].getHours()).toBe(directResult[0].getHours()) + expect(calculatorResult[0].getMinutes()).toBe(directResult[0].getMinutes()) + }) + }) + }) + + describe('DST and timezone edge cases', () => { + it('handles DST transitions consistently', () => { + // Test around DST spring forward (March 2024) + jest.setSystemTime(new Date('2024-03-08T10:00:00Z')) + + const expression = '0 2 * * *' // 2 AM daily (problematic during DST) + const timezone = 'America/New_York' + + const directResult = parseCronExpression(expression, timezone) + const data = createCronData({ cron_expression: expression, timezone }) + const calculatorResult = getNextExecutionTimes(data, 5) + + expect(directResult.length).toBeGreaterThan(0) + expect(calculatorResult.length).toBeGreaterThan(0) + + // Both should handle DST gracefully + // During DST spring forward, 2 AM becomes 3 AM - this is correct behavior + directResult.forEach(date => expect([2, 3]).toContain(date.getHours())) + calculatorResult.forEach(date => expect([2, 3]).toContain(date.getHours())) + + // Results should be identical + directResult.forEach((directDate, index) => { + expect(calculatorResult[index].getTime()).toBe(directDate.getTime()) + }) + }) + + it('complex timezone scenarios', () => { + const scenarios = [ + { tz: 'Asia/Kolkata', expr: '30 14 * * *', expectedHour: 14, expectedMinute: 30 }, // UTC+5:30 + { tz: 'Australia/Adelaide', expr: '0 8 * * *', expectedHour: 8, expectedMinute: 0 }, // UTC+9:30/+10:30 + { tz: 'Pacific/Kiritimati', expr: '0 12 * * *', expectedHour: 12, expectedMinute: 0 }, // UTC+14 + ] + + scenarios.forEach(({ tz, expr, expectedHour, expectedMinute }) => { + const directResult = parseCronExpression(expr, tz) + const data = createCronData({ cron_expression: expr, timezone: tz }) + const calculatorResult = getNextExecutionTimes(data, 2) + + expect(directResult.length).toBeGreaterThan(0) + expect(calculatorResult.length).toBeGreaterThan(0) + + // Validate expected time + directResult.forEach((date) => { + expect(date.getHours()).toBe(expectedHour) + expect(date.getMinutes()).toBe(expectedMinute) + }) + + calculatorResult.forEach((date) => { + expect(date.getHours()).toBe(expectedHour) + expect(date.getMinutes()).toBe(expectedMinute) + }) + + // Cross-validate consistency + expect(calculatorResult[0].getTime()).toBe(directResult[0].getTime()) + }) + }) + }) + + describe('performance and reliability', () => { + it('handles high-frequency expressions efficiently', () => { + const highFreqExpressions = [ + '*/1 * * * *', // Every minute + '*/5 * * * *', // Every 5 minutes + '0,15,30,45 * * * *', // Every 15 minutes + ] + + highFreqExpressions.forEach((expr) => { + const start = performance.now() + + // Test both direct and through calculator + const directResult = parseCronExpression(expr, 'UTC') + const data = createCronData({ cron_expression: expr }) + const calculatorResult = getNextExecutionTimes(data, 5) + + const end = performance.now() + + expect(directResult).toHaveLength(5) + expect(calculatorResult).toHaveLength(5) + expect(end - start).toBeLessThan(100) // Should be fast + + // Results should be consistent + directResult.forEach((directDate, index) => { + expect(calculatorResult[index].getTime()).toBe(directDate.getTime()) + }) + }) + }) + + it('stress test with complex expressions', () => { + const complexExpressions = [ + '15,45 8-18 1,15 JAN-MAR MON-FRI', // Business hours, specific days, Q1, weekdays + '0 */2 ? * SUN#1,SUN#3', // First and third Sunday, every 2 hours + '30 9 L * *', // Last day of month, 9:30 AM + ] + + complexExpressions.forEach((expr) => { + if (isValidCronExpression(expr)) { + const directResult = parseCronExpression(expr, 'America/New_York') + const data = createCronData({ + cron_expression: expr, + timezone: 'America/New_York', + }) + const calculatorResult = getNextExecutionTimes(data, 3) + + expect(directResult.length).toBeGreaterThan(0) + expect(calculatorResult.length).toBeGreaterThan(0) + + // Validate consistency where results exist + const minLength = Math.min(directResult.length, calculatorResult.length) + for (let i = 0; i < minLength; i++) + expect(calculatorResult[i].getTime()).toBe(directResult[i].getTime()) + } + }) + }) + }) + + describe('format compatibility', () => { + it('getNextExecutionTime formatting consistency', () => { + const testCases = [ + { expr: '0 9 * * *', timezone: 'UTC' }, + { expr: '30 14 * * 1-5', timezone: 'America/New_York' }, + { expr: '@daily', timezone: 'Asia/Tokyo' }, + ] + + testCases.forEach(({ expr, timezone }) => { + const data = createCronData({ cron_expression: expr, timezone }) + const timeString = getNextExecutionTime(data) + + // Should return a formatted time string, not '--' + expect(timeString).not.toBe('--') + expect(typeof timeString).toBe('string') + expect(timeString.length).toBeGreaterThan(0) + + // Should contain expected format elements + expect(timeString).toMatch(/\d+:\d+/) // Time format + expect(timeString).toMatch(/AM|PM/) // 12-hour format + expect(timeString).toMatch(/\d{4}/) // Year + }) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx new file mode 100644 index 0000000000..235593d7f3 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx @@ -0,0 +1,297 @@ +'use client' +import type { FC, ReactNode } from 'react' +import React, { useCallback, useMemo } from 'react' +import { RiDeleteBinLine } from '@remixicon/react' +import Input from '@/app/components/base/input' +import Checkbox from '@/app/components/base/checkbox' +import { SimpleSelect } from '@/app/components/base/select' +import { replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var' +import cn from '@/utils/classnames' + +// Tiny utility to judge whether a cell value is effectively present +const isPresent = (v: unknown): boolean => { + if (typeof v === 'string') return v.trim() !== '' + return !(v === '' || v === null || v === undefined || v === false) +} +// Column configuration types for table components +export type ColumnType = 'input' | 'select' | 'switch' | 'custom' + +export type SelectOption = { + name: string + value: string +} + +export type ColumnConfig = { + key: string + title: string + type: ColumnType + width?: string // CSS class for width (e.g., 'w-1/2', 'w-[140px]') + placeholder?: string + options?: SelectOption[] // For select type + render?: (value: unknown, row: GenericTableRow, index: number, onChange: (value: unknown) => void) => ReactNode + required?: boolean +} + +export type GenericTableRow = { + [key: string]: unknown +} + +type GenericTableProps = { + title: string + columns: ColumnConfig[] + data: GenericTableRow[] + onChange: (data: GenericTableRow[]) => void + readonly?: boolean + placeholder?: string + emptyRowData: GenericTableRow // Template for new empty rows + className?: string + showHeader?: boolean // Whether to show column headers +} + +// Internal type for stable mapping between rendered rows and data indices +type DisplayRow = { + row: GenericTableRow + dataIndex: number | null // null indicates the trailing UI-only row + isVirtual: boolean // whether this row is the extra empty row for adding new items +} + +const GenericTable: FC<GenericTableProps> = ({ + title, + columns, + data, + onChange, + readonly = false, + placeholder, + emptyRowData, + className, + showHeader = false, +}) => { + // Build the rows to display while keeping a stable mapping to original data + const displayRows = useMemo<DisplayRow[]>(() => { + // Helper to check empty + const isEmptyRow = (r: GenericTableRow) => + Object.values(r).every(v => v === '' || v === null || v === undefined || v === false) + + if (readonly) + return data.map((r, i) => ({ row: r, dataIndex: i, isVirtual: false })) + + const hasData = data.length > 0 + const rows: DisplayRow[] = [] + + if (!hasData) { + // Initialize with exactly one empty row when there is no data + rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true }) + return rows + } + + // Add configured rows, hide intermediate empty ones, keep mapping + data.forEach((r, i) => { + const isEmpty = isEmptyRow(r) + // Skip empty rows except the very last configured row + if (isEmpty && i < data.length - 1) + return + rows.push({ row: r, dataIndex: i, isVirtual: false }) + }) + + // If the last configured row has content, append a trailing empty row + const lastHasContent = !isEmptyRow(data[data.length - 1]) + if (lastHasContent) + rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true }) + + return rows + }, [data, emptyRowData, readonly]) + + const removeRow = useCallback((dataIndex: number) => { + if (readonly) return + if (dataIndex < 0 || dataIndex >= data.length) return // ignore virtual rows + const newData = data.filter((_, i) => i !== dataIndex) + onChange(newData) + }, [data, readonly, onChange]) + + const updateRow = useCallback((dataIndex: number | null, key: string, value: unknown) => { + if (readonly) return + + if (dataIndex !== null && dataIndex < data.length) { + // Editing existing configured row + const newData = [...data] + newData[dataIndex] = { ...newData[dataIndex], [key]: value } + onChange(newData) + return + } + + // Editing the trailing UI-only empty row: create a new configured row + const newRow = { ...emptyRowData, [key]: value } + const next = [...data, newRow] + onChange(next) + }, [data, emptyRowData, onChange, readonly]) + + // Determine the primary identifier column just once + const primaryKey = useMemo(() => ( + columns.find(col => col.key === 'key' || col.key === 'name')?.key ?? 'key' + ), [columns]) + + const renderCell = (column: ColumnConfig, row: GenericTableRow, dataIndex: number | null) => { + const value = row[column.key] + const handleChange = (newValue: unknown) => updateRow(dataIndex, column.key, newValue) + + switch (column.type) { + case 'input': + return ( + <Input + value={(value as string) || ''} + onChange={(e) => { + // Format variable names (replace spaces with underscores) + if (column.key === 'key' || column.key === 'name') + replaceSpaceWithUnderscoreInVarNameInput(e.target) + handleChange(e.target.value) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + e.currentTarget.blur() + } + }} + placeholder={column.placeholder} + disabled={readonly} + wrapperClassName="w-full min-w-0" + className={cn( + // Ghost/inline style: looks like plain text until focus/hover + 'h-6 rounded-none border-0 bg-transparent px-0 py-0 shadow-none', + 'hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent', + 'system-sm-regular text-text-secondary placeholder:text-text-quaternary', + )} + /> + ) + + case 'select': + return ( + <SimpleSelect + items={column.options || []} + defaultValue={value as string | undefined} + onSelect={item => handleChange(item.value)} + disabled={readonly} + placeholder={column.placeholder} + hideChecked={false} + notClearable={true} + // wrapper provides compact height, trigger is transparent like text + wrapperClassName="h-6 w-full min-w-0" + className={cn( + 'h-6 rounded-none bg-transparent pl-0 pr-6 text-text-secondary', + 'hover:bg-transparent focus-visible:bg-transparent group-hover/simple-select:bg-transparent', + )} + optionWrapClassName="w-26 min-w-26 z-[60] -ml-3" + /> + ) + + case 'switch': + return ( + <div className="flex h-7 items-center"> + <Checkbox + id={`${column.key}-${String(dataIndex ?? 'v')}`} + checked={Boolean(value)} + onCheck={() => handleChange(!value)} + disabled={readonly} + /> + </div> + ) + + case 'custom': + return column.render ? column.render(value, row, (dataIndex ?? -1), handleChange) : null + + default: + return null + } + } + + const renderTable = () => { + return ( + <div className="rounded-lg border border-divider-regular"> + {showHeader && ( + <div className="system-xs-medium-uppercase flex h-7 items-center leading-7 text-text-tertiary"> + {columns.map((column, index) => ( + <div + key={column.key} + className={cn( + 'h-full pl-3', + column.width && column.width.startsWith('w-') ? 'shrink-0' : 'flex-1', + column.width, + // Add right border except for last column + index < columns.length - 1 && 'border-r border-divider-regular', + )} + > + {column.title} + </div> + ))} + </div> + )} + <div className="divide-y divide-divider-subtle"> + {displayRows.map(({ row, dataIndex, isVirtual: _isVirtual }, renderIndex) => { + const rowKey = `row-${renderIndex}` + + // Check if primary identifier column has content + const primaryValue = row[primaryKey] + const hasContent = isPresent(primaryValue) + + return ( + <div + key={rowKey} + className={cn( + 'group relative flex border-t border-divider-regular', + hasContent ? 'hover:bg-state-destructive-hover' : 'hover:bg-state-base-hover', + )} + style={{ minHeight: '28px' }} + > + {columns.map((column, columnIndex) => ( + <div + key={column.key} + className={cn( + 'shrink-0 pl-3', + column.width, + // Add right border except for last column + columnIndex < columns.length - 1 && 'border-r border-divider-regular', + )} + > + {renderCell(column, row, dataIndex)} + </div> + ))} + {!readonly && dataIndex !== null && hasContent && ( + <div className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100"> + <button + type="button" + onClick={() => removeRow(dataIndex)} + className="p-1" + aria-label="Delete row" + > + <RiDeleteBinLine className="h-3.5 w-3.5 text-text-destructive" /> + </button> + </div> + )} + </div> + ) + })} + </div> + </div> + ) + } + + // Show placeholder only when readonly and there is no data configured + const showPlaceholder = readonly && data.length === 0 + + return ( + <div className={className}> + <div className="mb-3 flex items-center justify-between"> + <h4 className="system-sm-semibold-uppercase text-text-secondary">{title}</h4> + </div> + + {showPlaceholder ? ( + <div className="flex h-7 items-center justify-center rounded-lg border border-divider-regular bg-components-panel-bg text-xs font-normal leading-[18px] text-text-quaternary"> + {placeholder} + </div> + ) : ( + renderTable() + )} + </div> + ) +} + +export default React.memo(GenericTable) diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/header-table.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/header-table.tsx new file mode 100644 index 0000000000..25e3cd4137 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/components/header-table.tsx @@ -0,0 +1,78 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import GenericTable from './generic-table' +import type { ColumnConfig, GenericTableRow } from './generic-table' +import type { WebhookHeader } from '../types' + +type HeaderTableProps = { + readonly?: boolean + headers?: WebhookHeader[] + onChange: (headers: WebhookHeader[]) => void +} + +const HeaderTable: FC<HeaderTableProps> = ({ + readonly = false, + headers = [], + onChange, +}) => { + const { t } = useTranslation() + + // Define columns for header table - matching prototype design + const columns: ColumnConfig[] = [ + { + key: 'name', + title: t('workflow.nodes.triggerWebhook.varName'), + type: 'input', + width: 'flex-1', + placeholder: t('workflow.nodes.triggerWebhook.varNamePlaceholder'), + }, + { + key: 'required', + title: t('workflow.nodes.triggerWebhook.required'), + type: 'switch', + width: 'w-[88px]', + }, + ] + + // No default prefilled row; table initializes with one empty row + + // Empty row template for new rows + const emptyRowData: GenericTableRow = { + name: '', + required: false, + } + + // Convert WebhookHeader[] to GenericTableRow[] + const tableData: GenericTableRow[] = headers.map(header => ({ + name: header.name, + required: header.required, + })) + + // Handle data changes + const handleDataChange = (data: GenericTableRow[]) => { + const newHeaders: WebhookHeader[] = data + .filter(row => row.name && typeof row.name === 'string' && row.name.trim() !== '') + .map(row => ({ + name: (row.name as string) || '', + required: !!row.required, + })) + onChange(newHeaders) + } + + return ( + <GenericTable + title="Header Parameters" + columns={columns} + data={tableData} + onChange={handleDataChange} + readonly={readonly} + placeholder={t('workflow.nodes.triggerWebhook.noHeaders')} + emptyRowData={emptyRowData} + showHeader={true} + /> + ) +} + +export default React.memo(HeaderTable) diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/paragraph-input.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/paragraph-input.tsx new file mode 100644 index 0000000000..f3946f5d3d --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/components/paragraph-input.tsx @@ -0,0 +1,57 @@ +'use client' +import type { FC } from 'react' +import React, { useRef } from 'react' +import cn from '@/utils/classnames' + +type ParagraphInputProps = { + value: string + onChange: (value: string) => void + placeholder?: string + disabled?: boolean + className?: string +} + +const ParagraphInput: FC<ParagraphInputProps> = ({ + value, + onChange, + placeholder, + disabled = false, + className, +}) => { + const textareaRef = useRef<HTMLTextAreaElement>(null) + + const lines = value ? value.split('\n') : [''] + const lineCount = Math.max(3, lines.length) + + return ( + <div className={cn('rounded-xl bg-components-input-bg-normal px-3 pb-2 pt-3', className)}> + <div className="relative"> + <div className="pointer-events-none absolute left-0 top-0 flex flex-col"> + {Array.from({ length: lineCount }, (_, index) => ( + <span + key={index} + className="flex h-[20px] select-none items-center font-mono text-xs leading-[20px] text-text-quaternary" + > + {String(index + 1).padStart(2, '0')} + </span> + ))} + </div> + <textarea + ref={textareaRef} + value={value} + onChange={e => onChange(e.target.value)} + placeholder={placeholder} + disabled={disabled} + className="w-full resize-none border-0 bg-transparent pl-6 font-mono text-xs leading-[20px] text-text-secondary outline-none placeholder:text-text-quaternary" + style={{ + minHeight: `${Math.max(3, lineCount) * 20}px`, + lineHeight: '20px', + }} + rows={Math.max(3, lineCount)} + /> + </div> + </div> + ) +} + +export default React.memo(ParagraphInput) diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx new file mode 100644 index 0000000000..bf030c4340 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx @@ -0,0 +1,112 @@ +'use client' +import type { FC } from 'react' +import React, { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import GenericTable from './generic-table' +import type { ColumnConfig, GenericTableRow } from './generic-table' +import type { WebhookParameter } from '../types' +import { createParameterTypeOptions, normalizeParameterType } from '../utils/parameter-type-utils' +import { VarType } from '@/app/components/workflow/types' + +type ParameterTableProps = { + title: string + parameters: WebhookParameter[] + onChange: (params: WebhookParameter[]) => void + readonly?: boolean + placeholder?: string + contentType?: string +} + +const ParameterTable: FC<ParameterTableProps> = ({ + title, + parameters, + onChange, + readonly, + placeholder, + contentType, +}) => { + const { t } = useTranslation() + + // Memoize typeOptions to prevent unnecessary re-renders that cause SimpleSelect state resets + const typeOptions = useMemo(() => + createParameterTypeOptions(contentType), + [contentType], + ) + + // Define columns based on component type - matching prototype design + const columns: ColumnConfig[] = [ + { + key: 'key', + title: t('workflow.nodes.triggerWebhook.varName'), + type: 'input', + width: 'flex-1', + placeholder: t('workflow.nodes.triggerWebhook.varNamePlaceholder'), + }, + { + key: 'type', + title: t('workflow.nodes.triggerWebhook.varType'), + type: 'select', + width: 'w-[120px]', + placeholder: t('workflow.nodes.triggerWebhook.varType'), + options: typeOptions, + }, + { + key: 'required', + title: t('workflow.nodes.triggerWebhook.required'), + type: 'switch', + width: 'w-[88px]', + }, + ] + + // Choose sensible default type for new rows according to content type + const defaultTypeValue: VarType = typeOptions[0]?.value || 'string' + + // Empty row template for new rows + const emptyRowData: GenericTableRow = { + key: '', + type: defaultTypeValue, + required: false, + } + + const tableData: GenericTableRow[] = parameters.map(param => ({ + key: param.name, + type: param.type, + required: param.required, + })) + + const handleDataChange = (data: GenericTableRow[]) => { + // For text/plain, enforce single text body semantics: keep only first non-empty row and force string type + // For application/octet-stream, enforce single file body semantics: keep only first non-empty row and force file type + const isTextPlain = (contentType || '').toLowerCase() === 'text/plain' + const isOctetStream = (contentType || '').toLowerCase() === 'application/octet-stream' + + const normalized = data + .filter(row => typeof row.key === 'string' && (row.key as string).trim() !== '') + .map(row => ({ + name: String(row.key), + type: isTextPlain ? VarType.string : isOctetStream ? VarType.file : normalizeParameterType((row.type as string)), + required: Boolean(row.required), + })) + + const newParams: WebhookParameter[] = (isTextPlain || isOctetStream) + ? normalized.slice(0, 1) + : normalized + + onChange(newParams) + } + + return ( + <GenericTable + title={title} + columns={columns} + data={tableData} + onChange={handleDataChange} + readonly={readonly} + placeholder={placeholder || t('workflow.nodes.triggerWebhook.noParameters')} + emptyRowData={emptyRowData} + showHeader={true} + /> + ) +} + +export default ParameterTable diff --git a/web/app/components/workflow/nodes/trigger-webhook/default.ts b/web/app/components/workflow/nodes/trigger-webhook/default.ts new file mode 100644 index 0000000000..5071a79913 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/default.ts @@ -0,0 +1,64 @@ +import { BlockEnum } from '../../types' +import type { NodeDefault } from '../../types' +import { genNodeMetaData } from '../../utils' +import type { WebhookTriggerNodeType } from './types' +import { isValidParameterType } from './utils/parameter-type-utils' +import { createWebhookRawVariable } from './utils/raw-variable' + +const metaData = genNodeMetaData({ + sort: 3, + type: BlockEnum.TriggerWebhook, + helpLinkUri: 'webhook-trigger', + isStart: true, +}) + +const nodeDefault: NodeDefault<WebhookTriggerNodeType> = { + metaData, + defaultValue: { + webhook_url: '', + method: 'POST', + content_type: 'application/json', + headers: [], + params: [], + body: [], + async_mode: true, + status_code: 200, + response_body: '', + variables: [createWebhookRawVariable()], + }, + checkValid(payload: WebhookTriggerNodeType, t: any) { + // Require webhook_url to be configured + if (!payload.webhook_url || payload.webhook_url.trim() === '') { + return { + isValid: false, + errorMessage: t('workflow.nodes.triggerWebhook.validation.webhookUrlRequired'), + } + } + + // Validate parameter types for params and body + const parametersWithTypes = [ + ...(payload.params || []), + ...(payload.body || []), + ] + + for (const param of parametersWithTypes) { + // Validate parameter type is valid + if (!isValidParameterType(param.type)) { + return { + isValid: false, + errorMessage: t('workflow.nodes.triggerWebhook.validation.invalidParameterType', { + name: param.name, + type: param.type, + }), + } + } + } + + return { + isValid: true, + errorMessage: '', + } + }, +} + +export default nodeDefault diff --git a/web/app/components/workflow/nodes/trigger-webhook/node.tsx b/web/app/components/workflow/nodes/trigger-webhook/node.tsx new file mode 100644 index 0000000000..40c3b441da --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/node.tsx @@ -0,0 +1,25 @@ +import type { FC } from 'react' +import React from 'react' +import type { WebhookTriggerNodeType } from './types' +import type { NodeProps } from '@/app/components/workflow/types' + +const Node: FC<NodeProps<WebhookTriggerNodeType>> = ({ + data, +}) => { + return ( + <div className="mb-1 px-3 py-1"> + <div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-text-tertiary"> + URL + </div> + <div className="flex h-[26px] items-center rounded-md bg-workflow-block-parma-bg px-2 text-xs text-text-secondary"> + <div className="w-0 grow"> + <div className="truncate" title={data.webhook_url || '--'}> + {data.webhook_url || '--'} + </div> + </div> + </div> + </div> + ) +} + +export default React.memo(Node) diff --git a/web/app/components/workflow/nodes/trigger-webhook/panel.tsx b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx new file mode 100644 index 0000000000..1de18bd806 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx @@ -0,0 +1,240 @@ +import type { FC } from 'react' +import React, { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import type { HttpMethod, WebhookTriggerNodeType } from './types' +import useConfig from './use-config' +import ParameterTable from './components/parameter-table' +import HeaderTable from './components/header-table' +import ParagraphInput from './components/paragraph-input' +import { OutputVariablesContent } from './utils/render-output-vars' +import Field from '@/app/components/workflow/nodes/_base/components/field' +import Split from '@/app/components/workflow/nodes/_base/components/split' +import OutputVars from '@/app/components/workflow/nodes/_base/components/output-vars' +import type { NodePanelProps } from '@/app/components/workflow/types' +import InputWithCopy from '@/app/components/base/input-with-copy' +import { InputNumber } from '@/app/components/base/input-number' +import { SimpleSelect } from '@/app/components/base/select' +import Toast from '@/app/components/base/toast' +import Tooltip from '@/app/components/base/tooltip' +import copy from 'copy-to-clipboard' +import { isPrivateOrLocalAddress } from '@/utils/urlValidation' + +const i18nPrefix = 'workflow.nodes.triggerWebhook' + +const HTTP_METHODS = [ + { name: 'GET', value: 'GET' }, + { name: 'POST', value: 'POST' }, + { name: 'PUT', value: 'PUT' }, + { name: 'DELETE', value: 'DELETE' }, + { name: 'PATCH', value: 'PATCH' }, + { name: 'HEAD', value: 'HEAD' }, +] + +const CONTENT_TYPES = [ + { name: 'application/json', value: 'application/json' }, + { name: 'application/x-www-form-urlencoded', value: 'application/x-www-form-urlencoded' }, + { name: 'text/plain', value: 'text/plain' }, + { name: 'application/octet-stream', value: 'application/octet-stream' }, + { name: 'multipart/form-data', value: 'multipart/form-data' }, +] + +const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ + id, + data, +}) => { + const { t } = useTranslation() + const [debugUrlCopied, setDebugUrlCopied] = React.useState(false) + const [outputVarsCollapsed, setOutputVarsCollapsed] = useState(false) + const { + readOnly, + inputs, + handleMethodChange, + handleContentTypeChange, + handleHeadersChange, + handleParamsChange, + handleBodyChange, + handleStatusCodeChange, + handleStatusCodeBlur, + handleResponseBodyChange, + generateWebhookUrl, + } = useConfig(id, data) + + // Ensure we only attempt to generate URL once for a newly created node without url + const hasRequestedUrlRef = useRef(false) + useEffect(() => { + if (!readOnly && !inputs.webhook_url && !hasRequestedUrlRef.current) { + hasRequestedUrlRef.current = true + void generateWebhookUrl() + } + }, [readOnly, inputs.webhook_url, generateWebhookUrl]) + + return ( + <div className='mt-2'> + <div className='space-y-4 px-4 pb-3 pt-2'> + {/* Webhook URL Section */} + <Field title={t(`${i18nPrefix}.webhookUrl`)}> + <div className="space-y-1"> + <div className="flex gap-1" style={{ height: '32px' }}> + <div className="w-26 shrink-0"> + <SimpleSelect + items={HTTP_METHODS} + defaultValue={inputs.method} + onSelect={item => handleMethodChange(item.value as HttpMethod)} + disabled={readOnly} + className="h-8 pr-8 text-sm" + wrapperClassName="h-8" + optionWrapClassName="w-26 min-w-26 z-[5]" + allowSearch={false} + notClearable={true} + /> + </div> + <div className="flex-1" style={{ width: '284px' }}> + <InputWithCopy + value={inputs.webhook_url || ''} + placeholder={t(`${i18nPrefix}.webhookUrlPlaceholder`)} + readOnly + onCopy={() => { + Toast.notify({ + type: 'success', + message: t(`${i18nPrefix}.urlCopied`), + }) + }} + /> + </div> + </div> + {inputs.webhook_debug_url && ( + <div className="space-y-2"> + <Tooltip + popupContent={debugUrlCopied ? t(`${i18nPrefix}.debugUrlCopied`) : t(`${i18nPrefix}.debugUrlCopy`)} + popupClassName="system-xs-regular text-text-primary bg-components-tooltip-bg border border-components-panel-border shadow-lg backdrop-blur-sm rounded-md px-1.5 py-1" + position="top" + offset={{ mainAxis: -20 }} + needsDelay={true} + > + <div + className="flex cursor-pointer gap-1.5 rounded-lg px-1 py-1.5 transition-colors" + style={{ width: '368px', height: '38px' }} + onClick={() => { + copy(inputs.webhook_debug_url || '') + setDebugUrlCopied(true) + setTimeout(() => setDebugUrlCopied(false), 2000) + }} + > + <div className="mt-0.5 w-0.5 bg-divider-regular" style={{ height: '28px' }}></div> + <div className="flex-1" style={{ width: '352px', height: '32px' }}> + <div className="text-xs leading-4 text-text-tertiary"> + {t(`${i18nPrefix}.debugUrlTitle`)} + </div> + <div className="truncate text-xs leading-4 text-text-primary"> + {inputs.webhook_debug_url} + </div> + </div> + </div> + </Tooltip> + {isPrivateOrLocalAddress(inputs.webhook_debug_url) && ( + <div className="system-xs-regular mt-1 px-0 py-[2px] text-text-warning"> + {t(`${i18nPrefix}.debugUrlPrivateAddressWarning`)} + </div> + )} + </div> + )} + </div> + </Field> + + {/* Content Type */} + <Field title={t(`${i18nPrefix}.contentType`)}> + <div className="w-full"> + <SimpleSelect + items={CONTENT_TYPES} + defaultValue={inputs.content_type} + onSelect={item => handleContentTypeChange(item.value as string)} + disabled={readOnly} + className="h-8 text-sm" + wrapperClassName="h-8" + optionWrapClassName="min-w-48 z-[5]" + allowSearch={false} + notClearable={true} + /> + </div> + </Field> + + {/* Query Parameters */} + <ParameterTable + readonly={readOnly} + title="Query Parameters" + parameters={inputs.params} + onChange={handleParamsChange} + placeholder={t(`${i18nPrefix}.noQueryParameters`)} + /> + + {/* Header Parameters */} + <HeaderTable + readonly={readOnly} + headers={inputs.headers} + onChange={handleHeadersChange} + /> + + {/* Request Body Parameters */} + <ParameterTable + readonly={readOnly} + title="Request Body Parameters" + parameters={inputs.body} + onChange={handleBodyChange} + placeholder={t(`${i18nPrefix}.noBodyParameters`)} + contentType={inputs.content_type} + /> + + <Split /> + + {/* Response Configuration */} + <Field title={t(`${i18nPrefix}.responseConfiguration`)}> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <label className="system-sm-medium text-text-tertiary"> + {t(`${i18nPrefix}.statusCode`)} + </label> + <InputNumber + value={inputs.status_code} + onChange={(value) => { + handleStatusCodeChange(value || 200) + }} + disabled={readOnly} + wrapClassName="w-[120px]" + className="h-8" + defaultValue={200} + onBlur={() => { + handleStatusCodeBlur(inputs.status_code) + }} + /> + </div> + <div> + <label className="system-sm-medium mb-2 block text-text-tertiary"> + {t(`${i18nPrefix}.responseBody`)} + </label> + <ParagraphInput + value={inputs.response_body} + onChange={handleResponseBodyChange} + placeholder={t(`${i18nPrefix}.responseBodyPlaceholder`)} + disabled={readOnly} + /> + </div> + </div> + </Field> + </div> + + <Split /> + + <div className=''> + <OutputVars + collapsed={outputVarsCollapsed} + onCollapse={setOutputVarsCollapsed} + > + <OutputVariablesContent variables={inputs.variables} /> + </OutputVars> + </div> + </div> + ) +} + +export default Panel diff --git a/web/app/components/workflow/nodes/trigger-webhook/types.ts b/web/app/components/workflow/nodes/trigger-webhook/types.ts new file mode 100644 index 0000000000..d9632f20e1 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/types.ts @@ -0,0 +1,35 @@ +import type { CommonNodeType, VarType, Variable } from '@/app/components/workflow/types' + +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' + +export type ArrayElementType = 'string' | 'number' | 'boolean' | 'object' + +export const getArrayElementType = (arrayType: `array[${ArrayElementType}]`): ArrayElementType => { + const match = arrayType.match(/^array\[(.+)\]$/) + return (match?.[1] as ArrayElementType) || 'string' +} + +export type WebhookParameter = { + name: string + type: VarType + required: boolean +} + +export type WebhookHeader = { + name: string + required: boolean +} + +export type WebhookTriggerNodeType = CommonNodeType & { + webhook_url?: string + webhook_debug_url?: string + method: HttpMethod + content_type: string + headers: WebhookHeader[] + params: WebhookParameter[] + body: WebhookParameter[] + async_mode: boolean + status_code: number + response_body: string + variables: Variable[] +} diff --git a/web/app/components/workflow/nodes/trigger-webhook/use-config.ts b/web/app/components/workflow/nodes/trigger-webhook/use-config.ts new file mode 100644 index 0000000000..9b525ec758 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/use-config.ts @@ -0,0 +1,251 @@ +import { useCallback } from 'react' +import { produce } from 'immer' +import { useTranslation } from 'react-i18next' +import type { HttpMethod, WebhookHeader, WebhookParameter, WebhookTriggerNodeType } from './types' + +import { useNodesReadOnly, useWorkflow } from '@/app/components/workflow/hooks' +import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' +import { useStore as useAppStore } from '@/app/components/app/store' +import { fetchWebhookUrl } from '@/service/apps' +import type { Variable } from '@/app/components/workflow/types' +import { VarType } from '@/app/components/workflow/types' +import Toast from '@/app/components/base/toast' +import { checkKeys, hasDuplicateStr } from '@/utils/var' +import { WEBHOOK_RAW_VARIABLE_NAME } from './utils/raw-variable' + +const useConfig = (id: string, payload: WebhookTriggerNodeType) => { + const { t } = useTranslation() + const { nodesReadOnly: readOnly } = useNodesReadOnly() + const { inputs, setInputs } = useNodeCrud<WebhookTriggerNodeType>(id, payload) + const appId = useAppStore.getState().appDetail?.id + const { isVarUsedInNodes, removeUsedVarInNodes } = useWorkflow() + + const handleMethodChange = useCallback((method: HttpMethod) => { + setInputs(produce(inputs, (draft) => { + draft.method = method + })) + }, [inputs, setInputs]) + + const handleContentTypeChange = useCallback((contentType: string) => { + setInputs(produce(inputs, (draft) => { + const previousContentType = draft.content_type + draft.content_type = contentType + + // If the content type changes, reset body parameters and their variables, as the variable types might differ. + // However, we could consider retaining variables that are compatible with the new content type later. + if (previousContentType !== contentType) { + draft.body = [] + if (draft.variables) { + const bodyVariables = draft.variables.filter(v => v.label === 'body') + bodyVariables.forEach((v) => { + if (isVarUsedInNodes([id, v.variable])) + removeUsedVarInNodes([id, v.variable]) + }) + + draft.variables = draft.variables.filter(v => v.label !== 'body') + } + } + })) + }, [inputs, setInputs, id, isVarUsedInNodes, removeUsedVarInNodes]) + + const syncVariablesInDraft = useCallback(( + draft: WebhookTriggerNodeType, + newData: (WebhookParameter | WebhookHeader)[], + sourceType: 'param' | 'header' | 'body', + ) => { + if (!draft.variables) + draft.variables = [] + + const sanitizedEntries = newData.map(item => ({ + item, + sanitizedName: sourceType === 'header' ? item.name.replace(/-/g, '_') : item.name, + })) + + const hasReservedConflict = sanitizedEntries.some(entry => entry.sanitizedName === WEBHOOK_RAW_VARIABLE_NAME) + if (hasReservedConflict) { + Toast.notify({ + type: 'error', + message: t('appDebug.varKeyError.keyAlreadyExists', { + key: t('appDebug.variableConfig.varName'), + }), + }) + return false + } + const existingOtherVarNames = new Set( + draft.variables + .filter(v => v.label !== sourceType && v.variable !== WEBHOOK_RAW_VARIABLE_NAME) + .map(v => v.variable), + ) + + const crossScopeConflict = sanitizedEntries.find(entry => existingOtherVarNames.has(entry.sanitizedName)) + if (crossScopeConflict) { + Toast.notify({ + type: 'error', + message: t('appDebug.varKeyError.keyAlreadyExists', { + key: crossScopeConflict.sanitizedName, + }), + }) + return false + } + + if(hasDuplicateStr(sanitizedEntries.map(entry => entry.sanitizedName))) { + Toast.notify({ + type: 'error', + message: t('appDebug.varKeyError.keyAlreadyExists', { + key: t('appDebug.variableConfig.varName'), + }), + }) + return false + } + + for (const { sanitizedName } of sanitizedEntries) { + const { isValid, errorMessageKey } = checkKeys([sanitizedName], false) + if (!isValid) { + Toast.notify({ + type: 'error', + message: t(`appDebug.varKeyError.${errorMessageKey}`, { + key: t('appDebug.variableConfig.varName'), + }), + }) + return false + } + } + + // Create set of new variable names for this source + const newVarNames = new Set(sanitizedEntries.map(entry => entry.sanitizedName)) + + // Find variables from current source that will be deleted and clean up references + draft.variables + .filter(v => v.label === sourceType && !newVarNames.has(v.variable)) + .forEach((v) => { + // Clean up references if variable is used in other nodes + if (isVarUsedInNodes([id, v.variable])) + removeUsedVarInNodes([id, v.variable]) + }) + + // Remove variables that no longer exist in newData for this specific source type + draft.variables = draft.variables.filter((v) => { + // Keep variables from other sources + if (v.label !== sourceType) return true + return newVarNames.has(v.variable) + }) + + // Add or update variables + sanitizedEntries.forEach(({ item, sanitizedName }) => { + const existingVarIndex = draft.variables.findIndex(v => v.variable === sanitizedName) + + const inputVarType = 'type' in item + ? item.type + : VarType.string // Default to string for headers + + const newVar: Variable = { + value_type: inputVarType, + label: sourceType, // Use sourceType as label to identify source + variable: sanitizedName, + value_selector: [], + required: item.required, + } + + if (existingVarIndex >= 0) + draft.variables[existingVarIndex] = newVar + else + draft.variables.push(newVar) + }) + return true + }, [t, id, isVarUsedInNodes, removeUsedVarInNodes]) + + const handleParamsChange = useCallback((params: WebhookParameter[]) => { + setInputs(produce(inputs, (draft) => { + draft.params = params + syncVariablesInDraft(draft, params, 'param') + })) + }, [inputs, setInputs, syncVariablesInDraft]) + + const handleHeadersChange = useCallback((headers: WebhookHeader[]) => { + setInputs(produce(inputs, (draft) => { + draft.headers = headers + syncVariablesInDraft(draft, headers, 'header') + })) + }, [inputs, setInputs, syncVariablesInDraft]) + + const handleBodyChange = useCallback((body: WebhookParameter[]) => { + setInputs(produce(inputs, (draft) => { + draft.body = body + syncVariablesInDraft(draft, body, 'body') + })) + }, [inputs, setInputs, syncVariablesInDraft]) + + const handleAsyncModeChange = useCallback((asyncMode: boolean) => { + setInputs(produce(inputs, (draft) => { + draft.async_mode = asyncMode + })) + }, [inputs, setInputs]) + + const handleStatusCodeChange = useCallback((statusCode: number) => { + setInputs(produce(inputs, (draft) => { + draft.status_code = statusCode + })) + }, [inputs, setInputs]) + + const handleStatusCodeBlur = useCallback((statusCode: number) => { + // Only clamp when user finishes editing (on blur) + const clampedStatusCode = Math.min(Math.max(statusCode, 200), 399) + + setInputs(produce(inputs, (draft) => { + draft.status_code = clampedStatusCode + })) + }, [inputs, setInputs]) + + const handleResponseBodyChange = useCallback((responseBody: string) => { + setInputs(produce(inputs, (draft) => { + draft.response_body = responseBody + })) + }, [inputs, setInputs]) + + const generateWebhookUrl = useCallback(async () => { + // Idempotency: if we already have a URL, just return it. + if (inputs.webhook_url && inputs.webhook_url.length > 0) + return + + if (!appId) + return + + try { + // Call backend to generate or fetch webhook url for this node + const response = await fetchWebhookUrl({ appId, nodeId: id }) + + const newInputs = produce(inputs, (draft) => { + draft.webhook_url = response.webhook_url + draft.webhook_debug_url = response.webhook_debug_url + }) + setInputs(newInputs) + } + catch (error: unknown) { + // Fallback to mock URL when API is not ready or request fails + // Keep the UI unblocked and allow users to proceed in local/dev environments. + console.error('Failed to generate webhook URL:', error) + const newInputs = produce(inputs, (draft) => { + draft.webhook_url = '' + }) + setInputs(newInputs) + } + }, [appId, id, inputs, setInputs]) + + return { + readOnly, + inputs, + setInputs, + handleMethodChange, + handleContentTypeChange, + handleHeadersChange, + handleParamsChange, + handleBodyChange, + handleAsyncModeChange, + handleStatusCodeChange, + handleStatusCodeBlur, + handleResponseBodyChange, + generateWebhookUrl, + } +} + +export default useConfig diff --git a/web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.ts b/web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.ts new file mode 100644 index 0000000000..10f61a5e22 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.ts @@ -0,0 +1,125 @@ +import { VarType } from '@/app/components/workflow/types' + +// Constants for better maintainability and reusability +const BASIC_TYPES = [VarType.string, VarType.number, VarType.boolean, VarType.object, VarType.file] as const +const ARRAY_ELEMENT_TYPES = [VarType.arrayString, VarType.arrayNumber, VarType.arrayBoolean, VarType.arrayObject] as const + +// Generate all valid parameter types programmatically +const VALID_PARAMETER_TYPES: readonly VarType[] = [ + ...BASIC_TYPES, + ...ARRAY_ELEMENT_TYPES, +] as const + +// Type display name mappings +const TYPE_DISPLAY_NAMES: Record<VarType, string> = { + [VarType.string]: 'String', + [VarType.number]: 'Number', + [VarType.boolean]: 'Boolean', + [VarType.object]: 'Object', + [VarType.file]: 'File', + [VarType.arrayString]: 'Array[String]', + [VarType.arrayNumber]: 'Array[Number]', + [VarType.arrayBoolean]: 'Array[Boolean]', + [VarType.arrayObject]: 'Array[Object]', + [VarType.secret]: 'Secret', + [VarType.array]: 'Array', + 'array[file]': 'Array[File]', + [VarType.any]: 'Any', + 'array[any]': 'Array[Any]', + [VarType.integer]: 'Integer', +} as const + +// Content type configurations +const CONTENT_TYPE_CONFIGS = { + 'application/json': { + supportedTypes: [...BASIC_TYPES.filter(t => t !== 'file'), ...ARRAY_ELEMENT_TYPES], + description: 'JSON supports all types including arrays', + }, + 'text/plain': { + supportedTypes: [VarType.string] as const, + description: 'Plain text only supports string', + }, + 'application/x-www-form-urlencoded': { + supportedTypes: [VarType.string, VarType.number, VarType.boolean] as const, + description: 'Form data supports basic types', + }, + 'application/octet-stream': { + supportedTypes: [VarType.file] as const, + description: 'octet-stream supports only binary data', + }, + 'multipart/form-data': { + supportedTypes: [VarType.string, VarType.number, VarType.boolean, VarType.file] as const, + description: 'Multipart supports basic types plus files', + }, +} as const + +/** + * Type guard to check if a string is a valid parameter type + */ +export const isValidParameterType = (type: string): type is VarType => { + return (VALID_PARAMETER_TYPES as readonly string[]).includes(type) +} + +export const normalizeParameterType = (input: string | undefined | null): VarType => { + if (!input || typeof input !== 'string') + return VarType.string + + const trimmed = input.trim().toLowerCase() + if (trimmed === 'array[string]') + return VarType.arrayString + else if (trimmed === 'array[number]') + return VarType.arrayNumber + else if (trimmed === 'array[boolean]') + return VarType.arrayBoolean + else if (trimmed === 'array[object]') + return VarType.arrayObject + else if (trimmed === 'array') + // Migrate legacy 'array' type to 'array[string]' + return VarType.arrayString + else if (trimmed === 'number') + return VarType.number + else if (trimmed === 'boolean') + return VarType.boolean + else if (trimmed === 'object') + return VarType.object + else if (trimmed === 'file') + return VarType.file + + return VarType.string +} + +/** + * Gets display name for parameter types in UI components + */ +export const getParameterTypeDisplayName = (type: VarType): string => { + return TYPE_DISPLAY_NAMES[type] +} + +/** + * Gets available parameter types based on content type + * Provides context-aware type filtering for different webhook content types + */ +export const getAvailableParameterTypes = (contentType?: string): VarType[] => { + if (!contentType) + return [VarType.string, VarType.number, VarType.boolean] + + const normalizedContentType = (contentType || '').toLowerCase() + const configKey = normalizedContentType in CONTENT_TYPE_CONFIGS + ? normalizedContentType as keyof typeof CONTENT_TYPE_CONFIGS + : 'application/json' + + const config = CONTENT_TYPE_CONFIGS[configKey] + return [...config.supportedTypes] +} + +/** + * Creates type options for UI select components + */ +export const createParameterTypeOptions = (contentType?: string) => { + const availableTypes = getAvailableParameterTypes(contentType) + + return availableTypes.map(type => ({ + name: getParameterTypeDisplayName(type), + value: type, + })) +} diff --git a/web/app/components/workflow/nodes/trigger-webhook/utils/raw-variable.ts b/web/app/components/workflow/nodes/trigger-webhook/utils/raw-variable.ts new file mode 100644 index 0000000000..2be7d4c65f --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/utils/raw-variable.ts @@ -0,0 +1,12 @@ +import { VarType, type Variable } from '@/app/components/workflow/types' + +export const WEBHOOK_RAW_VARIABLE_NAME = '_webhook_raw' +export const WEBHOOK_RAW_VARIABLE_LABEL = 'raw' + +export const createWebhookRawVariable = (): Variable => ({ + variable: WEBHOOK_RAW_VARIABLE_NAME, + label: WEBHOOK_RAW_VARIABLE_LABEL, + value_type: VarType.object, + value_selector: [], + required: true, +}) diff --git a/web/app/components/workflow/nodes/trigger-webhook/utils/render-output-vars.tsx b/web/app/components/workflow/nodes/trigger-webhook/utils/render-output-vars.tsx new file mode 100644 index 0000000000..0e9cb8a309 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/utils/render-output-vars.tsx @@ -0,0 +1,75 @@ +import type { FC } from 'react' +import React from 'react' +import type { Variable } from '@/app/components/workflow/types' + +type OutputVariablesContentProps = { + variables?: Variable[] +} + +// Define the display order for variable labels to match the table order in the UI +const LABEL_ORDER = { raw: 0, param: 1, header: 2, body: 3 } as const + +const getLabelPrefix = (label: string): string => { + const prefixMap: Record<string, string> = { + raw: 'payload', + param: 'query_params', + header: 'header_params', + body: 'req_body_params', + } + return prefixMap[label] || label +} + +type VarItemProps = { + prefix: string + name: string + type: string +} + +const VarItem: FC<VarItemProps> = ({ prefix, name, type }) => { + return ( + <div className='py-1'> + <div className='flex items-center leading-[18px]'> + <span className='code-sm-regular text-text-tertiary'>{prefix}.</span> + <span className='code-sm-semibold text-text-secondary'>{name}</span> + <span className='system-xs-regular ml-2 text-text-tertiary'>{type}</span> + </div> + </div> + ) +} + +export const OutputVariablesContent: FC<OutputVariablesContentProps> = ({ variables = [] }) => { + if (!variables || variables.length === 0) { + return ( + <div className="system-sm-regular py-2 text-text-tertiary"> + No output variables + </div> + ) + } + + // Sort variables by label to match the table display order: param → header → body + // Unknown labels are placed at the end (order value 999) + const sortedVariables = [...variables].sort((a, b) => { + const labelA = typeof a.label === 'string' ? a.label : '' + const labelB = typeof b.label === 'string' ? b.label : '' + return (LABEL_ORDER[labelA as keyof typeof LABEL_ORDER] || 999) + - (LABEL_ORDER[labelB as keyof typeof LABEL_ORDER] || 999) + }) + + return ( + <div> + {sortedVariables.map((variable, index) => { + const label = typeof variable.label === 'string' ? variable.label : '' + const varName = typeof variable.variable === 'string' ? variable.variable : '' + + return ( + <VarItem + key={`${label}-${varName}-${index}`} + prefix={getLabelPrefix(label)} + name={varName} + type={variable.value_type || 'string'} + /> + ) + })} + </div> + ) +} diff --git a/web/app/components/workflow/operator/add-block.tsx b/web/app/components/workflow/operator/add-block.tsx index 3c11dbac32..c40f2277bb 100644 --- a/web/app/components/workflow/operator/add-block.tsx +++ b/web/app/components/workflow/operator/add-block.tsx @@ -13,10 +13,12 @@ import { } from '../utils' import { useAvailableBlocks, + useIsChatMode, useNodesMetaData, useNodesReadOnly, usePanelInteractions, } from '../hooks' +import { useHooksStore } from '../hooks-store' import { useWorkflowStore } from '../store' import TipPopup from './tip-popup' import cn from '@/utils/classnames' @@ -27,6 +29,7 @@ import type { import { BlockEnum, } from '@/app/components/workflow/types' +import { FlowType } from '@/types/common' type AddBlockProps = { renderTrigger?: (open: boolean) => React.ReactNode @@ -39,11 +42,14 @@ const AddBlock = ({ const { t } = useTranslation() const store = useStoreApi() const workflowStore = useWorkflowStore() + const isChatMode = useIsChatMode() const { nodesReadOnly } = useNodesReadOnly() const { handlePaneContextmenuCancel } = usePanelInteractions() const [open, setOpen] = useState(false) const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, false) const { nodesMap: nodesMetaDataMap } = useNodesMetaData() + const flowType = useHooksStore(s => s.configsMap?.flowType) + const showStartTab = flowType !== FlowType.ragPipeline && !isChatMode const handleOpenChange = useCallback((open: boolean) => { setOpen(open) @@ -51,7 +57,7 @@ const AddBlock = ({ handlePaneContextmenuCancel() }, [handlePaneContextmenuCancel]) - const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => { + const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { const { getNodes, } = store.getState() @@ -65,7 +71,7 @@ const AddBlock = ({ data: { ...(defaultValue as any), title: nodesWithSameType.length > 0 ? `${defaultValue.title} ${nodesWithSameType.length + 1}` : defaultValue.title, - ...toolDefaultValue, + ...pluginDefaultValue, _isCandidate: true, }, position: { @@ -108,6 +114,7 @@ const AddBlock = ({ trigger={renderTrigger || renderTriggerElement} popupClassName='!min-w-[256px]' availableBlocksTypes={availableNextBlocks} + showStartTab={showStartTab} /> ) } diff --git a/web/app/components/workflow/operator/control.tsx b/web/app/components/workflow/operator/control.tsx index cfc32bbc30..7f1225de86 100644 --- a/web/app/components/workflow/operator/control.tsx +++ b/web/app/components/workflow/operator/control.tsx @@ -24,7 +24,7 @@ import { useStore } from '../store' import Divider from '../../base/divider' import AddBlock from './add-block' import TipPopup from './tip-popup' -import ExportImage from './export-image' +import MoreActions from './more-actions' import { useOperator } from './hooks' import cn from '@/utils/classnames' @@ -89,7 +89,6 @@ const Control = () => { </div> </TipPopup> <Divider className='my-1 w-3.5' /> - <ExportImage /> <TipPopup title={t('workflow.panel.organizeBlocks')} shortcuts={['ctrl', 'o']}> <div className={cn( @@ -114,6 +113,7 @@ const Control = () => { {!maximizeCanvas && <RiAspectRatioLine className='h-4 w-4' />} </div> </TipPopup> + <MoreActions /> </div> ) } diff --git a/web/app/components/workflow/operator/index.tsx b/web/app/components/workflow/operator/index.tsx index 1100a7a905..b4fcf184a7 100644 --- a/web/app/components/workflow/operator/index.tsx +++ b/web/app/components/workflow/operator/index.tsx @@ -1,4 +1,5 @@ -import { memo, useEffect, useMemo, useRef } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef } from 'react' +import type { Node } from 'reactflow' import { MiniMap } from 'reactflow' import UndoRedo from '../header/undo-redo' import ZoomInOut from './zoom-in-out' @@ -24,6 +25,12 @@ const Operator = ({ handleUndo, handleRedo }: OperatorProps) => { return Math.max((workflowCanvasWidth - rightPanelWidth), 400) }, [workflowCanvasWidth, rightPanelWidth]) + const getMiniMapNodeClassName = useCallback((node: Node) => { + return node.data?.selected + ? 'bg-workflow-minimap-block border-components-option-card-option-selected-border' + : 'bg-workflow-minimap-block' + }, []) + // update bottom panel height useEffect(() => { if (bottomPanelRef.current) { @@ -65,6 +72,8 @@ const Operator = ({ handleUndo, handleRedo }: OperatorProps) => { height: 72, }} maskColor='var(--color-workflow-minimap-bg)' + nodeClassName={getMiniMapNodeClassName} + nodeStrokeWidth={3} className='!absolute !bottom-10 z-[9] !m-0 !h-[73px] !w-[103px] !rounded-lg !border-[0.5px] !border-divider-subtle !bg-background-default-subtle !shadow-md !shadow-shadow-shadow-5' /> diff --git a/web/app/components/workflow/operator/export-image.tsx b/web/app/components/workflow/operator/more-actions.tsx similarity index 87% rename from web/app/components/workflow/operator/export-image.tsx rename to web/app/components/workflow/operator/more-actions.tsx index 9b85847fd6..100df29560 100644 --- a/web/app/components/workflow/operator/export-image.tsx +++ b/web/app/components/workflow/operator/more-actions.tsx @@ -2,13 +2,15 @@ import type { FC } from 'react' import { memo, useCallback, + useMemo, useState, } from 'react' +import { useShallow } from 'zustand/react/shallow' import { useTranslation } from 'react-i18next' +import { RiExportLine, RiMoreFill } from '@remixicon/react' import { toJpeg, toPng, toSvg } from 'html-to-image' import { useNodesReadOnly } from '../hooks' import TipPopup from './tip-popup' -import { RiExportLine } from '@remixicon/react' import cn from '@/utils/classnames' import { PortalToFollowElem, @@ -18,8 +20,9 @@ import { import { getNodesBounds, useReactFlow } from 'reactflow' import ImagePreview from '@/app/components/base/image-uploader/image-preview' import { useStore } from '@/app/components/workflow/store' +import { useStore as useAppStore } from '@/app/components/app/store' -const ExportImage: FC = () => { +const MoreActions: FC = () => { const { t } = useTranslation() const { getNodesReadOnly } = useNodesReadOnly() const reactFlow = useReactFlow() @@ -29,6 +32,15 @@ const ExportImage: FC = () => { const [previewTitle, setPreviewTitle] = useState('') const knowledgeName = useStore(s => s.knowledgeName) const appName = useStore(s => s.appName) + const maximizeCanvas = useStore(s => s.maximizeCanvas) + const { appSidebarExpand } = useAppStore(useShallow(state => ({ + appSidebarExpand: state.appSidebarExpand, + }))) + + const crossAxisOffset = useMemo(() => { + if (maximizeCanvas) return 40 + return appSidebarExpand === 'expand' ? 188 : 40 + }, [appSidebarExpand, maximizeCanvas]) const handleExportImage = useCallback(async (type: 'png' | 'jpeg' | 'svg', currentWorkflow = false) => { if (!appName && !knowledgeName) @@ -53,14 +65,11 @@ const ExportImage: FC = () => { let dataUrl if (currentWorkflow) { - // Get all nodes and their bounds const nodes = reactFlow.getNodes() const nodesBounds = getNodesBounds(nodes) - // Save current viewport const currentViewport = reactFlow.getViewport() - // Calculate the required zoom to fit all nodes const viewportWidth = window.innerWidth const viewportHeight = window.innerHeight const zoom = Math.min( @@ -69,30 +78,25 @@ const ExportImage: FC = () => { 1, ) - // Calculate center position const centerX = nodesBounds.x + nodesBounds.width / 2 const centerY = nodesBounds.y + nodesBounds.height / 2 - // Set viewport to show all nodes reactFlow.setViewport({ x: viewportWidth / 2 - centerX * zoom, y: viewportHeight / 2 - centerY * zoom, zoom, }) - // Wait for the transition to complete await new Promise(resolve => setTimeout(resolve, 300)) - // Calculate actual content size with padding - const padding = 50 // More padding for better visualization + const padding = 50 const contentWidth = nodesBounds.width + padding * 2 const contentHeight = nodesBounds.height + padding * 2 - // Export with higher quality for whole workflow const exportOptions = { filter, - backgroundColor: '#1a1a1a', // Dark background to match previous style - pixelRatio: 2, // Higher resolution for better zoom + backgroundColor: '#1a1a1a', + pixelRatio: 2, width: contentWidth, height: contentHeight, style: { @@ -119,7 +123,6 @@ const ExportImage: FC = () => { filename += '-whole-workflow' - // Restore original viewport after a delay setTimeout(() => { reactFlow.setViewport(currentViewport) }, 500) @@ -142,11 +145,9 @@ const ExportImage: FC = () => { } if (currentWorkflow) { - // For whole workflow, show preview first setPreviewUrl(dataUrl) setPreviewTitle(`${filename}.${type}`) - // Also auto-download const link = document.createElement('a') link.href = dataUrl link.download = `${filename}.${type}` @@ -181,14 +182,14 @@ const ExportImage: FC = () => { <PortalToFollowElem open={open} onOpenChange={setOpen} - placement="top-start" + placement="bottom-end" offset={{ - mainAxis: 4, - crossAxis: -8, + mainAxis: -200, + crossAxis: crossAxisOffset, }} > <PortalToFollowElemTrigger> - <TipPopup title={t('workflow.common.exportImage')}> + <TipPopup title={t('workflow.common.moreActions')}> <div className={cn( 'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary', @@ -196,13 +197,17 @@ const ExportImage: FC = () => { )} onClick={handleTrigger} > - <RiExportLine className='h-4 w-4' /> + <RiMoreFill className='h-4 w-4' /> </div> </TipPopup> </PortalToFollowElemTrigger> <PortalToFollowElemContent className='z-10'> <div className='min-w-[180px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur text-text-secondary shadow-lg'> <div className='p-1'> + <div className='flex items-center gap-2 px-2 py-1 text-xs font-medium text-text-tertiary'> + <RiExportLine className='h-3 w-3' /> + {t('workflow.common.exportImage')} + </div> <div className='px-2 py-1 text-xs font-medium text-text-tertiary'> {t('workflow.common.currentView')} </div> @@ -264,4 +269,4 @@ const ExportImage: FC = () => { ) } -export default memo(ExportImage) +export default memo(MoreActions) diff --git a/web/app/components/workflow/panel/global-variable-panel/index.tsx b/web/app/components/workflow/panel/global-variable-panel/index.tsx index ad7996ab0c..a421a1605a 100644 --- a/web/app/components/workflow/panel/global-variable-panel/index.tsx +++ b/web/app/components/workflow/panel/global-variable-panel/index.tsx @@ -8,16 +8,53 @@ import Item from './item' import { useStore } from '@/app/components/workflow/store' import cn from '@/utils/classnames' +import { useTranslation } from 'react-i18next' +import { useIsChatMode } from '../../hooks' +import { isInWorkflowPage } from '../../constants' const Panel = () => { + const { t } = useTranslation() + const isChatMode = useIsChatMode() const setShowPanel = useStore(s => s.setShowGlobalVariablePanel) + const isWorkflowPage = isInWorkflowPage() const globalVariableList: GlobalVariable[] = [ - { + ...(isChatMode ? [{ name: 'conversation_id', - value_type: 'string', - description: 'conversation id', + value_type: 'string' as const, + description: t('workflow.globalVar.fieldsDescription.conversationId'), }, + { + name: 'dialog_count', + value_type: 'number' as const, + description: t('workflow.globalVar.fieldsDescription.dialogCount'), + }] : []), + { + name: 'user_id', + value_type: 'string', + description: t('workflow.globalVar.fieldsDescription.userId'), + }, + { + name: 'app_id', + value_type: 'string', + description: t('workflow.globalVar.fieldsDescription.appId'), + }, + { + name: 'workflow_id', + value_type: 'string', + description: t('workflow.globalVar.fieldsDescription.workflowId'), + }, + { + name: 'workflow_run_id', + value_type: 'string', + description: t('workflow.globalVar.fieldsDescription.workflowRunId'), + }, + // is workflow + ...((isWorkflowPage && !isChatMode) ? [{ + name: 'timestamp', + value_type: 'number' as const, + description: t('workflow.globalVar.fieldsDescription.triggerTimestamp'), + }] : []), ] return ( @@ -27,7 +64,7 @@ const Panel = () => { )} > <div className='system-xl-semibold flex shrink-0 items-center justify-between p-4 pb-0 text-text-primary'> - Global Variables(Current not show) + {t('workflow.globalVar.title')} <div className='flex items-center'> <div className='flex h-6 w-6 cursor-pointer items-center justify-center' @@ -37,9 +74,9 @@ const Panel = () => { </div> </div> </div> - <div className='system-sm-regular shrink-0 px-4 py-1 text-text-tertiary'>...</div> + <div className='system-sm-regular shrink-0 px-4 py-1 text-text-tertiary'>{t('workflow.globalVar.description')}</div> - <div className='grow overflow-y-auto rounded-b-2xl px-4'> + <div className='mt-4 grow overflow-y-auto rounded-b-2xl px-4'> {globalVariableList.map(item => ( <Item key={item.name} diff --git a/web/app/components/workflow/panel/global-variable-panel/item.tsx b/web/app/components/workflow/panel/global-variable-panel/item.tsx index ddf9abe1d3..5185c1bead 100644 --- a/web/app/components/workflow/panel/global-variable-panel/item.tsx +++ b/web/app/components/workflow/panel/global-variable-panel/item.tsx @@ -1,6 +1,7 @@ import { memo } from 'react' import { capitalize } from 'lodash-es' -import { Env } from '@/app/components/base/icons/src/vender/line/others' +import { GlobalVariable as GlobalVariableIcon } from '@/app/components/base/icons/src/vender/line/others' + import type { GlobalVariable } from '@/app/components/workflow/types' import cn from '@/utils/classnames' @@ -17,12 +18,15 @@ const Item = ({ )}> <div className='flex items-center justify-between'> <div className='flex grow items-center gap-1'> - <Env className='h-4 w-4 text-util-colors-violet-violet-600' /> - <div className='system-sm-medium text-text-primary'>{payload.name}</div> + <GlobalVariableIcon className='h-4 w-4 text-util-colors-orange-orange-600' /> + <div className='system-sm-medium text-text-primary'> + <span className='text-text-tertiary'>sys.</span> + {payload.name} + </div> <div className='system-xs-medium text-text-tertiary'>{capitalize(payload.value_type)}</div> </div> </div> - <div className='system-xs-regular truncate text-text-tertiary'>{payload.description}</div> + <div className='system-xs-regular mt-1.5 truncate text-text-tertiary'>{payload.description}</div> </div> ) } diff --git a/web/app/components/workflow/panel/workflow-preview.tsx b/web/app/components/workflow/panel/workflow-preview.tsx index fdb1767df9..292a964b9e 100644 --- a/web/app/components/workflow/panel/workflow-preview.tsx +++ b/web/app/components/workflow/panel/workflow-preview.tsx @@ -31,6 +31,7 @@ const WorkflowPreview = () => { const { t } = useTranslation() const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions() const workflowRunningData = useStore(s => s.workflowRunningData) + const isListening = useStore(s => s.isListening) const showInputsPanel = useStore(s => s.showInputsPanel) const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth) const panelWidth = useStore(s => s.previewPanelWidth) @@ -48,7 +49,16 @@ const WorkflowPreview = () => { }, [showDebugAndPreviewPanel, showInputsPanel]) useEffect(() => { - if ((workflowRunningData?.result.status === WorkflowRunningStatus.Succeeded || workflowRunningData?.result.status === WorkflowRunningStatus.Failed) && !workflowRunningData.resultText && !workflowRunningData.result.files?.length) + if (isListening) + switchTab('DETAIL') + }, [isListening]) + + useEffect(() => { + const status = workflowRunningData?.result.status + if (!workflowRunningData) + return + + if ((status === WorkflowRunningStatus.Succeeded || status === WorkflowRunningStatus.Failed) && !workflowRunningData.resultText && !workflowRunningData.result.files?.length) switchTab('DETAIL') }, [workflowRunningData]) diff --git a/web/app/components/workflow/run/index.tsx b/web/app/components/workflow/run/index.tsx index 2e9ae392a6..5f6b07033d 100644 --- a/web/app/components/workflow/run/index.tsx +++ b/web/app/components/workflow/run/index.tsx @@ -5,6 +5,8 @@ import { useContext } from 'use-context-selector' import { useTranslation } from 'react-i18next' import OutputPanel from './output-panel' import ResultPanel from './result-panel' +import StatusPanel from './status' +import { WorkflowRunningStatus } from '@/app/components/workflow/types' import TracingPanel from './tracing-panel' import cn from '@/utils/classnames' import { ToastContext } from '@/app/components/base/toast' @@ -12,6 +14,8 @@ import Loading from '@/app/components/base/loading' import { fetchRunDetail, fetchTracingList } from '@/service/log' import type { NodeTracing } from '@/types/workflow' import type { WorkflowRunDetailResponse } from '@/models/log' +import { useStore } from '../store' + export type RunProps = { hideResult?: boolean activeTab?: 'RESULT' | 'DETAIL' | 'TRACING' @@ -33,6 +37,7 @@ const RunPanel: FC<RunProps> = ({ const [loading, setLoading] = useState<boolean>(true) const [runDetail, setRunDetail] = useState<WorkflowRunDetailResponse>() const [list, setList] = useState<NodeTracing[]>([]) + const isListening = useStore(s => s.isListening) const executor = useMemo(() => { if (runDetail?.created_by_role === 'account') @@ -89,6 +94,11 @@ const RunPanel: FC<RunProps> = ({ await getTracingList() } + useEffect(() => { + if (isListening) + setCurrentTab('DETAIL') + }, [isListening]) + useEffect(() => { // fetch data if (runDetailUrl && tracingListUrl) @@ -166,6 +176,11 @@ const RunPanel: FC<RunProps> = ({ exceptionCounts={runDetail.exceptions_count} /> )} + {!loading && currentTab === 'DETAIL' && !runDetail && isListening && ( + <StatusPanel + status={WorkflowRunningStatus.Running} + /> + )} {!loading && currentTab === 'TRACING' && ( <TracingPanel className='bg-background-section-burn' diff --git a/web/app/components/workflow/run/status-container.tsx b/web/app/components/workflow/run/status-container.tsx index 47890da0b2..6837592c4e 100644 --- a/web/app/components/workflow/run/status-container.tsx +++ b/web/app/components/workflow/run/status-container.tsx @@ -14,6 +14,7 @@ const StatusContainer: FC<Props> = ({ children, }) => { const { theme } = useTheme() + return ( <div className={cn( diff --git a/web/app/components/workflow/run/status.tsx b/web/app/components/workflow/run/status.tsx index 5c533c9e5f..fa9559fcf8 100644 --- a/web/app/components/workflow/run/status.tsx +++ b/web/app/components/workflow/run/status.tsx @@ -5,6 +5,7 @@ import cn from '@/utils/classnames' import Indicator from '@/app/components/header/indicator' import StatusContainer from '@/app/components/workflow/run/status-container' import { useDocLink } from '@/context/i18n' +import { useStore } from '../store' type ResultProps = { status: string @@ -23,6 +24,7 @@ const StatusPanel: FC<ResultProps> = ({ }) => { const { t } = useTranslation() const docLink = useDocLink() + const isListening = useStore(s => s.isListening) return ( <StatusContainer status={status}> @@ -45,7 +47,7 @@ const StatusPanel: FC<ResultProps> = ({ {status === 'running' && ( <> <Indicator color={'blue'} /> - <span>Running</span> + <span>{isListening ? 'Listening' : 'Running'}</span> </> )} {status === 'succeeded' && ( diff --git a/web/app/components/workflow/run/utils/format-log/parallel/index.ts b/web/app/components/workflow/run/utils/format-log/parallel/index.ts index f5a1136e3f..22c96918e9 100644 --- a/web/app/components/workflow/run/utils/format-log/parallel/index.ts +++ b/web/app/components/workflow/run/utils/format-log/parallel/index.ts @@ -148,7 +148,7 @@ const format = (list: NodeTracing[], t: any, isPrint?: boolean): NodeTracing[] = return false const isParallelStartNode = node.parallelDetail?.isParallelStartNode - // eslint-disable-next-line sonarjs/prefer-single-boolean-return + if (!isParallelStartNode) return false diff --git a/web/app/components/workflow/shortcuts-name.tsx b/web/app/components/workflow/shortcuts-name.tsx index e7122c5ad5..9dd8c4bcd1 100644 --- a/web/app/components/workflow/shortcuts-name.tsx +++ b/web/app/components/workflow/shortcuts-name.tsx @@ -5,10 +5,12 @@ import cn from '@/utils/classnames' type ShortcutsNameProps = { keys: string[] className?: string + textColor?: 'default' | 'secondary' } const ShortcutsName = ({ keys, className, + textColor = 'default', }: ShortcutsNameProps) => { return ( <div className={cn( @@ -19,7 +21,10 @@ const ShortcutsName = ({ keys.map(key => ( <div key={key} - className='system-kbd flex h-4 min-w-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray capitalize' + className={cn( + 'system-kbd flex h-4 min-w-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray capitalize', + textColor === 'secondary' && 'text-text-tertiary', + )} > {getKeyboardKeyNameBySystem(key)} </div> diff --git a/web/app/components/workflow/store/__tests__/trigger-status.test.ts b/web/app/components/workflow/store/__tests__/trigger-status.test.ts new file mode 100644 index 0000000000..d7e1284487 --- /dev/null +++ b/web/app/components/workflow/store/__tests__/trigger-status.test.ts @@ -0,0 +1,293 @@ +import { act, renderHook } from '@testing-library/react' +import { useTriggerStatusStore } from '../trigger-status' +import type { EntryNodeStatus } from '../trigger-status' + +describe('useTriggerStatusStore', () => { + beforeEach(() => { + // Clear the store state before each test + const { result } = renderHook(() => useTriggerStatusStore()) + act(() => { + result.current.clearTriggerStatuses() + }) + }) + + describe('Initial State', () => { + it('should initialize with empty trigger statuses', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + expect(result.current.triggerStatuses).toEqual({}) + }) + + it('should return "disabled" for non-existent trigger status', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + const status = result.current.getTriggerStatus('non-existent-id') + expect(status).toBe('disabled') + }) + }) + + describe('setTriggerStatus', () => { + it('should set trigger status for a single node', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + act(() => { + result.current.setTriggerStatus('node-1', 'enabled') + }) + + expect(result.current.triggerStatuses['node-1']).toBe('enabled') + expect(result.current.getTriggerStatus('node-1')).toBe('enabled') + }) + + it('should update existing trigger status', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + // Set initial status + act(() => { + result.current.setTriggerStatus('node-1', 'enabled') + }) + expect(result.current.getTriggerStatus('node-1')).toBe('enabled') + + // Update status + act(() => { + result.current.setTriggerStatus('node-1', 'disabled') + }) + expect(result.current.getTriggerStatus('node-1')).toBe('disabled') + }) + + it('should handle multiple nodes independently', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + act(() => { + result.current.setTriggerStatus('node-1', 'enabled') + result.current.setTriggerStatus('node-2', 'disabled') + }) + + expect(result.current.getTriggerStatus('node-1')).toBe('enabled') + expect(result.current.getTriggerStatus('node-2')).toBe('disabled') + }) + }) + + describe('setTriggerStatuses', () => { + it('should set multiple trigger statuses at once', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + const statuses = { + 'node-1': 'enabled' as EntryNodeStatus, + 'node-2': 'disabled' as EntryNodeStatus, + 'node-3': 'enabled' as EntryNodeStatus, + } + + act(() => { + result.current.setTriggerStatuses(statuses) + }) + + expect(result.current.triggerStatuses).toEqual(statuses) + expect(result.current.getTriggerStatus('node-1')).toBe('enabled') + expect(result.current.getTriggerStatus('node-2')).toBe('disabled') + expect(result.current.getTriggerStatus('node-3')).toBe('enabled') + }) + + it('should replace existing statuses completely', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + // Set initial statuses + act(() => { + result.current.setTriggerStatuses({ + 'node-1': 'enabled', + 'node-2': 'disabled', + }) + }) + + // Replace with new statuses + act(() => { + result.current.setTriggerStatuses({ + 'node-3': 'enabled', + 'node-4': 'disabled', + }) + }) + + expect(result.current.triggerStatuses).toEqual({ + 'node-3': 'enabled', + 'node-4': 'disabled', + }) + expect(result.current.getTriggerStatus('node-1')).toBe('disabled') // default + expect(result.current.getTriggerStatus('node-2')).toBe('disabled') // default + }) + + it('should handle empty object', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + // Set some initial data + act(() => { + result.current.setTriggerStatus('node-1', 'enabled') + }) + + // Clear with empty object + act(() => { + result.current.setTriggerStatuses({}) + }) + + expect(result.current.triggerStatuses).toEqual({}) + expect(result.current.getTriggerStatus('node-1')).toBe('disabled') + }) + }) + + describe('getTriggerStatus', () => { + it('should return the correct status for existing nodes', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + act(() => { + result.current.setTriggerStatuses({ + 'enabled-node': 'enabled', + 'disabled-node': 'disabled', + }) + }) + + expect(result.current.getTriggerStatus('enabled-node')).toBe('enabled') + expect(result.current.getTriggerStatus('disabled-node')).toBe('disabled') + }) + + it('should return "disabled" as default for non-existent nodes', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + expect(result.current.getTriggerStatus('non-existent')).toBe('disabled') + expect(result.current.getTriggerStatus('')).toBe('disabled') + expect(result.current.getTriggerStatus('undefined-node')).toBe('disabled') + }) + }) + + describe('clearTriggerStatuses', () => { + it('should clear all trigger statuses', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + // Set some statuses + act(() => { + result.current.setTriggerStatuses({ + 'node-1': 'enabled', + 'node-2': 'disabled', + 'node-3': 'enabled', + }) + }) + + expect(Object.keys(result.current.triggerStatuses)).toHaveLength(3) + + // Clear all + act(() => { + result.current.clearTriggerStatuses() + }) + + expect(result.current.triggerStatuses).toEqual({}) + expect(result.current.getTriggerStatus('node-1')).toBe('disabled') + expect(result.current.getTriggerStatus('node-2')).toBe('disabled') + expect(result.current.getTriggerStatus('node-3')).toBe('disabled') + }) + + it('should not throw when clearing empty statuses', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + expect(() => { + act(() => { + result.current.clearTriggerStatuses() + }) + }).not.toThrow() + + expect(result.current.triggerStatuses).toEqual({}) + }) + }) + + describe('Store Reactivity', () => { + it('should notify subscribers when status changes', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + const initialTriggerStatuses = result.current.triggerStatuses + + act(() => { + result.current.setTriggerStatus('reactive-node', 'enabled') + }) + + // The reference should change, indicating reactivity + expect(result.current.triggerStatuses).not.toBe(initialTriggerStatuses) + expect(result.current.triggerStatuses['reactive-node']).toBe('enabled') + }) + + it('should maintain immutability when updating statuses', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + act(() => { + result.current.setTriggerStatus('node-1', 'enabled') + }) + + const firstSnapshot = result.current.triggerStatuses + + act(() => { + result.current.setTriggerStatus('node-2', 'disabled') + }) + + const secondSnapshot = result.current.triggerStatuses + + // References should be different (immutable updates) + expect(firstSnapshot).not.toBe(secondSnapshot) + // But the first node status should remain + expect(secondSnapshot['node-1']).toBe('enabled') + expect(secondSnapshot['node-2']).toBe('disabled') + }) + }) + + describe('Edge Cases', () => { + it('should handle rapid consecutive updates', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + act(() => { + result.current.setTriggerStatus('rapid-node', 'enabled') + result.current.setTriggerStatus('rapid-node', 'disabled') + result.current.setTriggerStatus('rapid-node', 'enabled') + }) + + expect(result.current.getTriggerStatus('rapid-node')).toBe('enabled') + }) + + it('should handle setting the same status multiple times', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + act(() => { + result.current.setTriggerStatus('same-node', 'enabled') + }) + + const firstSnapshot = result.current.triggerStatuses + + act(() => { + result.current.setTriggerStatus('same-node', 'enabled') + }) + + const secondSnapshot = result.current.triggerStatuses + + expect(result.current.getTriggerStatus('same-node')).toBe('enabled') + // Should still create new reference (Zustand behavior) + expect(firstSnapshot).not.toBe(secondSnapshot) + }) + + it('should handle special node ID formats', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + const specialNodeIds = [ + 'node-with-dashes', + 'node_with_underscores', + 'nodeWithCamelCase', + 'node123', + 'node-123-abc', + ] + + act(() => { + specialNodeIds.forEach((nodeId, index) => { + const status = index % 2 === 0 ? 'enabled' : 'disabled' + result.current.setTriggerStatus(nodeId, status as EntryNodeStatus) + }) + }) + + specialNodeIds.forEach((nodeId, index) => { + const expectedStatus = index % 2 === 0 ? 'enabled' : 'disabled' + expect(result.current.getTriggerStatus(nodeId)).toBe(expectedStatus) + }) + }) + }) +}) diff --git a/web/app/components/workflow/store/index.ts b/web/app/components/workflow/store/index.ts index 61cd5773ce..5ca06d2ec3 100644 --- a/web/app/components/workflow/store/index.ts +++ b/web/app/components/workflow/store/index.ts @@ -1 +1,2 @@ export * from './workflow' +export * from './trigger-status' diff --git a/web/app/components/workflow/store/trigger-status.ts b/web/app/components/workflow/store/trigger-status.ts new file mode 100644 index 0000000000..2f472c79b9 --- /dev/null +++ b/web/app/components/workflow/store/trigger-status.ts @@ -0,0 +1,42 @@ +import { create } from 'zustand' +import { subscribeWithSelector } from 'zustand/middleware' + +export type EntryNodeStatus = 'enabled' | 'disabled' + +type TriggerStatusState = { + // Map of nodeId to trigger status + triggerStatuses: Record<string, EntryNodeStatus> + + // Actions + setTriggerStatus: (nodeId: string, status: EntryNodeStatus) => void + setTriggerStatuses: (statuses: Record<string, EntryNodeStatus>) => void + getTriggerStatus: (nodeId: string) => EntryNodeStatus + clearTriggerStatuses: () => void +} + +export const useTriggerStatusStore = create<TriggerStatusState>()( + subscribeWithSelector((set, get) => ({ + triggerStatuses: {}, + + setTriggerStatus: (nodeId: string, status: EntryNodeStatus) => { + set(state => ({ + triggerStatuses: { + ...state.triggerStatuses, + [nodeId]: status, + }, + })) + }, + + setTriggerStatuses: (statuses: Record<string, EntryNodeStatus>) => { + set({ triggerStatuses: statuses }) + }, + + getTriggerStatus: (nodeId: string): EntryNodeStatus => { + return get().triggerStatuses[nodeId] || 'disabled' + }, + + clearTriggerStatuses: () => { + set({ triggerStatuses: {} }) + }, + })), +) diff --git a/web/app/components/workflow/store/workflow/chat-variable-slice.ts b/web/app/components/workflow/store/workflow/chat-variable-slice.ts index 0d81446005..96fe8b00b8 100644 --- a/web/app/components/workflow/store/workflow/chat-variable-slice.ts +++ b/web/app/components/workflow/store/workflow/chat-variable-slice.ts @@ -20,7 +20,12 @@ export const createChatVariableSlice: StateCreator<ChatVariableSliceShape> = (se return ({ showChatVariablePanel: false, - setShowChatVariablePanel: showChatVariablePanel => set(() => ({ showChatVariablePanel })), + setShowChatVariablePanel: showChatVariablePanel => set(() => { + if (showChatVariablePanel) + return { ...hideAllPanel, showChatVariablePanel: true } + else + return { showChatVariablePanel: false } + }), showGlobalVariablePanel: false, setShowGlobalVariablePanel: showGlobalVariablePanel => set(() => { if (showGlobalVariablePanel) diff --git a/web/app/components/workflow/store/workflow/env-variable-slice.ts b/web/app/components/workflow/store/workflow/env-variable-slice.ts index de60e7dd5f..2ba6ce084a 100644 --- a/web/app/components/workflow/store/workflow/env-variable-slice.ts +++ b/web/app/components/workflow/store/workflow/env-variable-slice.ts @@ -10,11 +10,24 @@ export type EnvVariableSliceShape = { setEnvSecrets: (envSecrets: Record<string, string>) => void } -export const createEnvVariableSlice: StateCreator<EnvVariableSliceShape> = set => ({ - showEnvPanel: false, - setShowEnvPanel: showEnvPanel => set(() => ({ showEnvPanel })), - environmentVariables: [], - setEnvironmentVariables: environmentVariables => set(() => ({ environmentVariables })), - envSecrets: {}, - setEnvSecrets: envSecrets => set(() => ({ envSecrets })), -}) +export const createEnvVariableSlice: StateCreator<EnvVariableSliceShape> = (set) => { + const hideAllPanel = { + showDebugAndPreviewPanel: false, + showEnvPanel: false, + showChatVariablePanel: false, + showGlobalVariablePanel: false, + } + return ({ + showEnvPanel: false, + setShowEnvPanel: showEnvPanel => set(() => { + if (showEnvPanel) + return { ...hideAllPanel, showEnvPanel: true } + else + return { showEnvPanel: false } + }), + environmentVariables: [], + setEnvironmentVariables: environmentVariables => set(() => ({ environmentVariables })), + envSecrets: {}, + setEnvSecrets: envSecrets => set(() => ({ envSecrets })), + }) +} diff --git a/web/app/components/workflow/store/workflow/node-slice.ts b/web/app/components/workflow/store/workflow/node-slice.ts index 2068ee0ba1..3463fdee57 100644 --- a/web/app/components/workflow/store/workflow/node-slice.ts +++ b/web/app/components/workflow/store/workflow/node-slice.ts @@ -48,6 +48,11 @@ export type NodeSliceShape = { setLoopTimes: (loopTimes: number) => void iterParallelLogMap: Map<string, Map<string, NodeTracing[]>> setIterParallelLogMap: (iterParallelLogMap: Map<string, Map<string, NodeTracing[]>>) => void + pendingSingleRun?: { + nodeId: string + action: 'run' | 'stop' + } + setPendingSingleRun: (payload?: NodeSliceShape['pendingSingleRun']) => void } export const createNodeSlice: StateCreator<NodeSliceShape> = set => ({ @@ -73,4 +78,6 @@ export const createNodeSlice: StateCreator<NodeSliceShape> = set => ({ setLoopTimes: loopTimes => set(() => ({ loopTimes })), iterParallelLogMap: new Map<string, Map<string, NodeTracing[]>>(), setIterParallelLogMap: iterParallelLogMap => set(() => ({ iterParallelLogMap })), + pendingSingleRun: undefined, + setPendingSingleRun: payload => set(() => ({ pendingSingleRun: payload })), }) diff --git a/web/app/components/workflow/store/workflow/tool-slice.ts b/web/app/components/workflow/store/workflow/tool-slice.ts index c5180022fc..d5ff7743be 100644 --- a/web/app/components/workflow/store/workflow/tool-slice.ts +++ b/web/app/components/workflow/store/workflow/tool-slice.ts @@ -1,11 +1,24 @@ import type { StateCreator } from 'zustand' +import type { ToolWithProvider } from '../../types' export type ToolSliceShape = { toolPublished: boolean setToolPublished: (toolPublished: boolean) => void + lastPublishedHasUserInput: boolean + setLastPublishedHasUserInput: (hasUserInput: boolean) => void + buildInTools?: ToolWithProvider[] + customTools?: ToolWithProvider[] + workflowTools?: ToolWithProvider[] + mcpTools?: ToolWithProvider[] } export const createToolSlice: StateCreator<ToolSliceShape> = set => ({ toolPublished: false, setToolPublished: toolPublished => set(() => ({ toolPublished })), + lastPublishedHasUserInput: false, + setLastPublishedHasUserInput: hasUserInput => set(() => ({ lastPublishedHasUserInput: hasUserInput })), + buildInTools: undefined, + customTools: undefined, + workflowTools: undefined, + mcpTools: undefined, }) diff --git a/web/app/components/workflow/store/workflow/workflow-draft-slice.ts b/web/app/components/workflow/store/workflow/workflow-draft-slice.ts index a4048a9455..cae716dd52 100644 --- a/web/app/components/workflow/store/workflow/workflow-draft-slice.ts +++ b/web/app/components/workflow/store/workflow/workflow-draft-slice.ts @@ -21,6 +21,8 @@ export type WorkflowDraftSliceShape = { setSyncWorkflowDraftHash: (hash: string) => void isSyncingWorkflowDraft: boolean setIsSyncingWorkflowDraft: (isSyncingWorkflowDraft: boolean) => void + isWorkflowDataLoaded: boolean + setIsWorkflowDataLoaded: (loaded: boolean) => void } export const createWorkflowDraftSlice: StateCreator<WorkflowDraftSliceShape> = set => ({ @@ -33,4 +35,6 @@ export const createWorkflowDraftSlice: StateCreator<WorkflowDraftSliceShape> = s setSyncWorkflowDraftHash: syncWorkflowDraftHash => set(() => ({ syncWorkflowDraftHash })), isSyncingWorkflowDraft: false, setIsSyncingWorkflowDraft: isSyncingWorkflowDraft => set(() => ({ isSyncingWorkflowDraft })), + isWorkflowDataLoaded: false, + setIsWorkflowDataLoaded: loaded => set(() => ({ isWorkflowDataLoaded: loaded })), }) diff --git a/web/app/components/workflow/store/workflow/workflow-slice.ts b/web/app/components/workflow/store/workflow/workflow-slice.ts index 91dac42adb..35eeff07a7 100644 --- a/web/app/components/workflow/store/workflow/workflow-slice.ts +++ b/web/app/components/workflow/store/workflow/workflow-slice.ts @@ -1,6 +1,7 @@ import type { StateCreator } from 'zustand' import type { Node, + TriggerNodeType, WorkflowRunningData, } from '@/app/components/workflow/types' import type { FileUploadConfigResponse } from '@/models/common' @@ -13,6 +14,16 @@ type PreviewRunningData = WorkflowRunningData & { export type WorkflowSliceShape = { workflowRunningData?: PreviewRunningData setWorkflowRunningData: (workflowData: PreviewRunningData) => void + isListening: boolean + setIsListening: (listening: boolean) => void + listeningTriggerType: TriggerNodeType | null + setListeningTriggerType: (triggerType: TriggerNodeType | null) => void + listeningTriggerNodeId: string | null + setListeningTriggerNodeId: (nodeId: string | null) => void + listeningTriggerNodeIds: string[] + setListeningTriggerNodeIds: (nodeIds: string[]) => void + listeningTriggerIsAll: boolean + setListeningTriggerIsAll: (isAll: boolean) => void clipboardElements: Node[] setClipboardElements: (clipboardElements: Node[]) => void selection: null | { x1: number; y1: number; x2: number; y2: number } @@ -36,6 +47,16 @@ export type WorkflowSliceShape = { export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = set => ({ workflowRunningData: undefined, setWorkflowRunningData: workflowRunningData => set(() => ({ workflowRunningData })), + isListening: false, + setIsListening: listening => set(() => ({ isListening: listening })), + listeningTriggerType: null, + setListeningTriggerType: triggerType => set(() => ({ listeningTriggerType: triggerType })), + listeningTriggerNodeId: null, + setListeningTriggerNodeId: nodeId => set(() => ({ listeningTriggerNodeId: nodeId })), + listeningTriggerNodeIds: [], + setListeningTriggerNodeIds: nodeIds => set(() => ({ listeningTriggerNodeIds: nodeIds })), + listeningTriggerIsAll: false, + setListeningTriggerIsAll: isAll => set(() => ({ listeningTriggerIsAll: isAll })), clipboardElements: [], setClipboardElements: clipboardElements => set(() => ({ clipboardElements })), selection: null, diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index 324443cfd1..5ae8d530a8 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -5,10 +5,7 @@ import type { XYPosition, } from 'reactflow' import type { Resolution, TransferMethod } from '@/types/app' -import type { - DataSourceDefaultValue, - ToolDefaultValue, -} from '@/app/components/workflow/block-selector/types' +import type { PluginDefaultValue } from '@/app/components/workflow/block-selector/types' import type { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' import type { FileResponse, NodeTracing, PanelProps } from '@/types/workflow' import type { Collection, Tool } from '@/app/components/tools/types' @@ -50,6 +47,9 @@ export enum BlockEnum { DataSource = 'datasource', DataSourceEmpty = 'datasource-empty', KnowledgeBase = 'knowledge-index', + TriggerSchedule = 'trigger-schedule', + TriggerWebhook = 'trigger-webhook', + TriggerPlugin = 'trigger-plugin', } export enum ControlMode { @@ -103,9 +103,11 @@ export type CommonNodeType<T = {}> = { retry_config?: WorkflowRetryConfig default_value?: DefaultValueForm[] credential_id?: string + subscription_id?: string + provider_id?: string _dimmed?: boolean -} & T & Partial<Pick<ToolDefaultValue, 'provider_id' | 'provider_type' | 'provider_name' | 'tool_name'>> - & Partial<Pick<DataSourceDefaultValue, 'plugin_id' | 'provider_type' | 'provider_name' | 'datasource_name'>> + _pluginInstallLocked?: boolean +} & T & Partial<PluginDefaultValue> export type CommonEdgeType = { _hovering?: boolean @@ -176,7 +178,7 @@ export type ConversationVariable = { export type GlobalVariable = { name: string - value_type: 'string' | 'number' + value_type: 'string' | 'number' | 'integer' description: string } @@ -341,7 +343,7 @@ export type NodeDefault<T = {}> = { }) => Var[] } -export type OnSelectBlock = (type: BlockEnum, toolDefaultValue?: ToolDefaultValue | DataSourceDefaultValue) => void +export type OnSelectBlock = (type: BlockEnum, pluginDefaultValue?: PluginDefaultValue) => void export enum WorkflowRunningStatus { Waiting = 'waiting', @@ -359,6 +361,7 @@ export enum WorkflowVersion { export enum NodeRunningStatus { NotStart = 'not-start', Waiting = 'waiting', + Listening = 'listening', Running = 'running', Succeeded = 'succeeded', Failed = 'failed', @@ -372,7 +375,7 @@ export type OnNodeAdd = ( nodeType: BlockEnum sourceHandle?: string targetHandle?: string - toolDefaultValue?: ToolDefaultValue | DataSourceDefaultValue + pluginDefaultValue?: PluginDefaultValue }, oldNodesPayload: { prevNodeId?: string @@ -449,6 +452,7 @@ export type MoreInfo = { export type ToolWithProvider = Collection & { tools: Tool[] meta: PluginMeta + plugin_unique_identifier?: string } export type RAGRecommendedPlugins = { @@ -493,3 +497,23 @@ export enum VersionHistoryContextMenuOptions { export type ChildNodeTypeCount = { [key: string]: number; } + +export const TRIGGER_NODE_TYPES = [ + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, +] as const + +// Type-safe trigger node type extracted from TRIGGER_NODE_TYPES array +export type TriggerNodeType = typeof TRIGGER_NODE_TYPES[number] + +export function isTriggerNode(nodeType: BlockEnum): boolean { + return TRIGGER_NODE_TYPES.includes(nodeType as any) +} + +export type Block = { + classification?: string + type: BlockEnum + title: string + description?: string +} diff --git a/web/app/components/workflow/utils/node-navigation.ts b/web/app/components/workflow/utils/node-navigation.ts index 5522764949..57106ae6ee 100644 --- a/web/app/components/workflow/utils/node-navigation.ts +++ b/web/app/components/workflow/utils/node-navigation.ts @@ -97,13 +97,14 @@ export function setupScrollToNodeListener( const node = nodes.find(n => n.id === nodeId) if (node) { // Use ReactFlow's fitView API to scroll to the node - reactflow.fitView({ - nodes: [node], - padding: 0.2, - duration: 800, - minZoom: 0.5, - maxZoom: 1, - }) + const nodePosition = { x: node.position.x, y: node.position.y } + + // Calculate position to place node in top-left area + // Move the center point right and down to show node in top-left + const targetX = nodePosition.x + window.innerWidth * 0.25 + const targetY = nodePosition.y + window.innerHeight * 0.25 + + reactflow.setCenter(targetX, targetY, { zoom: 1, duration: 800 }) } } } diff --git a/web/app/components/workflow/utils/trigger.ts b/web/app/components/workflow/utils/trigger.ts new file mode 100644 index 0000000000..f6d197c69c --- /dev/null +++ b/web/app/components/workflow/utils/trigger.ts @@ -0,0 +1,52 @@ +import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' +import type { PluginTriggerNodeType } from '@/app/components/workflow/nodes/trigger-plugin/types' + +export type TriggerCheckParams = { + triggerInputsSchema: Array<{ + variable: string + label: string + required?: boolean + }> + isReadyForCheckValid: boolean +} + +export const getTriggerCheckParams = ( + triggerData: PluginTriggerNodeType, + triggerProviders: TriggerWithProvider[] | undefined, + language: string, +): TriggerCheckParams => { + if (!triggerProviders) { + return { + triggerInputsSchema: [], + isReadyForCheckValid: false, + } + } + + const { + provider_id, + provider_name, + event_name, + } = triggerData + + const provider = triggerProviders.find(item => + item.name === provider_name + || item.id === provider_id + || (provider_id && item.plugin_id === provider_id), + ) + + const currentEvent = provider?.events.find(event => event.name === event_name) + + const triggerInputsSchema = (currentEvent?.parameters || []).map((parameter) => { + const label = parameter.label?.[language] || parameter.label?.en_US || parameter.name + return { + variable: parameter.name, + label, + required: parameter.required, + } + }) + + return { + triggerInputsSchema, + isReadyForCheckValid: true, + } +} diff --git a/web/app/components/workflow/utils/workflow-entry.ts b/web/app/components/workflow/utils/workflow-entry.ts new file mode 100644 index 0000000000..724a68a85b --- /dev/null +++ b/web/app/components/workflow/utils/workflow-entry.ts @@ -0,0 +1,26 @@ +import { BlockEnum, type Node, isTriggerNode } from '../types' + +/** + * Get the workflow entry node + * Priority: trigger nodes > start node + */ +export function getWorkflowEntryNode(nodes: Node[]): Node | undefined { + const triggerNode = nodes.find(node => isTriggerNode(node.data.type)) + if (triggerNode) return triggerNode + + return nodes.find(node => node.data.type === BlockEnum.Start) +} + +/** + * Check if a node type is a workflow entry node + */ +export function isWorkflowEntryNode(nodeType: BlockEnum): boolean { + return nodeType === BlockEnum.Start || isTriggerNode(nodeType) +} + +/** + * Check if workflow is in trigger mode + */ +export function isTriggerWorkflow(nodes: Node[]): boolean { + return nodes.some(node => isTriggerNode(node.data.type)) +} diff --git a/web/app/components/workflow/utils/workflow.ts b/web/app/components/workflow/utils/workflow.ts index 48cb819086..14b1eb87d5 100644 --- a/web/app/components/workflow/utils/workflow.ts +++ b/web/app/components/workflow/utils/workflow.ts @@ -34,6 +34,9 @@ export const canRunBySingle = (nodeType: BlockEnum, isChildNode: boolean) => { || nodeType === BlockEnum.VariableAggregator || nodeType === BlockEnum.Assigner || nodeType === BlockEnum.DataSource + || nodeType === BlockEnum.TriggerSchedule + || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerPlugin } export const isSupportCustomRunForm = (nodeType: BlockEnum) => { @@ -92,18 +95,29 @@ export const getNodesConnectedSourceOrTargetHandleIdsMap = (changes: ConnectedSo return nodesConnectedSourceOrTargetHandleIdsMap } -export const getValidTreeNodes = (startNode: Node, nodes: Node[], edges: Edge[]) => { - if (!startNode) { +export const getValidTreeNodes = (nodes: Node[], edges: Edge[]) => { + // Find all start nodes (Start and Trigger nodes) + const startNodes = nodes.filter(node => + node.data.type === BlockEnum.Start + || node.data.type === BlockEnum.TriggerSchedule + || node.data.type === BlockEnum.TriggerWebhook + || node.data.type === BlockEnum.TriggerPlugin, + ) + + if (startNodes.length === 0) { return { validNodes: [], maxDepth: 0, } } - const list: Node[] = [startNode] - let maxDepth = 1 + const list: Node[] = [] + let maxDepth = 0 const traverse = (root: Node, depth: number) => { + // Add the current node to the list + list.push(root) + if (depth > maxDepth) maxDepth = depth @@ -111,19 +125,19 @@ export const getValidTreeNodes = (startNode: Node, nodes: Node[], edges: Edge[]) if (outgoers.length) { outgoers.forEach((outgoer) => { - list.push(outgoer) + // Only traverse if we haven't processed this node yet (avoid cycles) + if (!list.find(n => n.id === outgoer.id)) { + if (outgoer.data.type === BlockEnum.Iteration) + list.push(...nodes.filter(node => node.parentId === outgoer.id)) + if (outgoer.data.type === BlockEnum.Loop) + list.push(...nodes.filter(node => node.parentId === outgoer.id)) - if (outgoer.data.type === BlockEnum.Iteration) - list.push(...nodes.filter(node => node.parentId === outgoer.id)) - if (outgoer.data.type === BlockEnum.Loop) - list.push(...nodes.filter(node => node.parentId === outgoer.id)) - - traverse(outgoer, depth + 1) + traverse(outgoer, depth + 1) + } }) } else { - list.push(root) - + // Leaf node - add iteration/loop children if any if (root.data.type === BlockEnum.Iteration) list.push(...nodes.filter(node => node.parentId === root.id)) if (root.data.type === BlockEnum.Loop) @@ -131,7 +145,11 @@ export const getValidTreeNodes = (startNode: Node, nodes: Node[], edges: Edge[]) } } - traverse(startNode, maxDepth) + // Start traversal from all start nodes + startNodes.forEach((startNode) => { + if (!list.find(n => n.id === startNode.id)) + traverse(startNode, 1) + }) return { validNodes: uniqBy(list, 'id'), diff --git a/web/app/components/workflow/variable-inspect/listening.tsx b/web/app/components/workflow/variable-inspect/listening.tsx new file mode 100644 index 0000000000..1f2577f150 --- /dev/null +++ b/web/app/components/workflow/variable-inspect/listening.tsx @@ -0,0 +1,219 @@ +import { type FC, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { type Node, useStoreApi } from 'reactflow' +import Button from '@/app/components/base/button' +import BlockIcon from '@/app/components/workflow/block-icon' +import { BlockEnum } from '@/app/components/workflow/types' +import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' +import { useStore } from '../store' +import { useGetToolIcon } from '@/app/components/workflow/hooks/use-tool-icon' +import type { TFunction } from 'i18next' +import { getNextExecutionTime } from '@/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator' +import type { ScheduleTriggerNodeType } from '@/app/components/workflow/nodes/trigger-schedule/types' +import type { WebhookTriggerNodeType } from '@/app/components/workflow/nodes/trigger-webhook/types' +import Tooltip from '@/app/components/base/tooltip' +import copy from 'copy-to-clipboard' + +const resolveListeningDescription = ( + message: string | undefined, + triggerNode: Node | undefined, + triggerType: BlockEnum, + t: TFunction, +): string => { + if (message) + return message + + if (triggerType === BlockEnum.TriggerSchedule) { + const scheduleData = triggerNode?.data as ScheduleTriggerNodeType | undefined + const nextTriggerTime = scheduleData ? getNextExecutionTime(scheduleData) : '' + return t('workflow.debug.variableInspect.listening.tipSchedule', { + nextTriggerTime: nextTriggerTime || t('workflow.debug.variableInspect.listening.defaultScheduleTime'), + }) + } + + if (triggerType === BlockEnum.TriggerPlugin) { + const pluginName = (triggerNode?.data as { provider_name?: string; title?: string })?.provider_name + || (triggerNode?.data as { title?: string })?.title + || t('workflow.debug.variableInspect.listening.defaultPluginName') + return t('workflow.debug.variableInspect.listening.tipPlugin', { pluginName }) + } + + if (triggerType === BlockEnum.TriggerWebhook) { + const nodeName = (triggerNode?.data as { title?: string })?.title || t('workflow.debug.variableInspect.listening.defaultNodeName') + return t('workflow.debug.variableInspect.listening.tip', { nodeName }) + } + + const nodeDescription = (triggerNode?.data as { desc?: string })?.desc + if (nodeDescription) + return nodeDescription + + return t('workflow.debug.variableInspect.listening.tipFallback') +} + +const resolveMultipleListeningDescription = ( + nodes: Node[], + t: TFunction, +): string => { + if (!nodes.length) + return t('workflow.debug.variableInspect.listening.tipFallback') + + const titles = nodes + .map(node => (node.data as { title?: string })?.title) + .filter((title): title is string => Boolean(title)) + + if (titles.length) + return t('workflow.debug.variableInspect.listening.tip', { nodeName: titles.join(', ') }) + + return t('workflow.debug.variableInspect.listening.tipFallback') +} + +export type ListeningProps = { + onStop: () => void + message?: string +} + +const Listening: FC<ListeningProps> = ({ + onStop, + message, +}) => { + const { t } = useTranslation() + const store = useStoreApi() + + // Get the current trigger type and node ID from store + const listeningTriggerType = useStore(s => s.listeningTriggerType) + const listeningTriggerNodeId = useStore(s => s.listeningTriggerNodeId) + const listeningTriggerNodeIds = useStore(s => s.listeningTriggerNodeIds) + const listeningTriggerIsAll = useStore(s => s.listeningTriggerIsAll) + + const getToolIcon = useGetToolIcon() + + // Get the trigger node data to extract icon information + const { getNodes } = store.getState() + const nodes = getNodes() + const triggerNode = listeningTriggerNodeId + ? nodes.find(node => node.id === listeningTriggerNodeId) + : undefined + const inferredTriggerType = (triggerNode?.data as { type?: BlockEnum })?.type + const triggerType = listeningTriggerType || inferredTriggerType || BlockEnum.TriggerWebhook + const webhookDebugUrl = triggerType === BlockEnum.TriggerWebhook + ? (triggerNode?.data as WebhookTriggerNodeType | undefined)?.webhook_debug_url + : undefined + const [debugUrlCopied, setDebugUrlCopied] = useState(false) + + useEffect(() => { + if (!debugUrlCopied) + return + + const timer = window.setTimeout(() => { + setDebugUrlCopied(false) + }, 2000) + + return () => { + window.clearTimeout(timer) + } + }, [debugUrlCopied]) + + let displayNodes: Node[] = [] + + if (listeningTriggerIsAll) { + if (listeningTriggerNodeIds.length > 0) { + displayNodes = nodes.filter(node => listeningTriggerNodeIds.includes(node.id)) + } + else { + displayNodes = nodes.filter((node) => { + const nodeType = (node.data as { type?: BlockEnum })?.type + return nodeType === BlockEnum.TriggerSchedule + || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerPlugin + }) + } + } + else if (triggerNode) { + displayNodes = [triggerNode] + } + + const iconsToRender = displayNodes.map((node) => { + const blockType = (node.data as { type?: BlockEnum })?.type || BlockEnum.TriggerWebhook + const icon = getToolIcon(node.data as any) + return { + key: node.id, + type: blockType, + toolIcon: icon, + } + }) + + if (iconsToRender.length === 0) { + iconsToRender.push({ + key: 'default', + type: listeningTriggerIsAll ? BlockEnum.TriggerWebhook : triggerType, + toolIcon: !listeningTriggerIsAll && triggerNode ? getToolIcon(triggerNode.data as any) : undefined, + }) + } + + const description = listeningTriggerIsAll + ? resolveMultipleListeningDescription(displayNodes, t) + : resolveListeningDescription(message, triggerNode, triggerType, t) + + return ( + <div className='flex h-full flex-col gap-4 rounded-xl bg-background-section p-8'> + <div className='flex flex-row flex-wrap items-center gap-3'> + {iconsToRender.map(icon => ( + <BlockIcon + key={icon.key} + type={icon.type} + toolIcon={icon.toolIcon} + size="md" + className="!h-10 !w-10 !rounded-xl [&_svg]:!h-7 [&_svg]:!w-7" + /> + ))} + </div> + <div className='flex flex-col gap-1'> + <div className='system-sm-semibold text-text-secondary'>{t('workflow.debug.variableInspect.listening.title')}</div> + <div className='system-xs-regular whitespace-pre-line text-text-tertiary'>{description}</div> + </div> + {webhookDebugUrl && ( + <div className='flex items-center gap-2'> + <div className='system-xs-regular shrink-0 whitespace-pre-line text-text-tertiary'> + {t('workflow.nodes.triggerWebhook.debugUrlTitle')} + </div> + <Tooltip + popupContent={debugUrlCopied + ? t('workflow.nodes.triggerWebhook.debugUrlCopied') + : t('workflow.nodes.triggerWebhook.debugUrlCopy')} + popupClassName="system-xs-regular text-text-primary bg-components-tooltip-bg border border-components-panel-border shadow-lg backdrop-blur-sm rounded-md px-1.5 py-1" + position="top" + offset={{ mainAxis: -4 }} + needsDelay={true} + > + <button + type='button' + aria-label={t('workflow.nodes.triggerWebhook.debugUrlCopy') || ''} + className={`inline-flex items-center rounded-[6px] border border-divider-regular bg-components-badge-white-to-dark px-1.5 py-[2px] font-mono text-[13px] leading-[18px] text-text-secondary transition-colors hover:bg-components-panel-on-panel-item-bg-hover focus:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-components-panel-border ${debugUrlCopied ? 'bg-components-panel-on-panel-item-bg-hover text-text-primary' : ''}`} + onClick={() => { + copy(webhookDebugUrl) + setDebugUrlCopied(true) + }} + > + <span className='whitespace-nowrap text-text-primary'> + {webhookDebugUrl} + </span> + </button> + </Tooltip> + </div> + )} + <div> + <Button + size='medium' + className='px-3' + variant='primary' + onClick={onStop} + > + <StopCircle className='mr-1 size-4' /> + {t('workflow.debug.variableInspect.listening.stopButton')} + </Button> + </div> + </div> + ) +} + +export default Listening diff --git a/web/app/components/workflow/variable-inspect/panel.tsx b/web/app/components/workflow/variable-inspect/panel.tsx index db0a6da8ab..c0ad4cd159 100644 --- a/web/app/components/workflow/variable-inspect/panel.tsx +++ b/web/app/components/workflow/variable-inspect/panel.tsx @@ -7,6 +7,7 @@ import { import { useStore } from '../store' import useCurrentVars from '../hooks/use-inspect-vars-crud' import Empty from './empty' +import Listening from './listening' import Left from './left' import Right from './right' import ActionButton from '@/app/components/base/action-button' @@ -16,6 +17,8 @@ import { VarInInspectType } from '@/types/workflow' import cn from '@/utils/classnames' import type { NodeProps } from '../types' import useMatchSchemaType from '../nodes/_base/components/variable/use-match-schema-type' +import { useEventEmitterContextContext } from '@/context/event-emitter' +import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types' export type currentVarType = { nodeId: string @@ -32,6 +35,7 @@ const Panel: FC = () => { const bottomPanelWidth = useStore(s => s.bottomPanelWidth) const setShowVariableInspectPanel = useStore(s => s.setShowVariableInspectPanel) const [showLeftPanel, setShowLeftPanel] = useState(true) + const isListening = useStore(s => s.isListening) const environmentVariables = useStore(s => s.environmentVariables) const currentFocusNodeId = useStore(s => s.currentFocusNodeId) @@ -135,6 +139,11 @@ const Panel: FC = () => { }, [setCurrentFocusNodeId, setCurrentVarId]) const { isLoading, schemaTypeDefinitions } = useMatchSchemaType() + const { eventEmitter } = useEventEmitterContextContext() + + const handleStopListening = useCallback(() => { + eventEmitter?.emit({ type: EVENT_WORKFLOW_STOP } as any) + }, [eventEmitter]) useEffect(() => { if (currentFocusNodeId && currentVarId && !isLoading) { @@ -144,6 +153,24 @@ const Panel: FC = () => { } }, [currentFocusNodeId, currentVarId, nodesWithInspectVars, fetchInspectVarValue, schemaTypeDefinitions, isLoading]) + if (isListening) { + return ( + <div className={cn('flex h-full flex-col')}> + <div className='flex shrink-0 items-center justify-between pl-4 pr-2 pt-2'> + <div className='system-sm-semibold-uppercase text-text-primary'>{t('workflow.debug.variableInspect.title')}</div> + <ActionButton onClick={() => setShowVariableInspectPanel(false)}> + <RiCloseLine className='h-4 w-4' /> + </ActionButton> + </div> + <div className='grow p-2'> + <Listening + onStop={handleStopListening} + /> + </div> + </div> + ) + } + if (isEmpty) { return ( <div className={cn('flex h-full flex-col')}> diff --git a/web/app/components/workflow/variable-inspect/right.tsx b/web/app/components/workflow/variable-inspect/right.tsx index 4e38e66269..9627a7ea43 100644 --- a/web/app/components/workflow/variable-inspect/right.tsx +++ b/web/app/components/workflow/variable-inspect/right.tsx @@ -24,7 +24,7 @@ import useNodeInfo from '../nodes/_base/hooks/use-node-info' import { useBoolean } from 'ahooks' import GetAutomaticResModal from '@/app/components/app/configuration/config/automatic/get-automatic-res' import GetCodeGeneratorResModal from '../../app/configuration/config/code-generator/get-code-generator-res' -import { AppType } from '@/types/app' +import { AppModeEnum } from '@/types/app' import { useHooksStore } from '../hooks-store' import { useCallback, useMemo } from 'react' import { useNodesInteractions, useToolIcon } from '../hooks' @@ -282,7 +282,7 @@ const Right = ({ isCodeBlock ? <GetCodeGeneratorResModal isShow - mode={AppType.chat} + mode={AppModeEnum.CHAT} onClose={handleHidePromptGenerator} flowId={configsMap?.flowId || ''} nodeId={nodeId} @@ -291,7 +291,7 @@ const Right = ({ onFinished={handleUpdatePrompt} /> : <GetAutomaticResModal - mode={AppType.chat} + mode={AppModeEnum.CHAT} isShow onClose={handleHidePromptGenerator} onFinished={handleUpdatePrompt} diff --git a/web/app/components/workflow/workflow-preview/components/node-handle.tsx b/web/app/components/workflow/workflow-preview/components/node-handle.tsx index 4ff08354be..2211e3397f 100644 --- a/web/app/components/workflow/workflow-preview/components/node-handle.tsx +++ b/web/app/components/workflow/workflow-preview/components/node-handle.tsx @@ -34,7 +34,10 @@ export const NodeTargetHandle = memo(({ 'after:absolute after:left-1.5 after:top-1 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle', 'transition-all hover:scale-125', !connected && 'after:opacity-0', - data.type === BlockEnum.Start && 'opacity-0', + (data.type === BlockEnum.Start + || data.type === BlockEnum.TriggerWebhook + || data.type === BlockEnum.TriggerSchedule + || data.type === BlockEnum.TriggerPlugin) && 'opacity-0', handleClassName, )} > diff --git a/web/app/education-apply/hooks.ts b/web/app/education-apply/hooks.ts index df3ee38795..9d45a5ee69 100644 --- a/web/app/education-apply/hooks.ts +++ b/web/app/education-apply/hooks.ts @@ -20,6 +20,7 @@ import timezone from 'dayjs/plugin/timezone' import { useAppContext } from '@/context/app-context' import { useRouter } from 'next/navigation' import { useProviderContext } from '@/context/provider-context' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' dayjs.extend(utc) dayjs.extend(timezone) @@ -155,7 +156,7 @@ export const useEducationInit = () => { useEffect(() => { if (educationVerifying === 'yes' || educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION) { - setShowAccountSettingModal({ payload: 'billing' }) + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING }) if (educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION) localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes') diff --git a/web/assets/search-menu.svg b/web/assets/search-menu.svg new file mode 100644 index 0000000000..8f7131c2ce --- /dev/null +++ b/web/assets/search-menu.svg @@ -0,0 +1,7 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M28.0049 16C28.0049 20.4183 24.4231 24 20.0049 24C15.5866 24 12.0049 20.4183 12.0049 16C12.0049 11.5817 15.5866 8 20.0049 8C24.4231 8 28.0049 11.5817 28.0049 16Z" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M4.00488 16H6.67155" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M4.00488 9.33301H8.00488" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M4.00488 22.667H8.00488" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M26 22L29.3333 25.3333" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +</svg> diff --git a/web/config/index.ts b/web/config/index.ts index 4e98182c0e..7b2b9e1084 100644 --- a/web/config/index.ts +++ b/web/config/index.ts @@ -421,6 +421,8 @@ export const ZENDESK_FIELD_IDS = { } export const APP_VERSION = pkg.version +export const IS_MARKETPLACE = globalThis.document?.body?.getAttribute('data-is-marketplace') === 'true' + export const RAG_PIPELINE_PREVIEW_CHUNK_NUM = 20 export const PROVIDER_WITH_PRESET_TONE = ['langgenius/openai/openai', 'langgenius/azure_openai/azure_openai'] diff --git a/web/context/debug-configuration.ts b/web/context/debug-configuration.ts index 1358940e39..5301835f12 100644 --- a/web/context/debug-configuration.ts +++ b/web/context/debug-configuration.ts @@ -22,6 +22,7 @@ import type { import type { ExternalDataTool } from '@/models/common' import type { DataSet } from '@/models/datasets' import type { VisionSettings } from '@/types/app' +import { AppModeEnum } from '@/types/app' import { ModelModeType, RETRIEVE_TYPE, Resolution, TransferMethod } from '@/types/app' import { ANNOTATION_DEFAULT, DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' @@ -32,7 +33,7 @@ type IDebugConfiguration = { appId: string isAPIKeySet: boolean isTrailFinished: boolean - mode: string + mode: AppModeEnum modelModeType: ModelModeType promptMode: PromptMode setPromptMode: (promptMode: PromptMode) => void @@ -111,7 +112,7 @@ const DebugConfigurationContext = createContext<IDebugConfiguration>({ appId: '', isAPIKeySet: false, isTrailFinished: false, - mode: '', + mode: AppModeEnum.CHAT, modelModeType: ModelModeType.chat, promptMode: PromptMode.simple, setPromptMode: noop, diff --git a/web/context/modal-context.tsx b/web/context/modal-context.tsx index 5baadc934b..e0228b8ca8 100644 --- a/web/context/modal-context.tsx +++ b/web/context/modal-context.tsx @@ -1,9 +1,9 @@ 'use client' import type { Dispatch, SetStateAction } from 'react' -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { createContext, useContext, useContextSelector } from 'use-context-selector' -import { useRouter, useSearchParams } from 'next/navigation' +import { useSearchParams } from 'next/navigation' import type { ConfigurationMethodEnum, Credential, @@ -12,8 +12,15 @@ import type { ModelProvider, } from '@/app/components/header/account-setting/model-provider-page/declarations' import { + EDUCATION_PRICING_SHOW_ACTION, EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, } from '@/app/education-apply/constants' +import type { AccountSettingTab } from '@/app/components/header/account-setting/constants' +import { + ACCOUNT_SETTING_MODAL_ACTION, + DEFAULT_ACCOUNT_SETTING_TAB, + isValidAccountSettingTab, +} from '@/app/components/header/account-setting/constants' import type { ModerationConfig, PromptVariable } from '@/models/debug' import type { ApiBasedExtension, @@ -90,7 +97,7 @@ export type ModelModalType = { } export type ModalContextState = { - setShowAccountSettingModal: Dispatch<SetStateAction<ModalState<string> | null>> + setShowAccountSettingModal: Dispatch<SetStateAction<ModalState<AccountSettingTab> | null>> setShowApiBasedExtensionModal: Dispatch<SetStateAction<ModalState<ApiBasedExtension> | null>> setShowModerationSettingModal: Dispatch<SetStateAction<ModalState<ModerationConfig> | null>> setShowExternalDataToolModal: Dispatch<SetStateAction<ModalState<ExternalDataTool> | null>> @@ -107,6 +114,9 @@ export type ModalContextState = { setShowUpdatePluginModal: Dispatch<SetStateAction<ModalState<UpdatePluginPayload> | null>> setShowEducationExpireNoticeModal: Dispatch<SetStateAction<ModalState<ExpireNoticeModalPayloadProps> | null>> } +const PRICING_MODAL_QUERY_PARAM = 'pricing' +const PRICING_MODAL_QUERY_VALUE = 'open' + const ModalContext = createContext<ModalContextState>({ setShowAccountSettingModal: noop, setShowApiBasedExtensionModal: noop, @@ -135,7 +145,16 @@ type ModalContextProviderProps = { export const ModalContextProvider = ({ children, }: ModalContextProviderProps) => { - const [showAccountSettingModal, setShowAccountSettingModal] = useState<ModalState<string> | null>(null) + const searchParams = useSearchParams() + + const [showAccountSettingModal, setShowAccountSettingModal] = useState<ModalState<AccountSettingTab> | null>(() => { + if (searchParams.get('action') === ACCOUNT_SETTING_MODAL_ACTION) { + const tabParam = searchParams.get('tab') + const tab = isValidAccountSettingTab(tabParam) ? tabParam : DEFAULT_ACCOUNT_SETTING_TAB + return { payload: tab } + } + return null + }) const [showApiBasedExtensionModal, setShowApiBasedExtensionModal] = useState<ModalState<ApiBasedExtension> | null>(null) const [showModerationSettingModal, setShowModerationSettingModal] = useState<ModalState<ModerationConfig> | null>(null) const [showExternalDataToolModal, setShowExternalDataToolModal] = useState<ModalState<ExternalDataTool> | null>(null) @@ -150,9 +169,9 @@ export const ModalContextProvider = ({ const [showUpdatePluginModal, setShowUpdatePluginModal] = useState<ModalState<UpdatePluginPayload> | null>(null) const [showEducationExpireNoticeModal, setShowEducationExpireNoticeModal] = useState<ModalState<ExpireNoticeModalPayloadProps> | null>(null) - const searchParams = useSearchParams() - const router = useRouter() - const [showPricingModal, setShowPricingModal] = useState(searchParams.get('show-pricing') === '1') + const [showPricingModal, setShowPricingModal] = useState( + searchParams.get(PRICING_MODAL_QUERY_PARAM) === PRICING_MODAL_QUERY_VALUE, + ) const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false) const handleCancelAccountSettingModal = () => { const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) @@ -161,11 +180,54 @@ export const ModalContextProvider = ({ localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) removeSpecificQueryParam('action') + removeSpecificQueryParam('tab') setShowAccountSettingModal(null) if (showAccountSettingModal?.onCancelCallback) showAccountSettingModal?.onCancelCallback() } + const handleAccountSettingTabChange = useCallback((tab: AccountSettingTab) => { + setShowAccountSettingModal((prev) => { + if (!prev) + return { payload: tab } + if (prev.payload === tab) + return prev + return { ...prev, payload: tab } + }) + }, [setShowAccountSettingModal]) + + useEffect(() => { + if (typeof window === 'undefined') + return + const url = new URL(window.location.href) + if (!showAccountSettingModal?.payload) { + if (url.searchParams.get('action') !== ACCOUNT_SETTING_MODAL_ACTION) + return + url.searchParams.delete('action') + url.searchParams.delete('tab') + window.history.replaceState(null, '', url.toString()) + return + } + url.searchParams.set('action', ACCOUNT_SETTING_MODAL_ACTION) + url.searchParams.set('tab', showAccountSettingModal.payload) + window.history.replaceState(null, '', url.toString()) + }, [showAccountSettingModal]) + + useEffect(() => { + if (typeof window === 'undefined') + return + const url = new URL(window.location.href) + if (showPricingModal) { + url.searchParams.set(PRICING_MODAL_QUERY_PARAM, PRICING_MODAL_QUERY_VALUE) + } + else { + url.searchParams.delete(PRICING_MODAL_QUERY_PARAM) + if (url.searchParams.get('action') === EDUCATION_PRICING_SHOW_ACTION) + url.searchParams.delete('action') + } + window.history.replaceState(null, '', url.toString()) + }, [showPricingModal]) + const handleCancelModerationSettingModal = () => { setShowModerationSettingModal(null) if (showModerationSettingModal?.onCancelCallback) @@ -250,13 +312,21 @@ export const ModalContextProvider = ({ setShowOpeningModal(null) } + const handleShowPricingModal = useCallback(() => { + setShowPricingModal(true) + }, []) + + const handleCancelPricingModal = useCallback(() => { + setShowPricingModal(false) + }, []) + return ( <ModalContext.Provider value={{ setShowAccountSettingModal, setShowApiBasedExtensionModal, setShowModerationSettingModal, setShowExternalDataToolModal, - setShowPricingModal: () => setShowPricingModal(true), + setShowPricingModal: handleShowPricingModal, setShowAnnotationFullModal: () => setShowAnnotationFullModal(true), setShowModelModal, setShowExternalKnowledgeAPIModal, @@ -272,6 +342,7 @@ export const ModalContextProvider = ({ <AccountSetting activeTab={showAccountSettingModal.payload} onCancel={handleCancelAccountSettingModal} + onTabChange={handleAccountSettingTabChange} /> ) } @@ -307,12 +378,7 @@ export const ModalContextProvider = ({ { !!showPricingModal && ( - <Pricing onCancel={() => { - if (searchParams.get('show-pricing') === '1') - router.push(location.pathname, { forceOptimisticNavigation: true } as any) - removeSpecificQueryParam('action') - setShowPricingModal(false) - }} /> + <Pricing onCancel={handleCancelPricingModal} /> ) } diff --git a/web/context/provider-context.tsx b/web/context/provider-context.tsx index 755131c859..90233fbc21 100644 --- a/web/context/provider-context.tsx +++ b/web/context/provider-context.tsx @@ -17,7 +17,8 @@ import { } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { Model, ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { RETRIEVE_METHOD } from '@/types/app' -import { Plan, type UsagePlanInfo } from '@/app/components/billing/type' +import type { Plan } from '@/app/components/billing/type' +import type { UsagePlanInfo } from '@/app/components/billing/type' import { fetchCurrentPlanInfo } from '@/service/billing' import { parseCurrentPlan } from '@/app/components/billing/utils' import { defaultPlan } from '@/app/components/billing/config' @@ -70,23 +71,7 @@ const ProviderContext = createContext<ProviderContextState>({ textGenerationModelList: [], supportRetrievalMethods: [], isAPIKeySet: true, - plan: { - type: Plan.sandbox, - usage: { - vectorSpace: 32, - buildApps: 12, - teamMembers: 1, - annotatedResponse: 1, - documentsUploadQuota: 50, - }, - total: { - vectorSpace: 200, - buildApps: 50, - teamMembers: 1, - annotatedResponse: 10, - documentsUploadQuota: 500, - }, - }, + plan: defaultPlan, isFetchedPlan: false, enableBilling: false, onPlanInfoChanged: noop, diff --git a/web/hooks/use-oauth.ts b/web/hooks/use-oauth.ts index 9f21a476b3..34ed8bafb0 100644 --- a/web/hooks/use-oauth.ts +++ b/web/hooks/use-oauth.ts @@ -4,16 +4,38 @@ import { validateRedirectUrl } from '@/utils/urlValidation' export const useOAuthCallback = () => { useEffect(() => { + const urlParams = new URLSearchParams(window.location.search) + const subscriptionId = urlParams.get('subscription_id') + const error = urlParams.get('error') + const errorDescription = urlParams.get('error_description') + if (window.opener) { - window.opener.postMessage({ - type: 'oauth_callback', - }, '*') + if (subscriptionId) { + window.opener.postMessage({ + type: 'oauth_callback', + success: true, + subscriptionId, + }, '*') + } + else if (error) { + window.opener.postMessage({ + type: 'oauth_callback', + success: false, + error, + errorDescription, + }, '*') + } + else { + window.opener.postMessage({ + type: 'oauth_callback', + }, '*') + } window.close() } }, []) } -export const openOAuthPopup = (url: string, callback: () => void) => { +export const openOAuthPopup = (url: string, callback: (data?: any) => void) => { const width = 600 const height = 600 const left = window.screenX + (window.outerWidth - width) / 2 @@ -29,10 +51,20 @@ export const openOAuthPopup = (url: string, callback: () => void) => { const handleMessage = (event: MessageEvent) => { if (event.data?.type === 'oauth_callback') { window.removeEventListener('message', handleMessage) - callback() + callback(event.data) } } window.addEventListener('message', handleMessage) + + // Fallback for window close detection + const checkClosed = setInterval(() => { + if (popup?.closed) { + clearInterval(checkClosed) + window.removeEventListener('message', handleMessage) + callback() + } + }, 1000) + return popup } diff --git a/web/i18n-config/i18next-config.ts b/web/i18n-config/i18next-config.ts index af04802e42..360d2afb29 100644 --- a/web/i18n-config/i18next-config.ts +++ b/web/i18n-config/i18next-config.ts @@ -38,6 +38,7 @@ const NAMESPACES = [ 'oauth', 'pipeline', 'plugin-tags', + 'plugin-trigger', 'plugin', 'register', 'run-log', diff --git a/web/i18n/de-DE/billing.ts b/web/i18n/de-DE/billing.ts index fc45f3889c..6601bbb179 100644 --- a/web/i18n/de-DE/billing.ts +++ b/web/i18n/de-DE/billing.ts @@ -83,7 +83,7 @@ const translation = { cloud: 'Cloud-Dienst', apiRateLimitTooltip: 'Die API-Datenbeschränkung gilt für alle Anfragen, die über die Dify-API gemacht werden, einschließlich Textgenerierung, Chat-Konversationen, Workflow-Ausführungen und Dokumentenverarbeitung.', getStarted: 'Loslegen', - apiRateLimitUnit: '{{count,number}}/Tag', + apiRateLimitUnit: '{{count,number}}/Monat', documentsTooltip: 'Vorgabe für die Anzahl der Dokumente, die aus der Wissensdatenquelle importiert werden.', apiRateLimit: 'API-Datenlimit', documents: '{{count,number}} Wissensdokumente', diff --git a/web/i18n/de-DE/workflow.ts b/web/i18n/de-DE/workflow.ts index 4353e5e10c..28aa8bdc19 100644 --- a/web/i18n/de-DE/workflow.ts +++ b/web/i18n/de-DE/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Veröffentlicht', publish: 'Veröffentlichen', update: 'Aktualisieren', - run: 'Ausführen', + run: 'Test ausführen', running: 'Wird ausgeführt', inRunMode: 'Im Ausführungsmodus', inPreview: 'In der Vorschau', @@ -18,7 +18,6 @@ const translation = { runHistory: 'Ausführungsverlauf', goBackToEdit: 'Zurück zum Editor', conversationLog: 'Konversationsprotokoll', - features: 'Funktionen', debugAndPreview: 'Vorschau', restart: 'Neustarten', currentDraft: 'Aktueller Entwurf', @@ -91,10 +90,8 @@ const translation = { addParallelNode: 'Parallelen Knoten hinzufügen', parallel: 'PARALLEL', branch: 'ZWEIG', - featuresDocLink: 'Weitere Informationen', ImageUploadLegacyTip: 'Sie können jetzt Dateitypvariablen im Startformular erstellen. Wir werden die Funktion zum Hochladen von Bildern in Zukunft nicht mehr unterstützen.', fileUploadTip: 'Die Funktionen zum Hochladen von Bildern wurden auf das Hochladen von Dateien aktualisiert.', - featuresDescription: 'Verbessern Sie die Benutzererfahrung von Web-Apps', importWarning: 'Vorsicht', importWarningDetails: 'Der Unterschied zwischen den DSL-Versionen kann sich auf bestimmte Funktionen auswirken', openInExplore: 'In Explore öffnen', @@ -110,11 +107,12 @@ const translation = { exitVersions: 'Ausgangsversionen', exportPNG: 'Als PNG exportieren', addBlock: 'Knoten hinzufügen', - needEndNode: 'Der Endknoten muss hinzugefügt werden.', + needOutputNode: 'Der Ausgabeknoten muss hinzugefügt werden', needAnswerNode: 'Der Antwortknoten muss hinzugefügt werden.', tagBound: 'Anzahl der Apps, die dieses Tag verwenden', currentWorkflow: 'Aktueller Arbeitsablauf', currentView: 'Aktuelle Ansicht', + moreActions: 'Weitere Aktionen', }, env: { envPanelTitle: 'Umgebungsvariablen', @@ -139,6 +137,19 @@ const translation = { export: 'DSL mit geheimen Werten exportieren', }, }, + globalVar: { + title: 'Systemvariablen', + description: 'Systemvariablen sind globale Variablen, die von jedem Knoten ohne Verkabelung referenziert werden können, sofern der Typ passt, etwa Endnutzer-ID und Workflow-ID.', + fieldsDescription: { + conversationId: 'Konversations-ID', + dialogCount: 'Konversationsanzahl', + userId: 'Benutzer-ID', + triggerTimestamp: 'Zeitstempel des Anwendungsstarts', + appId: 'Anwendungs-ID', + workflowId: 'Workflow-ID', + workflowRunId: 'Workflow-Ausführungs-ID', + }, + }, chatVariable: { panelTitle: 'Gesprächsvariablen', panelDescription: 'Gesprächsvariablen werden verwendet, um interaktive Informationen zu speichern, die das LLM benötigt, einschließlich Gesprächsverlauf, hochgeladene Dateien und Benutzereinstellungen. Sie sind les- und schreibbar.', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'Start', - 'end': 'Ende', + 'end': 'Ausgabe', 'answer': 'Antwort', 'llm': 'LLM', 'knowledge-retrieval': 'Wissensabruf', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'Definieren Sie die Anfangsparameter zum Starten eines Workflows', - 'end': 'Definieren Sie das Ende und den Ergebnistyp eines Workflows', + 'end': 'Definieren Sie die Ausgabe und den Ergebnistyp eines Workflows', 'answer': 'Definieren Sie den Antwortinhalt einer Chat-Konversation', 'llm': 'Große Sprachmodelle aufrufen, um Fragen zu beantworten oder natürliche Sprache zu verarbeiten', 'knowledge-retrieval': 'Ermöglicht das Abfragen von Textinhalten, die sich auf Benutzerfragen aus der Wissensdatenbank beziehen', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'Benutzereingabefeld', - helpLink: 'Hilfelink', + helpLink: 'Hilfe', about: 'Über', createdBy: 'Erstellt von ', nextStep: 'Nächster Schritt', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'Alle Probleme wurden gelöst', change: 'Ändern', optional: '(optional)', - moveToThisNode: 'Bewege zu diesem Knoten', selectNextStep: 'Nächsten Schritt auswählen', addNextStep: 'Fügen Sie den nächsten Schritt in diesem Arbeitsablauf hinzu.', organizeBlocks: 'Knoten organisieren', changeBlock: 'Knoten ändern', maximize: 'Maximiere die Leinwand', minimize: 'Vollbildmodus beenden', + scrollToSelectedNode: 'Zum ausgewählten Knoten scrollen', optional_and_hidden: '(optional & hidden)', }, nodes: { diff --git a/web/i18n/en-US/app-log.ts b/web/i18n/en-US/app-log.ts index e00e1cc675..7c5024a68f 100644 --- a/web/i18n/en-US/app-log.ts +++ b/web/i18n/en-US/app-log.ts @@ -18,8 +18,9 @@ const translation = { status: 'STATUS', runtime: 'RUN TIME', tokens: 'TOKENS', - user: 'End User or Account', + user: 'END USER OR ACCOUNT', version: 'VERSION', + triggered_from: 'TRIGGER BY', }, pagination: { previous: 'Prev', @@ -97,6 +98,15 @@ const translation = { iteration: 'Iteration', finalProcessing: 'Final Processing', }, + triggerBy: { + debugging: 'Debugging', + appRun: 'WebApp', + webhook: 'Webhook', + schedule: 'Schedule', + plugin: 'Plugin', + ragPipelineRun: 'RAG Pipeline', + ragPipelineDebugging: 'RAG Debugging', + }, } export default translation diff --git a/web/i18n/en-US/app-overview.ts b/web/i18n/en-US/app-overview.ts index feedc32e6b..4e88840b6d 100644 --- a/web/i18n/en-US/app-overview.ts +++ b/web/i18n/en-US/app-overview.ts @@ -30,6 +30,7 @@ const translation = { overview: { title: 'Overview', appInfo: { + title: 'Web App', explanation: 'Ready-to-use AI web app', accessibleAddress: 'Public URL', preview: 'Preview', @@ -37,6 +38,10 @@ const translation = { regenerate: 'Regenerate', regenerateNotice: 'Do you want to regenerate the public URL?', preUseReminder: 'Please enable web app before continuing.', + enableTooltip: { + description: 'To enable this feature, please add a User Input node to the canvas. (May already exist in draft, takes effect after publishing)', + learnMore: 'Learn more', + }, settings: { entry: 'Settings', title: 'Web App Settings', @@ -121,6 +126,14 @@ const translation = { accessibleAddress: 'Service API Endpoint', doc: 'API Reference', }, + triggerInfo: { + title: 'Triggers', + explanation: 'Workflow trigger management', + triggersAdded: '{{count}} Triggers added', + noTriggerAdded: 'No trigger added', + triggerStatusDescription: 'Trigger node status appears here. (May already exist in draft, takes effect after publishing)', + learnAboutTriggers: 'Learn about Triggers', + }, status: { running: 'In Service', disable: 'Disabled', diff --git a/web/i18n/en-US/app.ts b/web/i18n/en-US/app.ts index 7b3fead6e4..99bab2893c 100644 --- a/web/i18n/en-US/app.ts +++ b/web/i18n/en-US/app.ts @@ -254,6 +254,8 @@ const translation = { notSetDesc: 'Currently nobody can access the web app. Please set permissions.', }, noAccessPermission: 'No permission to access web app', + noUserInputNode: 'Missing user input node', + notPublishedYet: 'App is not published yet', maxActiveRequests: 'Max concurrent requests', maxActiveRequestsPlaceholder: 'Enter 0 for unlimited', maxActiveRequestsTip: 'Maximum number of concurrent active requests per app (0 for unlimited)', diff --git a/web/i18n/en-US/billing.ts b/web/i18n/en-US/billing.ts index 9169631281..0bd26c1075 100644 --- a/web/i18n/en-US/billing.ts +++ b/web/i18n/en-US/billing.ts @@ -7,6 +7,8 @@ const translation = { documentsUploadQuota: 'Documents Upload Quota', vectorSpace: 'Knowledge Data Storage', vectorSpaceTooltip: 'Documents with the High Quality indexing mode will consume Knowledge Data Storage resources. When Knowledge Data Storage reaches the limit, new documents will not be uploaded.', + triggerEvents: 'Trigger Events', + perMonth: 'per month', }, teamMembers: 'Team Members', upgradeBtn: { @@ -62,7 +64,7 @@ const translation = { documentsRequestQuota: '{{count,number}}/min Knowledge Request Rate Limit', documentsRequestQuotaTooltip: 'Specifies the total number of actions a workspace can perform per minute within the knowledge base, including dataset creation, deletion, updates, document uploads, modifications, archiving, and knowledge base queries. This metric is used to evaluate the performance of knowledge base requests. For example, if a Sandbox user performs 10 consecutive hit tests within one minute, their workspace will be temporarily restricted from performing the following actions for the next minute: dataset creation, deletion, updates, and document uploads or modifications. ', apiRateLimit: 'API Rate Limit', - apiRateLimitUnit: '{{count,number}}/day', + apiRateLimitUnit: '{{count,number}}/month', unlimitedApiRate: 'No API Rate Limit', apiRateLimitTooltip: 'API Rate Limit applies to all requests made through the Dify API, including text generation, chat conversations, workflow executions, and document processing.', documentProcessingPriority: ' Document Processing', @@ -72,6 +74,20 @@ const translation = { 'priority': 'Priority', 'top-priority': 'Top Priority', }, + triggerEvents: { + sandbox: '{{count,number}} Trigger Events', + professional: '{{count,number}} Trigger Events/month', + unlimited: 'Unlimited Trigger Events', + }, + workflowExecution: { + standard: 'Standard Workflow Execution', + faster: 'Faster Workflow Execution', + priority: 'Priority Workflow Execution', + }, + startNodes: { + limited: 'Up to {{count}} Start Nodes per Workflow', + unlimited: 'Unlimited Start Nodes per Workflow', + }, logsHistory: '{{days}} Log history', customTools: 'Custom Tools', unavailable: 'Unavailable', diff --git a/web/i18n/en-US/common.ts b/web/i18n/en-US/common.ts index bd0f27bd92..26c6ed89f2 100644 --- a/web/i18n/en-US/common.ts +++ b/web/i18n/en-US/common.ts @@ -29,6 +29,11 @@ const translation = { refresh: 'Restart', reset: 'Reset', search: 'Search', + noSearchResults: 'No {{content}} were found', + resetKeywords: 'Reset keywords', + selectCount: '{{count}} Selected', + searchCount: 'Find {{count}} {{content}}', + noSearchCount: '0 {{content}}', change: 'Change', remove: 'Remove', send: 'Send', @@ -41,6 +46,7 @@ const translation = { downloadFailed: 'Download failed. Please try again later.', viewDetails: 'View Details', delete: 'Delete', + now: 'Now', deleteApp: 'Delete App', settings: 'Settings', setup: 'Setup', @@ -79,7 +85,9 @@ const translation = { placeholder: { input: 'Please enter', select: 'Please select', + search: 'Search...', }, + noData: 'No data', label: { optional: '(optional)', }, @@ -174,7 +182,7 @@ const translation = { emailSupport: 'Email Support', workspace: 'Workspace', createWorkspace: 'Create Workspace', - helpCenter: 'Docs', + helpCenter: 'View Docs', support: 'Support', compliance: 'Compliance', forum: 'Forum', @@ -769,6 +777,12 @@ const translation = { supportedFormats: 'Supports PNG, JPG, JPEG, WEBP and GIF', }, you: 'You', + dynamicSelect: { + error: 'Loading options failed', + noData: 'No options available', + loading: 'Loading options...', + selected: '{{count}} selected', + }, } export default translation diff --git a/web/i18n/en-US/plugin-trigger.ts b/web/i18n/en-US/plugin-trigger.ts new file mode 100644 index 0000000000..aedd0c6225 --- /dev/null +++ b/web/i18n/en-US/plugin-trigger.ts @@ -0,0 +1,186 @@ +const translation = { + subscription: { + title: 'Subscriptions', + listNum: '{{num}} subscriptions', + empty: { + title: 'No subscriptions', + button: 'New subscription', + }, + createButton: { + oauth: 'New subscription with OAuth', + apiKey: 'New subscription with API Key', + manual: 'Paste URL to create a new subscription', + }, + createSuccess: 'Subscription created successfully', + createFailed: 'Failed to create subscription', + maxCount: 'Max {{num}} subscriptions', + selectPlaceholder: 'Select subscription', + noSubscriptionSelected: 'No subscription selected', + subscriptionRemoved: 'Subscription removed', + list: { + title: 'Subscriptions', + addButton: 'Add', + tip: 'Receive events via Subscription', + item: { + enabled: 'Enabled', + disabled: 'Disabled', + credentialType: { + api_key: 'API Key', + oauth2: 'OAuth', + unauthorized: 'Manual', + }, + actions: { + delete: 'Delete', + deleteConfirm: { + title: 'Delete {{name}}?', + success: 'Subscription {{name}} deleted successfully', + error: 'Failed to delete subscription {{name}}', + content: 'Once deleted, this subscription cannot be recovered. Please confirm.', + contentWithApps: 'The current subscription is referenced by {{count}} applications. Deleting it will cause the configured applications to stop receiving subscription events.', + confirm: 'Confirm Delete', + cancel: 'Cancel', + confirmInputWarning: 'Please enter the correct name to confirm.', + confirmInputPlaceholder: 'Enter "{{name}}" to confirm.', + confirmInputTip: 'Please enter “{{name}}” to confirm.', + }, + }, + status: { + active: 'Active', + inactive: 'Inactive', + }, + usedByNum: 'Used by {{num}} workflows', + noUsed: 'No workflow used', + }, + }, + addType: { + title: 'Add subscription', + description: 'Choose how you want to create your trigger subscription', + options: { + apikey: { + title: 'Create with API Key', + description: 'Automatically create subscription using API credentials', + }, + oauth: { + title: 'Create with OAuth', + description: 'Authorize with third-party platform to create subscription', + clientSettings: 'OAuth Client Settings', + clientTitle: 'OAuth Client', + default: 'Default', + custom: 'Custom', + }, + manual: { + title: 'Manual Setup', + description: 'Paste URL to create a new subscription', + tip: 'Configure URL on third-party platform manually', + }, + }, + }, + }, + modal: { + steps: { + verify: 'Verify', + configuration: 'Configuration', + }, + common: { + cancel: 'Cancel', + back: 'Back', + next: 'Next', + create: 'Create', + verify: 'Verify', + authorize: 'Authorize', + creating: 'Creating...', + verifying: 'Verifying...', + authorizing: 'Authorizing...', + }, + oauthRedirectInfo: 'As no system client secrets found for this tool provider, setup it manually is required, for redirect_uri, please use', + apiKey: { + title: 'Create with API Key', + verify: { + title: 'Verify Credentials', + description: 'Please provide your API credentials to verify access', + error: 'Credential verification failed. Please check your API key.', + success: 'Credentials verified successfully', + }, + configuration: { + title: 'Configure Subscription', + description: 'Set up your subscription parameters', + }, + }, + oauth: { + title: 'Create with OAuth', + authorization: { + title: 'OAuth Authorization', + description: 'Authorize Dify to access your account', + redirectUrl: 'Redirect URL', + redirectUrlHelp: 'Use this URL in your OAuth app configuration', + authorizeButton: 'Authorize with {{provider}}', + waitingAuth: 'Waiting for authorization...', + authSuccess: 'Authorization successful', + authFailed: 'Failed to get OAuth authorization information', + waitingJump: 'Authorized, waiting for jump', + }, + configuration: { + title: 'Configure Subscription', + description: 'Set up your subscription parameters after authorization', + success: 'OAuth configuration successful', + failed: 'OAuth configuration failed', + }, + remove: { + success: 'OAuth remove successful', + failed: 'OAuth remove failed', + }, + save: { + success: 'OAuth configuration saved successfully', + }, + }, + manual: { + title: 'Manual Setup', + description: 'Configure your webhook subscription manually', + logs: { + title: 'Request Logs', + request: 'Request', + loading: 'Awaiting request from {{pluginName}}...', + }, + }, + form: { + subscriptionName: { + label: 'Subscription Name', + placeholder: 'Enter subscription name', + required: 'Subscription name is required', + }, + callbackUrl: { + label: 'Callback URL', + description: 'This URL will receive webhook events', + tooltip: 'Provide a publicly accessible endpoint that can receive callback requests from the trigger provider.', + placeholder: 'Generating...', + privateAddressWarning: 'This URL appears to be an internal address, which may cause webhook requests to fail. You may change TRIGGER_URL to a public address.', + }, + }, + errors: { + createFailed: 'Failed to create subscription', + verifyFailed: 'Failed to verify credentials', + authFailed: 'Authorization failed', + networkError: 'Network error, please try again', + }, + }, + events: { + title: 'Available Events', + description: 'Events that this trigger plugin can subscribe to', + empty: 'No events available', + event: 'Event', + events: 'Events', + actionNum: '{{num}} {{event}} INCLUDED', + item: { + parameters: '{{count}} parameters', + noParameters: 'No parameters', + }, + output: 'Output', + }, + node: { + status: { + warning: 'Disconnect', + }, + }, +} + +export default translation diff --git a/web/i18n/en-US/plugin.ts b/web/i18n/en-US/plugin.ts index 18fc69c841..62a5f35c0b 100644 --- a/web/i18n/en-US/plugin.ts +++ b/web/i18n/en-US/plugin.ts @@ -8,6 +8,7 @@ const translation = { tools: 'Tools', agents: 'Agent Strategies', extensions: 'Extensions', + triggers: 'Triggers', bundles: 'Bundles', datasources: 'Data Sources', }, @@ -16,6 +17,7 @@ const translation = { tool: 'Tool', agent: 'Agent Strategy', extension: 'Extension', + trigger: 'Trigger', bundle: 'Bundle', datasource: 'Data Source', }, @@ -62,6 +64,7 @@ const translation = { checkUpdate: 'Check Update', viewDetail: 'View Detail', remove: 'Remove', + back: 'Back', }, actionNum: '{{num}} {{action}} INCLUDED', strategyNum: '{{num}} {{strategy}} INCLUDED', @@ -306,6 +309,12 @@ const translation = { connectedWorkspace: 'Connected Workspace', emptyAuth: 'Please configure authentication', }, + readmeInfo: { + title: 'README', + needHelpCheckReadme: 'Need help? Check the README.', + noReadmeAvailable: 'No README available', + failedToFetch: 'Failed to fetch README', + }, } export default translation diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index c59f4e9d6b..92a0b110c7 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -9,8 +9,11 @@ const translation = { publish: 'Publish', update: 'Update', publishUpdate: 'Publish Update', - run: 'Run', + run: 'Test Run', running: 'Running', + listening: 'Listening', + chooseStartNodeToRun: 'Choose the start node to run', + runAllTriggers: 'Run all triggers', inRunMode: 'In Run Mode', inPreview: 'In Preview', inPreviewMode: 'In Preview Mode', @@ -47,7 +50,8 @@ const translation = { needConnectTip: 'This step is not connected to anything', maxTreeDepth: 'Maximum limit of {{depth}} nodes per branch', needAdd: '{{node}} node must be added', - needEndNode: 'The End node must be added', + needOutputNode: 'The Output node must be added', + needStartNode: 'At least one start node must be added', needAnswerNode: 'The Answer node must be added', workflowProcess: 'Workflow Process', notRunning: 'Not running yet', @@ -77,12 +81,14 @@ const translation = { exportSVG: 'Export as SVG', currentView: 'Current View', currentWorkflow: 'Current Workflow', + moreActions: 'More Actions', model: 'Model', workflowAsTool: 'Workflow as Tool', configureRequired: 'Configure Required', configure: 'Configure', manageInTools: 'Manage in Tools', workflowAsToolTip: 'Tool reconfiguration is required after the workflow update.', + workflowAsToolDisabledHint: 'Publish the latest workflow and ensure a connected User Input node before configuring it as a tool.', viewDetailInTracingPanel: 'View details', syncingData: 'Syncing data, just a few seconds.', importDSL: 'Import DSL', @@ -140,6 +146,19 @@ const translation = { export: 'Export DSL with secret values ', }, }, + globalVar: { + title: 'System Variables', + description: 'System variables are global variables that can be referenced by any node without wiring when the type is correct, such as end-user ID and workflow ID.', + fieldsDescription: { + conversationId: 'Conversation ID', + dialogCount: 'Conversation Count', + userId: 'User ID', + triggerTimestamp: 'Application start timestamp', + appId: 'Application ID', + workflowId: 'Workflow ID', + workflowRunId: 'Workflow run ID', + }, + }, sidebar: { exportWarning: 'Export Current Saved Version', exportWarningDesc: 'This will export the current saved version of your workflow. If you have unsaved changes in the editor, please save them first by using the export option in the workflow canvas.', @@ -213,6 +232,16 @@ const translation = { invalidVariable: 'Invalid variable', noValidTool: '{{field}} no valid tool selected', toolParameterRequired: '{{field}}: parameter [{{param}}] is required', + startNodeRequired: 'Please add a start node first before {{operation}}', + }, + error: { + startNodeRequired: 'Please add a start node first before {{operation}}', + operations: { + connectingNodes: 'connecting nodes', + addingNodes: 'adding nodes', + modifyingWorkflow: 'modifying workflow', + updatingWorkflow: 'updating workflow', + }, }, singleRun: { testRun: 'Test Run', @@ -227,8 +256,11 @@ const translation = { }, tabs: { 'searchBlock': 'Search node', + 'start': 'Start', 'blocks': 'Nodes', 'searchTool': 'Search tool', + 'searchTrigger': 'Search triggers...', + 'allTriggers': 'All triggers', 'tools': 'Tools', 'allTool': 'All', 'plugin': 'Plugin', @@ -239,15 +271,28 @@ const translation = { 'transform': 'Transform', 'utilities': 'Utilities', 'noResult': 'No match found', + 'noPluginsFound': 'No plugins were found', + 'requestToCommunity': 'Requests to the community', 'agent': 'Agent Strategy', 'allAdded': 'All added', 'addAll': 'Add all', 'sources': 'Sources', 'searchDataSource': 'Search Data Source', + 'featuredTools': 'Featured', + 'showMoreFeatured': 'Show more', + 'showLessFeatured': 'Show less', + 'installed': 'Installed', + 'pluginByAuthor': 'By {{author}}', + 'usePlugin': 'Select tool', + 'hideActions': 'Hide tools', + 'noFeaturedPlugins': 'Discover more tools in Marketplace', + 'noFeaturedTriggers': 'Discover more triggers in Marketplace', + 'startDisabledTip': 'Trigger node and user input node are mutually exclusive.', }, blocks: { - 'start': 'Start', - 'end': 'End', + 'start': 'User Input', + 'originalStartNode': 'original start node', + 'end': 'Output', 'answer': 'Answer', 'llm': 'LLM', 'knowledge-retrieval': 'Knowledge Retrieval', @@ -270,10 +315,14 @@ const translation = { 'loop-end': 'Exit Loop', 'knowledge-index': 'Knowledge Base', 'datasource': 'Data Source', + 'trigger-schedule': 'Schedule Trigger', + 'trigger-webhook': 'Webhook Trigger', + 'trigger-plugin': 'Plugin Trigger', }, + customWebhook: 'Custom Webhook', blocksAbout: { 'start': 'Define the initial parameters for launching a workflow', - 'end': 'Define the end and result type of a workflow', + 'end': 'Define the output and result type of a workflow', 'answer': 'Define the reply content of a chat conversation', 'llm': 'Invoking large language models to answer questions or process natural language', 'knowledge-retrieval': 'Allows you to query text content related to user questions from the Knowledge', @@ -294,7 +343,11 @@ const translation = { 'agent': 'Invoking large language models to answer questions or process natural language', 'knowledge-index': 'Knowledge Base About', 'datasource': 'Data Source About', + 'trigger-schedule': 'Time-based workflow trigger that starts workflows on a schedule', + 'trigger-webhook': 'Webhook Trigger receives HTTP pushes from third-party systems to automatically trigger workflows.', + 'trigger-plugin': 'Third-party integration trigger that starts workflows from external platform events', }, + difyTeam: 'Dify Team', operator: { zoomIn: 'Zoom In', zoomOut: 'Zoom Out', @@ -324,22 +377,24 @@ const translation = { panel: { userInputField: 'User Input Field', changeBlock: 'Change Node', - helpLink: 'Help Link', + helpLink: 'View Docs', about: 'About', createdBy: 'Created By ', nextStep: 'Next Step', addNextStep: 'Add the next step in this workflow', selectNextStep: 'Select Next Step', runThisStep: 'Run this step', - moveToThisNode: 'Move to this node', checklist: 'Checklist', checklistTip: 'Make sure all issues are resolved before publishing', checklistResolved: 'All issues are resolved', + goTo: 'Go to', + startNode: 'Start Node', organizeBlocks: 'Organize nodes', change: 'Change', optional: '(optional)', maximize: 'Maximize Canvas', minimize: 'Exit Full Screen', + scrollToSelectedNode: 'Scroll to selected node', optional_and_hidden: '(optional & hidden)', }, nodes: { @@ -719,6 +774,50 @@ const translation = { json: 'tool generated json', }, }, + triggerPlugin: { + authorized: 'Authorized', + notConfigured: 'Not Configured', + notAuthorized: 'Not Authorized', + selectSubscription: 'Select Subscription', + availableSubscriptions: 'Available Subscriptions', + addSubscription: 'Add New Subscription', + removeSubscription: 'Remove Subscription', + subscriptionRemoved: 'Subscription removed successfully', + error: 'Error', + configuration: 'Configuration', + remove: 'Remove', + or: 'OR', + useOAuth: 'Use OAuth', + useApiKey: 'Use API Key', + authenticationFailed: 'Authentication failed', + authenticationSuccess: 'Authentication successful', + oauthConfigFailed: 'OAuth configuration failed', + configureOAuthClient: 'Configure OAuth Client', + oauthClientDescription: 'Configure OAuth client credentials to enable authentication', + oauthClientSaved: 'OAuth client configuration saved successfully', + configureApiKey: 'Configure API Key', + apiKeyDescription: 'Configure API key credentials for authentication', + apiKeyConfigured: 'API key configured successfully', + configurationFailed: 'Configuration failed', + failedToStart: 'Failed to start authentication flow', + credentialsVerified: 'Credentials verified successfully', + credentialVerificationFailed: 'Credential verification failed', + verifyAndContinue: 'Verify & Continue', + configureParameters: 'Configure Parameters', + parametersDescription: 'Configure trigger parameters and properties', + configurationComplete: 'Configuration Complete', + configurationCompleteDescription: 'Your trigger has been configured successfully', + configurationCompleteMessage: 'Your trigger configuration is now complete and ready to use.', + parameters: 'Parameters', + properties: 'Properties', + propertiesDescription: 'Additional configuration properties for this trigger', + noConfigurationRequired: 'No additional configuration required for this trigger.', + subscriptionName: 'Subscription Name', + subscriptionNameDescription: 'Enter a unique name for this trigger subscription', + subscriptionNamePlaceholder: 'Enter subscription name...', + subscriptionNameRequired: 'Subscription name is required', + subscriptionRequired: 'Subscription is required', + }, questionClassifiers: { model: 'model', inputVars: 'Input Variables', @@ -966,6 +1065,108 @@ const translation = { rerankingModelIsRequired: 'Reranking model is required', rerankingModelIsInvalid: 'Reranking model is invalid', }, + triggerSchedule: { + title: 'Schedule', + nodeTitle: 'Schedule Trigger', + notConfigured: 'Not configured', + useCronExpression: 'Use cron expression', + useVisualPicker: 'Use visual picker', + frequency: { + label: 'FREQUENCY', + hourly: 'Hourly', + daily: 'Daily', + weekly: 'Weekly', + monthly: 'Monthly', + }, + selectFrequency: 'Select frequency', + frequencyLabel: 'Frequency', + nextExecution: 'Next execution', + weekdays: 'Week days', + time: 'Time', + cronExpression: 'Cron expression', + nextExecutionTime: 'NEXT EXECUTION TIME', + nextExecutionTimes: 'Next 5 execution times', + startTime: 'Start Time', + executeNow: 'Execution now', + selectDateTime: 'Select Date & Time', + hours: 'Hours', + minutes: 'Minutes', + onMinute: 'On Minute', + days: 'Days', + lastDay: 'Last day', + lastDayTooltip: 'Not all months have 31 days. Use the \'last day\' option to select each month\'s final day.', + mode: 'Mode', + timezone: 'Timezone', + visualConfig: 'Visual Configuration', + monthlyDay: 'Monthly Day', + executionTime: 'Execution Time', + invalidTimezone: 'Invalid timezone', + invalidCronExpression: 'Invalid cron expression', + noValidExecutionTime: 'No valid execution time can be calculated', + executionTimeCalculationError: 'Failed to calculate execution times', + invalidFrequency: 'Invalid frequency', + invalidStartTime: 'Invalid start time', + startTimeMustBeFuture: 'Start time must be in the future', + invalidTimeFormat: 'Invalid time format (expected HH:MM AM/PM)', + invalidWeekday: 'Invalid weekday: {{weekday}}', + invalidMonthlyDay: 'Monthly day must be between 1-31 or "last"', + invalidOnMinute: 'On minute must be between 0-59', + invalidExecutionTime: 'Invalid execution time', + executionTimeMustBeFuture: 'Execution time must be in the future', + }, + triggerWebhook: { + title: 'Webhook Trigger', + nodeTitle: '🔗 Webhook Trigger', + configPlaceholder: 'Webhook trigger configuration will be implemented here', + webhookUrl: 'Webhook URL', + webhookUrlPlaceholder: 'Click generate to create webhook URL', + generate: 'Generate', + copy: 'Copy', + test: 'Test', + urlGenerated: 'Webhook URL generated successfully', + urlGenerationFailed: 'Failed to generate webhook URL', + urlCopied: 'URL copied to clipboard', + method: 'Method', + contentType: 'Content Type', + queryParameters: 'Query Parameters', + headerParameters: 'Header Parameters', + requestBodyParameters: 'Request Body Parameters', + parameterName: 'Variable name', + varName: 'Variable name', + varType: 'Type', + varNamePlaceholder: 'Enter variable name...', + required: 'Required', + addParameter: 'Add', + addHeader: 'Add', + noParameters: 'No parameters configured', + noQueryParameters: 'No query parameters configured', + noHeaders: 'No headers configured', + noBodyParameters: 'No body parameters configured', + debugUrlTitle: 'For test runs, always use this URL', + debugUrlCopy: 'Click to copy', + debugUrlCopied: 'Copied!', + debugUrlPrivateAddressWarning: 'This URL appears to be an internal address, which may cause webhook requests to fail. You may change TRIGGER_URL to a public address.', + errorHandling: 'Error Handling', + errorStrategy: 'Error Handling', + responseConfiguration: 'Response', + asyncMode: 'Async Mode', + statusCode: 'Status Code', + responseBody: 'Response Body', + responseBodyPlaceholder: 'Write your response body here', + headers: 'Headers', + validation: { + webhookUrlRequired: 'Webhook URL is required', + invalidParameterType: 'Invalid parameter type "{{type}}" for parameter "{{name}}"', + }, + }, + }, + triggerStatus: { + enabled: 'TRIGGER', + disabled: 'TRIGGER • DISABLED', + }, + entryNodeStatus: { + enabled: 'START', + disabled: 'START • DISABLED', }, tracing: { stopBy: 'Stop by {{user}}', @@ -1027,6 +1228,18 @@ const translation = { view: 'View log', edited: 'Edited', reset: 'Reset to last run value', + listening: { + title: 'Listening for events from triggers...', + tip: 'You can now simulate event triggers by sending test requests to HTTP {{nodeName}} endpoint or use it as a callback URL for live event debugging. All outputs can be viewed directly in the Variable Inspector.', + tipPlugin: 'Now you can create events in {{- pluginName}}, and retrieve outputs from these events in the Variable Inspector.', + tipSchedule: 'Listening for events from schedule triggers.\nNext scheduled run: {{nextTriggerTime}}', + tipFallback: 'Await incoming trigger events. Outputs will appear here.', + defaultNodeName: 'this trigger', + defaultPluginName: 'this plugin trigger', + defaultScheduleTime: 'Not configured', + selectedTriggers: 'selected triggers', + stopButton: 'Stop', + }, trigger: { normal: 'Variable Inspect', running: 'Caching running status', @@ -1052,6 +1265,22 @@ const translation = { noDependents: 'No dependents', }, }, + onboarding: { + title: 'Select a start node to begin', + description: 'Different start nodes have different capabilities. Don\'t worry, you can always change them later.', + userInputFull: 'User Input (original start node)', + userInputDescription: 'Start node that allows setting user input variables, with web app, service API, MCP server, and workflow as tool capabilities.', + trigger: 'Trigger', + triggerDescription: 'Triggers can serve as the start node of a workflow, such as scheduled tasks, custom webhooks, or integrations with other apps.', + back: 'Back', + learnMore: 'Learn more', + aboutStartNode: 'about start node.', + escTip: { + press: 'Press', + key: 'esc', + toDismiss: 'to dismiss', + }, + }, } export default translation diff --git a/web/i18n/es-ES/billing.ts b/web/i18n/es-ES/billing.ts index a8180e2d07..1632776e30 100644 --- a/web/i18n/es-ES/billing.ts +++ b/web/i18n/es-ES/billing.ts @@ -76,7 +76,7 @@ const translation = { priceTip: 'por espacio de trabajo/', teamMember_one: '{{count, número}} Miembro del Equipo', getStarted: 'Comenzar', - apiRateLimitUnit: '{{count, número}}/día', + apiRateLimitUnit: '{{count, número}}/mes', freeTrialTipSuffix: 'No se requiere tarjeta de crédito', unlimitedApiRate: 'Sin límite de tasa de API', apiRateLimit: 'Límite de tasa de API', diff --git a/web/i18n/es-ES/workflow.ts b/web/i18n/es-ES/workflow.ts index d7a6bef9e7..dd9519b68f 100644 --- a/web/i18n/es-ES/workflow.ts +++ b/web/i18n/es-ES/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Publicado', publish: 'Publicar', update: 'Actualizar', - run: 'Ejecutar', + run: 'Ejecutar prueba', running: 'Ejecutando', inRunMode: 'En modo de ejecución', inPreview: 'En vista previa', @@ -18,7 +18,6 @@ const translation = { runHistory: 'Historial de ejecución', goBackToEdit: 'Volver al editor', conversationLog: 'Registro de conversación', - features: 'Funcionalidades', debugAndPreview: 'Vista previa', restart: 'Reiniciar', currentDraft: 'Borrador actual', @@ -93,8 +92,6 @@ const translation = { branch: 'RAMA', fileUploadTip: 'Las funciones de carga de imágenes se han actualizado a la carga de archivos.', ImageUploadLegacyTip: 'Ahora puede crear variables de tipo de archivo en el formulario de inicio. Ya no admitiremos la función de carga de imágenes en el futuro.', - featuresDescription: 'Mejorar la experiencia del usuario de la aplicación web', - featuresDocLink: 'Aprende más', importWarning: 'Cautela', importWarningDetails: 'La diferencia de versión de DSL puede afectar a ciertas características', openInExplore: 'Abrir en Explorar', @@ -110,11 +107,12 @@ const translation = { publishUpdate: 'Publicar actualización', exportImage: 'Exportar imagen', needAnswerNode: 'Se debe agregar el nodo de respuesta', - needEndNode: 'Se debe agregar el nodo Final', + needOutputNode: 'Se debe agregar el nodo de Salida', addBlock: 'Agregar nodo', tagBound: 'Número de aplicaciones que utilizan esta etiqueta', currentView: 'Vista actual', currentWorkflow: 'Flujo de trabajo actual', + moreActions: 'Más acciones', }, env: { envPanelTitle: 'Variables de Entorno', @@ -139,6 +137,19 @@ const translation = { export: 'Exportar DSL con valores secretos', }, }, + globalVar: { + title: 'Variables del sistema', + description: 'Las variables del sistema son variables globales que cualquier nodo puede usar sin conexiones cuando el tipo es correcto, como el ID del usuario final y el ID del flujo de trabajo.', + fieldsDescription: { + conversationId: 'ID de la conversación', + dialogCount: 'Número de conversaciones', + userId: 'ID de usuario', + triggerTimestamp: 'Marca de tiempo de inicio de la aplicación', + appId: 'ID de la aplicación', + workflowId: 'ID del flujo de trabajo', + workflowRunId: 'ID de ejecución del flujo de trabajo', + }, + }, chatVariable: { panelTitle: 'Variables de Conversación', panelDescription: 'Las Variables de Conversación se utilizan para almacenar información interactiva que el LLM necesita recordar, incluyendo el historial de conversación, archivos subidos y preferencias del usuario. Son de lectura y escritura.', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'Inicio', - 'end': 'Fin', + 'end': 'Salida', 'answer': 'Respuesta', 'llm': 'LLM', 'knowledge-retrieval': 'Recuperación de conocimiento', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'Define los parámetros iniciales para iniciar un flujo de trabajo', - 'end': 'Define el final y el tipo de resultado de un flujo de trabajo', + 'end': 'Define la salida y el tipo de resultado de un flujo de trabajo', 'answer': 'Define el contenido de respuesta de una conversación de chat', 'llm': 'Invoca modelos de lenguaje grandes para responder preguntas o procesar lenguaje natural', 'knowledge-retrieval': 'Te permite consultar contenido de texto relacionado con las preguntas de los usuarios desde el conocimiento', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'Campo de entrada del usuario', - helpLink: 'Enlace de ayuda', + helpLink: 'Ayuda', about: 'Acerca de', createdBy: 'Creado por ', nextStep: 'Siguiente paso', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'Se resolvieron todos los problemas', change: 'Cambiar', optional: '(opcional)', - moveToThisNode: 'Mueve a este nodo', organizeBlocks: 'Organizar nodos', addNextStep: 'Agrega el siguiente paso en este flujo de trabajo', changeBlock: 'Cambiar Nodo', selectNextStep: 'Seleccionar siguiente paso', maximize: 'Maximizar Canvas', minimize: 'Salir de pantalla completa', + scrollToSelectedNode: 'Desplácese hasta el nodo seleccionado', optional_and_hidden: '(opcional y oculto)', }, nodes: { diff --git a/web/i18n/fa-IR/billing.ts b/web/i18n/fa-IR/billing.ts index 3749036f3c..e5121bb65b 100644 --- a/web/i18n/fa-IR/billing.ts +++ b/web/i18n/fa-IR/billing.ts @@ -73,7 +73,7 @@ const translation = { }, ragAPIRequestTooltip: 'به تعداد درخواست‌های API که فقط قابلیت‌های پردازش پایگاه دانش Dify را فراخوانی می‌کنند اشاره دارد.', receiptInfo: 'فقط صاحب تیم و مدیر تیم می‌توانند اشتراک تهیه کنند و اطلاعات صورتحساب را مشاهده کنند', - apiRateLimitUnit: '{{count,number}}/روز', + apiRateLimitUnit: '{{count,number}}/ماه', cloud: 'سرویس ابری', documents: '{{count,number}} سندهای دانش', self: 'خود میزبان', diff --git a/web/i18n/fa-IR/workflow.ts b/web/i18n/fa-IR/workflow.ts index aba3a25010..e27b8934e2 100644 --- a/web/i18n/fa-IR/workflow.ts +++ b/web/i18n/fa-IR/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'منتشر شده', publish: 'انتشار', update: 'به‌روزرسانی', - run: 'اجرا', + run: 'اجرای تست', running: 'در حال اجرا', inRunMode: 'در حالت اجرا', inPreview: 'در پیش‌نمایش', @@ -18,7 +18,6 @@ const translation = { runHistory: 'تاریخچه اجرا', goBackToEdit: 'بازگشت به ویرایشگر', conversationLog: 'گزارش مکالمات', - features: 'ویژگی‌ها', debugAndPreview: 'پیش‌نمایش', restart: 'راه‌اندازی مجدد', currentDraft: 'پیش‌نویس فعلی', @@ -91,8 +90,6 @@ const translation = { addParallelNode: 'افزودن گره موازی', parallel: 'موازی', branch: 'شاخه', - featuresDocLink: 'بیشتر بدانید', - featuresDescription: 'بهبود تجربه کاربری برنامه وب', ImageUploadLegacyTip: 'اکنون می توانید متغیرهای نوع فایل را در فرم شروع ایجاد کنید. ما دیگر از ویژگی آپلود تصویر در آینده پشتیبانی نخواهیم کرد.', fileUploadTip: 'ویژگی های آپلود تصویر برای آپلود فایل ارتقا یافته است.', importWarning: 'احتیاط', @@ -109,12 +106,13 @@ const translation = { exportImage: 'تصویر را صادر کنید', versionHistory: 'تاریخچه نسخه', publishUpdate: 'به‌روزرسانی منتشر کنید', - needEndNode: 'باید گره پایان اضافه شود', + needOutputNode: 'باید گره خروجی اضافه شود', needAnswerNode: 'باید گره پاسخ اضافه شود', addBlock: 'نود اضافه کنید', tagBound: 'تعداد برنامه‌هایی که از این برچسب استفاده می‌کنند', currentView: 'نمای فعلی', currentWorkflow: 'گردش کار فعلی', + moreActions: 'اقدامات بیشتر', }, env: { envPanelTitle: 'متغیرهای محیطی', @@ -139,6 +137,19 @@ const translation = { export: 'صادر کردن DSL با مقادیر مخفی', }, }, + globalVar: { + title: 'متغیرهای سیستمی', + description: 'متغیرهای سیستمی متغیرهای سراسری هستند که هر گره در صورت مطابقت نوع می‌تواند بدون سیم‌کشی از آن‌ها استفاده کند، مانند شناسه کاربر نهایی و شناسه گردش‌کار.', + fieldsDescription: { + conversationId: 'شناسه گفتگو', + dialogCount: 'تعداد گفتگو', + userId: 'شناسه کاربر', + triggerTimestamp: 'برچسب زمانی شروع اجرای برنامه', + appId: 'شناسه برنامه', + workflowId: 'شناسه گردش‌کار', + workflowRunId: 'شناسه اجرای گردش‌کار', + }, + }, chatVariable: { panelTitle: 'متغیرهای مکالمه', panelDescription: 'متغیرهای مکالمه برای ذخیره اطلاعات تعاملی که LLM نیاز به یادآوری دارد استفاده می‌شوند، از جمله تاریخچه مکالمه، فایل‌های آپلود شده و ترجیحات کاربر. آنها قابل خواندن و نوشتن هستند.', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'شروع', - 'end': 'پایان', + 'end': 'خروجی', 'answer': 'پاسخ', 'llm': 'مدل زبان بزرگ', 'knowledge-retrieval': 'استخراج دانش', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'پارامترهای اولیه برای راه‌اندازی جریان کار را تعریف کنید', - 'end': 'پایان و نوع نتیجه یک جریان کار را تعریف کنید', + 'end': 'خروجی و نوع نتیجه یک جریان کار را تعریف کنید', 'answer': 'محتوای پاسخ مکالمه چت را تعریف کنید', 'llm': 'استفاده از مدل‌های زبان بزرگ برای پاسخ به سوالات یا پردازش زبان طبیعی', 'knowledge-retrieval': 'اجازه می‌دهد تا محتوای متنی مرتبط با سوالات کاربر از دانش استخراج شود', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'فیلد ورودی کاربر', - helpLink: 'لینک کمک', + helpLink: 'راهنما', about: 'درباره', createdBy: 'ساخته شده توسط', nextStep: 'مرحله بعدی', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'تمام مسائل حل شده‌اند', change: 'تغییر', optional: '(اختیاری)', - moveToThisNode: 'به این گره بروید', selectNextStep: 'گام بعدی را انتخاب کنید', changeBlock: 'تغییر گره', organizeBlocks: 'گره‌ها را سازماندهی کنید', addNextStep: 'مرحله بعدی را به این فرآیند اضافه کنید', minimize: 'خروج از حالت تمام صفحه', maximize: 'بیشینه‌سازی بوم', + scrollToSelectedNode: 'به گره انتخاب شده بروید', optional_and_hidden: '(اختیاری و پنهان)', }, nodes: { diff --git a/web/i18n/fr-FR/billing.ts b/web/i18n/fr-FR/billing.ts index a41eed7e23..9715a1e805 100644 --- a/web/i18n/fr-FR/billing.ts +++ b/web/i18n/fr-FR/billing.ts @@ -73,7 +73,7 @@ const translation = { ragAPIRequestTooltip: 'Fait référence au nombre d\'appels API invoquant uniquement les capacités de traitement de la base de connaissances de Dify.', receiptInfo: 'Seuls le propriétaire de l\'équipe et l\'administrateur de l\'équipe peuvent s\'abonner et consulter les informations de facturation', annotationQuota: 'Quota d’annotation', - apiRateLimitUnit: '{{count,number}}/jour', + apiRateLimitUnit: '{{count,number}}/mois', priceTip: 'par espace de travail/', freeTrialTipSuffix: 'Aucune carte de crédit requise', teamWorkspace: '{{count,number}} Espace de travail d\'équipe', diff --git a/web/i18n/fr-FR/workflow.ts b/web/i18n/fr-FR/workflow.ts index f6c1899cac..c6405e0851 100644 --- a/web/i18n/fr-FR/workflow.ts +++ b/web/i18n/fr-FR/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Publié', publish: 'Publier', update: 'Mettre à jour', - run: 'Exécuter', + run: 'Exécuter test', running: 'En cours d\'exécution', inRunMode: 'En mode exécution', inPreview: 'En aperçu', @@ -18,7 +18,6 @@ const translation = { runHistory: 'Historique des exécutions', goBackToEdit: 'Retour à l\'éditeur', conversationLog: 'Journal de conversation', - features: 'Fonctionnalités', debugAndPreview: 'Aperçu', restart: 'Redémarrer', currentDraft: 'Brouillon actuel', @@ -91,10 +90,8 @@ const translation = { addParallelNode: 'Ajouter un nœud parallèle', parallel: 'PARALLÈLE', branch: 'BRANCHE', - featuresDocLink: 'Pour en savoir plus', ImageUploadLegacyTip: 'Vous pouvez désormais créer des variables de type de fichier dans le formulaire de démarrage. À l’avenir, nous ne prendrons plus en charge la fonctionnalité de téléchargement d’images.', fileUploadTip: 'Les fonctionnalités de téléchargement d’images ont été mises à niveau vers le téléchargement de fichiers.', - featuresDescription: 'Améliorer l’expérience utilisateur de l’application web', importWarning: 'Prudence', importWarningDetails: 'La différence de version DSL peut affecter certaines fonctionnalités', openInExplore: 'Ouvrir dans Explorer', @@ -109,12 +106,13 @@ const translation = { versionHistory: 'Historique des versions', exportImage: 'Exporter l\'image', exportJPEG: 'Exporter en JPEG', - needEndNode: 'Le nœud de fin doit être ajouté', + needOutputNode: 'Le nœud de sortie doit être ajouté', needAnswerNode: 'Le nœud de réponse doit être ajouté.', addBlock: 'Ajouter un nœud', tagBound: 'Nombre d\'applications utilisant cette étiquette', currentView: 'Vue actuelle', currentWorkflow: 'Flux de travail actuel', + moreActions: 'Plus d’actions', }, env: { envPanelTitle: 'Variables d\'Environnement', @@ -139,6 +137,19 @@ const translation = { export: 'Exporter les DSL avec des valeurs secrètes', }, }, + globalVar: { + title: 'Variables système', + description: 'Les variables système sont des variables globales que tout nœud peut référencer sans câblage lorsque le type correspond, comme l\'ID utilisateur final et l\'ID du workflow.', + fieldsDescription: { + conversationId: 'ID de conversation', + dialogCount: 'Nombre de conversations', + userId: 'ID utilisateur', + triggerTimestamp: 'Horodatage du lancement de l\'application', + appId: 'ID de l\'application', + workflowId: 'ID du workflow', + workflowRunId: 'ID d\'exécution du workflow', + }, + }, chatVariable: { panelTitle: 'Variables de Conversation', panelDescription: 'Les Variables de Conversation sont utilisées pour stocker des informations interactives dont le LLM a besoin de se souvenir, y compris l\'historique des conversations, les fichiers téléchargés et les préférences de l\'utilisateur. Elles sont en lecture-écriture.', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'Début', - 'end': 'Fin', + 'end': 'Sortie', 'answer': 'Réponse', 'llm': 'LLM', 'knowledge-retrieval': 'Récupération de connaissances', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'Définir les paramètres initiaux pour lancer un flux de travail', - 'end': 'Définir la fin et le type de résultat d\'un flux de travail', + 'end': 'Définir la sortie et le type de résultat d\'un flux de travail', 'answer': 'Définir le contenu de la réponse d\'une conversation', 'llm': 'Inviter de grands modèles de langage pour répondre aux questions ou traiter le langage naturel', 'knowledge-retrieval': 'Permet de consulter le contenu textuel lié aux questions des utilisateurs à partir de la base de connaissances', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'Champ de saisie de l\'utilisateur', - helpLink: 'Lien d\'aide', + helpLink: 'Aide', about: 'À propos', createdBy: 'Créé par', nextStep: 'Étape suivante', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'Tous les problèmes ont été résolus', change: 'Modifier', optional: '(facultatif)', - moveToThisNode: 'Déplacer vers ce nœud', organizeBlocks: 'Organiser les nœuds', addNextStep: 'Ajoutez la prochaine étape dans ce flux de travail', selectNextStep: 'Sélectionner la prochaine étape', changeBlock: 'Changer de nœud', maximize: 'Maximiser le Canvas', minimize: 'Sortir du mode plein écran', + scrollToSelectedNode: 'Faites défiler jusqu’au nœud sélectionné', optional_and_hidden: '(optionnel et caché)', }, nodes: { diff --git a/web/i18n/hi-IN/billing.ts b/web/i18n/hi-IN/billing.ts index fbc6dffc7c..7164a13d6f 100644 --- a/web/i18n/hi-IN/billing.ts +++ b/web/i18n/hi-IN/billing.ts @@ -96,7 +96,7 @@ const translation = { freeTrialTip: '200 ओपनएआई कॉल्स का मुफ्त परीक्षण।', documents: '{{count,number}} ज्ञान दस्तावेज़', freeTrialTipSuffix: 'कोई क्रेडिट कार्ड की आवश्यकता नहीं है', - apiRateLimitUnit: '{{count,number}}/दिन', + apiRateLimitUnit: '{{count,number}}/माह', teamWorkspace: '{{count,number}} टीम कार्यक्षेत्र', apiRateLimitTooltip: 'Dify API के माध्यम से की गई सभी अनुरोधों पर API दर सीमा लागू होती है, जिसमें टेक्स्ट जनरेशन, चैट वार्तालाप, कार्यप्रवाह निष्पादन और दस्तावेज़ प्रसंस्करण शामिल हैं।', teamMember_one: '{{count,number}} टीम सदस्य', diff --git a/web/i18n/hi-IN/workflow.ts b/web/i18n/hi-IN/workflow.ts index 224f3acaeb..f739f64cf0 100644 --- a/web/i18n/hi-IN/workflow.ts +++ b/web/i18n/hi-IN/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'प्रकाशित', publish: 'प्रकाशित करें', update: 'अपडेट करें', - run: 'चलाएं', + run: 'परीक्षण चलाएं', running: 'चल रहा है', inRunMode: 'रन मोड में', inPreview: 'पूर्वावलोकन में', @@ -18,7 +18,6 @@ const translation = { runHistory: 'रन इतिहास', goBackToEdit: 'संपादक पर वापस जाएं', conversationLog: 'वार्तालाप लॉग', - features: 'विशेषताएं', debugAndPreview: 'पूर्वावलोकन', restart: 'पुनः आरंभ करें', currentDraft: 'वर्तमान ड्राफ्ट', @@ -94,8 +93,6 @@ const translation = { addParallelNode: 'समानांतर नोड जोड़ें', parallel: 'समानांतर', branch: 'शाखा', - featuresDocLink: 'और जानो', - featuresDescription: 'वेब ऐप उपयोगकर्ता अनुभव को बेहतर बनाएं', fileUploadTip: 'छवि अपलोड सुविधाओं को फ़ाइल अपलोड में अपग्रेड किया गया है।', ImageUploadLegacyTip: 'अब आप प्रारंभ प्रपत्र में फ़ाइल प्रकार चर बना सकते हैं। हम अब भविष्य में छवि अपलोड सुविधा का समर्थन नहीं करेंगे।', importWarning: 'सावधानी', @@ -114,10 +111,11 @@ const translation = { versionHistory: 'संस्करण इतिहास', needAnswerNode: 'उत्तर नोड जोड़ा जाना चाहिए', addBlock: 'नोड जोड़ें', - needEndNode: 'अंत नोड जोड़ा जाना चाहिए', + needOutputNode: 'आउटपुट नोड जोड़ा जाना चाहिए', tagBound: 'इस टैग का उपयोग करने वाले ऐप्स की संख्या', currentView: 'वर्तमान दृश्य', currentWorkflow: 'वर्तमान कार्यप्रवाह', + moreActions: 'अधिक क्रियाएँ', }, env: { envPanelTitle: 'पर्यावरण चर', @@ -142,6 +140,19 @@ const translation = { export: 'गुप्त मानों के साथ DSL निर्यात करें', }, }, + globalVar: { + title: 'सिस्टम वेरिएबल्स', + description: 'सिस्टम वेरिएबल्स वैश्विक वेरिएबल्स हैं जिन्हें सही प्रकार होने पर किसी भी नोड द्वारा बिना वायरिंग के संदर्भित किया जा सकता है, जैसे एंड-यूज़र ID और वर्कफ़्लो ID.', + fieldsDescription: { + conversationId: 'संवाद ID', + dialogCount: 'संवाद गणना', + userId: 'उपयोगकर्ता ID', + triggerTimestamp: 'एप्लिकेशन शुरू होने का टाइमस्टैम्प', + appId: 'एप्लिकेशन ID', + workflowId: 'वर्कफ़्लो ID', + workflowRunId: 'वर्कफ़्लो रन ID', + }, + }, chatVariable: { panelTitle: 'वार्तालाप चर', panelDescription: 'वार्तालाप चर का उपयोग इंटरैक्टिव जानकारी संग्रहित करने के लिए किया जाता है जिसे LLM को याद रखने की आवश्यकता होती है, जिसमें वार्तालाप इतिहास, अपलोड की गई फाइलें, उपयोगकर्ता प्राथमिकताएं शामिल हैं। वे पठनीय और लेखनीय हैं।', @@ -245,7 +256,7 @@ const translation = { }, blocks: { 'start': 'प्रारंभ', - 'end': 'समाप्त', + 'end': 'आउटपुट', 'answer': 'उत्तर', 'llm': 'एलएलएम', 'knowledge-retrieval': 'ज्ञान पुनर्प्राप्ति', @@ -271,7 +282,7 @@ const translation = { }, blocksAbout: { 'start': 'वर्कफ़्लो लॉन्च करने के लिए प्रारंभिक पैरामीटर को परिभाषित करें', - 'end': 'वर्कफ़्लो का अंत और परिणाम प्रकार परिभाषित करें', + 'end': 'वर्कफ़्लो का आउटपुट और परिणाम प्रकार परिभाषित करें', 'answer': 'चैट संवाद के उत्तर सामग्री को परिभाषित करें', 'llm': 'प्रश्नों के उत्तर देने या प्राकृतिक भाषा को संसाधित करने के लिए बड़े भाषा मॉडल को आमंत्रित करना', 'knowledge-retrieval': @@ -322,7 +333,7 @@ const translation = { }, panel: { userInputField: 'उपयोगकर्ता इनपुट फ़ील्ड', - helpLink: 'सहायता लिंक', + helpLink: 'सहायता', about: 'के बारे में', createdBy: 'द्वारा बनाया गया ', nextStep: 'अगला कदम', @@ -333,13 +344,13 @@ const translation = { checklistResolved: 'सभी समस्याएं हल हो गई हैं', change: 'बदलें', optional: '(वैकल्पिक)', - moveToThisNode: 'इस नोड पर जाएं', changeBlock: 'नोड बदलें', addNextStep: 'इस कार्यप्रवाह में अगला कदम जोड़ें', selectNextStep: 'अगला कदम चुनें', organizeBlocks: 'नोड्स का आयोजन करें', minimize: 'पूर्ण स्क्रीन से बाहर निकलें', maximize: 'कैनवास का अधिकतम लाभ उठाएँ', + scrollToSelectedNode: 'चुने गए नोड पर स्क्रॉल करें', optional_and_hidden: '(वैकल्पिक और छिपा हुआ)', }, nodes: { diff --git a/web/i18n/id-ID/workflow.ts b/web/i18n/id-ID/workflow.ts index 4ef6b2b832..506b17d925 100644 --- a/web/i18n/id-ID/workflow.ts +++ b/web/i18n/id-ID/workflow.ts @@ -90,7 +90,7 @@ const translation = { exportJPEG: 'Ekspor sebagai JPEG', addBlock: 'Tambahkan Node', processData: 'Proses Data', - needEndNode: 'Node Akhir harus ditambahkan', + needOutputNode: 'Node Output harus ditambahkan', manageInTools: 'Kelola di Alat', pointerMode: 'Mode Penunjuk', accessAPIReference: 'Referensi API Akses', @@ -137,6 +137,19 @@ const translation = { envPanelTitle: 'Variabel Lingkungan', envDescription: 'Variabel lingkungan dapat digunakan untuk menyimpan informasi pribadi dan kredensial. Mereka hanya baca dan dapat dipisahkan dari file DSL selama ekspor.', }, + globalVar: { + title: 'Variabel Sistem', + description: 'Variabel sistem adalah variabel global yang dapat dirujuk oleh node apa pun tanpa koneksi jika tipenya sesuai, seperti ID pengguna akhir dan ID alur kerja.', + fieldsDescription: { + conversationId: 'ID percakapan', + dialogCount: 'Jumlah percakapan', + userId: 'ID pengguna', + triggerTimestamp: 'Cap waktu saat aplikasi mulai berjalan', + appId: 'ID aplikasi', + workflowId: 'ID alur kerja', + workflowRunId: 'ID eksekusi alur kerja', + }, + }, chatVariable: { modal: { valuePlaceholder: 'Nilai default, biarkan kosong untuk tidak diatur', @@ -249,7 +262,7 @@ const translation = { 'answer': 'Jawaban', 'parameter-extractor': 'Ekstraktor Parameter', 'document-extractor': 'Ekstraktor Dokumen', - 'end': 'Ujung', + 'end': 'Keluaran', 'if-else': 'JIKA/LAIN', 'loop-start': 'Mulai Loop', 'variable-aggregator': 'Agregator Variabel', @@ -275,7 +288,7 @@ const translation = { 'variable-assigner': 'Agregatkan variabel multi-cabang menjadi satu variabel untuk konfigurasi terpadu simpul hilir.', 'loop': 'Jalankan perulangan logika hingga kondisi penghentian terpenuhi atau jumlah perulangan maksimum tercapai.', 'variable-aggregator': 'Agregatkan variabel multi-cabang menjadi satu variabel untuk konfigurasi terpadu simpul hilir.', - 'end': 'Menentukan jenis akhir dan hasil alur kerja', + 'end': 'Menentukan output dan jenis hasil alur kerja', 'list-operator': 'Digunakan untuk memfilter atau mengurutkan konten array.', 'datasource': 'Sumber Data Tentang', 'knowledge-index': 'Basis Pengetahuan Tentang', @@ -321,7 +334,7 @@ const translation = { userInputField: 'Bidang Input Pengguna', checklistResolved: 'Semua masalah terselesaikan', createdBy: 'Dibuat oleh', - helpLink: 'Tautan Bantuan', + helpLink: 'Docs', changeBlock: 'Ubah Node', runThisStep: 'Jalankan langkah ini', maximize: 'Maksimalkan Kanvas', diff --git a/web/i18n/it-IT/billing.ts b/web/i18n/it-IT/billing.ts index ef6b1943e3..fc5d67520b 100644 --- a/web/i18n/it-IT/billing.ts +++ b/web/i18n/it-IT/billing.ts @@ -88,7 +88,7 @@ const translation = { freeTrialTipPrefix: 'Iscriviti e ricevi un', teamMember_one: '{{count,number}} membro del team', documents: '{{count,number}} Documenti di Conoscenza', - apiRateLimitUnit: '{{count,number}}/giorno', + apiRateLimitUnit: '{{count,number}}/mese', documentsRequestQuota: '{{count,number}}/min Limite di richiesta di conoscenza', teamMember_other: '{{count,number}} membri del team', freeTrialTip: 'prova gratuita di 200 chiamate OpenAI.', diff --git a/web/i18n/it-IT/workflow.ts b/web/i18n/it-IT/workflow.ts index 314b8e0c52..b188bc3666 100644 --- a/web/i18n/it-IT/workflow.ts +++ b/web/i18n/it-IT/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Pubblicato', publish: 'Pubblica', update: 'Aggiorna', - run: 'Esegui', + run: 'Esegui test', running: 'In esecuzione', inRunMode: 'In modalità di esecuzione', inPreview: 'In anteprima', @@ -18,7 +18,6 @@ const translation = { runHistory: 'Cronologia esecuzioni', goBackToEdit: 'Torna all\'editor', conversationLog: 'Registro conversazioni', - features: 'Caratteristiche', debugAndPreview: 'Anteprima', restart: 'Riavvia', currentDraft: 'Bozza corrente', @@ -95,8 +94,6 @@ const translation = { addParallelNode: 'Aggiungi nodo parallelo', parallel: 'PARALLELO', branch: 'RAMO', - featuresDocLink: 'Ulteriori informazioni', - featuresDescription: 'Migliora l\'esperienza utente dell\'app Web', fileUploadTip: 'Le funzioni di caricamento delle immagini sono state aggiornate al caricamento dei file.', ImageUploadLegacyTip: 'Ora è possibile creare variabili di tipo file nel modulo iniziale. In futuro non supporteremo più la funzione di caricamento delle immagini.', importWarning: 'Cautela', @@ -113,12 +110,13 @@ const translation = { exportImage: 'Esporta immagine', exportJPEG: 'Esporta come JPEG', exportPNG: 'Esporta come PNG', - needEndNode: 'Deve essere aggiunto il nodo finale', + needOutputNode: 'Deve essere aggiunto il nodo di uscita', addBlock: 'Aggiungi nodo', needAnswerNode: 'Deve essere aggiunto il nodo di risposta', tagBound: 'Numero di app che utilizzano questo tag', currentWorkflow: 'Flusso di lavoro corrente', currentView: 'Vista corrente', + moreActions: 'Altre azioni', }, env: { envPanelTitle: 'Variabili d\'Ambiente', @@ -143,6 +141,19 @@ const translation = { export: 'Esporta DSL con valori segreti', }, }, + globalVar: { + title: 'Variabili di sistema', + description: 'Le variabili di sistema sono variabili globali che possono essere richiamate da qualsiasi nodo senza collegamenti quando il tipo è corretto, come l\'ID dell\'utente finale e l\'ID del workflow.', + fieldsDescription: { + conversationId: 'ID conversazione', + dialogCount: 'Conteggio conversazioni', + userId: 'ID utente', + triggerTimestamp: 'Timestamp di avvio dell\'applicazione', + appId: 'ID applicazione', + workflowId: 'ID workflow', + workflowRunId: 'ID esecuzione workflow', + }, + }, chatVariable: { panelTitle: 'Variabili di Conversazione', panelDescription: 'Le Variabili di Conversazione sono utilizzate per memorizzare informazioni interattive che il LLM deve ricordare, inclusi la cronologia delle conversazioni, i file caricati e le preferenze dell\'utente. Sono in lettura e scrittura.', @@ -247,7 +258,7 @@ const translation = { }, blocks: { 'start': 'Inizio', - 'end': 'Fine', + 'end': 'Uscita', 'answer': 'Risposta', 'llm': 'LLM', 'knowledge-retrieval': 'Recupero Conoscenza', @@ -273,7 +284,7 @@ const translation = { }, blocksAbout: { 'start': 'Definisci i parametri iniziali per l\'avvio di un flusso di lavoro', - 'end': 'Definisci la fine e il tipo di risultato di un flusso di lavoro', + 'end': 'Definisci l\'uscita e il tipo di risultato di un flusso di lavoro', 'answer': 'Definisci il contenuto della risposta di una conversazione chat', 'llm': 'Invoca modelli di linguaggio di grandi dimensioni per rispondere a domande o elaborare il linguaggio naturale', 'knowledge-retrieval': @@ -325,7 +336,7 @@ const translation = { }, panel: { userInputField: 'Campo di Input Utente', - helpLink: 'Link di Aiuto', + helpLink: 'Aiuto', about: 'Informazioni', createdBy: 'Creato da ', nextStep: 'Prossimo Passo', @@ -336,13 +347,13 @@ const translation = { checklistResolved: 'Tutti i problemi sono risolti', change: 'Cambia', optional: '(opzionale)', - moveToThisNode: 'Sposta a questo nodo', changeBlock: 'Cambia Nodo', selectNextStep: 'Seleziona il prossimo passo', organizeBlocks: 'Organizzare i nodi', addNextStep: 'Aggiungi il prossimo passo in questo flusso di lavoro', minimize: 'Esci dalla modalità schermo intero', maximize: 'Massimizza Canvas', + scrollToSelectedNode: 'Scorri fino al nodo selezionato', optional_and_hidden: '(opzionale e nascosto)', }, nodes: { diff --git a/web/i18n/ja-JP/app-log.ts b/web/i18n/ja-JP/app-log.ts index 1ead075d14..aa23d8352d 100644 --- a/web/i18n/ja-JP/app-log.ts +++ b/web/i18n/ja-JP/app-log.ts @@ -20,6 +20,7 @@ const translation = { tokens: 'トークン', user: 'エンドユーザーまたはアカウント', version: 'バージョン', + triggered_from: 'トリガー方法', }, pagination: { previous: '前へ', @@ -97,6 +98,15 @@ const translation = { iteration: '反復', finalProcessing: '最終処理', }, + triggerBy: { + debugging: 'デバッグ', + appRun: 'ウェブアプリ', + webhook: 'Webhook', + schedule: 'スケジュール', + plugin: 'プラグイン', + ragPipelineRun: 'RAGパイプライン', + ragPipelineDebugging: 'RAGデバッグ', + }, } export default translation diff --git a/web/i18n/ja-JP/app-overview.ts b/web/i18n/ja-JP/app-overview.ts index 7c0378bc6b..ad1abb78fa 100644 --- a/web/i18n/ja-JP/app-overview.ts +++ b/web/i18n/ja-JP/app-overview.ts @@ -30,12 +30,17 @@ const translation = { overview: { title: '概要', appInfo: { + title: 'Web App', explanation: '使いやすい AI Web アプリ', accessibleAddress: '公開 URL', preview: 'プレビュー', regenerate: '再生成', regenerateNotice: '公開 URL を再生成しますか?', preUseReminder: '続行する前に Web アプリを有効にしてください。', + enableTooltip: { + description: 'この機能を有効にするには、キャンバスにユーザー入力ノードを追加してください。(下書きに既に存在する可能性があり、公開後に有効になります)', + learnMore: '詳細を見る', + }, settings: { entry: '設定', title: 'Web アプリの設定', @@ -121,6 +126,14 @@ const translation = { accessibleAddress: 'サービス API エンドポイント', doc: 'API リファレンス', }, + triggerInfo: { + title: 'トリガー', + explanation: 'ワークフロートリガー管理', + triggersAdded: '{{count}} 個のトリガーが追加されました', + noTriggerAdded: 'トリガーが追加されていません', + triggerStatusDescription: 'トリガーノードの状態がここに表示されます。(下書きに既に存在する可能性があり、公開後に有効になります)', + learnAboutTriggers: 'トリガーについて学ぶ', + }, status: { running: '稼働中', disable: '無効', diff --git a/web/i18n/ja-JP/billing.ts b/web/i18n/ja-JP/billing.ts index 6dbff60d5a..b679ae571a 100644 --- a/web/i18n/ja-JP/billing.ts +++ b/web/i18n/ja-JP/billing.ts @@ -7,6 +7,8 @@ const translation = { documentsUploadQuota: 'ドキュメント・アップロード・クォータ', vectorSpace: 'ナレッジベースのデータストレージ', vectorSpaceTooltip: '高品質インデックスモードのドキュメントは、ナレッジベースのデータストレージのリソースを消費します。ナレッジベースのデータストレージの上限に達すると、新しいドキュメントはアップロードされません。', + triggerEvents: 'トリガーイベント', + perMonth: '月あたり', }, upgradeBtn: { plain: 'プランをアップグレード', @@ -60,7 +62,7 @@ const translation = { documentsRequestQuota: '{{count,number}}/分のナレッジ リクエストのレート制限', documentsRequestQuotaTooltip: 'ナレッジベース内でワークスペースが 1 分間に実行できる操作の総数を示します。これには、データセットの作成、削除、更新、ドキュメントのアップロード、修正、アーカイブ、およびナレッジベースクエリが含まれます。この指標は、ナレッジベースリクエストのパフォーマンスを評価するために使用されます。例えば、Sandbox ユーザーが 1 分間に 10 回連続でヒットテストを実行した場合、そのワークスペースは次の 1 分間、データセットの作成、削除、更新、ドキュメントのアップロードや修正などの操作を一時的に実行できなくなります。', apiRateLimit: 'API レート制限', - apiRateLimitUnit: '{{count,number}}/日', + apiRateLimitUnit: '{{count,number}}/月', unlimitedApiRate: '無制限の API コール', apiRateLimitTooltip: 'API レート制限は、テキスト生成、チャットボット、ワークフロー、ドキュメント処理など、Dify API 経由のすべてのリクエストに適用されます。', documentProcessingPriority: '文書処理', diff --git a/web/i18n/ja-JP/common.ts b/web/i18n/ja-JP/common.ts index f3fd94de5c..53bb5301fe 100644 --- a/web/i18n/ja-JP/common.ts +++ b/web/i18n/ja-JP/common.ts @@ -66,6 +66,7 @@ const translation = { more: 'もっと', selectAll: 'すべて選択', deSelectAll: 'すべて選択解除', + now: '今', config: 'コンフィグ', yes: 'はい', no: 'いいえ', @@ -170,7 +171,7 @@ const translation = { emailSupport: 'サポート', workspace: 'ワークスペース', createWorkspace: 'ワークスペースを作成', - helpCenter: 'ヘルプ', + helpCenter: 'ドキュメントを見る', support: 'サポート', compliance: 'コンプライアンス', roadmap: 'ロードマップ', diff --git a/web/i18n/ja-JP/plugin-trigger.ts b/web/i18n/ja-JP/plugin-trigger.ts new file mode 100644 index 0000000000..a4f0a8c5df --- /dev/null +++ b/web/i18n/ja-JP/plugin-trigger.ts @@ -0,0 +1,216 @@ +const translation = { + subscription: { + title: 'サブスクリプション', + listNum: '{{num}} サブスクリプション', + empty: { + title: 'サブスクリプションがありません', + description: 'イベントの受信を開始するために最初のサブスクリプションを作成してください', + button: '新しいサブスクリプション', + }, + createButton: { + oauth: 'OAuth で新しいサブスクリプション', + apiKey: 'API キーで新しいサブスクリプション', + manual: 'URL を貼り付けて新しいサブスクリプションを作成', + }, + list: { + title: 'サブスクリプション', + addButton: '追加', + tip: 'サブスクリプション経由でイベントを受信', + item: { + enabled: '有効', + disabled: '無効', + credentialType: { + api_key: 'API キー', + oauth2: 'OAuth', + unauthorized: '手動', + }, + actions: { + delete: '削除', + deleteConfirm: { + title: 'サブスクリプションを削除', + content: '「{{name}}」を削除してもよろしいですか?', + contentWithApps: 'このサブスクリプションは {{count}} 個のアプリで使用されています。「{{name}}」を削除してもよろしいですか?', + confirm: '削除', + cancel: 'キャンセル', + confirmInputWarning: '確認するために正しい名前を入力してください。', + }, + }, + status: { + active: 'アクティブ', + inactive: '非アクティブ', + }, + usedByNum: '{{num}} ワークフローで使用中', + noUsed: 'ワークフローで使用されていません', + }, + }, + addType: { + title: 'サブスクリプションを追加', + description: 'トリガーサブスクリプションの作成方法を選択してください', + options: { + apiKey: { + title: 'API キー経由', + description: 'API 認証情報を使用してサブスクリプションを自動作成', + }, + oauth: { + title: 'OAuth 経由', + description: 'サードパーティプラットフォームで認証してサブスクリプションを作成', + custom: 'カスタム', + default: 'デフォルト', + clientSettings: 'OAuthクライアント設定', + clientTitle: 'OAuth クライアント', + }, + manual: { + title: '手動設定', + description: 'URL を貼り付けて新しいサブスクリプションを作成', + tip: 'サードパーティプラットフォームで URL を手動設定', + }, + apikey: { + title: 'APIキーで作成', + description: 'API資格情報を使用してサブスクリプションを自動的に作成する', + }, + }, + }, + subscriptionRemoved: 'サブスクリプションが解除されました', + createSuccess: 'サブスクリプションが正常に作成されました', + noSubscriptionSelected: 'サブスクリプションが選択されていません', + selectPlaceholder: 'サブスクリプションを選択', + createFailed: 'サブスクリプションの作成に失敗しました', + }, + modal: { + steps: { + verify: '検証', + configuration: '設定', + }, + common: { + cancel: 'キャンセル', + back: '戻る', + next: '次へ', + create: '作成', + verify: '検証', + authorize: '認証', + creating: '作成中...', + verifying: '検証中...', + authorizing: '認証中...', + }, + oauthRedirectInfo: 'このツールプロバイダーのシステムクライアントシークレットが見つからないため、手動設定が必要です。redirect_uri には以下を使用してください', + apiKey: { + title: 'API キーで作成', + verify: { + title: '認証情報を検証', + description: 'アクセスを検証するために API 認証情報を提供してください', + error: '認証情報の検証に失敗しました。API キーをご確認ください。', + success: '認証情報が正常に検証されました', + }, + configuration: { + title: 'サブスクリプションを設定', + description: 'サブスクリプションパラメータを設定', + }, + }, + oauth: { + title: 'OAuth で作成', + authorization: { + title: 'OAuth 認証', + description: 'Dify があなたのアカウントにアクセスすることを認証', + redirectUrl: 'リダイレクト URL', + redirectUrlHelp: 'OAuth アプリ設定でこの URL を使用', + authorizeButton: '{{provider}} で認証', + waitingAuth: '認証を待機中...', + authSuccess: '認証が成功しました', + authFailed: '認証に失敗しました', + waitingJump: '承認済み、ジャンプ待機中', + }, + configuration: { + title: 'サブスクリプションを設定', + description: '認証後にサブスクリプションパラメータを設定', + success: 'OAuth設定が成功しました', + failed: 'OAuthの設定に失敗しました', + }, + remove: { + success: 'OAuthの削除に成功しました', + failed: 'OAuthの削除に失敗しました', + }, + save: { + success: 'OAuth の設定が正常に保存されました', + }, + }, + manual: { + title: '手動設定', + description: 'Webhook サブスクリプションを手動で設定', + instruction: { + title: '設定手順', + step1: '1. 以下のコールバック URL をコピー', + step2: '2. サードパーティプラットフォームの Webhook 設定に移動', + step3: '3. コールバック URL を Webhook エンドポイントとして追加', + step4: '4. 受信したいイベントを設定', + step5: '5. イベントをトリガーして Webhook をテスト', + step6: '6. ここに戻って Webhook が動作していることを確認し、設定を完了', + }, + logs: { + title: 'リクエストログ', + description: '受信 Webhook リクエストを監視', + empty: 'まだリクエストを受信していません。Webhook 設定をテストしてください。', + status: { + success: '成功', + error: 'エラー', + }, + expandAll: 'すべて展開', + collapseAll: 'すべて折りたたむ', + timestamp: 'タイムスタンプ', + method: 'メソッド', + path: 'パス', + headers: 'ヘッダー', + body: 'ボディ', + response: 'レスポンス', + request: 'リクエスト', + }, + }, + form: { + subscriptionName: { + label: 'サブスクリプション名', + placeholder: 'サブスクリプション名を入力', + required: 'サブスクリプション名は必須です', + }, + callbackUrl: { + label: 'コールバック URL', + description: 'この URL で Webhook イベントを受信します', + copy: 'コピー', + copied: 'コピーしました!', + placeholder: '生成中...', + privateAddressWarning: 'このURLは内部アドレスのようで、Webhookリクエストが失敗する可能性があります。', + tooltip: 'トリガープロバイダーからのコールバックリクエストを受信できる、公開アクセス可能なエンドポイントを提供してください。', + }, + }, + errors: { + createFailed: 'サブスクリプションの作成に失敗しました', + verifyFailed: '認証情報の検証に失敗しました', + authFailed: '認証に失敗しました', + networkError: 'ネットワークエラーです。再試行してください', + }, + }, + events: { + title: '利用可能なイベント', + description: 'このトリガープラグインが購読できるイベント', + empty: '利用可能なイベントがありません', + event: 'イベント', + events: 'イベント', + actionNum: '{{num}} {{event}} が含まれています', + item: { + parameters: '{{count}} パラメータ', + noParameters: 'パラメータなし', + }, + output: '出力', + }, + provider: { + github: 'GitHub', + gitlab: 'GitLab', + notion: 'Notion', + webhook: 'Webhook', + }, + node: { + status: { + warning: '切断', + }, + }, +} + +export default translation diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 3320f5a89f..07241b8c4f 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -9,8 +9,10 @@ const translation = { publish: '公開する', update: '更新', publishUpdate: '更新を公開', - run: '実行', + run: 'テスト実行', running: '実行中', + chooseStartNodeToRun: '実行する開始ノードを選択', + runAllTriggers: 'すべてのトリガーを実行', inRunMode: '実行モード中', inPreview: 'プレビュー中', inPreviewMode: 'プレビューモード中', @@ -19,11 +21,8 @@ const translation = { runHistory: '実行履歴', goBackToEdit: '編集に戻る', conversationLog: '会話ログ', - features: '機能', - featuresDescription: 'Web アプリの操作性を向上させる機能', ImageUploadLegacyTip: '開始フォームでファイル型変数が作成可能になりました。画像アップロード機能は今後サポート終了となります。', fileUploadTip: '画像アップロード機能がファイルアップロードに拡張されました', - featuresDocLink: '詳細を見る', debugAndPreview: 'プレビュー', restart: '再起動', currentDraft: '現在の下書き', @@ -47,7 +46,8 @@ const translation = { needConnectTip: '接続されていないステップがあります', maxTreeDepth: '1 ブランチあたりの最大ノード数:{{depth}}', needAdd: '{{node}}ノードを追加する必要があります', - needEndNode: '終了ブロックを追加する必要があります', + needOutputNode: '出力ノードを追加する必要があります', + needStartNode: '少なくとも1つのスタートノードを追加する必要があります', needAnswerNode: '回答ブロックを追加する必要があります', workflowProcess: 'ワークフロー処理', notRunning: 'まだ実行されていません', @@ -83,6 +83,7 @@ const translation = { configure: '設定', manageInTools: 'ツールページで管理', workflowAsToolTip: 'ワークフロー更新後はツールの再設定が必要です', + workflowAsToolDisabledHint: '最新のワークフローを公開し、接続済みの User Input ノードを用意してからツールとして設定してください。', viewDetailInTracingPanel: '詳細を表示', syncingData: 'データ同期中。。。', importDSL: 'DSL をインポート', @@ -116,6 +117,7 @@ const translation = { loadMore: 'さらに読み込む', noHistory: '履歴がありません', tagBound: 'このタグを使用しているアプリの数', + moreActions: 'さらにアクション', }, env: { envPanelTitle: '環境変数', @@ -140,6 +142,19 @@ const translation = { export: 'シークレット値付きでエクスポート', }, }, + globalVar: { + title: 'システム変数', + description: 'システム変数は、タイプが適合していれば配線なしで任意のノードから参照できるグローバル変数です。エンドユーザーIDやワークフローIDなどが含まれます。', + fieldsDescription: { + conversationId: '会話ID', + dialogCount: '会話数', + userId: 'ユーザーID', + triggerTimestamp: 'アプリケーションの起動タイムスタンプ', + appId: 'アプリケーションID', + workflowId: 'ワークフローID', + workflowRunId: 'ワークフロー実行ID', + }, + }, sidebar: { exportWarning: '現在保存されているバージョンをエクスポート', exportWarningDesc: 'これは現在保存されているワークフローのバージョンをエクスポートします。エディターで未保存の変更がある場合は、まずワークフローキャンバスのエクスポートオプションを使用して保存してください。', @@ -213,6 +228,16 @@ const translation = { invalidVariable: '無効な変数です', noValidTool: '{{field}} に利用可能なツールがありません', toolParameterRequired: '{{field}}: パラメータ [{{param}}] は必須です', + startNodeRequired: '{{operation}}前に開始ノードを追加してください', + }, + error: { + startNodeRequired: '{{operation}}前に開始ノードを追加してください', + operations: { + connectingNodes: 'ノード接続', + addingNodes: 'ノード追加', + modifyingWorkflow: 'ワークフロー変更', + updatingWorkflow: 'ワークフロー更新', + }, }, singleRun: { testRun: 'テスト実行', @@ -229,7 +254,9 @@ const translation = { 'searchBlock': 'ブロック検索', 'blocks': 'ブロック', 'searchTool': 'ツール検索', + 'searchTrigger': 'トリガー検索...', 'tools': 'ツール', + 'allTriggers': 'すべてのトリガー', 'allTool': 'すべて', 'customTool': 'カスタム', 'workflowTool': 'ワークフロー', @@ -238,16 +265,23 @@ const translation = { 'transform': '変換', 'utilities': 'ツール', 'noResult': '該当なし', + 'noPluginsFound': 'プラグインが見つかりません', + 'requestToCommunity': 'コミュニティにリクエスト', 'plugin': 'プラグイン', 'agent': 'エージェント戦略', + 'noFeaturedPlugins': 'マーケットプレイスでさらにツールを見つける', + 'noFeaturedTriggers': 'マーケットプレイスでさらにトリガーを見つける', 'addAll': 'すべてを追加する', 'allAdded': 'すべて追加されました', 'searchDataSource': 'データソースを検索', 'sources': 'ソース', + 'start': '始める', + 'startDisabledTip': 'トリガーノードとユーザー入力ノードは互いに排他です。', }, blocks: { - 'start': '開始', - 'end': '終了', + 'start': 'ユーザー入力', + 'originalStartNode': '元の開始ノード', + 'end': '出力', 'answer': '回答', 'llm': 'LLM', 'knowledge-retrieval': '知識検索', @@ -270,10 +304,14 @@ const translation = { 'loop-end': 'ループ完了', 'knowledge-index': '知識ベース', 'datasource': 'データソース', + 'trigger-plugin': 'プラグイントリガー', + 'trigger-webhook': 'Webhook トリガー', + 'trigger-schedule': 'スケジュールトリガー', }, + customWebhook: 'カスタムWebhook', blocksAbout: { 'start': 'ワークフロー開始時の初期パラメータを定義します。', - 'end': 'ワークフローの終了条件と結果のタイプを定義します。', + 'end': 'ワークフローの出力と結果のタイプを定義します', 'answer': 'チャットダイアログの返答内容を定義します。', 'llm': '大規模言語モデルを呼び出して質問回答や自然言語処理を実行します。', 'knowledge-retrieval': 'ナレッジベースからユーザー質問に関連するテキストを検索します。', @@ -294,7 +332,11 @@ const translation = { 'agent': '大規模言語モデルを活用した質問応答や自然言語処理を実行します。', 'knowledge-index': '知識ベースについて', 'datasource': 'データソースについて', + 'trigger-schedule': 'スケジュールに基づいてワークフローを開始する時間ベースのトリガー', + 'trigger-webhook': 'Webhook トリガーは第三者システムからの HTTP プッシュを受信してワークフローを自動的に開始します。', + 'trigger-plugin': 'サードパーティ統合トリガー、外部プラットフォームのイベントによってワークフローを開始します', }, + difyTeam: 'Dify チーム', operator: { zoomIn: '拡大', zoomOut: '縮小', @@ -324,7 +366,7 @@ const translation = { panel: { userInputField: 'ユーザー入力欄', changeBlock: 'ノード変更', - helpLink: 'ヘルプリンク', + helpLink: 'ドキュメントを見る', about: '詳細', createdBy: '作成者', nextStep: '次のステップ', @@ -334,12 +376,14 @@ const translation = { checklist: 'チェックリスト', checklistTip: '公開前に全ての項目を確認してください', checklistResolved: '全てのチェックが完了しました', + goTo: '移動', + startNode: '開始ノード', organizeBlocks: 'ノード整理', change: '変更', optional: '(任意)', - moveToThisNode: 'このノードに移動する', maximize: 'キャンバスを最大化する', minimize: '全画面を終了する', + scrollToSelectedNode: '選択したノードまでスクロール', optional_and_hidden: '(オプションおよび非表示)', }, nodes: { @@ -964,6 +1008,137 @@ const translation = { embeddingModelIsInvalid: '埋め込みモデルが無効です', rerankingModelIsInvalid: 'リランキングモデルは無効です', }, + triggerSchedule: { + frequency: { + label: '頻度', + monthly: '毎月', + weekly: '毎週', + daily: '毎日', + hourly: '毎時', + }, + frequencyLabel: '頻度', + days: '日', + title: 'スケジュール', + minutes: '分', + time: '時刻', + useCronExpression: 'Cron 式を使用', + nextExecutionTimes: '次の5回の実行時刻', + nextExecution: '次回実行', + notConfigured: '未設定', + startTime: '開始時刻', + hours: '時間', + onMinute: '分', + executeNow: '今すぐ実行', + weekdays: '曜日', + selectDateTime: '日時を選択', + cronExpression: 'Cron 式', + selectFrequency: '頻度を選択', + lastDay: '月末', + nextExecutionTime: '次回実行時刻', + lastDayTooltip: 'すべての月に31日があるわけではありません。「月末」オプションを使用して各月の最終日を選択してください。', + useVisualPicker: 'ビジュアル設定を使用', + nodeTitle: 'スケジュールトリガー', + mode: 'モード', + timezone: 'タイムゾーン', + visualConfig: 'ビジュアル設定', + monthlyDay: '月の日', + executionTime: '実行時間', + invalidTimezone: '無効なタイムゾーン', + invalidCronExpression: '無効なCron式', + noValidExecutionTime: '有効な実行時間を計算できません', + executionTimeCalculationError: '実行時間の計算に失敗しました', + invalidFrequency: '無効な頻度', + invalidStartTime: '無効な開始時間', + startTimeMustBeFuture: '開始時間は未来の時間である必要があります', + invalidTimeFormat: '無効な時間形式(期待される形式:HH:MM AM/PM)', + invalidWeekday: '無効な曜日:{{weekday}}', + invalidMonthlyDay: '月の日は1-31の間または"last"である必要があります', + invalidOnMinute: '分は0-59の間である必要があります', + invalidExecutionTime: '無効な実行時間', + executionTimeMustBeFuture: '実行時間は未来の時間である必要があります', + }, + triggerWebhook: { + title: 'Webhook トリガー', + nodeTitle: '🔗 Webhook トリガー', + configPlaceholder: 'Webhook トリガーの設定がここに実装されます', + webhookUrl: 'Webhook URL', + webhookUrlPlaceholder: '生成をクリックして Webhook URL を作成', + generate: '生成', + copy: 'コピー', + test: 'テスト', + urlGenerated: 'Webhook URL を生成しました', + urlGenerationFailed: 'Webhook URL の生成に失敗しました', + urlCopied: 'URL をクリップボードにコピーしました', + method: 'メソッド', + contentType: 'コンテンツタイプ', + queryParameters: 'クエリパラメータ', + headerParameters: 'ヘッダーパラメータ', + requestBodyParameters: 'リクエストボディパラメータ', + parameterName: '変数名', + varName: '変数名', + varType: 'タイプ', + varNamePlaceholder: '変数名を入力...', + headerName: '変数名', + required: '必須', + addParameter: '追加', + addHeader: '追加', + noParameters: '設定されたパラメータはありません', + noQueryParameters: 'クエリパラメータは設定されていません', + noHeaders: 'ヘッダーは設定されていません', + noBodyParameters: 'ボディパラメータは設定されていません', + debugUrlTitle: 'テスト実行には、常にこのURLを使用してください', + debugUrlCopy: 'クリックしてコピー', + debugUrlCopied: 'コピーしました!', + errorHandling: 'エラー処理', + errorStrategy: 'エラー処理', + responseConfiguration: 'レスポンス', + asyncMode: '非同期モード', + statusCode: 'ステータスコード', + responseBody: 'レスポンスボディ', + responseBodyPlaceholder: 'ここにレスポンスボディを入力してください', + headers: 'ヘッダー', + validation: { + webhookUrlRequired: 'Webhook URLが必要です', + invalidParameterType: 'パラメータ"{{name}}"の無効なパラメータタイプ"{{type}}"です', + }, + }, + triggerPlugin: { + authorized: '認可された', + notConfigured: '設定されていません', + error: 'エラー', + configuration: '構成', + remove: '削除する', + or: 'または', + useOAuth: 'OAuth を使用', + useApiKey: 'API キーを使用', + authenticationFailed: '認証に失敗しました', + authenticationSuccess: '認証に成功しました', + oauthConfigFailed: 'OAuth 設定に失敗しました', + configureOAuthClient: 'OAuth クライアントを設定', + oauthClientDescription: '認証を有効にするために OAuth クライアント認証情報を設定してください', + oauthClientSaved: 'OAuth クライアント設定が正常に保存されました', + configureApiKey: 'API キーを設定', + apiKeyDescription: '認証のための API キー認証情報を設定してください', + apiKeyConfigured: 'API キーが正常に設定されました', + configurationFailed: '設定に失敗しました', + failedToStart: '認証フローの開始に失敗しました', + credentialsVerified: '認証情報が正常に検証されました', + credentialVerificationFailed: '認証情報の検証に失敗しました', + verifyAndContinue: '検証して続行', + configureParameters: 'パラメーターを設定', + parametersDescription: 'トリガーのパラメーターとプロパティを設定してください', + configurationComplete: '設定完了', + configurationCompleteDescription: 'トリガーが正常に設定されました', + configurationCompleteMessage: 'トリガーの設定が完了し、使用する準備ができました。', + parameters: 'パラメーター', + properties: 'プロパティ', + propertiesDescription: 'このトリガーの追加設定プロパティ', + noConfigurationRequired: 'このトリガーには追加の設定は必要ありません。', + subscriptionName: 'サブスクリプション名', + subscriptionNameDescription: 'このトリガーサブスクリプションの一意な名前を入力してください', + subscriptionNamePlaceholder: 'サブスクリプション名を入力...', + subscriptionNameRequired: 'サブスクリプション名は必須です', + }, }, tracing: { stopBy: '{{user}}によって停止', @@ -1008,6 +1183,18 @@ const translation = { description: '最後の実行の結果がここに表示されます', }, variableInspect: { + listening: { + title: 'トリガーからのイベントを待機中…', + tip: 'HTTP {{nodeName}} エンドポイントにテストリクエストを送信するか、ライブイベントデバッグ用のコールバック URL として利用してイベントトリガーをシミュレートできます。すべての出力は Variable Inspector で直接確認できます。', + tipPlugin: '{{- pluginName}} でイベントを作成し、これらのイベントの出力を Variable Inspector で取得できます。', + tipSchedule: 'スケジュールトリガーからのイベントを待機しています。\n次回の予定実行: {{nextTriggerTime}}', + tipFallback: 'トリガーイベントを待機しています。出力はここに表示されます。', + defaultNodeName: 'このトリガー', + defaultPluginName: 'このプラグイントリガー', + defaultScheduleTime: '未設定', + selectedTriggers: '選択したトリガー', + stopButton: '停止', + }, trigger: { clear: 'クリア', running: 'キャッシング実行状況', @@ -1050,6 +1237,30 @@ const translation = { lastRunInputsCopied: '前回の実行から{{count}}個の入力をコピーしました', lastOutput: '最後の出力', }, + triggerStatus: { + enabled: 'トリガー', + disabled: 'トリガー • 無効', + }, + entryNodeStatus: { + enabled: 'スタート', + disabled: '開始 • 無効', + }, + onboarding: { + title: '開始するには開始ノードを選択してください', + description: '異なる開始ノードには異なる機能があります。心配しないでください、いつでも変更できます。', + userInputFull: 'ユーザー入力(元の開始ノード)', + userInputDescription: 'ユーザー入力変数の設定を可能にする開始ノードで、Webアプリ、サービスAPI、MCPサーバー、およびツールとしてのワークフロー機能を持ちます。', + trigger: 'トリガー', + triggerDescription: 'トリガーは、スケジュールされたタスク、カスタムwebhook、または他のアプリとの統合など、ワークフローの開始ノードとして機能できます。', + back: '戻る', + learnMore: '詳細を見る', + aboutStartNode: '開始ノードについて。', + escTip: { + press: '', + key: 'esc', + toDismiss: 'キーで閉じる', + }, + }, } export default translation diff --git a/web/i18n/ko-KR/billing.ts b/web/i18n/ko-KR/billing.ts index c5f081d41b..112fa1bc63 100644 --- a/web/i18n/ko-KR/billing.ts +++ b/web/i18n/ko-KR/billing.ts @@ -88,7 +88,7 @@ const translation = { freeTrialTip: '200 회의 OpenAI 호출 무료 체험을 받으세요. ', annualBilling: '연간 청구', getStarted: '시작하기', - apiRateLimitUnit: '{{count,number}}/일', + apiRateLimitUnit: '{{count,number}}/월', freeTrialTipSuffix: '신용카드 없음', teamWorkspace: '{{count,number}} 팀 작업 공간', self: '자체 호스팅', diff --git a/web/i18n/ko-KR/workflow.ts b/web/i18n/ko-KR/workflow.ts index 427452943a..e661b6b340 100644 --- a/web/i18n/ko-KR/workflow.ts +++ b/web/i18n/ko-KR/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: '게시됨', publish: '게시하기', update: '업데이트', - run: '실행', + run: '테스트 실행', running: '실행 중', inRunMode: '실행 모드', inPreview: '미리보기 중', @@ -18,7 +18,6 @@ const translation = { runHistory: '실행 기록', goBackToEdit: '편집기로 돌아가기', conversationLog: '대화 로그', - features: '기능', debugAndPreview: '미리보기', restart: '재시작', currentDraft: '현재 초안', @@ -93,9 +92,7 @@ const translation = { addParallelNode: '병렬 노드 추가', parallel: '병렬', branch: '브랜치', - featuresDocLink: '더 알아보세요', fileUploadTip: '이미지 업로드 기능이 파일 업로드로 업그레이드되었습니다.', - featuresDescription: '웹앱 사용자 경험 향상', ImageUploadLegacyTip: '이제 시작 양식에서 파일 형식 변수를 만들 수 있습니다. 앞으로 이미지 업로드 기능은 더 이상 지원되지 않습니다.', importWarning: '주의', @@ -115,10 +112,11 @@ const translation = { exportPNG: 'PNG 로 내보내기', addBlock: '노드 추가', needAnswerNode: '답변 노드를 추가해야 합니다.', - needEndNode: '종단 노드를 추가해야 합니다.', + needOutputNode: '출력 노드를 추가해야 합니다', tagBound: '이 태그를 사용하는 앱 수', currentView: '현재 보기', currentWorkflow: '현재 워크플로', + moreActions: '더 많은 작업', }, env: { envPanelTitle: '환경 변수', @@ -145,6 +143,19 @@ const translation = { export: '비밀 값이 포함된 DSL 내보내기', }, }, + globalVar: { + title: '시스템 변수', + description: '시스템 변수는 타입이 맞으면 배선 없이도 모든 노드에서 참조할 수 있는 전역 변수로, 엔드유저 ID와 워크플로 ID 등이 포함됩니다.', + fieldsDescription: { + conversationId: '대화 ID', + dialogCount: '대화 수', + userId: '사용자 ID', + triggerTimestamp: '애플리케이션 시작 타임스탬프', + appId: '애플리케이션 ID', + workflowId: '워크플로 ID', + workflowRunId: '워크플로 실행 ID', + }, + }, chatVariable: { panelTitle: '대화 변수', panelDescription: @@ -251,7 +262,7 @@ const translation = { }, blocks: { 'start': '시작', - 'end': '끝', + 'end': '출력', 'answer': '답변', 'llm': 'LLM', 'knowledge-retrieval': '지식 검색', @@ -277,7 +288,7 @@ const translation = { }, blocksAbout: { 'start': '워크플로우를 시작하기 위한 초기 매개변수를 정의합니다', - 'end': '워크플로우의 종료 및 결과 유형을 정의합니다', + 'end': '워크플로의 출력 및 결과 유형을 정의합니다', 'answer': '대화의 답변 내용을 정의합니다', 'llm': '질문에 답하거나 자연어를 처리하기 위해 대형 언어 모델을 호출합니다', 'knowledge-retrieval': @@ -332,7 +343,7 @@ const translation = { }, panel: { userInputField: '사용자 입력 필드', - helpLink: '도움말 링크', + helpLink: '도움말 센터', about: '정보', createdBy: '작성자 ', nextStep: '다음 단계', @@ -342,13 +353,13 @@ const translation = { checklistResolved: '모든 문제가 해결되었습니다', change: '변경', optional: '(선택사항)', - moveToThisNode: '이 노드로 이동', organizeBlocks: '노드 정리하기', selectNextStep: '다음 단계 선택', changeBlock: '노드 변경', addNextStep: '이 워크플로우에 다음 단계를 추가하세요.', minimize: '전체 화면 종료', maximize: '캔버스 전체 화면', + scrollToSelectedNode: '선택한 노드로 스크롤', optional_and_hidden: '(선택 사항 및 숨김)', }, nodes: { diff --git a/web/i18n/pl-PL/billing.ts b/web/i18n/pl-PL/billing.ts index cf0859468b..31aa337478 100644 --- a/web/i18n/pl-PL/billing.ts +++ b/web/i18n/pl-PL/billing.ts @@ -91,7 +91,7 @@ const translation = { freeTrialTipPrefix: 'Zarejestruj się i zdobądź', teamMember_other: '{{count,number}} członków zespołu', teamWorkspace: '{{count,number}} Zespół Workspace', - apiRateLimitUnit: '{{count,number}}/dzień', + apiRateLimitUnit: '{{count,number}}/miesiąc', cloud: 'Usługa chmurowa', teamMember_one: '{{count,number}} Członek zespołu', priceTip: 'na przestrzeń roboczą/', diff --git a/web/i18n/pl-PL/workflow.ts b/web/i18n/pl-PL/workflow.ts index 7c4d85e3ec..f30e9350f7 100644 --- a/web/i18n/pl-PL/workflow.ts +++ b/web/i18n/pl-PL/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Opublikowane', publish: 'Opublikuj', update: 'Aktualizuj', - run: 'Uruchom', + run: 'Uruchom test', running: 'Uruchamianie', inRunMode: 'W trybie uruchamiania', inPreview: 'W podglądzie', @@ -18,7 +18,6 @@ const translation = { runHistory: 'Historia uruchomień', goBackToEdit: 'Wróć do edytora', conversationLog: 'Dziennik rozmów', - features: 'Funkcje', debugAndPreview: 'Podgląd', restart: 'Uruchom ponownie', currentDraft: 'Bieżący szkic', @@ -93,8 +92,6 @@ const translation = { branch: 'GAŁĄŹ', ImageUploadLegacyTip: 'Teraz można tworzyć zmienne typu pliku w formularzu startowym. W przyszłości nie będziemy już obsługiwać funkcji przesyłania obrazów.', fileUploadTip: 'Funkcje przesyłania obrazów zostały zaktualizowane do przesyłania plików.', - featuresDescription: 'Ulepszanie środowiska użytkownika aplikacji internetowej', - featuresDocLink: 'Dowiedz się więcej', importWarning: 'Ostrożność', importWarningDetails: 'Różnica w wersji DSL może mieć wpływ na niektóre funkcje', openInExplore: 'Otwieranie w obszarze Eksploruj', @@ -110,11 +107,12 @@ const translation = { exportPNG: 'Eksportuj jako PNG', publishUpdate: 'Opublikuj aktualizację', addBlock: 'Dodaj węzeł', - needEndNode: 'Należy dodać węzeł końcowy', + needOutputNode: 'Należy dodać węzeł wyjściowy', needAnswerNode: 'Węzeł odpowiedzi musi zostać dodany', tagBound: 'Liczba aplikacji korzystających z tego tagu', currentWorkflow: 'Bieżący przepływ pracy', currentView: 'Bieżący widok', + moreActions: 'Więcej akcji', }, env: { envPanelTitle: 'Zmienne Środowiskowe', @@ -139,6 +137,19 @@ const translation = { export: 'Eksportuj DSL z tajnymi wartościami', }, }, + globalVar: { + title: 'Zmienne systemowe', + description: 'Zmienne systemowe to zmienne globalne, do których może odwołać się każdy węzeł bez okablowania, jeśli typ jest zgodny, na przykład identyfikator użytkownika końcowego i identyfikator przepływu pracy.', + fieldsDescription: { + conversationId: 'ID konwersacji', + dialogCount: 'Liczba konwersacji', + userId: 'ID użytkownika', + triggerTimestamp: 'Znacznik czasu uruchomienia aplikacji', + appId: 'ID aplikacji', + workflowId: 'ID przepływu pracy', + workflowRunId: 'ID uruchomienia przepływu pracy', + }, + }, chatVariable: { panelTitle: 'Zmienne Konwersacji', panelDescription: 'Zmienne Konwersacji służą do przechowywania interaktywnych informacji, które LLM musi pamiętać, w tym historii konwersacji, przesłanych plików, preferencji użytkownika. Są one do odczytu i zapisu.', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'Start', - 'end': 'Koniec', + 'end': 'Wyjście', 'answer': 'Odpowiedź', 'llm': 'LLM', 'knowledge-retrieval': 'Wyszukiwanie wiedzy', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'Zdefiniuj początkowe parametry uruchamiania przepływu pracy', - 'end': 'Zdefiniuj zakończenie i typ wyniku przepływu pracy', + 'end': 'Zdefiniuj wyjście i typ wyniku przepływu pracy', 'answer': 'Zdefiniuj treść odpowiedzi w rozmowie', 'llm': 'Wywołaj duże modele językowe do odpowiadania na pytania lub przetwarzania języka naturalnego', 'knowledge-retrieval': 'Pozwala na wyszukiwanie treści tekstowych związanych z pytaniami użytkowników z bazy wiedzy', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'Pole wprowadzania użytkownika', - helpLink: 'Link do pomocy', + helpLink: 'Pomoc', about: 'O', createdBy: 'Stworzone przez ', nextStep: 'Następny krok', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'Wszystkie problemy zostały rozwiązane', change: 'Zmień', optional: '(opcjonalne)', - moveToThisNode: 'Przenieś do tego węzła', selectNextStep: 'Wybierz następny krok', addNextStep: 'Dodaj następny krok w tym procesie roboczym', changeBlock: 'Zmień węzeł', organizeBlocks: 'Organizuj węzły', minimize: 'Wyjdź z trybu pełnoekranowego', maximize: 'Maksymalizuj płótno', + scrollToSelectedNode: 'Przewiń do wybranego węzła', optional_and_hidden: '(opcjonalne i ukryte)', }, nodes: { diff --git a/web/i18n/pt-BR/billing.ts b/web/i18n/pt-BR/billing.ts index e4ca0a064a..9e58b24af4 100644 --- a/web/i18n/pt-BR/billing.ts +++ b/web/i18n/pt-BR/billing.ts @@ -80,7 +80,7 @@ const translation = { documentsRequestQuota: '{{count,number}}/min Limite de Taxa de Solicitação de Conhecimento', cloud: 'Serviço de Nuvem', teamWorkspace: '{{count,number}} Espaço de Trabalho da Equipe', - apiRateLimitUnit: '{{count,number}}/dia', + apiRateLimitUnit: '{{count,number}}/mês', freeTrialTipSuffix: 'Nenhum cartão de crédito necessário', teamMember_other: '{{count,number}} Membros da Equipe', comparePlanAndFeatures: 'Compare planos e recursos', diff --git a/web/i18n/pt-BR/workflow.ts b/web/i18n/pt-BR/workflow.ts index bd5cf49ed7..265274c979 100644 --- a/web/i18n/pt-BR/workflow.ts +++ b/web/i18n/pt-BR/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Publicado', publish: 'Publicar', update: 'Atualizar', - run: 'Executar', + run: 'Executar teste', running: 'Executando', inRunMode: 'No modo de execução', inPreview: 'Em visualização', @@ -18,7 +18,6 @@ const translation = { runHistory: 'Histórico de execução', goBackToEdit: 'Voltar para o editor', conversationLog: 'Registro de conversa', - features: 'Recursos', debugAndPreview: 'Visualizar', restart: 'Reiniciar', currentDraft: 'Rascunho atual', @@ -91,8 +90,6 @@ const translation = { addParallelNode: 'Adicionar nó paralelo', parallel: 'PARALELO', branch: 'RAMIFICAÇÃO', - featuresDocLink: 'Saiba Mais', - featuresDescription: 'Melhore a experiência do usuário do aplicativo Web', ImageUploadLegacyTip: 'Agora você pode criar variáveis de tipo de arquivo no formulário inicial. Não daremos mais suporte ao recurso de upload de imagens no futuro.', fileUploadTip: 'Os recursos de upload de imagens foram atualizados para upload de arquivos.', importWarning: 'Cuidado', @@ -110,11 +107,12 @@ const translation = { exportSVG: 'Exportar como SVG', exportJPEG: 'Exportar como JPEG', addBlock: 'Adicionar Nó', - needEndNode: 'O nó de Fim deve ser adicionado', + needOutputNode: 'O nó de Saída deve ser adicionado', needAnswerNode: 'O nó de resposta deve ser adicionado', tagBound: 'Número de aplicativos usando esta tag', currentView: 'Visualização atual', currentWorkflow: 'Fluxo de trabalho atual', + moreActions: 'Mais ações', }, env: { envPanelTitle: 'Variáveis de Ambiente', @@ -139,6 +137,19 @@ const translation = { export: 'Exportar DSL com valores secretos', }, }, + globalVar: { + title: 'Variáveis do sistema', + description: 'Variáveis do sistema são variáveis globais que qualquer nó pode referenciar sem conexões quando o tipo está correto, como o ID do usuário final e o ID do fluxo de trabalho.', + fieldsDescription: { + conversationId: 'ID da conversa', + dialogCount: 'Contagem de conversas', + userId: 'ID do usuário', + triggerTimestamp: 'Carimbo de data/hora do início da aplicação', + appId: 'ID da aplicação', + workflowId: 'ID do fluxo de trabalho', + workflowRunId: 'ID da execução do fluxo de trabalho', + }, + }, chatVariable: { panelTitle: 'Variáveis de Conversação', panelDescription: 'As Variáveis de Conversação são usadas para armazenar informações interativas que o LLM precisa lembrar, incluindo histórico de conversas, arquivos carregados, preferências do usuário. Elas são de leitura e escrita.', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'Iniciar', - 'end': 'Fim', + 'end': 'Saída', 'answer': 'Resposta', 'llm': 'LLM', 'knowledge-retrieval': 'Recuperação de conhecimento', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'Definir os parâmetros iniciais para iniciar um fluxo de trabalho', - 'end': 'Definir o fim e o tipo de resultado de um fluxo de trabalho', + 'end': 'Definir a saída e o tipo de resultado de um fluxo de trabalho', 'answer': 'Definir o conteúdo da resposta de uma conversa', 'llm': 'Invocar grandes modelos de linguagem para responder perguntas ou processar linguagem natural', 'knowledge-retrieval': 'Permite consultar conteúdo de texto relacionado a perguntas do usuário a partir da base de conhecimento', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'Campo de entrada do usuário', - helpLink: 'Link de ajuda', + helpLink: 'Ajuda', about: 'Sobre', createdBy: 'Criado por ', nextStep: 'Próximo passo', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'Todos os problemas foram resolvidos', change: 'Mudar', optional: '(opcional)', - moveToThisNode: 'Mova-se para este nó', changeBlock: 'Mudar Nó', addNextStep: 'Adicione o próximo passo neste fluxo de trabalho', organizeBlocks: 'Organizar nós', selectNextStep: 'Selecione o próximo passo', maximize: 'Maximize Canvas', minimize: 'Sair do Modo Tela Cheia', + scrollToSelectedNode: 'Role até o nó selecionado', optional_and_hidden: '(opcional & oculto)', }, nodes: { diff --git a/web/i18n/ro-RO/billing.ts b/web/i18n/ro-RO/billing.ts index 3f5577dc32..0d787bb661 100644 --- a/web/i18n/ro-RO/billing.ts +++ b/web/i18n/ro-RO/billing.ts @@ -82,7 +82,7 @@ const translation = { documentsTooltip: 'Cota pe numărul de documente importate din Sursele de Date de Cunoștințe.', getStarted: 'Întrebați-vă', cloud: 'Serviciu de cloud', - apiRateLimitUnit: '{{count,number}}/zi', + apiRateLimitUnit: '{{count,number}}/lună', comparePlanAndFeatures: 'Compară planurile și caracteristicile', documentsRequestQuota: '{{count,number}}/min Limita de rată a cererilor de cunoștințe', documents: '{{count,number}} Documente de Cunoaștere', diff --git a/web/i18n/ro-RO/workflow.ts b/web/i18n/ro-RO/workflow.ts index ffa1282380..8d55033929 100644 --- a/web/i18n/ro-RO/workflow.ts +++ b/web/i18n/ro-RO/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Publicat', publish: 'Publică', update: 'Actualizează', - run: 'Rulează', + run: 'Rulează test', running: 'Rulând', inRunMode: 'În modul de rulare', inPreview: 'În previzualizare', @@ -18,7 +18,6 @@ const translation = { runHistory: 'Istoric rulări', goBackToEdit: 'Înapoi la editor', conversationLog: 'Jurnal conversație', - features: 'Funcționalități', debugAndPreview: 'Previzualizare', restart: 'Repornește', currentDraft: 'Schimbare curentă', @@ -91,8 +90,6 @@ const translation = { addParallelNode: 'Adăugare nod paralel', parallel: 'PARALEL', branch: 'RAMURĂ', - featuresDescription: 'Îmbunătățiți experiența utilizatorului aplicației web', - featuresDocLink: 'Află mai multe', fileUploadTip: 'Funcțiile de încărcare a imaginilor au fost actualizate la încărcarea fișierelor.', ImageUploadLegacyTip: 'Acum puteți crea variabile de tip de fișier în formularul de pornire. Nu vom mai accepta funcția de încărcare a imaginilor în viitor.', importWarning: 'Prudență', @@ -111,10 +108,11 @@ const translation = { exportJPEG: 'Exportă ca JPEG', addBlock: 'Adaugă nod', needAnswerNode: 'Nodul de răspuns trebuie adăugat', - needEndNode: 'Nodul de sfârșit trebuie adăugat', + needOutputNode: 'Nodul de ieșire trebuie adăugat', tagBound: 'Numărul de aplicații care folosesc acest tag', currentView: 'Vizualizare curentă', currentWorkflow: 'Flux de lucru curent', + moreActions: 'Mai multe acțiuni', }, env: { envPanelTitle: 'Variabile de Mediu', @@ -139,6 +137,19 @@ const translation = { export: 'Exportă DSL cu valori secrete', }, }, + globalVar: { + title: 'Variabile de sistem', + description: 'Variabilele de sistem sunt variabile globale care pot fi folosite de orice nod fără conexiuni dacă tipul este corect, precum ID-ul utilizatorului final și ID-ul fluxului de lucru.', + fieldsDescription: { + conversationId: 'ID conversație', + dialogCount: 'Număr conversații', + userId: 'ID utilizator', + triggerTimestamp: 'Marcaj temporal al pornirii aplicației', + appId: 'ID aplicație', + workflowId: 'ID flux de lucru', + workflowRunId: 'ID rulare flux de lucru', + }, + }, chatVariable: { panelTitle: 'Variabile de Conversație', panelDescription: 'Variabilele de Conversație sunt utilizate pentru a stoca informații interactive pe care LLM trebuie să le rețină, inclusiv istoricul conversației, fișiere încărcate, preferințele utilizatorului. Acestea sunt citibile și inscriptibile.', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'Începe', - 'end': 'Sfârșit', + 'end': 'Ieșire', 'answer': 'Răspuns', 'llm': 'LLM', 'knowledge-retrieval': 'Recuperare de cunoștințe', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'Definiți parametrii inițiali pentru lansarea unui flux de lucru', - 'end': 'Definiți sfârșitul și tipul rezultatului unui flux de lucru', + 'end': 'Definiți ieșirea și tipul rezultatului unui flux de lucru', 'answer': 'Definiți conținutul răspunsului unei conversații', 'llm': 'Invocarea modelelor de limbaj mari pentru a răspunde la întrebări sau pentru a procesa limbajul natural', 'knowledge-retrieval': 'Permite interogarea conținutului textului legat de întrebările utilizatorului din baza de cunoștințe', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'Câmp de introducere utilizator', - helpLink: 'Link de ajutor', + helpLink: 'Ajutor', about: 'Despre', createdBy: 'Creat de ', nextStep: 'Pasul următor', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'Toate problemele au fost rezolvate', change: 'Schimbă', optional: '(opțional)', - moveToThisNode: 'Mutați la acest nod', organizeBlocks: 'Organizează nodurile', addNextStep: 'Adăugați următorul pas în acest flux de lucru', changeBlock: 'Schimbă nodul', selectNextStep: 'Selectați Pasul Următor', maximize: 'Maximize Canvas', minimize: 'Iesi din modul pe tot ecranul', + scrollToSelectedNode: 'Derulați la nodul selectat', optional_and_hidden: '(opțional și ascuns)', }, nodes: { diff --git a/web/i18n/ru-RU/billing.ts b/web/i18n/ru-RU/billing.ts index 7017f90cc2..1f3071a325 100644 --- a/web/i18n/ru-RU/billing.ts +++ b/web/i18n/ru-RU/billing.ts @@ -78,7 +78,7 @@ const translation = { apiRateLimit: 'Ограничение скорости API', self: 'Самостоятельно размещенный', teamMember_other: '{{count,number}} Члены команды', - apiRateLimitUnit: '{{count,number}}/день', + apiRateLimitUnit: '{{count,number}}/месяц', unlimitedApiRate: 'Нет ограничений на количество запросов к API', freeTrialTip: 'бесплатная пробная версия из 200 вызовов OpenAI.', freeTrialTipSuffix: 'Кредитная карта не требуется', diff --git a/web/i18n/ru-RU/workflow.ts b/web/i18n/ru-RU/workflow.ts index 78be03ba91..9d7c99acea 100644 --- a/web/i18n/ru-RU/workflow.ts +++ b/web/i18n/ru-RU/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Опубликовано', publish: 'Опубликовать', update: 'Обновить', - run: 'Запустить', + run: 'Тестовый запуск', running: 'Выполняется', inRunMode: 'В режиме выполнения', inPreview: 'В режиме предпросмотра', @@ -18,7 +18,6 @@ const translation = { runHistory: 'История запусков', goBackToEdit: 'Вернуться к редактору', conversationLog: 'Журнал разговоров', - features: 'Функции', debugAndPreview: 'Предпросмотр', restart: 'Перезапустить', currentDraft: 'Текущий черновик', @@ -91,9 +90,7 @@ const translation = { addParallelNode: 'Добавить параллельный узел', parallel: 'ПАРАЛЛЕЛЬНЫЙ', branch: 'ВЕТКА', - featuresDocLink: 'Подробнее', fileUploadTip: 'Функции загрузки изображений были обновлены до загрузки файлов.', - featuresDescription: 'Улучшение взаимодействия с пользователем веб-приложения', ImageUploadLegacyTip: 'Теперь вы можете создавать переменные типа файла в стартовой форме. В будущем мы больше не будем поддерживать функцию загрузки изображений.', importWarning: 'Осторожность', importWarningDetails: 'Разница в версии DSL может повлиять на некоторые функции', @@ -111,10 +108,11 @@ const translation = { publishUpdate: 'Опубликовать обновление', addBlock: 'Добавить узел', needAnswerNode: 'В узел ответа необходимо добавить', - needEndNode: 'Узел конца должен быть добавлен', + needOutputNode: 'Необходимо добавить узел вывода', tagBound: 'Количество приложений, использующих этот тег', currentView: 'Текущий вид', currentWorkflow: 'Текущий рабочий процесс', + moreActions: 'Больше действий', }, env: { envPanelTitle: 'Переменные среды', @@ -139,6 +137,19 @@ const translation = { export: 'Экспортировать DSL с секретными значениями ', }, }, + globalVar: { + title: 'Системные переменные', + description: 'Системные переменные — это глобальные переменные, к которым любой узел может обращаться без соединений при корректном типе, например идентификатор конечного пользователя и идентификатор рабочего процесса.', + fieldsDescription: { + conversationId: 'ID беседы', + dialogCount: 'Количество бесед', + userId: 'ID пользователя', + triggerTimestamp: 'Отметка времени запуска приложения', + appId: 'ID приложения', + workflowId: 'ID рабочего процесса', + workflowRunId: 'ID запуска рабочего процесса', + }, + }, chatVariable: { panelTitle: 'Переменные разговора', panelDescription: 'Переменные разговора используются для хранения интерактивной информации, которую LLM необходимо запомнить, включая историю разговоров, загруженные файлы, пользовательские настройки. Они доступны для чтения и записи. ', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'Начало', - 'end': 'Конец', + 'end': 'Вывод', 'answer': 'Ответ', 'llm': 'LLM', 'knowledge-retrieval': 'Поиск знаний', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'Определите начальные параметры для запуска рабочего процесса', - 'end': 'Определите конец и тип результата рабочего процесса', + 'end': 'Определите вывод и тип результата рабочего процесса', 'answer': 'Определите содержимое ответа в чате', 'llm': 'Вызов больших языковых моделей для ответа на вопросы или обработки естественного языка', 'knowledge-retrieval': 'Позволяет запрашивать текстовый контент, связанный с вопросами пользователей, из базы знаний', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'Поле ввода пользователя', - helpLink: 'Ссылка на справку', + helpLink: 'Помощь', about: 'О программе', createdBy: 'Создано ', nextStep: 'Следующий шаг', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'Все проблемы решены', change: 'Изменить', optional: '(необязательно)', - moveToThisNode: 'Перейдите к этому узлу', selectNextStep: 'Выберите следующий шаг', organizeBlocks: 'Организовать узлы', addNextStep: 'Добавьте следующий шаг в этот рабочий процесс', changeBlock: 'Изменить узел', minimize: 'Выйти из полноэкранного режима', maximize: 'Максимизировать холст', + scrollToSelectedNode: 'Прокрутите до выбранного узла', optional_and_hidden: '(необязательно и скрыто)', }, nodes: { diff --git a/web/i18n/sl-SI/billing.ts b/web/i18n/sl-SI/billing.ts index fb9d9ec435..ef8c767090 100644 --- a/web/i18n/sl-SI/billing.ts +++ b/web/i18n/sl-SI/billing.ts @@ -86,7 +86,7 @@ const translation = { teamMember_one: '{{count,number}} član ekipe', teamMember_other: '{{count,number}} Članov ekipe', documentsRequestQuota: '{{count,number}}/min Omejitev stopnje zahtev po znanju', - apiRateLimitUnit: '{{count,number}}/dan', + apiRateLimitUnit: '{{count,number}}/mesec', priceTip: 'na delovnem prostoru/', freeTrialTipPrefix: 'Prijavite se in prejmite', cloud: 'Oblačna storitev', diff --git a/web/i18n/sl-SI/workflow.ts b/web/i18n/sl-SI/workflow.ts index dbc4a75c43..fb1f709162 100644 --- a/web/i18n/sl-SI/workflow.ts +++ b/web/i18n/sl-SI/workflow.ts @@ -18,8 +18,7 @@ const translation = { }, versionHistory: 'Zgodovina različic', published: 'Objavljeno', - run: 'Teči', - featuresDocLink: 'Nauči se več', + run: 'Testni tek', notRunning: 'Še ne teče', exportImage: 'Izvozi sliko', openInExplore: 'Odpri v Raziskovanju', @@ -42,7 +41,7 @@ const translation = { inPreview: 'V predogledu', workflowAsToolTip: 'Zaradi posodobitve delovnega poteka je potrebna ponovna konfiguracija orodja.', variableNamePlaceholder: 'Ime spremenljivke', - needEndNode: 'Skrivnostna vozlišča je treba dodati.', + needOutputNode: 'Dodati je treba izhodiščno vozlišče', onFailure: 'O neuspehu', embedIntoSite: 'Vstavite v spletno stran', conversationLog: 'Pogovor Log', @@ -77,12 +76,10 @@ const translation = { fileUploadTip: 'Funkcije nalaganja slik so bile nadgrajene na nalaganje datotek.', backupCurrentDraft: 'Varnostno kopiraj trenutni osnutek', overwriteAndImport: 'Prepiši in uvozi', - features: 'Značilnosti', exportPNG: 'Izvozi kot PNG', chooseDSL: 'Izberi DSL datoteko', unpublished: 'Nepublikirano', pasteHere: 'Prilepite tukaj', - featuresDescription: 'Izboljšanje uporabniške izkušnje spletne aplikacije', exitVersions: 'Izhodne različice', editing: 'Urejanje', addFailureBranch: 'Dodaj neuspešno vejo', @@ -115,6 +112,7 @@ const translation = { tagBound: 'Število aplikacij, ki uporabljajo to oznako', currentView: 'Trenutni pogled', currentWorkflow: 'Trenutni potek dela', + moreActions: 'Več dejanj', }, env: { modal: { @@ -139,6 +137,19 @@ const translation = { envPanelButton: 'Dodaj spremenljivko', envDescription: 'Okoljske spremenljivke se lahko uporabljajo za shranjevanje zasebnih informacij in poverilnic. So samo za branje in jih je mogoče ločiti od DSL datoteke med izvozem.', }, + globalVar: { + title: 'Sistemske spremenljivke', + description: 'Sistemske spremenljivke so globalne spremenljivke, do katerih lahko vsako vozlišče dostopa brez povezovanja, če je tip pravilen, na primer ID končnega uporabnika in ID poteka dela.', + fieldsDescription: { + conversationId: 'ID pogovora', + dialogCount: 'Število pogovorov', + userId: 'ID uporabnika', + triggerTimestamp: 'Časovni žig začetka delovanja aplikacije', + appId: 'ID aplikacije', + workflowId: 'ID poteka dela', + workflowRunId: 'ID izvajanja poteka dela', + }, + }, chatVariable: { modal: { namePlaceholder: 'Ime spremenljivke', @@ -255,7 +266,7 @@ const translation = { 'code': 'Koda', 'template-transform': 'Predloga', 'answer': 'Odgovor', - 'end': 'Konec', + 'end': 'Izhod', 'iteration-start': 'Začetek iteracije', 'list-operator': 'Seznam operater', 'variable-aggregator': 'Spremenljivka agregator', @@ -275,7 +286,7 @@ const translation = { 'loop-end': 'Enakovredno „prekini“. Ta vozlišče nima konfiguracijskih elementov. Ko telo zanke doseže to vozlišče, zanka preneha.', 'document-extractor': 'Uporabljeno za razčlenitev prenesenih dokumentov v besedilno vsebino, ki jo je enostavno razumeti za LLM.', 'answer': 'Določi vsebino odgovora v pogovoru.', - 'end': 'Določite tip konca in rezultata delovnega toka', + 'end': 'Določite izhod in tip rezultata delovnega toka', 'knowledge-retrieval': 'Omogoča vam, da poizvedujete o besedilnih vsebinah, povezanih z vprašanji uporabnikov iz znanja.', 'http-request': 'Dovoli pošiljanje zahtevkov strežniku prek protokola HTTP', 'llm': 'Uporaba velikih jezikovnih modelov za odgovarjanje na vprašanja ali obdelavo naravnega jezika', @@ -324,10 +335,9 @@ const translation = { runThisStep: 'Izvedi ta korak', changeBlock: 'Spremeni vozlišče', addNextStep: 'Dodajte naslednji korak v ta delovni potek', - moveToThisNode: 'Premakni se na to vozlišče', checklistTip: 'Prepričajte se, da so vse težave rešene, preden objavite.', selectNextStep: 'Izberi naslednji korak', - helpLink: 'Pomočna povezava', + helpLink: 'Pomoč', checklist: 'Kontrolni seznam', checklistResolved: 'Vse težave so rešene', createdBy: 'Ustvarjeno z', @@ -335,6 +345,7 @@ const translation = { minimize: 'Izhod iz celotnega zaslona', maximize: 'Maksimiziraj platno', optional: '(neobvezno)', + scrollToSelectedNode: 'Pomaknite se do izbranega vozlišča', optional_and_hidden: '(neobvezno in skrito)', }, nodes: { diff --git a/web/i18n/th-TH/billing.ts b/web/i18n/th-TH/billing.ts index 461e4a8240..a3bd5b85bc 100644 --- a/web/i18n/th-TH/billing.ts +++ b/web/i18n/th-TH/billing.ts @@ -82,7 +82,7 @@ const translation = { teamMember_one: '{{count,number}} สมาชิกทีม', unlimitedApiRate: 'ไม่มีข้อจำกัดอัตราการเรียก API', self: 'โฮสต์ด้วยตัวเอง', - apiRateLimitUnit: '{{count,number}}/วัน', + apiRateLimitUnit: '{{count,number}}/เดือน', teamMember_other: '{{count,number}} สมาชิกทีม', teamWorkspace: '{{count,number}} ทีมทำงาน', priceTip: 'ต่อพื้นที่ทำงาน/', diff --git a/web/i18n/th-TH/workflow.ts b/web/i18n/th-TH/workflow.ts index 419b577a02..51e9b4d088 100644 --- a/web/i18n/th-TH/workflow.ts +++ b/web/i18n/th-TH/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'เผย แพร่', publish: 'ตีพิมพ์', update: 'อัพเดต', - run: 'วิ่ง', + run: 'ทดสอบการทำงาน', running: 'กำลัง เรียก ใช้', inRunMode: 'ในโหมดเรียกใช้', inPreview: 'ในการแสดงตัวอย่าง', @@ -18,11 +18,8 @@ const translation = { runHistory: 'ประวัติการวิ่ง', goBackToEdit: 'กลับไปที่ตัวแก้ไข', conversationLog: 'บันทึกการสนทนา', - features: 'หน้าตา', - featuresDescription: 'ปรับปรุงประสบการณ์ผู้ใช้เว็บแอป', ImageUploadLegacyTip: 'ตอนนี้คุณสามารถสร้างตัวแปรชนิดไฟล์ในฟอร์มเริ่มต้นได้แล้ว เราจะไม่รองรับฟีเจอร์การอัปโหลดรูปภาพอีกต่อไปในอนาคต', fileUploadTip: 'ฟีเจอร์การอัปโหลดรูปภาพได้รับการอัปเกรดเป็นการอัปโหลดไฟล์', - featuresDocLink: 'ศึกษาเพิ่มเติม', debugAndPreview: 'ดูตัวอย่าง', restart: 'เริ่มใหม่', currentDraft: 'ร่างปัจจุบัน', @@ -111,10 +108,11 @@ const translation = { exportSVG: 'ส่งออกเป็น SVG', needAnswerNode: 'ต้องเพิ่มโหนดคำตอบ', addBlock: 'เพิ่มโนด', - needEndNode: 'ต้องเพิ่มโหนดจบ', + needOutputNode: 'ต้องเพิ่มโหนดเอาต์พุต', tagBound: 'จำนวนแอปพลิเคชันที่ใช้แท็กนี้', currentWorkflow: 'เวิร์กโฟลว์ปัจจุบัน', currentView: 'ปัจจุบัน View', + moreActions: 'การดําเนินการเพิ่มเติม', }, env: { envPanelTitle: 'ตัวแปรสภาพแวดล้อม', @@ -139,6 +137,19 @@ const translation = { export: 'ส่งออก DSL ด้วยค่าลับ', }, }, + globalVar: { + title: 'ตัวแปรระบบ', + description: 'ตัวแปรระบบเป็นตัวแปรแบบโกลบอลที่โหนดใด ๆ สามารถอ้างอิงได้โดยไม่ต้องเดินสายเมื่อชนิดข้อมูลถูกต้อง เช่น รหัสผู้ใช้ปลายทางและรหัสเวิร์กโฟลว์', + fieldsDescription: { + conversationId: 'รหัสการสนทนา', + dialogCount: 'จำนวนการสนทนา', + userId: 'รหัสผู้ใช้', + triggerTimestamp: 'ตราประทับเวลาที่แอปเริ่มทำงาน', + appId: 'รหัสแอปพลิเคชัน', + workflowId: 'รหัสเวิร์กโฟลว์', + workflowRunId: 'รหัสการรันเวิร์กโฟลว์', + }, + }, chatVariable: { panelTitle: 'ตัวแปรการสนทนา', panelDescription: 'ตัวแปรการสนทนาใช้เพื่อจัดเก็บข้อมูลแบบโต้ตอบที่ LLM จําเป็นต้องจดจํา รวมถึงประวัติการสนทนา ไฟล์ที่อัปโหลด การตั้งค่าของผู้ใช้ พวกเขาอ่าน-เขียน', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'เริ่ม', - 'end': 'ปลาย', + 'end': 'เอาต์พุต', 'answer': 'ตอบ', 'llm': 'นิติศาสตราจารย์', 'knowledge-retrieval': 'การดึงความรู้', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'กําหนดพารามิเตอร์เริ่มต้นสําหรับการเปิดใช้เวิร์กโฟลว์', - 'end': 'กําหนดชนิดสิ้นสุดและผลลัพธ์ของเวิร์กโฟลว์', + 'end': 'กำหนดเอาต์พุตและประเภทผลลัพธ์ของเวิร์กโฟลว์', 'answer': 'กําหนดเนื้อหาการตอบกลับของการสนทนาแชท', 'llm': 'การเรียกใช้โมเดลภาษาขนาดใหญ่เพื่อตอบคําถามหรือประมวลผลภาษาธรรมชาติ', 'knowledge-retrieval': 'ช่วยให้คุณสามารถสอบถามเนื้อหาข้อความที่เกี่ยวข้องกับคําถามของผู้ใช้จากความรู้', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'ฟิลด์ป้อนข้อมูลของผู้ใช้', - helpLink: 'ลิงค์ช่วยเหลือ', + helpLink: 'วิธีใช้', about: 'ประมาณ', createdBy: 'สร้างโดย', nextStep: 'ขั้นตอนถัดไป', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'ปัญหาทั้งหมดได้รับการแก้ไขแล้ว', change: 'เปลี่ยน', optional: '(ไม่บังคับ)', - moveToThisNode: 'ย้ายไปที่โหนดนี้', organizeBlocks: 'จัดระเบียบโหนด', addNextStep: 'เพิ่มขั้นตอนถัดไปในกระบวนการทำงานนี้', changeBlock: 'เปลี่ยนโหนด', selectNextStep: 'เลือกขั้นตอนถัดไป', minimize: 'ออกจากโหมดเต็มหน้าจอ', maximize: 'เพิ่มประสิทธิภาพผ้าใบ', + scrollToSelectedNode: 'เลื่อนไปยังโหนดที่เลือก', optional_and_hidden: '(ตัวเลือก & ซ่อน)', }, nodes: { diff --git a/web/i18n/tr-TR/billing.ts b/web/i18n/tr-TR/billing.ts index 6d01d9dd32..93c54fd1ed 100644 --- a/web/i18n/tr-TR/billing.ts +++ b/web/i18n/tr-TR/billing.ts @@ -78,7 +78,7 @@ const translation = { freeTrialTipPrefix: 'Kaydolun ve bir', priceTip: 'iş alanı başına/', documentsRequestQuota: '{{count,number}}/dakika Bilgi İsteği Oran Limiti', - apiRateLimitUnit: '{{count,number}}/gün', + apiRateLimitUnit: '{{count,number}}/ay', documents: '{{count,number}} Bilgi Belgesi', comparePlanAndFeatures: 'Planları ve özellikleri karşılaştır', self: 'Kendi Barındırılan', diff --git a/web/i18n/tr-TR/workflow.ts b/web/i18n/tr-TR/workflow.ts index 930664ce57..df4f9c8093 100644 --- a/web/i18n/tr-TR/workflow.ts +++ b/web/i18n/tr-TR/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Yayınlandı', publish: 'Yayınla', update: 'Güncelle', - run: 'Çalıştır', + run: 'Test çalıştır', running: 'Çalışıyor', inRunMode: 'Çalıştırma Modunda', inPreview: 'Ön İzlemede', @@ -18,7 +18,6 @@ const translation = { runHistory: 'Çalıştırma Geçmişi', goBackToEdit: 'Editöre geri dön', conversationLog: 'Konuşma Günlüğü', - features: 'Özellikler', debugAndPreview: 'Önizleme', restart: 'Yeniden Başlat', currentDraft: 'Geçerli Taslak', @@ -91,10 +90,8 @@ const translation = { disconnect: 'Ayırmak', parallel: 'PARALEL', branch: 'DAL', - featuresDocLink: 'Daha fazla bilgi edinin', fileUploadTip: 'Resim yükleme özellikleri, dosya yüklemeye yükseltildi.', ImageUploadLegacyTip: 'Artık başlangıç formunda dosya türü değişkenleri oluşturabilirsiniz. Gelecekte resim yükleme özelliğini artık desteklemeyeceğiz.', - featuresDescription: 'Web uygulaması kullanıcı deneyimini geliştirin', importWarningDetails: 'DSL sürüm farkı bazı özellikleri etkileyebilir', importWarning: 'Dikkat', openInExplore: 'Keşfet\'te Aç', @@ -111,10 +108,11 @@ const translation = { exportSVG: 'SVG olarak dışa aktar', addBlock: 'Düğüm Ekle', needAnswerNode: 'Cevap düğümü eklenmelidir.', - needEndNode: 'Son düğüm eklenmelidir', + needOutputNode: 'Çıktı düğümü eklenmelidir', tagBound: 'Bu etiketi kullanan uygulama sayısı', currentView: 'Geçerli Görünüm', currentWorkflow: 'Mevcut İş Akışı', + moreActions: 'Daha Fazla Eylem', }, env: { envPanelTitle: 'Çevre Değişkenleri', @@ -139,6 +137,19 @@ const translation = { export: 'Gizli değerlerle DSL\'yi dışa aktar', }, }, + globalVar: { + title: 'Sistem Değişkenleri', + description: 'Sistem değişkenleri, tipi uyumlu olduğunda herhangi bir düğümün bağlantı gerektirmeden başvurabileceği küresel değişkenlerdir; örneğin son kullanıcı kimliği ve iş akışı kimliği.', + fieldsDescription: { + conversationId: 'Konuşma Kimliği', + dialogCount: 'Konuşma Sayısı', + userId: 'Kullanıcı Kimliği', + triggerTimestamp: 'Uygulamanın çalışmaya başladığı zaman damgası', + appId: 'Uygulama Kimliği', + workflowId: 'İş Akışı Kimliği', + workflowRunId: 'İş akışı yürütme kimliği', + }, + }, chatVariable: { panelTitle: 'Konuşma Değişkenleri', panelDescription: 'Konuşma Değişkenleri, LLM\'nin hatırlaması gereken interaktif bilgileri (konuşma geçmişi, yüklenen dosyalar, kullanıcı tercihleri dahil) depolamak için kullanılır. Bunlar okunabilir ve yazılabilirdir.', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'Başlat', - 'end': 'Son', + 'end': 'Çıktı', 'answer': 'Yanıt', 'llm': 'LLM', 'knowledge-retrieval': 'Bilgi Geri Alımı', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'Bir iş akışını başlatmak için başlangıç parametrelerini tanımlayın', - 'end': 'Bir iş akışının sonunu ve sonuç türünü tanımlayın', + 'end': 'Bir iş akışının çıktısını ve sonuç türünü tanımlayın', 'answer': 'Bir sohbet konuşmasının yanıt içeriğini tanımlayın', 'llm': 'Büyük dil modellerini soruları yanıtlamak veya doğal dili işlemek için çağırın', 'knowledge-retrieval': 'Kullanıcı sorularıyla ilgili metin içeriğini Bilgi\'den sorgulamanıza olanak tanır', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'Kullanıcı Giriş Alanı', - helpLink: 'Yardım Linki', + helpLink: 'Yardım', about: 'Hakkında', createdBy: 'Oluşturan: ', nextStep: 'Sonraki Adım', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'Tüm sorunlar çözüldü', change: 'Değiştir', optional: '(isteğe bağlı)', - moveToThisNode: 'Bu düğüme geç', changeBlock: 'Düğümü Değiştir', addNextStep: 'Bu iş akışına bir sonraki adımı ekleyin', organizeBlocks: 'Düğümleri düzenle', selectNextStep: 'Sonraki Adımı Seç', minimize: 'Tam Ekrandan Çık', maximize: 'Kanvası Maksimize Et', + scrollToSelectedNode: 'Seçili düğüme kaydırma', optional_and_hidden: '(isteğe bağlı ve gizli)', }, nodes: { diff --git a/web/i18n/uk-UA/billing.ts b/web/i18n/uk-UA/billing.ts index 03b743e4fe..e98b3e6091 100644 --- a/web/i18n/uk-UA/billing.ts +++ b/web/i18n/uk-UA/billing.ts @@ -84,7 +84,7 @@ const translation = { priceTip: 'за робочим простором/', unlimitedApiRate: 'Немає обмеження на швидкість API', freeTrialTipSuffix: 'Кредитна картка не потрібна', - apiRateLimitUnit: '{{count,number}}/день', + apiRateLimitUnit: '{{count,number}}/місяць', getStarted: 'Почати', freeTrialTip: 'безкоштовна пробна версія з 200 запитів до OpenAI.', documents: '{{count,number}} Документів знань', diff --git a/web/i18n/uk-UA/workflow.ts b/web/i18n/uk-UA/workflow.ts index 2f4f298204..95425f0a32 100644 --- a/web/i18n/uk-UA/workflow.ts +++ b/web/i18n/uk-UA/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Опубліковано', publish: 'Опублікувати', update: 'Оновити', - run: 'Запустити', + run: 'Тестовий запуск', running: 'Запущено', inRunMode: 'У режимі запуску', inPreview: 'У режимі попереднього перегляду', @@ -18,7 +18,6 @@ const translation = { runHistory: 'Історія запусків', goBackToEdit: 'Повернутися до редактора', conversationLog: 'Журнал розмов', - features: 'Функції', debugAndPreview: 'Попередній перегляд', restart: 'Перезапустити', currentDraft: 'Поточний чернетка', @@ -91,8 +90,6 @@ const translation = { addParallelNode: 'Додати паралельний вузол', parallel: 'ПАРАЛЕЛЬНИЙ', branch: 'ГІЛКА', - featuresDocLink: 'Дізнатися більше', - featuresDescription: 'Покращення взаємодії з користувачем веб-додатку', fileUploadTip: 'Функції завантаження зображень були оновлені для завантаження файлів.', ImageUploadLegacyTip: 'Тепер ви можете створювати змінні типу файлу у початковій формі. У майбутньому ми більше не підтримуватимемо функцію завантаження зображень.', importWarning: 'Обережність', @@ -110,11 +107,12 @@ const translation = { exportSVG: 'Експортувати як SVG', exportJPEG: 'Експортувати як JPEG', addBlock: 'Додати вузол', - needEndNode: 'Необхідно додати кінцевий вузол', + needOutputNode: 'Необхідно додати вихідний вузол', needAnswerNode: 'Вузол Відповіді повинен бути доданий', tagBound: 'Кількість додатків, що використовують цей тег', currentView: 'Поточний вигляд', currentWorkflow: 'Поточний робочий процес', + moreActions: 'Більше дій', }, env: { envPanelTitle: 'Змінні середовища', @@ -139,6 +137,19 @@ const translation = { export: 'Експортувати DSL з секретними значеннями', }, }, + globalVar: { + title: 'Системні змінні', + description: 'Системні змінні — це глобальні змінні, до яких будь-який вузол може звертатися без з’єднання, якщо тип відповідає, наприклад ID кінцевого користувача та ID робочого процесу.', + fieldsDescription: { + conversationId: 'ID розмови', + dialogCount: 'Кількість розмов', + userId: 'ID користувача', + triggerTimestamp: 'Мітка часу запуску застосунку', + appId: 'ID застосунку', + workflowId: 'ID робочого процесу', + workflowRunId: 'ID запуску робочого процесу', + }, + }, chatVariable: { panelTitle: 'Змінні розмови', panelDescription: 'Змінні розмови використовуються для зберігання інтерактивної інформації, яку LLM повинен пам\'ятати, включаючи історію розмови, завантажені файли, вподобання користувача. Вони доступні для читання та запису.', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'Початок', - 'end': 'Кінець', + 'end': 'Вивід', 'answer': 'Відповідь', 'llm': 'LLM', 'knowledge-retrieval': 'Отримання знань', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'Визначте початкові параметри для запуску робочого потоку', - 'end': 'Визначте кінець і тип результату робочого потоку', + 'end': 'Визначте вивід і тип результату робочого потоку', 'answer': 'Визначте зміст відповіді у чаті', 'llm': 'Виклик великих мовних моделей для відповіді на запитання або обробки природної мови', 'knowledge-retrieval': 'Дозволяє виконувати запити текстового вмісту, пов\'язаного із запитаннями користувача, з бази знань', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'Поле введення користувача', - helpLink: 'Посилання на допомогу', + helpLink: 'Довідковий центр', about: 'Про', createdBy: 'Створено ', nextStep: 'Наступний крок', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'Всі проблеми вирішені', change: 'Змінити', optional: '(необов\'язково)', - moveToThisNode: 'Перемістіть до цього вузла', organizeBlocks: 'Організуйте вузли', changeBlock: 'Змінити вузол', selectNextStep: 'Виберіть наступний крок', addNextStep: 'Додайте наступний крок у цей робочий процес', minimize: 'Вийти з повноекранного режиму', maximize: 'Максимізувати полотно', + scrollToSelectedNode: 'Прокрутіть до вибраного вузла', optional_and_hidden: '(необов\'язково & приховано)', }, nodes: { diff --git a/web/i18n/vi-VN/billing.ts b/web/i18n/vi-VN/billing.ts index 0166185e45..c6a7458164 100644 --- a/web/i18n/vi-VN/billing.ts +++ b/web/i18n/vi-VN/billing.ts @@ -90,7 +90,7 @@ const translation = { teamMember_other: '{{count,number}} thành viên trong nhóm', documents: '{{count,number}} Tài liệu Kiến thức', getStarted: 'Bắt đầu', - apiRateLimitUnit: '{{count,number}}/ngày', + apiRateLimitUnit: '{{count,number}}/tháng', freeTrialTipSuffix: 'Không cần thẻ tín dụng', documentsRequestQuotaTooltip: 'Chỉ định tổng số hành động mà một không gian làm việc có thể thực hiện mỗi phút trong cơ sở tri thức, bao gồm tạo mới tập dữ liệu, xóa, cập nhật, tải tài liệu lên, thay đổi, lưu trữ và truy vấn cơ sở tri thức. Chỉ số này được sử dụng để đánh giá hiệu suất của các yêu cầu cơ sở tri thức. Ví dụ, nếu một người dùng Sandbox thực hiện 10 lần kiểm tra liên tiếp trong một phút, không gian làm việc của họ sẽ bị hạn chế tạm thời không thực hiện các hành động sau trong phút tiếp theo: tạo mới tập dữ liệu, xóa, cập nhật và tải tài liệu lên hoặc thay đổi.', startBuilding: 'Bắt đầu xây dựng', diff --git a/web/i18n/vi-VN/workflow.ts b/web/i18n/vi-VN/workflow.ts index 4a3a720cb3..4fe45a8cc6 100644 --- a/web/i18n/vi-VN/workflow.ts +++ b/web/i18n/vi-VN/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Đã xuất bản', publish: 'Xuất bản', update: 'Cập nhật', - run: 'Chạy', + run: 'Chạy thử nghiệm', running: 'Đang chạy', inRunMode: 'Chế độ chạy', inPreview: 'Trong chế độ xem trước', @@ -18,7 +18,6 @@ const translation = { runHistory: 'Lịch sử chạy', goBackToEdit: 'Quay lại trình chỉnh sửa', conversationLog: 'Nhật ký cuộc trò chuyện', - features: 'Tính năng', debugAndPreview: 'Xem trước', restart: 'Khởi động lại', currentDraft: 'Bản nháp hiện tại', @@ -91,9 +90,7 @@ const translation = { addParallelNode: 'Thêm nút song song', parallel: 'SONG SONG', branch: 'NHÁNH', - featuresDocLink: 'Tìm hiểu thêm', fileUploadTip: 'Các tính năng tải lên hình ảnh đã được nâng cấp để tải tệp lên.', - featuresDescription: 'Nâng cao trải nghiệm người dùng ứng dụng web', ImageUploadLegacyTip: 'Bây giờ bạn có thể tạo các biến loại tệp trong biểu mẫu bắt đầu. Chúng tôi sẽ không còn hỗ trợ tính năng tải lên hình ảnh trong tương lai.', importWarning: 'Thận trọng', importWarningDetails: 'Sự khác biệt về phiên bản DSL có thể ảnh hưởng đến một số tính năng nhất định', @@ -111,10 +108,11 @@ const translation = { exportJPEG: 'Xuất dưới dạng JPEG', needAnswerNode: 'Nút Trả lời phải được thêm vào', addBlock: 'Thêm Node', - needEndNode: 'Nút Kết thúc phải được thêm vào', + needOutputNode: 'Phải thêm nút Đầu ra', tagBound: 'Số lượng ứng dụng sử dụng thẻ này', currentWorkflow: 'Quy trình làm việc hiện tại', currentView: 'Hiện tại View', + moreActions: 'Hành động khác', }, env: { envPanelTitle: 'Biến Môi Trường', @@ -139,6 +137,19 @@ const translation = { export: 'Xuất DSL với giá trị bí mật', }, }, + globalVar: { + title: 'Biến hệ thống', + description: 'Biến hệ thống là biến toàn cục mà bất kỳ nút nào cũng có thể tham chiếu mà không cần nối dây khi kiểu dữ liệu phù hợp, chẳng hạn như ID người dùng cuối và ID quy trình làm việc.', + fieldsDescription: { + conversationId: 'ID cuộc trò chuyện', + dialogCount: 'Số lần trò chuyện', + userId: 'ID người dùng', + triggerTimestamp: 'Dấu thời gian ứng dụng bắt đầu chạy', + appId: 'ID ứng dụng', + workflowId: 'ID quy trình làm việc', + workflowRunId: 'ID lần chạy quy trình làm việc', + }, + }, chatVariable: { panelTitle: 'Biến Hội Thoại', panelDescription: 'Biến Hội Thoại được sử dụng để lưu trữ thông tin tương tác mà LLM cần ghi nhớ, bao gồm lịch sử hội thoại, tệp đã tải lên, tùy chọn người dùng. Chúng có thể đọc và ghi được.', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'Bắt đầu', - 'end': 'Kết thúc', + 'end': 'Đầu ra', 'answer': 'Trả lời', 'llm': 'LLM', 'knowledge-retrieval': 'Truy xuất kiến thức', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'Định nghĩa các tham số ban đầu để khởi chạy quy trình làm việc', - 'end': 'Định nghĩa kết thúc và loại kết quả của quy trình làm việc', + 'end': 'Định nghĩa đầu ra và loại kết quả của quy trình làm việc', 'answer': 'Định nghĩa nội dung trả lời của cuộc trò chuyện', 'llm': 'Gọi các mô hình ngôn ngữ lớn để trả lời câu hỏi hoặc xử lý ngôn ngữ tự nhiên', 'knowledge-retrieval': 'Cho phép truy vấn nội dung văn bản liên quan đến câu hỏi của người dùng từ cơ sở kiến thức', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'Trường đầu vào của người dùng', - helpLink: 'Liên kết trợ giúp', + helpLink: 'Trung tâm trợ giúp', about: 'Giới thiệu', createdBy: 'Tạo bởi ', nextStep: 'Bước tiếp theo', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'Tất cả các vấn đề đã được giải quyết', change: 'Thay đổi', optional: '(tùy chọn)', - moveToThisNode: 'Di chuyển đến nút này', changeBlock: 'Thay đổi Node', selectNextStep: 'Chọn bước tiếp theo', organizeBlocks: 'Tổ chức các nút', addNextStep: 'Thêm bước tiếp theo trong quy trình này', maximize: 'Tối đa hóa Canvas', minimize: 'Thoát chế độ toàn màn hình', + scrollToSelectedNode: 'Cuộn đến nút đã chọn', optional_and_hidden: '(tùy chọn & ẩn)', }, nodes: { diff --git a/web/i18n/zh-Hans/app-log.ts b/web/i18n/zh-Hans/app-log.ts index 21505e28e6..629d584642 100644 --- a/web/i18n/zh-Hans/app-log.ts +++ b/web/i18n/zh-Hans/app-log.ts @@ -20,6 +20,7 @@ const translation = { tokens: 'TOKENS', user: '用户或账户', version: '版本', + triggered_from: '触发方式', }, pagination: { previous: '上一页', @@ -97,6 +98,15 @@ const translation = { iteration: '迭代', finalProcessing: '最终处理', }, + triggerBy: { + debugging: '调试', + appRun: '网页应用', + webhook: 'Webhook', + schedule: '定时任务', + plugin: '插件', + ragPipelineRun: 'RAG 流水线', + ragPipelineDebugging: 'RAG 调试', + }, } export default translation diff --git a/web/i18n/zh-Hans/app-overview.ts b/web/i18n/zh-Hans/app-overview.ts index a41a86975a..730240b9f7 100644 --- a/web/i18n/zh-Hans/app-overview.ts +++ b/web/i18n/zh-Hans/app-overview.ts @@ -30,6 +30,7 @@ const translation = { overview: { title: '概览', appInfo: { + title: 'Web App', explanation: '开箱即用的 AI web app', accessibleAddress: '公开访问 URL', preview: '预览', @@ -37,6 +38,10 @@ const translation = { regenerate: '重新生成', regenerateNotice: '您是否要重新生成公开访问 URL?', preUseReminder: '使用前请先打开开关', + enableTooltip: { + description: '要启用此功能,请在画布中添加用户输入节点。(草稿中可能已存在,发布后生效)', + learnMore: '了解更多', + }, settings: { entry: '设置', title: 'web app 设置', @@ -121,6 +126,14 @@ const translation = { accessibleAddress: 'API 访问凭据', doc: '查阅 API 文档', }, + triggerInfo: { + title: '触发器', + explanation: '工作流触发器管理', + triggersAdded: '已添加 {{count}} 个触发器', + noTriggerAdded: '未添加触发器', + triggerStatusDescription: '触发器节点状态显示在这里。(草稿中可能已存在,发布后生效)', + learnAboutTriggers: '了解触发器', + }, status: { running: '运行中', disable: '已停用', diff --git a/web/i18n/zh-Hans/app.ts b/web/i18n/zh-Hans/app.ts index cdc2ba1ad7..f6eb3b2b1b 100644 --- a/web/i18n/zh-Hans/app.ts +++ b/web/i18n/zh-Hans/app.ts @@ -253,6 +253,8 @@ const translation = { notSetDesc: '当前任何人都无法访问 Web 应用。请设置访问权限。', }, noAccessPermission: '没有权限访问 web 应用', + noUserInputNode: '缺少用户输入节点', + notPublishedYet: '应用暂未发布', maxActiveRequests: '最大活跃请求数', maxActiveRequestsPlaceholder: '0 表示不限制', maxActiveRequestsTip: '当前应用的最大活跃请求数(0 表示不限制)', diff --git a/web/i18n/zh-Hans/billing.ts b/web/i18n/zh-Hans/billing.ts index 00a7dd909a..3c50abd01f 100644 --- a/web/i18n/zh-Hans/billing.ts +++ b/web/i18n/zh-Hans/billing.ts @@ -7,6 +7,8 @@ const translation = { documentsUploadQuota: '文档上传配额', vectorSpace: '知识库数据存储空间', vectorSpaceTooltip: '采用高质量索引模式的文档会消耗知识数据存储资源。当知识数据存储达到限制时,将不会上传新文档。', + triggerEvents: '触发事件', + perMonth: '每月', }, upgradeBtn: { plain: '查看套餐', @@ -61,7 +63,7 @@ const translation = { documentsRequestQuota: '{{count,number}}/分钟 知识库请求频率限制', documentsRequestQuotaTooltip: '指每分钟内,一个空间在知识库中可执行的操作总数,包括数据集的创建、删除、更新,文档的上传、修改、归档,以及知识库查询等,用于评估知识库请求的性能。例如,Sandbox 用户在 1 分钟内连续执行 10 次命中测试,其工作区将在接下来的 1 分钟内无法继续执行以下操作:数据集的创建、删除、更新,文档的上传、修改等操作。', apiRateLimit: 'API 请求频率限制', - apiRateLimitUnit: '{{count,number}} 次/天', + apiRateLimitUnit: '{{count,number}} 次/月', unlimitedApiRate: 'API 请求频率无限制', apiRateLimitTooltip: 'API 请求频率限制涵盖所有通过 Dify API 发起的调用,例如文本生成、聊天对话、工作流执行和文档处理等。', documentProcessingPriority: '文档处理', @@ -71,6 +73,20 @@ const translation = { 'priority': '优先', 'top-priority': '最高优先级', }, + triggerEvents: { + sandbox: '{{count,number}} 触发事件', + professional: '{{count,number}} 触发事件/月', + unlimited: '无限制触发事件', + }, + workflowExecution: { + standard: '标准工作流执行', + faster: '更快的工作流执行', + priority: '优先工作流执行', + }, + startNodes: { + limited: '每个工作流最多 {{count}} 个起始节点', + unlimited: '每个工作流无限制起始节点', + }, logsHistory: '{{days}}日志历史', customTools: '自定义工具', unavailable: '不可用', diff --git a/web/i18n/zh-Hans/common.ts b/web/i18n/zh-Hans/common.ts index 95c9e583c1..a9228a5a25 100644 --- a/web/i18n/zh-Hans/common.ts +++ b/web/i18n/zh-Hans/common.ts @@ -29,6 +29,11 @@ const translation = { refresh: '重新开始', reset: '重置', search: '搜索', + noSearchResults: '没有找到{{content}}', + resetKeywords: '重置关键词', + selectCount: '已选择 {{count}} 项', + searchCount: '找到 {{count}} 个 {{content}}', + noSearchCount: '0 个 {{content}}', change: '更改', remove: '移除', send: '发送', @@ -71,6 +76,7 @@ const translation = { more: '更多', selectAll: '全选', deSelectAll: '取消全选', + now: '现在', }, errorMsg: { fieldRequired: '{{field}} 为必填项', @@ -79,7 +85,9 @@ const translation = { placeholder: { input: '请输入', select: '请选择', + search: '搜索...', }, + noData: '暂无数据', label: { optional: '(可选)', }, @@ -173,7 +181,7 @@ const translation = { emailSupport: '邮件支持', workspace: '工作空间', createWorkspace: '创建工作空间', - helpCenter: '帮助文档', + helpCenter: '查看帮助文档', support: '支持', compliance: '合规', forum: '论坛', @@ -769,6 +777,12 @@ const translation = { title: '提供反馈', placeholder: '请描述发生了什么问题或我们可以如何改进...', }, + dynamicSelect: { + error: '加载选项失败', + noData: '没有可用的选项', + loading: '加载选项...', + selected: '已选择 {{count}} 项', + }, } export default translation diff --git a/web/i18n/zh-Hans/plugin-trigger.ts b/web/i18n/zh-Hans/plugin-trigger.ts new file mode 100644 index 0000000000..304cdd47bd --- /dev/null +++ b/web/i18n/zh-Hans/plugin-trigger.ts @@ -0,0 +1,186 @@ +const translation = { + subscription: { + title: '订阅', + listNum: '{{num}} 个订阅', + empty: { + title: '暂无订阅', + button: '新建订阅', + }, + createButton: { + oauth: '通过 OAuth 新建订阅', + apiKey: '通过 API Key 新建订阅', + manual: '粘贴 URL 以创建新订阅', + }, + createSuccess: '订阅创建成功', + createFailed: '订阅创建失败', + maxCount: '最多 {{num}} 个订阅', + selectPlaceholder: '选择订阅', + noSubscriptionSelected: '未选择订阅', + subscriptionRemoved: '订阅已移除', + list: { + title: '订阅列表', + addButton: '添加', + tip: '通过订阅接收事件', + item: { + enabled: '已启用', + disabled: '已禁用', + credentialType: { + api_key: 'API密钥', + oauth2: 'OAuth', + unauthorized: '手动', + }, + actions: { + delete: '删除', + deleteConfirm: { + title: '删除 {{name}}?', + success: '订阅 {{name}} 删除成功', + error: '订阅 {{name}} 删除失败', + content: '删除后,该订阅将无法恢复,请确认。', + contentWithApps: '该订阅正在被 {{count}} 个应用使用,删除它将导致这些应用停止接收订阅事件。', + confirm: '确认删除', + cancel: '取消', + confirmInputWarning: '请输入正确的名称确认。', + confirmInputPlaceholder: '输入 "{{name}}" 确认', + confirmInputTip: '请输入 “{{name}}” 确认:', + }, + }, + status: { + active: '活跃', + inactive: '非活跃', + }, + usedByNum: '被 {{num}} 个工作流使用', + noUsed: '未被工作流使用', + }, + }, + addType: { + title: '添加订阅', + description: '选择创建触发器订阅的方式', + options: { + apikey: { + title: '通过 API Key 创建', + description: '使用 API 凭据自动创建订阅', + }, + oauth: { + title: '通过 OAuth 创建', + description: '与第三方平台授权以创建订阅', + clientSettings: 'OAuth 客户端设置', + clientTitle: 'OAuth 客户端', + default: '默认', + custom: '自定义', + }, + manual: { + title: '手动设置', + description: '粘贴 URL 以创建新订阅', + tip: '手动配置 URL 到第三方平台', + }, + }, + }, + }, + modal: { + steps: { + verify: '验证', + configuration: '配置', + }, + common: { + cancel: '取消', + back: '返回', + next: '下一步', + create: '创建', + verify: '验证', + authorize: '授权', + creating: '创建中...', + verifying: '验证中...', + authorizing: '授权中...', + }, + oauthRedirectInfo: '由于未找到此工具提供方的系统客户端密钥,需要手动设置,对于 redirect_uri,请使用', + apiKey: { + title: '通过 API Key 创建', + verify: { + title: '验证凭据', + description: '请提供您的 API 凭据以验证访问权限', + error: '凭据验证失败,请检查您的 API 密钥。', + success: '凭据验证成功', + }, + configuration: { + title: '配置订阅', + description: '设置您的订阅参数', + }, + }, + oauth: { + title: '通过 OAuth 创建', + authorization: { + title: 'OAuth 授权', + description: '授权 Dify 访问您的账户', + redirectUrl: '重定向 URL', + redirectUrlHelp: '在您的 OAuth 应用配置中使用此 URL', + authorizeButton: '使用 {{provider}} 授权', + waitingAuth: '等待授权中...', + authSuccess: '授权成功', + authFailed: '获取 OAuth 授权信息失败', + waitingJump: '已授权,待跳转', + }, + configuration: { + title: '配置订阅', + description: '授权完成后设置您的订阅参数', + success: 'OAuth 配置成功', + failed: 'OAuth 配置失败', + }, + remove: { + success: 'OAuth 移除成功', + failed: 'OAuth 移除失败', + }, + save: { + success: 'OAuth 配置保存成功', + }, + }, + manual: { + title: '手动设置', + description: '手动配置您的 Webhook 订阅', + logs: { + title: '请求日志', + request: '请求', + loading: '等待 {{pluginName}} 的请求...', + }, + }, + form: { + subscriptionName: { + label: '订阅名称', + placeholder: '输入订阅名称', + required: '订阅名称为必填项', + }, + callbackUrl: { + label: '回调 URL', + description: '此 URL 将接收Webhook事件', + tooltip: '填写能被触发器提供方访问的公网地址,用于接收回调请求。', + placeholder: '生成中...', + privateAddressWarning: '此 URL 似乎是一个内部地址,可能会导致 Webhook 请求失败。', + }, + }, + errors: { + createFailed: '创建订阅失败', + verifyFailed: '验证凭据失败', + authFailed: '授权失败', + networkError: '网络错误,请重试', + }, + }, + events: { + title: '可用事件', + description: '此触发器插件可以订阅的事件', + empty: '没有可用事件', + event: '事件', + events: '事件', + actionNum: '包含 {{num}} 个 {{event}}', + item: { + parameters: '{{count}}个参数', + noParameters: '暂无参数', + }, + output: '输出', + }, + node: { + status: { + warning: '未连接', + }, + }, +} + +export default translation diff --git a/web/i18n/zh-Hans/plugin.ts b/web/i18n/zh-Hans/plugin.ts index adda0a3b8a..d648bccb85 100644 --- a/web/i18n/zh-Hans/plugin.ts +++ b/web/i18n/zh-Hans/plugin.ts @@ -8,6 +8,7 @@ const translation = { tools: '工具', agents: 'Agent 策略', extensions: '扩展', + triggers: '触发器', bundles: '插件集', datasources: '数据源', }, @@ -16,6 +17,7 @@ const translation = { tool: '工具', agent: 'Agent 策略', extension: '扩展', + trigger: '触发器', bundle: '插件集', datasource: '数据源', }, @@ -62,6 +64,7 @@ const translation = { checkUpdate: '检查更新', viewDetail: '查看详情', remove: '移除', + back: '返回', }, actionNum: '包含 {{num}} 个 {{action}}', strategyNum: '包含 {{num}} 个 {{strategy}}', @@ -77,7 +80,7 @@ const translation = { endpointModalDesc: '完成配置后可使用插件 API 端点提供的功能', serviceOk: '服务正常', disabled: '停用', - modelNum: '{{num}} 模型已包含', + modelNum: '包含 {{num}} 个模型', toolSelector: { title: '添加工具', toolSetting: '工具设置', @@ -306,6 +309,12 @@ const translation = { connectedWorkspace: '已连接的工作区', emptyAuth: '请配置凭据', }, + readmeInfo: { + title: 'README', + needHelpCheckReadme: '需要帮助?查看 README。', + noReadmeAvailable: 'README 文档不可用', + failedToFetch: '获取 README 文档失败', + }, } export default translation diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 2d869083b7..18e76caa64 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -9,8 +9,10 @@ const translation = { publish: '发布', update: '更新', publishUpdate: '发布更新', - run: '运行', + run: '测试运行', running: '运行中', + chooseStartNodeToRun: '选择启动节点进行运行', + runAllTriggers: '运行所有触发器', inRunMode: '在运行模式中', inPreview: '预览中', inPreviewMode: '预览中', @@ -46,7 +48,8 @@ const translation = { needConnectTip: '此节点尚未连接到其他节点', maxTreeDepth: '每个分支最大限制 {{depth}} 个节点', needAdd: '必须添加{{node}}节点', - needEndNode: '必须添加结束节点', + needOutputNode: '必须添加输出节点', + needStartNode: '必须添加至少一个开始节点', needAnswerNode: '必须添加直接回复节点', workflowProcess: '工作流', notRunning: '尚未运行', @@ -76,12 +79,14 @@ const translation = { exportSVG: '导出为 SVG', currentView: '当前视图', currentWorkflow: '整个工作流', + moreActions: '更多操作', model: '模型', workflowAsTool: '发布为工具', configureRequired: '需要进行配置', configure: '配置', manageInTools: '访问工具页', workflowAsToolTip: '工作流更新后需要重新配置工具参数', + workflowAsToolDisabledHint: '请先发布最新的工作流,并确保已连接的 User Input 节点后再配置为工具。', viewDetailInTracingPanel: '查看详细信息', syncingData: '同步数据中,只需几秒钟。', importDSL: '导入 DSL', @@ -140,6 +145,19 @@ const translation = { export: '导出包含 Secret 值的 DSL', }, }, + globalVar: { + title: '系统变量', + description: '系统变量是全局变量,在类型匹配时无需连线即可被任意节点引用,例如终端用户 ID 和工作流 ID。', + fieldsDescription: { + conversationId: '会话 ID', + dialogCount: '会话次数', + userId: '用户 ID', + triggerTimestamp: '应用开始运行的时间戳', + appId: '应用 ID', + workflowId: '工作流 ID', + workflowRunId: '工作流运行 ID', + }, + }, sidebar: { exportWarning: '导出当前已保存版本', exportWarningDesc: '这将导出您工作流的当前已保存版本。如果您在编辑器中有未保存的更改,请先使用工作流画布中的导出选项保存它们。', @@ -213,6 +231,16 @@ const translation = { invalidVariable: '无效的变量', noValidTool: '{{field}} 无可用工具', toolParameterRequired: '{{field}}: 参数 [{{param}}] 不能为空', + startNodeRequired: '请先添加开始节点,然后再{{operation}}', + }, + error: { + startNodeRequired: '请先添加开始节点,然后再{{operation}}', + operations: { + connectingNodes: '连接节点', + addingNodes: '添加节点', + modifyingWorkflow: '修改工作流', + updatingWorkflow: '更新工作流', + }, }, singleRun: { testRun: '测试运行', @@ -229,6 +257,8 @@ const translation = { 'searchBlock': '搜索节点', 'blocks': '节点', 'searchTool': '搜索工具', + 'searchTrigger': '搜索触发器...', + 'allTriggers': '全部触发器', 'tools': '工具', 'allTool': '全部', 'plugin': '插件', @@ -239,15 +269,29 @@ const translation = { 'transform': '转换', 'utilities': '工具', 'noResult': '未找到匹配项', + 'noPluginsFound': '未找到插件', + 'requestToCommunity': '向社区反馈', 'agent': 'Agent 策略', 'allAdded': '已添加全部', 'addAll': '添加全部', 'sources': '数据源', 'searchDataSource': '搜索数据源', + 'start': '开始', + 'featuredTools': '精选推荐', + 'showMoreFeatured': '查看更多', + 'showLessFeatured': '收起', + 'installed': '已安装', + 'pluginByAuthor': '来自 {{author}}', + 'usePlugin': '选择工具', + 'hideActions': '收起工具', + 'noFeaturedPlugins': '前往插件市场查看更多工具', + 'noFeaturedTriggers': '前往插件市场查看更多触发器', + 'startDisabledTip': '触发节点与用户输入节点互斥。', }, blocks: { - 'start': '开始', - 'end': '结束', + 'start': '用户输入', + 'originalStartNode': '原始开始节点', + 'end': '输出', 'answer': '直接回复', 'llm': 'LLM', 'knowledge-retrieval': '知识检索', @@ -270,10 +314,14 @@ const translation = { 'loop-end': '退出循环', 'knowledge-index': '知识库', 'datasource': '数据源', + 'trigger-webhook': 'Webhook 触发器', + 'trigger-schedule': '定时触发器', + 'trigger-plugin': '插件触发器', }, + customWebhook: '自定义 Webhook', blocksAbout: { 'start': '定义一个 workflow 流程启动的初始参数', - 'end': '定义一个 workflow 流程的结束和结果类型', + 'end': '定义一个 workflow 流程的输出和结果类型', 'answer': '定义一个聊天对话的回复内容', 'llm': '调用大语言模型回答问题或者对自然语言进行处理', 'knowledge-retrieval': '允许你从知识库中查询与用户问题相关的文本内容', @@ -294,7 +342,11 @@ const translation = { 'agent': '调用大型语言模型回答问题或处理自然语言', 'knowledge-index': '知识库节点', 'datasource': '数据源节点', + 'trigger-webhook': 'Webhook 触发器接收来自第三方系统的 HTTP 推送以自动触发工作流。', + 'trigger-schedule': '基于时间的工作流触发器,按计划启动工作流', + 'trigger-plugin': '从外部平台事件启动工作流的第三方集成触发器', }, + difyTeam: 'Dify 团队', operator: { zoomIn: '放大', zoomOut: '缩小', @@ -324,7 +376,7 @@ const translation = { panel: { userInputField: '用户输入字段', changeBlock: '更改节点', - helpLink: '帮助链接', + helpLink: '查看帮助文档', about: '关于', createdBy: '作者', nextStep: '下一步', @@ -334,12 +386,14 @@ const translation = { checklist: '检查清单', checklistTip: '发布前确保所有问题均已解决', checklistResolved: '所有问题均已解决', + goTo: '转到', + startNode: '开始节点', organizeBlocks: '整理节点', change: '更改', optional: '(选填)', - moveToThisNode: '定位至此节点', maximize: '最大化画布', minimize: '退出最大化', + scrollToSelectedNode: '滚动至选中节点', optional_and_hidden: '(选填 & 隐藏)', }, nodes: { @@ -966,6 +1020,138 @@ const translation = { rerankingModelIsRequired: 'Reranking 模型是必需的', rerankingModelIsInvalid: '无效的 Reranking 模型', }, + triggerSchedule: { + frequency: { + label: '频率', + monthly: '每月', + daily: '每日', + hourly: '每小时', + weekly: '每周', + }, + title: '定时触发', + nodeTitle: '定时触发器', + useCronExpression: '使用 Cron 表达式', + selectFrequency: '选择频率', + nextExecutionTimes: '接下来 5 次执行时间', + hours: '小时', + minutes: '分钟', + onMinute: '分钟', + cronExpression: 'Cron 表达式', + weekdays: '星期', + executeNow: '立即执行', + frequencyLabel: '频率', + nextExecution: '下次执行', + time: '时间', + lastDay: '最后一天', + startTime: '开始时间', + selectDateTime: '选择日期和时间', + lastDayTooltip: '并非所有月份都有 31 天。使用"最后一天"选项来选择每个月的最后一天。', + nextExecutionTime: '下次执行时间', + useVisualPicker: '使用可视化配置', + days: '天', + notConfigured: '未配置', + mode: '模式', + timezone: '时区', + visualConfig: '可视化配置', + monthlyDay: '月份日期', + executionTime: '执行时间', + invalidTimezone: '无效的时区', + invalidCronExpression: '无效的 Cron 表达式', + noValidExecutionTime: '无法计算有效的执行时间', + executionTimeCalculationError: '执行时间计算失败', + invalidFrequency: '无效的频率', + invalidStartTime: '无效的开始时间', + startTimeMustBeFuture: '开始时间必须是将来的时间', + invalidTimeFormat: '无效的时间格式(预期格式:HH:MM AM/PM)', + invalidWeekday: '无效的工作日:{{weekday}}', + invalidMonthlyDay: '月份日期必须在 1-31 之间或为"last"', + invalidOnMinute: '分钟必须在 0-59 之间', + invalidExecutionTime: '无效的执行时间', + executionTimeMustBeFuture: '执行时间必须是将来的时间', + }, + triggerWebhook: { + configPlaceholder: 'Webhook 触发器配置将在此处实现', + title: 'Webhook 触发器', + nodeTitle: '🔗 Webhook 触发器', + webhookUrl: 'Webhook URL', + webhookUrlPlaceholder: '点击生成以创建 webhook URL', + generate: '生成', + copy: '复制', + test: '测试', + urlGenerated: 'Webhook URL 生成成功', + urlGenerationFailed: '生成 Webhook URL 失败', + urlCopied: 'URL 已复制到剪贴板', + method: '方法', + contentType: '内容类型', + queryParameters: '查询参数', + headerParameters: 'Header 参数', + requestBodyParameters: '请求体参数', + parameterName: '变量名', + varName: '变量名', + varType: '类型', + varNamePlaceholder: '输入变量名...', + headerName: '变量名', + required: '必填', + addParameter: '添加', + addHeader: '添加', + noParameters: '未配置任何参数', + noQueryParameters: '未配置查询参数', + noHeaders: '未配置 Header', + noBodyParameters: '未配置请求体参数', + debugUrlTitle: '测试运行时,请始终使用此URL', + debugUrlCopy: '点击复制', + debugUrlCopied: '已复制!', + errorHandling: '错误处理', + errorStrategy: '错误处理', + responseConfiguration: '响应', + asyncMode: '异步模式', + statusCode: '状态码', + responseBody: '响应体', + responseBodyPlaceholder: '在此输入您的响应体', + headers: 'Headers', + validation: { + webhookUrlRequired: '需要提供Webhook URL', + invalidParameterType: '参数"{{name}}"的参数类型"{{type}}"无效', + }, + }, + triggerPlugin: { + authorized: '已授权', + notConfigured: '未配置', + error: '错误', + configuration: '配置', + remove: '移除', + or: '或', + useOAuth: '使用 OAuth', + useApiKey: '使用 API Key', + authenticationFailed: '身份验证失败', + authenticationSuccess: '身份验证成功', + oauthConfigFailed: 'OAuth 配置失败', + configureOAuthClient: '配置 OAuth 客户端', + oauthClientDescription: '配置 OAuth 客户端凭据以启用身份验证', + oauthClientSaved: 'OAuth 客户端配置保存成功', + configureApiKey: '配置 API Key', + apiKeyDescription: '配置 API key 凭据进行身份验证', + apiKeyConfigured: 'API key 配置成功', + configurationFailed: '配置失败', + failedToStart: '启动身份验证流程失败', + credentialsVerified: '凭据验证成功', + credentialVerificationFailed: '凭据验证失败', + verifyAndContinue: '验证并继续', + configureParameters: '配置参数', + parametersDescription: '配置触发器参数和属性', + configurationComplete: '配置完成', + configurationCompleteDescription: '您的触发器已成功配置', + configurationCompleteMessage: '您的触发器配置已完成,现在可以使用了。', + parameters: '参数', + properties: '属性', + propertiesDescription: '此触发器的额外配置属性', + noConfigurationRequired: '此触发器不需要额外配置。', + subscriptionName: '订阅名称', + subscriptionNameDescription: '为此触发器订阅输入一个唯一名称', + subscriptionNamePlaceholder: '输入订阅名称...', + subscriptionNameRequired: '订阅名称是必需的', + subscriptionRequired: '需要配置订阅', + }, }, tracing: { stopBy: '由{{user}}终止', @@ -1027,6 +1213,18 @@ const translation = { view: '查看记录', edited: '已编辑', reset: '还原至上一次运行', + listening: { + title: '正在监听触发器事件…', + tip: '您现在可以向 HTTP {{nodeName}} 端点发送测试请求以模拟事件触发,或将其用作实时事件调试的回调 URL。所有输出都可以在变量检查器中直接查看。', + tipPlugin: '现在您可以在 {{- pluginName}} 中创建事件,并在变量检查器中查看这些事件的输出。', + tipSchedule: '正在监听计划触发器事件。\n下一次计划运行时间:{{nextTriggerTime}}', + tipFallback: '正在等待触发器事件,输出结果将在此显示。', + defaultNodeName: '此触发器', + defaultPluginName: '此插件触发器', + defaultScheduleTime: '未设置', + selectedTriggers: '所选触发器', + stopButton: '停止', + }, trigger: { normal: '变量检查', running: '缓存中', @@ -1052,6 +1250,30 @@ const translation = { noDependents: '无被依赖', }, }, + triggerStatus: { + enabled: '触发器', + disabled: '触发器 • 已禁用', + }, + entryNodeStatus: { + enabled: '开始', + disabled: '开始 • 已禁用', + }, + onboarding: { + title: '选择开始节点来开始', + description: '不同的开始节点具有不同的功能。不用担心,您随时可以更改它们。', + userInputFull: '用户输入(原始开始节点)', + userInputDescription: '允许设置用户输入变量的开始节点,具有Web应用程序、服务API、MCP服务器和工作流即工具功能。', + trigger: '触发器', + triggerDescription: '触发器可以作为工作流的开始节点,例如定时任务、自定义webhook或与其他应用程序的集成。', + back: '返回', + learnMore: '了解更多', + aboutStartNode: '关于开始节点。', + escTip: { + press: '按', + key: 'esc', + toDismiss: '键关闭', + }, + }, } export default translation diff --git a/web/i18n/zh-Hant/billing.ts b/web/i18n/zh-Hant/billing.ts index f0f91a0966..38589179e7 100644 --- a/web/i18n/zh-Hant/billing.ts +++ b/web/i18n/zh-Hant/billing.ts @@ -74,7 +74,7 @@ const translation = { receiptInfo: '只有團隊所有者和團隊管理員才能訂閱和檢視賬單資訊', annotationQuota: '註釋配額', self: '自我主持', - apiRateLimitUnit: '{{count,number}}/天', + apiRateLimitUnit: '{{count,number}}/月', freeTrialTipPrefix: '註冊並獲得一個', annualBilling: '年度計費', freeTrialTipSuffix: '無需信用卡', diff --git a/web/i18n/zh-Hant/common.ts b/web/i18n/zh-Hant/common.ts index e5941c7d11..51cdcf8a0b 100644 --- a/web/i18n/zh-Hant/common.ts +++ b/web/i18n/zh-Hant/common.ts @@ -160,7 +160,8 @@ const translation = { emailSupport: '電子郵件支援', workspace: '工作空間', createWorkspace: '建立工作空間', - helpCenter: '幫助文件', + helpCenter: '查看幫助文件', + communityFeedback: '使用者反饋', roadmap: '路線圖', community: '社群', about: '關於', diff --git a/web/i18n/zh-Hant/workflow.ts b/web/i18n/zh-Hant/workflow.ts index 4bb5875d88..ce053d6e5b 100644 --- a/web/i18n/zh-Hant/workflow.ts +++ b/web/i18n/zh-Hant/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: '已發佈', publish: '發佈', update: '更新', - run: '運行', + run: '測試運行', running: '運行中', inRunMode: '在運行模式中', inPreview: '預覽中', @@ -18,7 +18,6 @@ const translation = { runHistory: '運行歷史', goBackToEdit: '返回編輯模式', conversationLog: '對話記錄', - features: '功能', debugAndPreview: '預覽', restart: '重新開始', currentDraft: '當前草稿', @@ -38,6 +37,8 @@ const translation = { setVarValuePlaceholder: '設置變數值', needConnectTip: '此節點尚未連接到其他節點', maxTreeDepth: '每個分支最大限制 {{depth}} 個節點', + needAdd: '必須新增{{node}}節點', + needOutputNode: '必須新增輸出節點', needEndNode: '必須新增結束節點', needAnswerNode: '必須新增直接回覆節點', workflowProcess: '工作流', @@ -94,10 +95,8 @@ const translation = { addParallelNode: '新增並行節點', parallel: '並行', branch: '分支', - featuresDocLink: '瞭解更多資訊', fileUploadTip: '圖片上傳功能已升級為檔上傳。', ImageUploadLegacyTip: '現在,您可以在起始表單中創建檔案類型變數。我們將來不再支持圖片上傳功能。', - featuresDescription: '增強 Web 應用程式用戶體驗', importWarning: '謹慎', importWarningDetails: 'DSL 版本差異可能會影響某些功能', openInExplore: '在“探索”中打開', @@ -115,6 +114,7 @@ const translation = { tagBound: '使用此標籤的應用程式數量', currentView: '當前檢視', currentWorkflow: '當前工作流程', + moreActions: '更多動作', }, env: { envPanelTitle: '環境變數', @@ -139,6 +139,19 @@ const translation = { export: '導出帶有機密值的 DSL', }, }, + globalVar: { + title: '系統變數', + description: '系統變數是全域變數,在類型符合時可由任意節點在無需連線的情況下引用,例如終端使用者 ID 與工作流程 ID。', + fieldsDescription: { + conversationId: '對話 ID', + dialogCount: '對話次數', + userId: '使用者 ID', + triggerTimestamp: '應用程式開始運行的時間戳', + appId: '應用程式 ID', + workflowId: '工作流程 ID', + workflowRunId: '工作流程執行 ID', + }, + }, chatVariable: { panelTitle: '對話變數', panelDescription: '對話變數用於儲存 LLM 需要記住的互動資訊,包括對話歷史、上傳的檔案、使用者偏好等。這些變數可讀寫。', @@ -224,6 +237,8 @@ const translation = { 'searchBlock': '搜索節點', 'blocks': '節點', 'tools': '工具', + 'searchTrigger': '搜尋觸發器...', + 'allTriggers': '所有觸發器', 'allTool': '全部', 'customTool': '自定義', 'workflowTool': '工作流', @@ -239,10 +254,12 @@ const translation = { 'addAll': '全部新增', 'sources': '來源', 'searchDataSource': '搜尋資料來源', + 'noFeaturedPlugins': '前往 Marketplace 查看更多工具', + 'noFeaturedTriggers': '前往 Marketplace 查看更多觸發器', }, blocks: { 'start': '開始', - 'end': '結束', + 'end': '輸出', 'answer': '直接回覆', 'llm': 'LLM', 'knowledge-retrieval': '知識檢索', @@ -268,7 +285,7 @@ const translation = { }, blocksAbout: { 'start': '定義一個 workflow 流程啟動的參數', - 'end': '定義一個 workflow 流程的結束和結果類型', + 'end': '定義一個 workflow 流程的輸出和結果類型', 'answer': '定義一個聊天對話的回覆內容', 'llm': '調用大語言模型回答問題或者對自然語言進行處理', 'knowledge-retrieval': '允許你從知識庫中查詢與用戶問題相關的文本內容', @@ -312,7 +329,7 @@ const translation = { panel: { userInputField: '用戶輸入字段', changeBlock: '更改節點', - helpLink: '幫助連接', + helpLink: '查看幫助文件', about: '關於', createdBy: '作者', nextStep: '下一步', @@ -325,9 +342,9 @@ const translation = { organizeBlocks: '整理節點', change: '更改', optional: '(選擇性)', - moveToThisNode: '定位至此節點', minimize: '退出全螢幕', maximize: '最大化畫布', + scrollToSelectedNode: '捲動至選取的節點', optional_and_hidden: '(可選且隱藏)', }, nodes: { @@ -1006,6 +1023,18 @@ const translation = { description: '上次運行的結果將顯示在這裡', }, variableInspect: { + listening: { + title: '正在監聽觸發器事件…', + tip: '您現在可以向 HTTP {{nodeName}} 端點發送測試請求來模擬事件觸發,或將其作為即時事件除錯的回呼 URL。所有輸出都可在變數檢視器中直接查看。', + tipPlugin: '您現在可以在 {{- pluginName}} 中建立事件,並在變數檢視器中檢視這些事件的輸出。', + tipSchedule: '正在監聽排程觸發器事件。\n下一次排程執行時間:{{nextTriggerTime}}', + tipFallback: '正在等待觸發器事件,輸出會顯示在此處。', + defaultNodeName: '此觸發器', + defaultPluginName: '此插件觸發器', + defaultScheduleTime: '未設定', + selectedTriggers: '已選觸發器', + stopButton: '停止', + }, trigger: { cached: '查看快取的變數', stop: '停止跑步', diff --git a/web/models/app.ts b/web/models/app.ts index 454cc5d1e8..e0f31ff26e 100644 --- a/web/models/app.ts +++ b/web/models/app.ts @@ -1,5 +1,15 @@ -import type { AliyunConfig, ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, TencentConfig, TracingProvider, WeaveConfig } from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type' -import type { App, AppMode, AppTemplate, SiteConfig } from '@/types/app' +import type { + AliyunConfig, + ArizeConfig, + LangFuseConfig, + LangSmithConfig, + OpikConfig, + PhoenixConfig, + TencentConfig, + TracingProvider, + WeaveConfig, +} from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type' +import type { App, AppModeEnum, AppTemplate, SiteConfig } from '@/types/app' import type { Dependency } from '@/app/components/plugins/types' export enum DSLImportMode { @@ -27,7 +37,7 @@ export type AppDetailResponse = App export type DSLImportResponse = { id: string status: DSLImportStatus - app_mode: AppMode + app_mode: AppModeEnum app_id?: string current_dsl_version?: string imported_dsl_version?: string @@ -111,3 +121,12 @@ export type TracingConfig = { tracing_provider: TracingProvider tracing_config: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | TencentConfig } + +export type WebhookTriggerResponse = { + id: string + webhook_id: string + webhook_url: string + webhook_debug_url: string + node_id: string + created_at: string +} diff --git a/web/models/datasets.ts b/web/models/datasets.ts index 39313d68a3..eb7b7de4a2 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -1,5 +1,5 @@ import type { DataSourceNotionPage, DataSourceProvider } from './common' -import type { AppIconType, AppMode, RetrievalConfig, TransferMethod } from '@/types/app' +import type { AppIconType, AppModeEnum, RetrievalConfig, TransferMethod } from '@/types/app' import type { Tag } from '@/app/components/base/tag-management/constant' import type { IndexingType } from '@/app/components/datasets/create/step-two' import type { MetadataFilteringVariableType } from '@/app/components/workflow/nodes/knowledge-retrieval/types' @@ -662,7 +662,7 @@ export type ExternalKnowledgeBaseHitTestingResponse = { export type RelatedApp = { id: string name: string - mode: AppMode + mode: AppModeEnum icon_type: AppIconType | null icon: string icon_background: string diff --git a/web/models/explore.ts b/web/models/explore.ts index ad243e931e..fbbd01837a 100644 --- a/web/models/explore.ts +++ b/web/models/explore.ts @@ -1,7 +1,7 @@ -import type { AppIconType, AppMode } from '@/types/app' +import type { AppIconType, AppModeEnum } from '@/types/app' export type AppBasicInfo = { id: string - mode: AppMode + mode: AppModeEnum icon_type: AppIconType | null icon: string icon_background: string diff --git a/web/models/log.ts b/web/models/log.ts index eff46372d0..baa07a59c4 100644 --- a/web/models/log.ts +++ b/web/models/log.ts @@ -229,11 +229,38 @@ export type AnnotationsCountResponse = { count: number } +export enum WorkflowRunTriggeredFrom { + DEBUGGING = 'debugging', + APP_RUN = 'app-run', + RAG_PIPELINE_RUN = 'rag-pipeline-run', + RAG_PIPELINE_DEBUGGING = 'rag-pipeline-debugging', + WEBHOOK = 'webhook', + SCHEDULE = 'schedule', + PLUGIN = 'plugin', +} + +export type TriggerMetadata = { + type?: string + endpoint_id?: string + plugin_unique_identifier?: string + provider_id?: string + event_name?: string + icon_filename?: string + icon_dark_filename?: string + icon?: string | null + icon_dark?: string | null +} + +export type WorkflowLogDetails = { + trigger_metadata?: TriggerMetadata +} + export type WorkflowRunDetail = { id: string version: string status: 'running' | 'succeeded' | 'failed' | 'stopped' error?: string + triggered_from?: WorkflowRunTriggeredFrom elapsed_time: number total_tokens: number total_price: number @@ -255,6 +282,7 @@ export type EndUserInfo = { export type WorkflowAppLogDetail = { id: string workflow_run: WorkflowRunDetail + details?: WorkflowLogDetails created_from: 'service-api' | 'web-app' | 'explore' created_by_role: 'account' | 'end_user' created_by_account?: AccountInfo diff --git a/web/next.config.js b/web/next.config.js index c4f6fc87b6..212bed0a9c 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -1,4 +1,7 @@ const { codeInspectorPlugin } = require('code-inspector-plugin') + +const isDev = process.env.NODE_ENV === 'development' + const withPWA = require('next-pwa')({ dest: 'public', register: true, @@ -137,6 +140,9 @@ const nextConfig = { ] }, output: 'standalone', + compiler: { + removeConsole: isDev ? false : { exclude: ['warn', 'error'] }, + } } module.exports = withPWA(withBundleAnalyzer(withMDX(nextConfig))) diff --git a/web/package.json b/web/package.json index a43f57b5c5..7e5a84048b 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "dify-web", - "version": "1.9.2", + "version": "1.10.0-rc1", "private": true, "packageManager": "pnpm@10.19.0+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8", "engines": { @@ -73,6 +73,7 @@ "classnames": "^2.5.1", "cmdk": "^1.1.1", "copy-to-clipboard": "^3.3.3", + "cron-parser": "^5.4.0", "dayjs": "^1.11.19", "decimal.js": "^10.6.0", "dompurify": "^3.3.0", @@ -197,6 +198,7 @@ "sass": "^1.93.2", "storybook": "9.1.13", "tailwindcss": "^3.4.18", + "ts-node": "^10.9.2", "typescript": "^5.9.3", "uglify-js": "^3.19.3" }, diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 73df76a4e1..8e638ed2df 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -101,7 +101,7 @@ importers: version: 0.37.0 '@monaco-editor/react': specifier: ^4.7.0 - version: 4.7.0(monaco-editor@0.52.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 4.7.0(monaco-editor@0.54.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@octokit/core': specifier: ^6.1.6 version: 6.1.6 @@ -147,6 +147,9 @@ importers: copy-to-clipboard: specifier: ^3.3.3 version: 3.3.3 + cron-parser: + specifier: ^5.4.0 + version: 5.4.0 dayjs: specifier: ^1.11.19 version: 1.11.19 @@ -345,7 +348,7 @@ importers: devDependencies: '@antfu/eslint-config': specifier: ^5.4.1 - version: 5.4.1(@eslint-react/eslint-plugin@1.53.1(eslint@9.38.0(jiti@1.21.7))(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3))(@next/eslint-plugin-next@15.5.4)(@vue/compiler-sfc@3.5.17)(eslint-plugin-react-hooks@5.2.0(eslint@9.38.0(jiti@1.21.7)))(eslint-plugin-react-refresh@0.4.24(eslint@9.38.0(jiti@1.21.7)))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + version: 5.4.1(@eslint-react/eslint-plugin@1.53.1(eslint@9.38.0(jiti@1.21.7))(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3))(@next/eslint-plugin-next@15.5.4)(@vue/compiler-sfc@3.5.22)(eslint-plugin-react-hooks@5.2.0(eslint@9.38.0(jiti@1.21.7)))(eslint-plugin-react-refresh@0.4.24(eslint@9.38.0(jiti@1.21.7)))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@babel/core': specifier: ^7.28.4 version: 7.28.4 @@ -492,7 +495,7 @@ importers: version: 29.7.0(@types/node@18.15.0)(ts-node@10.9.2(@types/node@18.15.0)(typescript@5.9.3)) knip: specifier: ^5.66.1 - version: 5.66.1(@types/node@18.15.0)(typescript@5.9.3) + version: 5.66.2(@types/node@18.15.0)(typescript@5.9.3) lint-staged: specifier: ^15.5.2 version: 15.5.2 @@ -514,6 +517,9 @@ importers: tailwindcss: specifier: ^3.4.18 version: 3.4.18(yaml@2.8.1) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@18.15.0)(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1302,11 +1308,11 @@ packages: resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} - '@emnapi/core@1.5.0': - resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} + '@emnapi/core@1.6.0': + resolution: {integrity: sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg==} - '@emnapi/runtime@1.5.0': - resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} + '@emnapi/runtime@1.6.0': + resolution: {integrity: sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==} '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} @@ -1558,8 +1564,8 @@ packages: resolution: {integrity: sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/markdown@7.4.0': - resolution: {integrity: sha512-VQykmMjBb4tQoJOXVWXa+oQbQeCZlE7W3rAsOpmtpKLvJd75saZZ04PVVs7+zgMDJGghd4/gyFV6YlvdJFaeNQ==} + '@eslint/markdown@7.4.1': + resolution: {integrity: sha512-fhcQcylVqgb7GLPr2+6hlDQXK4J3d/fPY6qzk9/i7IYtQkIr15NKI5Zg39Dv2cV/bn5J0Znm69rmu9vJI/7Tlw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -1570,10 +1576,6 @@ packages: resolution: {integrity: sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/plugin-kit@0.3.5': - resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -2295,98 +2297,98 @@ packages: '@octokit/types@14.1.0': resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} - '@oxc-resolver/binding-android-arm-eabi@11.10.0': - resolution: {integrity: sha512-qvSSjeeBvYh3KlpMwDbLr0m/bmEfEzaAv2yW4RnYDGrsFVgTHlNc3WzQSji0+Bf2g3kLgyZ5pwylaJpS9baUIA==} + '@oxc-resolver/binding-android-arm-eabi@11.11.0': + resolution: {integrity: sha512-aN0UJg1xr0N1dADQ135z4p3bP9AYAUN1Ey2VvLMK6IwWYIJGWpKT+cr1l3AiyBeLK8QZyFDb4IDU8LHgjO9TDQ==} cpu: [arm] os: [android] - '@oxc-resolver/binding-android-arm64@11.10.0': - resolution: {integrity: sha512-rjiCqkhH1di5Sb/KpOmuC/1OCGZVDdUyVIxxPsmzkdgrTgS6Of5cwOHTBVNxXuVdlIMz0swN8wrmqUM9jspPAQ==} + '@oxc-resolver/binding-android-arm64@11.11.0': + resolution: {integrity: sha512-FckvvMclo8CSJqQjKpHueIIbKrg9L638NKWQTiJQaD8W9F61h8hTjF8+QFLlCHh6R9RcE5roVHdkkiBKHlB2Zw==} cpu: [arm64] os: [android] - '@oxc-resolver/binding-darwin-arm64@11.10.0': - resolution: {integrity: sha512-qr2+vw0BKxZVuaw3Ssbzfe0999FYs5BkKqezP8ocwYE9pJUC4hNlWUWhGLDxj0tBSjMEFvWQNF7IxCeZk6nzKw==} + '@oxc-resolver/binding-darwin-arm64@11.11.0': + resolution: {integrity: sha512-7ZcpgaXSBnwRHM1YR8Vazq7mCTtGdYRvM7k46CscA+oipCVqmI4LbW2wLsc6HVjqX+SM/KPOfFGoGjEgmQPFTQ==} cpu: [arm64] os: [darwin] - '@oxc-resolver/binding-darwin-x64@11.10.0': - resolution: {integrity: sha512-2XFEd89yVnnkk7u0LACdXsiHDN3rMthzcdSHj2VROaItiAW6qfKy+SJwLK94lYCVv9nFjxJUVHiVJUsKIn70tQ==} + '@oxc-resolver/binding-darwin-x64@11.11.0': + resolution: {integrity: sha512-Wsd1JWORokMmOKrR4t4jxpwYEWG11+AHWu9bdzjCO5EIyi0AuNpPIAEcEFCP9FNd0h8c+VUYbMRU/GooD2zOIg==} cpu: [x64] os: [darwin] - '@oxc-resolver/binding-freebsd-x64@11.10.0': - resolution: {integrity: sha512-EHapmlf+bg92Pf3+0E0nYSKQgQ5u2V++KXB0WTushFJSU+k6gXEL/P/y1QwKqzJ986Q14YWHh7IiT/nQvpaz4Q==} + '@oxc-resolver/binding-freebsd-x64@11.11.0': + resolution: {integrity: sha512-YX+W10kHrMouu/+Y+rqJdCWO3dFBKM1DIils30PHsmXWp1v+ZZvhibaST2BP6zrWkWquZ8pMmsObD6N10lLgiA==} cpu: [x64] os: [freebsd] - '@oxc-resolver/binding-linux-arm-gnueabihf@11.10.0': - resolution: {integrity: sha512-NhSAeelg0EU4ymM8XrUfGJL74jBHs2Q3WdVbXIve+ROge0UAB7yXpk40u7quIOmbyqAEUp/QPlhtEmWc+lWcPg==} + '@oxc-resolver/binding-linux-arm-gnueabihf@11.11.0': + resolution: {integrity: sha512-UAhlhVkW2ui98bClmEkDLKQz4XBSccxMahG7rMeX2RepS2QByAWxYFFThaNbHtBSB+B4Rc1hudkihq8grQkU3g==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm-musleabihf@11.10.0': - resolution: {integrity: sha512-9rjZigo5/92O3jayjucIdhhq4eJBgf61K9UZZF1r1uoIhS4i0wz7W29gMWkCVYbwZAfkHxfmTn3zu8Vv34NvUQ==} + '@oxc-resolver/binding-linux-arm-musleabihf@11.11.0': + resolution: {integrity: sha512-5pEliabSEiimXz/YyPxzyBST82q8PbM6BoEMS8kOyaDbEBuzTr7pWU1U0F7ILGBFjJmHaj3N7IAhQgeXdpdySg==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm64-gnu@11.10.0': - resolution: {integrity: sha512-73pz+sYfPfMzl8OVdjsWJXu5LO868LBpy8M/a/m4a7HUREwBz1/CK59ifxhbIkIeAv2ZkhwKiouFxsKmCsQRrw==} + '@oxc-resolver/binding-linux-arm64-gnu@11.11.0': + resolution: {integrity: sha512-CiyufPFIOJrW/HovAMGsH0AbV7BSCb0oE0KDtt7z1+e+qsDo7HRlTSnqE3JbNuhJRg3Cz/j7qEYzgGqco9SE4Q==} cpu: [arm64] os: [linux] - '@oxc-resolver/binding-linux-arm64-musl@11.10.0': - resolution: {integrity: sha512-s8AMNkiguFn2XJtnAaSHl+ak97Zwkq6biouUNuApDRZh34ckAjWxPTQRhUZLCFybNxgZtwVbglVQv0BJYieIXg==} + '@oxc-resolver/binding-linux-arm64-musl@11.11.0': + resolution: {integrity: sha512-w07MfGtDLZV0rISdXl2cGASxD/sRrrR93Qd4q27O2Hsky4MGbLw94trbzhmAkc7OKoJI0iDg1217i3jfxmVk1Q==} cpu: [arm64] os: [linux] - '@oxc-resolver/binding-linux-ppc64-gnu@11.10.0': - resolution: {integrity: sha512-70eHfsX9Xw+wGqmwFhlIxT/LhzGDlnI4ECQ7w0VLZsYpAUjRiQPUQCDKkfP65ikzHPSLeY8pARKVIc2gdC0HEA==} + '@oxc-resolver/binding-linux-ppc64-gnu@11.11.0': + resolution: {integrity: sha512-gzM+ZfIjfcCofwX/m1eLCoTT+3T70QLWaKDOW5Hf3+ddLlxMEVRIQtUoRsp0e/VFanr7u7VKS57TxhkRubseNg==} cpu: [ppc64] os: [linux] - '@oxc-resolver/binding-linux-riscv64-gnu@11.10.0': - resolution: {integrity: sha512-geibi+L5hKmDwZ9iLEUzuvRG4o6gZWB8shlNBLiKnGtYD5SMAvCcJiHpz1Sf6ESm8laXjiIf6T/pTZZpaeStyw==} + '@oxc-resolver/binding-linux-riscv64-gnu@11.11.0': + resolution: {integrity: sha512-oCR0ImJQhIwmqwNShsRT0tGIgKF5/H4nhtIEkQAQ9bLzMgjtRqIrZ3DtGHqd7w58zhXWfIZdyPNF9IrSm+J/fQ==} cpu: [riscv64] os: [linux] - '@oxc-resolver/binding-linux-riscv64-musl@11.10.0': - resolution: {integrity: sha512-oL1B0jGu9vYoQKyJiMvjtuxDzmV9P8M/xdu6wjUjvaGC/gIwvhILzlHgD3SMtFJJhzLVf4HPmYAF7BsLWvTugA==} + '@oxc-resolver/binding-linux-riscv64-musl@11.11.0': + resolution: {integrity: sha512-MjCEqsUzXMfWPfsEUX+UXttzXz6xiNU11r7sj00C5og/UCyqYw1OjrbC/B1f/dloDpTn0rd4xy6c/LTvVQl2tg==} cpu: [riscv64] os: [linux] - '@oxc-resolver/binding-linux-s390x-gnu@11.10.0': - resolution: {integrity: sha512-Sj6ooR4RZ+04SSc/iV7oK8C2TxoWzJbD5yirsF64ULFukTvQHz99ImjtwgauBUnR+3loyca3s6o8DiAmqHaxAw==} + '@oxc-resolver/binding-linux-s390x-gnu@11.11.0': + resolution: {integrity: sha512-4TaTX7gT3357vWQsTe3IfDtWyJNe0FejypQ4ngwxB3v1IVaW6KAUt0huSvx/tmj+YWxd3zzXdWd8AzW0jo6dpg==} cpu: [s390x] os: [linux] - '@oxc-resolver/binding-linux-x64-gnu@11.10.0': - resolution: {integrity: sha512-wH5nPRgIaEhuOD9M70NujV91FscboRkNf38wKAYiy9xuKeVsc43JzFqvmgxU1vXsKwUJBc/qMt4nFNluLXwVzw==} + '@oxc-resolver/binding-linux-x64-gnu@11.11.0': + resolution: {integrity: sha512-ch1o3+tBra9vmrgXqrufVmYnvRPFlyUb7JWs/VXndBmyNSuP2KP+guAUrC0fr2aSGoOQOasAiZza7MTFU7Vrxg==} cpu: [x64] os: [linux] - '@oxc-resolver/binding-linux-x64-musl@11.10.0': - resolution: {integrity: sha512-rDrv1Joh6hAidV/hixAA1+6keNr1aJA3rUU6VD8mqTedbUMV1CdQJ55f9UmQZn0nO35tQvwF0eLBNmumErCNLw==} + '@oxc-resolver/binding-linux-x64-musl@11.11.0': + resolution: {integrity: sha512-llTdl2gJAqXaGV7iV1w5BVlqXACcoT1YD3o840pCQx1ZmKKAAz7ydPnTjYVdkGImXNWPOIWJixHW0ryDm4Mx7w==} cpu: [x64] os: [linux] - '@oxc-resolver/binding-wasm32-wasi@11.10.0': - resolution: {integrity: sha512-VE+fuYPMqObhwEoLOUp9UgebrMFBBCuvCBY+auk+o3bFWOYXLpvCa5PzC4ttF7gOotQD/TWqbVWtfOh0CdBSHw==} + '@oxc-resolver/binding-wasm32-wasi@11.11.0': + resolution: {integrity: sha512-cROavohP0nX91NtIVVgOTugqoxlUSNxI9j7MD+B7fmD3gEFl8CVyTamR0/p6loDxLv51bQYTHRKn/ZYTd3ENzw==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@oxc-resolver/binding-win32-arm64-msvc@11.10.0': - resolution: {integrity: sha512-M70Fr5P1SnQY4vm7ZTeodE27mDV6zqxLkQMHF4t43xt55dIFIlHiRTgCzykiI9ggan3M1YWffLeB97Q3X2yxSg==} + '@oxc-resolver/binding-win32-arm64-msvc@11.11.0': + resolution: {integrity: sha512-6amVs34yHmxE6Q3CtTPXnSvIYGqwQJ/lVVRYccLzg9smge3WJ1knyBV5jpKKayp0n316uPYzB4EgEbgcuRvrPw==} cpu: [arm64] os: [win32] - '@oxc-resolver/binding-win32-ia32-msvc@11.10.0': - resolution: {integrity: sha512-UJfRwzXAAIduNJa0cZlwT8L8eAOSX85VfKQ0i0NCJWNjwFzjeeOpvd/vNXMd1jmYU22a8fulFX3k8AzdwI7wYw==} + '@oxc-resolver/binding-win32-ia32-msvc@11.11.0': + resolution: {integrity: sha512-v/IZ5s2/3auHUoi0t6Ea1CDsWxrE9BvgvbDcJ04QX+nEbmTBazWPZeLsH8vWkRAh8EUKCZHXxjQsPhEH5Yk5pQ==} cpu: [ia32] os: [win32] - '@oxc-resolver/binding-win32-x64-msvc@11.10.0': - resolution: {integrity: sha512-Q8gwXHjDeEokECEFCECkJW1OEOEgfFUGoLZs88jDpZ/QmdBklH/SbMLKJdYeIPztQ6HD069GAVPnP3WcXyHoUA==} + '@oxc-resolver/binding-win32-x64-msvc@11.11.0': + resolution: {integrity: sha512-qvm+IQ6r2q4HZitSV69O+OmvCD1y4pH7SbhR6lPwLsfZS5QRHS8V20VHxmG1jJzSPPw7S8Bb1rdNcxDSqc4bYA==} cpu: [x64] os: [win32] @@ -3082,8 +3084,8 @@ packages: '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} - '@types/chai@5.2.2': - resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -3340,63 +3342,63 @@ packages: '@types/yargs@17.0.33': resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} - '@typescript-eslint/eslint-plugin@8.46.1': - resolution: {integrity: sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==} + '@typescript-eslint/eslint-plugin@8.46.2': + resolution: {integrity: sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.46.1 + '@typescript-eslint/parser': ^8.46.2 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.46.1': - resolution: {integrity: sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==} + '@typescript-eslint/parser@8.46.2': + resolution: {integrity: sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.46.1': - resolution: {integrity: sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==} + '@typescript-eslint/project-service@8.46.2': + resolution: {integrity: sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.46.1': - resolution: {integrity: sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==} + '@typescript-eslint/scope-manager@8.46.2': + resolution: {integrity: sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.46.1': - resolution: {integrity: sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==} + '@typescript-eslint/tsconfig-utils@8.46.2': + resolution: {integrity: sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.46.1': - resolution: {integrity: sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==} + '@typescript-eslint/type-utils@8.46.2': + resolution: {integrity: sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.46.1': - resolution: {integrity: sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==} + '@typescript-eslint/types@8.46.2': + resolution: {integrity: sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.46.1': - resolution: {integrity: sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==} + '@typescript-eslint/typescript-estree@8.46.2': + resolution: {integrity: sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.46.1': - resolution: {integrity: sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==} + '@typescript-eslint/utils@8.46.2': + resolution: {integrity: sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.46.1': - resolution: {integrity: sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==} + '@typescript-eslint/visitor-keys@8.46.2': + resolution: {integrity: sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -3438,26 +3440,17 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@vue/compiler-core@3.5.17': - resolution: {integrity: sha512-Xe+AittLbAyV0pabcN7cP7/BenRBNcteM4aSDCtRvGw0d9OL+HG1u/XHLY/kt1q4fyMeZYXyIYrsHuPSiDPosA==} - '@vue/compiler-core@3.5.22': resolution: {integrity: sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==} - '@vue/compiler-dom@3.5.17': - resolution: {integrity: sha512-+2UgfLKoaNLhgfhV5Ihnk6wB4ljyW1/7wUIog2puUqajiC29Lp5R/IKDdkebh9jTbTogTbsgB+OY9cEWzG95JQ==} - '@vue/compiler-dom@3.5.22': resolution: {integrity: sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==} - '@vue/compiler-sfc@3.5.17': - resolution: {integrity: sha512-rQQxbRJMgTqwRugtjw0cnyQv9cP4/4BxWfTdRBkqsTfLOHWykLzbOc3C4GGzAmdMDxhzU/1Ija5bTjMVrddqww==} + '@vue/compiler-sfc@3.5.22': + resolution: {integrity: sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==} - '@vue/compiler-ssr@3.5.17': - resolution: {integrity: sha512-hkDbA0Q20ZzGgpj5uZjb9rBzQtIHLS78mMilwrlpWk2Ep37DYntUz0PonQ6kr113vfOEdM+zTBuJDaceNIW0tQ==} - - '@vue/shared@3.5.17': - resolution: {integrity: sha512-CabR+UN630VnsJO/jHWYBC1YVXyMq94KKp6iF5MQgZJs5I8cmjw6oVMO1oDbtBkENSHSSn/UadWlW/OAgdmKrg==} + '@vue/compiler-ssr@3.5.22': + resolution: {integrity: sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==} '@vue/shared@3.5.22': resolution: {integrity: sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==} @@ -4205,6 +4198,10 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cron-parser@5.4.0: + resolution: {integrity: sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA==} + engines: {node: '>=18'} + cross-env@10.1.0: resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} engines: {node: '>=20'} @@ -4558,6 +4555,9 @@ packages: resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} engines: {node: '>= 4'} + dompurify@3.1.7: + resolution: {integrity: sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==} + dompurify@3.3.0: resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==} @@ -5948,8 +5948,8 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} - knip@5.66.1: - resolution: {integrity: sha512-Ad3VUPIk9GZYovKuwKtGMheupek7IoPGaDEBAvnCYLKJXnwmqNLyXqMp+l5r3OOpFVjF7DdkFIZFVrXESDNylQ==} + knip@5.66.2: + resolution: {integrity: sha512-5wvsdc17C5bMxjuGfN9KVS/tW5KIvzP1RClfpTMdLYm8IXIsfWsiHlFkTvZIca9skwoVDyTyXmbRq4w1Poim+A==} engines: {node: '>=18.18.0'} hasBin: true peerDependencies: @@ -6087,6 +6087,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -6124,6 +6128,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + marked@15.0.12: resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} engines: {node: '>= 18'} @@ -6399,8 +6408,8 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - monaco-editor@0.52.2: - resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==} + monaco-editor@0.54.0: + resolution: {integrity: sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==} mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} @@ -6561,8 +6570,8 @@ packages: os-browserify@0.3.0: resolution: {integrity: sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==} - oxc-resolver@11.10.0: - resolution: {integrity: sha512-LNJkji0qsBvZ7+yze3S1qsWufZ3VBcyU1wAnC5bBP0QzHsKf4rrNhG5I4c0RIDQGKsKDpVWh8vhUAGE3cb53kA==} + oxc-resolver@11.11.0: + resolution: {integrity: sha512-vVeBJf77zBeqOA/LBCTO/pr0/ETHGSleCRsI5Kmsf2OsfB5opzhhZptt6VxkqjKWZH+eF1se88fYDG5DGRLjkg==} p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} @@ -6704,6 +6713,10 @@ packages: resolution: {integrity: sha512-wfRLBZ0feWRhCIkoMB6ete7czJcnNnqRpcoWQBLqatqXXmelSRqfdDK4F3u9T2s2cXas/hQJcryI/4lAL+XTlA==} engines: {node: '>=0.12'} + pbkdf2@3.1.5: + resolution: {integrity: sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==} + engines: {node: '>= 0.10'} + pdfjs-dist@4.4.168: resolution: {integrity: sha512-MbkAjpwka/dMHaCfQ75RY1FXX3IewBVu6NGZOcxerRFlaBiIkZmUoR0jotX5VUzYZEXAGzSFtknWs5xRKliXPA==} engines: {node: '>=18'} @@ -7321,8 +7334,8 @@ packages: resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} engines: {node: '>=10'} - resolve@1.22.10: - resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} hasBin: true @@ -8429,15 +8442,15 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@antfu/eslint-config@5.4.1(@eslint-react/eslint-plugin@1.53.1(eslint@9.38.0(jiti@1.21.7))(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3))(@next/eslint-plugin-next@15.5.4)(@vue/compiler-sfc@3.5.17)(eslint-plugin-react-hooks@5.2.0(eslint@9.38.0(jiti@1.21.7)))(eslint-plugin-react-refresh@0.4.24(eslint@9.38.0(jiti@1.21.7)))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': + '@antfu/eslint-config@5.4.1(@eslint-react/eslint-plugin@1.53.1(eslint@9.38.0(jiti@1.21.7))(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3))(@next/eslint-plugin-next@15.5.4)(@vue/compiler-sfc@3.5.22)(eslint-plugin-react-hooks@5.2.0(eslint@9.38.0(jiti@1.21.7)))(eslint-plugin-react-refresh@0.4.24(eslint@9.38.0(jiti@1.21.7)))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 0.11.0 '@eslint-community/eslint-plugin-eslint-comments': 4.5.0(eslint@9.38.0(jiti@1.21.7)) - '@eslint/markdown': 7.4.0 + '@eslint/markdown': 7.4.1 '@stylistic/eslint-plugin': 5.5.0(eslint@9.38.0(jiti@1.21.7)) - '@typescript-eslint/eslint-plugin': 8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@vitest/eslint-plugin': 1.3.23(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) ansis: 4.2.0 cac: 6.7.14 @@ -8457,10 +8470,10 @@ snapshots: eslint-plugin-regexp: 2.10.0(eslint@9.38.0(jiti@1.21.7)) eslint-plugin-toml: 0.12.0(eslint@9.38.0(jiti@1.21.7)) eslint-plugin-unicorn: 61.0.2(eslint@9.38.0(jiti@1.21.7)) - eslint-plugin-unused-imports: 4.3.0(@typescript-eslint/eslint-plugin@8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7)) - eslint-plugin-vue: 10.5.1(@stylistic/eslint-plugin@5.5.0(eslint@9.38.0(jiti@1.21.7)))(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.38.0(jiti@1.21.7))) + eslint-plugin-unused-imports: 4.3.0(@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7)) + eslint-plugin-vue: 10.5.1(@stylistic/eslint-plugin@5.5.0(eslint@9.38.0(jiti@1.21.7)))(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.38.0(jiti@1.21.7))) eslint-plugin-yml: 1.19.0(eslint@9.38.0(jiti@1.21.7)) - eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.17)(eslint@9.38.0(jiti@1.21.7)) + eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.22)(eslint@9.38.0(jiti@1.21.7)) globals: 16.4.0 jsonc-eslint-parser: 2.4.1 local-pkg: 1.1.2 @@ -8569,7 +8582,7 @@ snapshots: '@babel/helper-plugin-utils': 7.27.1 debug: 4.4.3 lodash.debounce: 4.0.8 - resolve: 1.22.10 + resolve: 1.22.11 transitivePeerDependencies: - supports-color @@ -9408,17 +9421,16 @@ snapshots: '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 - optional: true '@discoveryjs/json-ext@0.5.7': {} - '@emnapi/core@1.5.0': + '@emnapi/core@1.6.0': dependencies: '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.5.0': + '@emnapi/runtime@1.6.0': dependencies: tslib: 2.8.1 optional: true @@ -9435,7 +9447,7 @@ snapshots: '@es-joy/jsdoccomment@0.50.2': dependencies: '@types/estree': 1.0.8 - '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/types': 8.46.2 comment-parser: 1.4.1 esquery: 1.6.0 jsdoc-type-pratt-parser: 4.1.0 @@ -9443,7 +9455,7 @@ snapshots: '@es-joy/jsdoccomment@0.58.0': dependencies: '@types/estree': 1.0.8 - '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/types': 8.46.2 comment-parser: 1.4.1 esquery: 1.6.0 jsdoc-type-pratt-parser: 5.4.0 @@ -9539,9 +9551,9 @@ snapshots: '@eslint-react/ast@1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-react/eff': 1.53.1 - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/typescript-estree': 8.46.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) string-ts: 2.2.1 ts-pattern: 5.8.0 transitivePeerDependencies: @@ -9556,10 +9568,10 @@ snapshots: '@eslint-react/kit': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/shared': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/var': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/type-utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/type-utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) birecord: 0.1.1 ts-pattern: 5.8.0 transitivePeerDependencies: @@ -9574,10 +9586,10 @@ snapshots: '@eslint-react/eff': 1.53.1 '@eslint-react/kit': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/shared': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/type-utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/type-utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint: 9.38.0(jiti@1.21.7) eslint-plugin-react-debug: 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint-plugin-react-dom: 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) @@ -9594,7 +9606,7 @@ snapshots: '@eslint-react/kit@1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-react/eff': 1.53.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) ts-pattern: 5.8.0 zod: 4.1.12 transitivePeerDependencies: @@ -9606,7 +9618,7 @@ snapshots: dependencies: '@eslint-react/eff': 1.53.1 '@eslint-react/kit': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) ts-pattern: 5.8.0 zod: 4.1.12 transitivePeerDependencies: @@ -9618,9 +9630,9 @@ snapshots: dependencies: '@eslint-react/ast': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/eff': 1.53.1 - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) string-ts: 2.2.1 ts-pattern: 5.8.0 transitivePeerDependencies: @@ -9670,10 +9682,10 @@ snapshots: '@eslint/js@9.38.0': {} - '@eslint/markdown@7.4.0': + '@eslint/markdown@7.4.1': dependencies: '@eslint/core': 0.16.0 - '@eslint/plugin-kit': 0.3.5 + '@eslint/plugin-kit': 0.3.4 github-slugger: 2.0.0 mdast-util-from-markdown: 2.0.2 mdast-util-frontmatter: 2.0.1 @@ -9691,11 +9703,6 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 - '@eslint/plugin-kit@0.3.5': - dependencies: - '@eslint/core': 0.15.2 - levn: 0.4.1 - '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -9926,12 +9933,12 @@ snapshots: '@img/sharp-wasm32@0.33.5': dependencies: - '@emnapi/runtime': 1.5.0 + '@emnapi/runtime': 1.6.0 optional: true '@img/sharp-wasm32@0.34.4': dependencies: - '@emnapi/runtime': 1.5.0 + '@emnapi/runtime': 1.6.0 optional: true '@img/sharp-win32-arm64@0.34.4': @@ -10164,7 +10171,6 @@ snapshots: dependencies: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - optional: true '@lexical/clipboard@0.36.2': dependencies: @@ -10425,17 +10431,17 @@ snapshots: dependencies: state-local: 1.0.7 - '@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + '@monaco-editor/react@4.7.0(monaco-editor@0.54.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@monaco-editor/loader': 1.5.0 - monaco-editor: 0.52.2 + monaco-editor: 0.54.0 react: 19.1.1 react-dom: 19.1.1(react@19.1.1) '@napi-rs/wasm-runtime@1.0.7': dependencies: - '@emnapi/core': 1.5.0 - '@emnapi/runtime': 1.5.0 + '@emnapi/core': 1.6.0 + '@emnapi/runtime': 1.6.0 '@tybys/wasm-util': 0.10.1 optional: true @@ -10588,63 +10594,63 @@ snapshots: dependencies: '@octokit/openapi-types': 25.1.0 - '@oxc-resolver/binding-android-arm-eabi@11.10.0': + '@oxc-resolver/binding-android-arm-eabi@11.11.0': optional: true - '@oxc-resolver/binding-android-arm64@11.10.0': + '@oxc-resolver/binding-android-arm64@11.11.0': optional: true - '@oxc-resolver/binding-darwin-arm64@11.10.0': + '@oxc-resolver/binding-darwin-arm64@11.11.0': optional: true - '@oxc-resolver/binding-darwin-x64@11.10.0': + '@oxc-resolver/binding-darwin-x64@11.11.0': optional: true - '@oxc-resolver/binding-freebsd-x64@11.10.0': + '@oxc-resolver/binding-freebsd-x64@11.11.0': optional: true - '@oxc-resolver/binding-linux-arm-gnueabihf@11.10.0': + '@oxc-resolver/binding-linux-arm-gnueabihf@11.11.0': optional: true - '@oxc-resolver/binding-linux-arm-musleabihf@11.10.0': + '@oxc-resolver/binding-linux-arm-musleabihf@11.11.0': optional: true - '@oxc-resolver/binding-linux-arm64-gnu@11.10.0': + '@oxc-resolver/binding-linux-arm64-gnu@11.11.0': optional: true - '@oxc-resolver/binding-linux-arm64-musl@11.10.0': + '@oxc-resolver/binding-linux-arm64-musl@11.11.0': optional: true - '@oxc-resolver/binding-linux-ppc64-gnu@11.10.0': + '@oxc-resolver/binding-linux-ppc64-gnu@11.11.0': optional: true - '@oxc-resolver/binding-linux-riscv64-gnu@11.10.0': + '@oxc-resolver/binding-linux-riscv64-gnu@11.11.0': optional: true - '@oxc-resolver/binding-linux-riscv64-musl@11.10.0': + '@oxc-resolver/binding-linux-riscv64-musl@11.11.0': optional: true - '@oxc-resolver/binding-linux-s390x-gnu@11.10.0': + '@oxc-resolver/binding-linux-s390x-gnu@11.11.0': optional: true - '@oxc-resolver/binding-linux-x64-gnu@11.10.0': + '@oxc-resolver/binding-linux-x64-gnu@11.11.0': optional: true - '@oxc-resolver/binding-linux-x64-musl@11.10.0': + '@oxc-resolver/binding-linux-x64-musl@11.11.0': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.10.0': + '@oxc-resolver/binding-wasm32-wasi@11.11.0': dependencies: '@napi-rs/wasm-runtime': 1.0.7 optional: true - '@oxc-resolver/binding-win32-arm64-msvc@11.10.0': + '@oxc-resolver/binding-win32-arm64-msvc@11.11.0': optional: true - '@oxc-resolver/binding-win32-ia32-msvc@11.10.0': + '@oxc-resolver/binding-win32-ia32-msvc@11.11.0': optional: true - '@oxc-resolver/binding-win32-x64-msvc@11.10.0': + '@oxc-resolver/binding-win32-x64-msvc@11.11.0': optional: true '@parcel/watcher-android-arm64@2.5.1': @@ -11026,7 +11032,7 @@ snapshots: builtin-modules: 3.3.0 deepmerge: 4.3.1 is-module: 1.0.0 - resolve: 1.22.10 + resolve: 1.22.11 rollup: 2.79.2 '@rollup/plugin-replace@2.4.2(rollup@2.79.2)': @@ -11232,7 +11238,7 @@ snapshots: react: 19.1.1 react-docgen: 7.1.1 react-dom: 19.1.1(react@19.1.1) - resolve: 1.22.10 + resolve: 1.22.11 semver: 7.7.3 storybook: 9.1.13(@testing-library/dom@10.4.1) tsconfig-paths: 4.2.0 @@ -11279,7 +11285,7 @@ snapshots: '@stylistic/eslint-plugin@5.5.0(eslint@9.38.0(jiti@1.21.7))': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0(jiti@1.21.7)) - '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/types': 8.46.2 eslint: 9.38.0(jiti@1.21.7) eslint-visitor-keys: 4.2.1 espree: 10.4.0 @@ -11395,17 +11401,13 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 - '@tsconfig/node10@1.0.11': - optional: true + '@tsconfig/node10@1.0.11': {} - '@tsconfig/node12@1.0.11': - optional: true + '@tsconfig/node12@1.0.11': {} - '@tsconfig/node14@1.0.3': - optional: true + '@tsconfig/node14@1.0.3': {} - '@tsconfig/node16@1.0.4': - optional: true + '@tsconfig/node16@1.0.4': {} '@tybys/wasm-util@0.10.1': dependencies: @@ -11442,9 +11444,10 @@ snapshots: '@types/node': 18.15.0 '@types/responselike': 1.0.3 - '@types/chai@5.2.2': + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 '@types/d3-array@3.2.2': {} @@ -11723,14 +11726,14 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/type-utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.46.1 + '@typescript-eslint/parser': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/type-utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.46.2 eslint: 9.38.0(jiti@1.21.7) graphemer: 1.4.0 ignore: 7.0.5 @@ -11740,41 +11743,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/typescript-estree': 8.46.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.46.1 + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.46.2 debug: 4.4.3 eslint: 9.38.0(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.46.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.46.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.46.1(typescript@5.9.3) - '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.46.1': + '@typescript-eslint/scope-manager@8.46.2': dependencies: - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/visitor-keys': 8.46.1 + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/visitor-keys': 8.46.2 - '@typescript-eslint/tsconfig-utils@8.46.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.46.2(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/typescript-estree': 8.46.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) debug: 4.4.3 eslint: 9.38.0(jiti@1.21.7) ts-api-utils: 2.1.0(typescript@5.9.3) @@ -11782,14 +11785,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.46.1': {} + '@typescript-eslint/types@8.46.2': {} - '@typescript-eslint/typescript-estree@8.46.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.46.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.46.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.46.1(typescript@5.9.3) - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/visitor-keys': 8.46.1 + '@typescript-eslint/project-service': 8.46.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/visitor-keys': 8.46.2 debug: 4.4.3 fast-glob: 3.3.3 is-glob: 4.0.3 @@ -11800,28 +11803,28 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/utils@8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0(jiti@1.21.7)) - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/typescript-estree': 8.46.1(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) eslint: 9.38.0(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.46.1': + '@typescript-eslint/visitor-keys@8.46.2': dependencies: - '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/types': 8.46.2 eslint-visitor-keys: 4.2.1 '@ungap/structured-clone@1.3.0': {} '@vitest/eslint-plugin@1.3.23(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint: 9.38.0(jiti@1.21.7) optionalDependencies: typescript: 5.9.3 @@ -11830,7 +11833,7 @@ snapshots: '@vitest/expect@3.2.4': dependencies: - '@types/chai': 5.2.2 + '@types/chai': 5.2.3 '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.3.3 @@ -11856,14 +11859,6 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vue/compiler-core@3.5.17': - dependencies: - '@babel/parser': 7.28.5 - '@vue/shared': 3.5.17 - entities: 4.5.0 - estree-walker: 2.0.2 - source-map-js: 1.2.1 - '@vue/compiler-core@3.5.22': dependencies: '@babel/parser': 7.28.4 @@ -11872,34 +11867,27 @@ snapshots: estree-walker: 2.0.2 source-map-js: 1.2.1 - '@vue/compiler-dom@3.5.17': - dependencies: - '@vue/compiler-core': 3.5.17 - '@vue/shared': 3.5.17 - '@vue/compiler-dom@3.5.22': dependencies: '@vue/compiler-core': 3.5.22 '@vue/shared': 3.5.22 - '@vue/compiler-sfc@3.5.17': + '@vue/compiler-sfc@3.5.22': dependencies: '@babel/parser': 7.28.5 - '@vue/compiler-core': 3.5.17 - '@vue/compiler-dom': 3.5.17 - '@vue/compiler-ssr': 3.5.17 - '@vue/shared': 3.5.17 + '@vue/compiler-core': 3.5.22 + '@vue/compiler-dom': 3.5.22 + '@vue/compiler-ssr': 3.5.22 + '@vue/shared': 3.5.22 estree-walker: 2.0.2 magic-string: 0.30.21 postcss: 8.5.6 source-map-js: 1.2.1 - '@vue/compiler-ssr@3.5.17': + '@vue/compiler-ssr@3.5.22': dependencies: - '@vue/compiler-dom': 3.5.17 - '@vue/shared': 3.5.17 - - '@vue/shared@3.5.17': {} + '@vue/compiler-dom': 3.5.22 + '@vue/shared': 3.5.22 '@vue/shared@3.5.22': {} @@ -12085,8 +12073,7 @@ snapshots: are-docs-informative@0.0.2: {} - arg@4.1.3: - optional: true + arg@4.1.3: {} arg@5.0.2: {} @@ -12710,8 +12697,11 @@ snapshots: - supports-color - ts-node - create-require@1.1.1: - optional: true + create-require@1.1.1: {} + + cron-parser@5.4.0: + dependencies: + luxon: 3.7.2 cross-env@10.1.0: dependencies: @@ -13033,8 +13023,7 @@ snapshots: diff-sequences@29.6.3: {} - diff@4.0.2: - optional: true + diff@4.0.2: {} diffie-hellman@5.0.3: dependencies: @@ -13074,6 +13063,8 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.1.7: {} + dompurify@3.3.0: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -13275,7 +13266,7 @@ snapshots: eslint-plugin-import-lite@0.3.0(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3): dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0(jiti@1.21.7)) - '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/types': 8.46.2 eslint: 9.38.0(jiti@1.21.7) optionalDependencies: typescript: 5.9.3 @@ -13335,8 +13326,8 @@ snapshots: eslint-plugin-perfectionist@4.15.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3): dependencies: - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint: 9.38.0(jiti@1.21.7) natural-orderby: 5.0.0 transitivePeerDependencies: @@ -13361,10 +13352,10 @@ snapshots: '@eslint-react/kit': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/shared': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/var': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/type-utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/type-utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint: 9.38.0(jiti@1.21.7) string-ts: 2.2.1 ts-pattern: 5.8.0 @@ -13381,9 +13372,9 @@ snapshots: '@eslint-react/kit': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/shared': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/var': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) compare-versions: 6.1.1 eslint: 9.38.0(jiti@1.21.7) string-ts: 2.2.1 @@ -13401,10 +13392,10 @@ snapshots: '@eslint-react/kit': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/shared': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/var': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/type-utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/type-utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint: 9.38.0(jiti@1.21.7) string-ts: 2.2.1 ts-pattern: 5.8.0 @@ -13425,10 +13416,10 @@ snapshots: '@eslint-react/kit': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/shared': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/var': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/type-utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/type-utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint: 9.38.0(jiti@1.21.7) string-ts: 2.2.1 ts-pattern: 5.8.0 @@ -13449,9 +13440,9 @@ snapshots: '@eslint-react/kit': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/shared': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/var': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint: 9.38.0(jiti@1.21.7) string-ts: 2.2.1 ts-pattern: 5.8.0 @@ -13468,10 +13459,10 @@ snapshots: '@eslint-react/kit': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/shared': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/var': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/type-utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/type-utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) compare-versions: 6.1.1 eslint: 9.38.0(jiti@1.21.7) is-immutable-type: 5.0.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) @@ -13510,7 +13501,7 @@ snapshots: eslint-plugin-storybook@9.1.13(eslint@9.38.0(jiti@1.21.7))(storybook@9.1.13(@testing-library/dom@10.4.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint: 9.38.0(jiti@1.21.7) storybook: 9.1.13(@testing-library/dom@10.4.1) transitivePeerDependencies: @@ -13555,13 +13546,13 @@ snapshots: semver: 7.7.3 strip-indent: 4.1.1 - eslint-plugin-unused-imports@4.3.0(@typescript-eslint/eslint-plugin@8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7)): + eslint-plugin-unused-imports@4.3.0(@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7)): dependencies: eslint: 9.38.0(jiti@1.21.7) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - eslint-plugin-vue@10.5.1(@stylistic/eslint-plugin@5.5.0(eslint@9.38.0(jiti@1.21.7)))(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.38.0(jiti@1.21.7))): + eslint-plugin-vue@10.5.1(@stylistic/eslint-plugin@5.5.0(eslint@9.38.0(jiti@1.21.7)))(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.38.0(jiti@1.21.7))): dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0(jiti@1.21.7)) eslint: 9.38.0(jiti@1.21.7) @@ -13573,7 +13564,7 @@ snapshots: xml-name-validator: 4.0.0 optionalDependencies: '@stylistic/eslint-plugin': 5.5.0(eslint@9.38.0(jiti@1.21.7)) - '@typescript-eslint/parser': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint-plugin-yml@1.19.0(eslint@9.38.0(jiti@1.21.7)): dependencies: @@ -13587,9 +13578,9 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.17)(eslint@9.38.0(jiti@1.21.7)): + eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.22)(eslint@9.38.0(jiti@1.21.7)): dependencies: - '@vue/compiler-sfc': 3.5.17 + '@vue/compiler-sfc': 3.5.22 eslint: 9.38.0(jiti@1.21.7) eslint-scope@5.1.1: @@ -13615,7 +13606,7 @@ snapshots: '@eslint/core': 0.16.0 '@eslint/eslintrc': 3.3.1 '@eslint/js': 9.38.0 - '@eslint/plugin-kit': 0.3.5 + '@eslint/plugin-kit': 0.3.4 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 @@ -14396,7 +14387,7 @@ snapshots: is-immutable-type@5.0.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3): dependencies: - '@typescript-eslint/type-utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint: 9.38.0(jiti@1.21.7) ts-api-utils: 2.1.0(typescript@5.9.3) ts-declaration-location: 1.0.7(typescript@5.9.3) @@ -14670,7 +14661,7 @@ snapshots: jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) jest-util: 29.7.0 jest-validate: 29.7.0 - resolve: 1.22.10 + resolve: 1.22.11 resolve.exports: 2.0.3 slash: 3.0.0 @@ -14888,7 +14879,7 @@ snapshots: kleur@3.0.3: {} - knip@5.66.1(@types/node@18.15.0)(typescript@5.9.3): + knip@5.66.2(@types/node@18.15.0)(typescript@5.9.3): dependencies: '@nodelib/fs.walk': 1.2.8 '@types/node': 18.15.0 @@ -14897,7 +14888,7 @@ snapshots: jiti: 2.6.1 js-yaml: 4.1.0 minimist: 1.2.8 - oxc-resolver: 11.10.0 + oxc-resolver: 11.11.0 picocolors: 1.1.1 picomatch: 4.0.3 smol-toml: 1.4.2 @@ -15044,6 +15035,8 @@ snapshots: dependencies: yallist: 3.1.1 + luxon@3.7.2: {} + lz-string@1.5.0: {} magic-string@0.25.9: @@ -15072,8 +15065,7 @@ snapshots: dependencies: semver: 7.7.3 - make-error@1.3.6: - optional: true + make-error@1.3.6: {} makeerror@1.0.12: dependencies: @@ -15083,6 +15075,8 @@ snapshots: markdown-table@3.0.4: {} + marked@14.0.0: {} + marked@15.0.12: {} md5.js@1.3.5: @@ -15664,7 +15658,10 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.1 - monaco-editor@0.52.2: {} + monaco-editor@0.54.0: + dependencies: + dompurify: 3.1.7 + marked: 14.0.0 mrmime@2.0.1: {} @@ -15849,27 +15846,27 @@ snapshots: os-browserify@0.3.0: {} - oxc-resolver@11.10.0: + oxc-resolver@11.11.0: optionalDependencies: - '@oxc-resolver/binding-android-arm-eabi': 11.10.0 - '@oxc-resolver/binding-android-arm64': 11.10.0 - '@oxc-resolver/binding-darwin-arm64': 11.10.0 - '@oxc-resolver/binding-darwin-x64': 11.10.0 - '@oxc-resolver/binding-freebsd-x64': 11.10.0 - '@oxc-resolver/binding-linux-arm-gnueabihf': 11.10.0 - '@oxc-resolver/binding-linux-arm-musleabihf': 11.10.0 - '@oxc-resolver/binding-linux-arm64-gnu': 11.10.0 - '@oxc-resolver/binding-linux-arm64-musl': 11.10.0 - '@oxc-resolver/binding-linux-ppc64-gnu': 11.10.0 - '@oxc-resolver/binding-linux-riscv64-gnu': 11.10.0 - '@oxc-resolver/binding-linux-riscv64-musl': 11.10.0 - '@oxc-resolver/binding-linux-s390x-gnu': 11.10.0 - '@oxc-resolver/binding-linux-x64-gnu': 11.10.0 - '@oxc-resolver/binding-linux-x64-musl': 11.10.0 - '@oxc-resolver/binding-wasm32-wasi': 11.10.0 - '@oxc-resolver/binding-win32-arm64-msvc': 11.10.0 - '@oxc-resolver/binding-win32-ia32-msvc': 11.10.0 - '@oxc-resolver/binding-win32-x64-msvc': 11.10.0 + '@oxc-resolver/binding-android-arm-eabi': 11.11.0 + '@oxc-resolver/binding-android-arm64': 11.11.0 + '@oxc-resolver/binding-darwin-arm64': 11.11.0 + '@oxc-resolver/binding-darwin-x64': 11.11.0 + '@oxc-resolver/binding-freebsd-x64': 11.11.0 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.11.0 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.11.0 + '@oxc-resolver/binding-linux-arm64-gnu': 11.11.0 + '@oxc-resolver/binding-linux-arm64-musl': 11.11.0 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.11.0 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.11.0 + '@oxc-resolver/binding-linux-riscv64-musl': 11.11.0 + '@oxc-resolver/binding-linux-s390x-gnu': 11.11.0 + '@oxc-resolver/binding-linux-x64-gnu': 11.11.0 + '@oxc-resolver/binding-linux-x64-musl': 11.11.0 + '@oxc-resolver/binding-wasm32-wasi': 11.11.0 + '@oxc-resolver/binding-win32-arm64-msvc': 11.11.0 + '@oxc-resolver/binding-win32-ia32-msvc': 11.11.0 + '@oxc-resolver/binding-win32-x64-msvc': 11.11.0 p-cancelable@2.1.1: {} @@ -15923,7 +15920,7 @@ snapshots: asn1.js: 4.10.1 browserify-aes: 1.2.0 evp_bytestokey: 1.0.3 - pbkdf2: 3.1.3 + pbkdf2: 3.1.5 safe-buffer: 5.2.1 parse-entities@2.0.0: @@ -16010,6 +16007,15 @@ snapshots: sha.js: 2.4.12 to-buffer: 1.2.2 + pbkdf2@3.1.5: + dependencies: + create-hash: 1.2.0 + create-hmac: 1.1.7 + ripemd160: 2.0.3 + safe-buffer: 5.2.1 + sha.js: 2.4.12 + to-buffer: 1.2.2 + pdfjs-dist@4.4.168: optionalDependencies: canvas: 3.2.0 @@ -16082,7 +16088,7 @@ snapshots: postcss: 8.5.6 postcss-value-parser: 4.2.0 read-cache: 1.0.0 - resolve: 1.22.10 + resolve: 1.22.11 postcss-js@4.1.0(postcss@8.5.6): dependencies: @@ -16304,7 +16310,7 @@ snapshots: '@types/doctrine': 0.0.9 '@types/resolve': 1.20.6 doctrine: 3.0.0 - resolve: 1.22.10 + resolve: 1.22.11 strip-indent: 4.1.1 transitivePeerDependencies: - supports-color @@ -16727,7 +16733,7 @@ snapshots: resolve.exports@2.0.3: {} - resolve@1.22.10: + resolve@1.22.11: dependencies: is-core-module: '@nolyfill/is-core-module@1.0.39' path-parse: 1.0.7 @@ -17195,7 +17201,7 @@ snapshots: postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(yaml@2.8.1) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 - resolve: 1.22.10 + resolve: 1.22.11 sucrase: 3.35.0 transitivePeerDependencies: - tsx @@ -17347,7 +17353,6 @@ snapshots: typescript: 5.9.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - optional: true ts-pattern@5.8.0: {} @@ -17550,8 +17555,7 @@ snapshots: uuid@11.1.0: {} - v8-compile-cache-lib@3.0.1: - optional: true + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.3.0: dependencies: @@ -17899,8 +17903,7 @@ snapshots: dependencies: lib0: 0.2.114 - yn@3.1.1: - optional: true + yn@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/web/service/apps.ts b/web/service/apps.ts index 5602f75791..b1124767ad 100644 --- a/web/service/apps.ts +++ b/web/service/apps.ts @@ -1,8 +1,8 @@ import type { Fetcher } from 'swr' import { del, get, patch, post, put } from './base' -import type { ApiKeysListResponse, AppDailyConversationsResponse, AppDailyEndUsersResponse, AppDailyMessagesResponse, AppDetailResponse, AppListResponse, AppStatisticsResponse, AppTemplatesResponse, AppTokenCostsResponse, AppVoicesListResponse, CreateApiKeyResponse, DSLImportMode, DSLImportResponse, GenerationIntroductionResponse, TracingConfig, TracingStatus, UpdateAppModelConfigResponse, UpdateAppSiteCodeResponse, UpdateOpenAIKeyResponse, ValidateOpenAIKeyResponse, WorkflowDailyConversationsResponse } from '@/models/app' +import type { ApiKeysListResponse, AppDailyConversationsResponse, AppDailyEndUsersResponse, AppDailyMessagesResponse, AppDetailResponse, AppListResponse, AppStatisticsResponse, AppTemplatesResponse, AppTokenCostsResponse, AppVoicesListResponse, CreateApiKeyResponse, DSLImportMode, DSLImportResponse, GenerationIntroductionResponse, TracingConfig, TracingStatus, UpdateAppModelConfigResponse, UpdateAppSiteCodeResponse, UpdateOpenAIKeyResponse, ValidateOpenAIKeyResponse, WebhookTriggerResponse, WorkflowDailyConversationsResponse } from '@/models/app' import type { CommonResponse } from '@/models/common' -import type { AppIconType, AppMode, ModelConfig } from '@/types/app' +import type { AppIconType, AppModeEnum, ModelConfig } from '@/types/app' import type { TracingProvider } from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type' export const fetchAppList: Fetcher<AppListResponse, { url: string; params?: Record<string, any> }> = ({ url, params }) => { @@ -22,7 +22,7 @@ export const fetchAppTemplates: Fetcher<AppTemplatesResponse, { url: string }> = return get<AppTemplatesResponse>(url) } -export const createApp: Fetcher<AppDetailResponse, { name: string; icon_type?: AppIconType; icon?: string; icon_background?: string; mode: AppMode; description?: string; config?: ModelConfig }> = ({ name, icon_type, icon, icon_background, mode, description, config }) => { +export const createApp: Fetcher<AppDetailResponse, { name: string; icon_type?: AppIconType; icon?: string; icon_background?: string; mode: AppModeEnum; description?: string; config?: ModelConfig }> = ({ name, icon_type, icon, icon_background, mode, description, config }) => { return post<AppDetailResponse>('apps', { body: { name, icon_type, icon, icon_background, mode, description, model_config: config } }) } @@ -31,7 +31,7 @@ export const updateAppInfo: Fetcher<AppDetailResponse, { appID: string; name: st return put<AppDetailResponse>(`apps/${appID}`, { body }) } -export const copyApp: Fetcher<AppDetailResponse, { appID: string; name: string; icon_type: AppIconType; icon: string; icon_background?: string | null; mode: AppMode; description?: string }> = ({ appID, name, icon_type, icon, icon_background, mode, description }) => { +export const copyApp: Fetcher<AppDetailResponse, { appID: string; name: string; icon_type: AppIconType; icon: string; icon_background?: string | null; mode: AppModeEnum; description?: string }> = ({ appID, name, icon_type, icon, icon_background, mode, description }) => { return post<AppDetailResponse>(`apps/${appID}/copy`, { body: { name, icon_type, icon, icon_background, mode, description } }) } @@ -162,6 +162,11 @@ export const updateTracingStatus: Fetcher<CommonResponse, { appId: string; body: return post(`/apps/${appId}/trace`, { body }) } +// Webhook Trigger +export const fetchWebhookUrl: Fetcher<WebhookTriggerResponse, { appId: string; nodeId: string }> = ({ appId, nodeId }) => { + return get<WebhookTriggerResponse>(`apps/${appId}/workflows/triggers/webhook`, { params: { node_id: nodeId } }) +} + export const fetchTracingConfig: Fetcher<TracingConfig & { has_not_configured: true }, { appId: string; provider: TracingProvider }> = ({ appId, provider }) => { return get(`/apps/${appId}/trace-config`, { params: { diff --git a/web/service/base.ts b/web/service/base.ts index ccb48bd46b..e966fa74aa 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -155,7 +155,7 @@ export function format(text: string) { return res.replaceAll('\n', '<br/>').replaceAll('```', '') } -const handleStream = ( +export const handleStream = ( response: Response, onData: IOnData, onCompleted?: IOnCompleted, diff --git a/web/service/debug.ts b/web/service/debug.ts index fab2910c5e..3f3abda2d2 100644 --- a/web/service/debug.ts +++ b/web/service/debug.ts @@ -1,8 +1,9 @@ import { get, post, ssePost } from './base' import type { IOnCompleted, IOnData, IOnError, IOnFile, IOnMessageEnd, IOnMessageReplace, IOnThought } from './base' import type { ChatPromptConfig, CompletionPromptConfig } from '@/models/debug' -import type { ModelModeType } from '@/types/app' +import type { AppModeEnum, ModelModeType } from '@/types/app' import type { ModelParameterRule } from '@/app/components/header/account-setting/model-provider-page/declarations' + export type BasicAppFirstRes = { prompt: string variables: string[] @@ -105,7 +106,7 @@ export const fetchPromptTemplate = ({ mode, modelName, hasSetDataSet, -}: { appMode: string; mode: ModelModeType; modelName: string; hasSetDataSet: boolean }) => { +}: { appMode: AppModeEnum; mode: ModelModeType; modelName: string; hasSetDataSet: boolean }) => { return get<Promise<{ chat_prompt_config: ChatPromptConfig; completion_prompt_config: CompletionPromptConfig; stop: [] }>>('/app/prompt-templates', { params: { app_mode: appMode, diff --git a/web/service/demo/index.tsx b/web/service/demo/index.tsx index aa02968549..5cbfa7c52a 100644 --- a/web/service/demo/index.tsx +++ b/web/service/demo/index.tsx @@ -4,6 +4,8 @@ import React from 'react' import useSWR, { useSWRConfig } from 'swr' import { createApp, fetchAppDetail, fetchAppList, getAppDailyConversations, getAppDailyEndUsers, updateAppApiStatus, updateAppModelConfig, updateAppRateLimit, updateAppSiteAccessToken, updateAppSiteConfig, updateAppSiteStatus } from '../apps' import Loading from '@/app/components/base/loading' +import { AppModeEnum } from '@/types/app' + const Service: FC = () => { const { data: appList, error: appListError } = useSWR({ url: '/apps', params: { page: 1 } }, fetchAppList) const { data: firstApp, error: appDetailError } = useSWR({ url: '/apps', id: '1' }, fetchAppDetail) @@ -21,7 +23,7 @@ const Service: FC = () => { const handleCreateApp = async () => { await createApp({ name: `new app${Math.round(Math.random() * 100)}`, - mode: 'chat', + mode: AppModeEnum.CHAT, }) // reload app list mutate({ url: '/apps', params: { page: 1 } }) diff --git a/web/service/fetch.ts b/web/service/fetch.ts index 8d663c902b..030549bdab 100644 --- a/web/service/fetch.ts +++ b/web/service/fetch.ts @@ -2,7 +2,7 @@ import type { AfterResponseHook, BeforeErrorHook, BeforeRequestHook, Hooks } fro import ky from 'ky' import type { IOtherOptions } from './base' import Toast from '@/app/components/base/toast' -import { API_PREFIX, APP_VERSION, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, MARKETPLACE_API_PREFIX, PASSPORT_HEADER_NAME, PUBLIC_API_PREFIX, WEB_APP_SHARE_CODE_HEADER_NAME } from '@/config' +import { API_PREFIX, APP_VERSION, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, IS_MARKETPLACE, MARKETPLACE_API_PREFIX, PASSPORT_HEADER_NAME, PUBLIC_API_PREFIX, WEB_APP_SHARE_CODE_HEADER_NAME } from '@/config' import Cookies from 'js-cookie' import { getWebAppAccessToken, getWebAppPassport } from './webapp-auth' @@ -160,7 +160,7 @@ async function base<T>(url: string, options: FetchOptionType = {}, otherOptions: // ! For Marketplace API, help to filter tags added in new version if (isMarketplaceAPI) - (headers as any).set('X-Dify-Version', APP_VERSION) + (headers as any).set('X-Dify-Version', !IS_MARKETPLACE ? APP_VERSION : '999.0.0') const client = baseClient.extend({ hooks: { diff --git a/web/service/share.ts b/web/service/share.ts index b19dbc896d..df08f0f3d6 100644 --- a/web/service/share.ts +++ b/web/service/share.ts @@ -295,7 +295,8 @@ export const fetchAccessToken = async ({ userId, appCode }: { userId?: string, a if (accessToken) headers.append('Authorization', `Bearer ${accessToken}`) const params = new URLSearchParams() - userId && params.append('user_id', userId) + if (userId) + params.append('user_id', userId) const url = `/passport?${params.toString()}` return get<{ access_token: string }>(url, { headers }) as Promise<{ access_token: string }> } diff --git a/web/service/use-base.ts b/web/service/use-base.ts index 37af55a74a..b6445f4baf 100644 --- a/web/service/use-base.ts +++ b/web/service/use-base.ts @@ -3,9 +3,11 @@ import { useQueryClient, } from '@tanstack/react-query' -export const useInvalid = (key: QueryKey) => { +export const useInvalid = (key?: QueryKey) => { const queryClient = useQueryClient() return () => { + if (!key) + return queryClient.invalidateQueries( { queryKey: key, @@ -14,9 +16,11 @@ export const useInvalid = (key: QueryKey) => { } } -export const useReset = (key: QueryKey) => { +export const useReset = (key?: QueryKey) => { const queryClient = useQueryClient() return () => { + if (!key) + return queryClient.resetQueries( { queryKey: key, diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index 5d2bc080d3..f6dbecaeba 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -19,7 +19,6 @@ import type { PluginDetail, PluginInfoFromMarketPlace, PluginTask, - PluginType, PluginsFromMarketplaceByInfoResponse, PluginsFromMarketplaceResponse, ReferenceSetting, @@ -28,7 +27,7 @@ import type { uploadGitHubResponse, } from '@/app/components/plugins/types' import { TaskStatus } from '@/app/components/plugins/types' -import { PluginType as PluginTypeEnum } from '@/app/components/plugins/types' +import { PluginCategoryEnum } from '@/app/components/plugins/types' import type { PluginsSearchParams, } from '@/app/components/plugins/marketplace/types' @@ -45,6 +44,7 @@ import useReferenceSetting from '@/app/components/plugins/plugin-page/use-refere import { uninstallPlugin } from '@/service/plugins' import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' import { cloneDeep } from 'lodash-es' +import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils' const NAME_SPACE = 'plugins' @@ -68,6 +68,66 @@ export const useCheckInstalled = ({ }) } +const useRecommendedMarketplacePluginsKey = [NAME_SPACE, 'recommendedMarketplacePlugins'] +export const useRecommendedMarketplacePlugins = ({ + collection = '__recommended-plugins-tools', + enabled = true, + limit = 15, +}: { + collection?: string + enabled?: boolean + limit?: number +} = {}) => { + return useQuery<Plugin[]>({ + queryKey: [...useRecommendedMarketplacePluginsKey, collection, limit], + queryFn: async () => { + const response = await postMarketplace<{ data: { plugins: Plugin[] } }>( + `/collections/${collection}/plugins`, + { + body: { + limit, + }, + }, + ) + return response.data.plugins.map(plugin => getFormattedPlugin(plugin)) + }, + enabled, + staleTime: 60 * 1000, + }) +} + +export const useFeaturedToolsRecommendations = (enabled: boolean, limit = 15) => { + const { + data: plugins = [], + isLoading, + } = useRecommendedMarketplacePlugins({ + collection: '__recommended-plugins-tools', + enabled, + limit, + }) + + return { + plugins, + isLoading, + } +} + +export const useFeaturedTriggersRecommendations = (enabled: boolean, limit = 15) => { + const { + data: plugins = [], + isLoading, + } = useRecommendedMarketplacePlugins({ + collection: '__recommended-plugins-triggers', + enabled, + limit, + }) + + return { + plugins, + isLoading, + } +} + export const useInstalledPluginList = (disable?: boolean, pageSize = 100) => { const fetchPlugins = async ({ pageParam = 1 }) => { const response = await get<InstalledPluginListWithTotalResponse>( @@ -518,7 +578,7 @@ export const useFetchPluginsInMarketPlaceByInfo = (infos: Record<string, any>[]) } const usePluginTaskListKey = [NAME_SPACE, 'pluginTaskList'] -export const usePluginTaskList = (category?: PluginType) => { +export const usePluginTaskList = (category?: PluginCategoryEnum | string) => { const [initialized, setInitialized] = useState(false) const { canManagement, @@ -544,20 +604,20 @@ export const usePluginTaskList = (category?: PluginType) => { useEffect(() => { // After first fetch, refresh plugin list each time all tasks are done // Skip initialization period, because the query cache is not updated yet - if (initialized && !isRefetching) { - const lastData = cloneDeep(data) - const taskDone = lastData?.tasks.every(task => task.status === TaskStatus.success || task.status === TaskStatus.failed) - const taskAllFailed = lastData?.tasks.every(task => task.status === TaskStatus.failed) - if (taskDone) { - if (lastData?.tasks.length && !taskAllFailed) - refreshPluginList(category ? { category } as any : undefined, !category) - } - } - }, [isRefetching]) + if (!initialized || isRefetching) + return + + const lastData = cloneDeep(data) + const taskDone = lastData?.tasks.every(task => task.status === TaskStatus.success || task.status === TaskStatus.failed) + const taskAllFailed = lastData?.tasks.every(task => task.status === TaskStatus.failed) + if (taskDone && lastData?.tasks.length && !taskAllFailed) + refreshPluginList(category ? { category } as any : undefined, !category) + }, [initialized, isRefetching, data, category, refreshPluginList]) useEffect(() => { - setInitialized(true) - }, []) + if (isFetched && !initialized) + setInitialized(true) + }, [isFetched, initialized]) const handleRefetch = useCallback(() => { refetch() @@ -641,7 +701,7 @@ export const usePluginInfo = (providerName?: string) => { const name = parts[1] try { const response = await fetchPluginInfoFromMarketPlace({ org, name }) - return response.data.plugin.category === PluginTypeEnum.model ? response.data.plugin : null + return response.data.plugin.category === PluginCategoryEnum.model ? response.data.plugin : null } catch { return null @@ -651,7 +711,7 @@ export const usePluginInfo = (providerName?: string) => { }) } -export const useFetchDynamicOptions = (plugin_id: string, provider: string, action: string, parameter: string, provider_type: 'tool') => { +export const useFetchDynamicOptions = (plugin_id: string, provider: string, action: string, parameter: string, provider_type?: string, extra?: Record<string, any>) => { return useMutation({ mutationFn: () => get<{ options: FormOption[] }>('/workspaces/current/plugin/parameters/dynamic-options', { params: { @@ -660,7 +720,26 @@ export const useFetchDynamicOptions = (plugin_id: string, provider: string, acti action, parameter, provider_type, + ...extra, }, }), }) } + +export const usePluginReadme = ({ plugin_unique_identifier, language }: { plugin_unique_identifier: string, language?: string }) => { + return useQuery({ + queryKey: ['pluginReadme', plugin_unique_identifier, language], + queryFn: () => get<{ readme: string }>('/workspaces/current/plugin/readme', { params: { plugin_unique_identifier, language } }, { silent: true }), + enabled: !!plugin_unique_identifier, + retry: 0, + }) +} + +export const usePluginReadmeAsset = ({ file_name, plugin_unique_identifier }: { file_name?: string, plugin_unique_identifier?: string }) => { + const normalizedFileName = file_name?.replace(/(^\.\/_assets\/|^_assets\/)/, '') + return useQuery({ + queryKey: ['pluginReadmeAsset', plugin_unique_identifier, file_name], + queryFn: () => get<Blob>('/workspaces/current/plugin/asset', { params: { plugin_unique_identifier, file_name: normalizedFileName } }, { silent: true }), + enabled: !!plugin_unique_identifier && !!file_name && /(^\.\/_assets|^_assets)/.test(file_name), + }) +} diff --git a/web/service/use-tools.ts b/web/service/use-tools.ts index 306cb903df..ad483bea11 100644 --- a/web/service/use-tools.ts +++ b/web/service/use-tools.ts @@ -84,8 +84,9 @@ const useInvalidToolsKeyMap: Record<string, QueryKey> = { [CollectionType.workflow]: useAllWorkflowToolsKey, [CollectionType.mcp]: useAllMCPToolsKey, } -export const useInvalidToolsByType = (type: CollectionType | string) => { - return useInvalid(useInvalidToolsKeyMap[type]) +export const useInvalidToolsByType = (type?: CollectionType | string) => { + const queryKey = type ? useInvalidToolsKeyMap[type] : undefined + return useInvalid(queryKey) } export const useCreateMCP = () => { @@ -339,3 +340,53 @@ export const useRAGRecommendedPlugins = () => { export const useInvalidateRAGRecommendedPlugins = () => { return useInvalid(useRAGRecommendedPluginListKey) } + +// App Triggers API hooks +export type AppTrigger = { + id: string + trigger_type: 'trigger-webhook' | 'trigger-schedule' | 'trigger-plugin' + title: string + node_id: string + provider_name: string + icon: string + status: 'enabled' | 'disabled' | 'unauthorized' + created_at: string + updated_at: string +} + +export const useAppTriggers = (appId: string | undefined, options?: any) => { + return useQuery<{ data: AppTrigger[] }>({ + queryKey: [NAME_SPACE, 'app-triggers', appId], + queryFn: () => get<{ data: AppTrigger[] }>(`/apps/${appId}/triggers`), + enabled: !!appId, + ...options, // Merge additional options while maintaining backward compatibility + }) +} + +export const useInvalidateAppTriggers = () => { + const queryClient = useQueryClient() + return (appId: string) => { + queryClient.invalidateQueries({ + queryKey: [NAME_SPACE, 'app-triggers', appId], + }) + } +} + +export const useUpdateTriggerStatus = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'update-trigger-status'], + mutationFn: (payload: { + appId: string + triggerId: string + enableTrigger: boolean + }) => { + const { appId, triggerId, enableTrigger } = payload + return post<AppTrigger>(`/apps/${appId}/trigger-enable`, { + body: { + trigger_id: triggerId, + enable_trigger: enableTrigger, + }, + }) + }, + }) +} diff --git a/web/service/use-triggers.ts b/web/service/use-triggers.ts new file mode 100644 index 0000000000..cfb786e4a9 --- /dev/null +++ b/web/service/use-triggers.ts @@ -0,0 +1,320 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { del, get, post } from './base' +import type { + TriggerLogEntity, + TriggerOAuthClientParams, + TriggerOAuthConfig, + TriggerProviderApiEntity, + TriggerSubscription, + TriggerSubscriptionBuilder, + TriggerWithProvider, +} from '@/app/components/workflow/block-selector/types' +import { CollectionType } from '@/app/components/tools/types' +import { useInvalid } from './use-base' + +const NAME_SPACE = 'triggers' + +// Trigger Provider Service - Provider ID Format: plugin_id/provider_name + +// Convert backend API response to frontend ToolWithProvider format +const convertToTriggerWithProvider = (provider: TriggerProviderApiEntity): TriggerWithProvider => { + return { + // Collection fields + id: provider.plugin_id || provider.name, + name: provider.name, + author: provider.author, + description: provider.description, + icon: provider.icon || '', + label: provider.label, + type: CollectionType.trigger, + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: provider.tags || [], + plugin_id: provider.plugin_id, + plugin_unique_identifier: provider.plugin_unique_identifier || '', + events: provider.events.map(event => ({ + name: event.name, + author: provider.author, + label: event.identity.label, + description: event.description, + parameters: event.parameters.map(param => ({ + name: param.name, + label: param.label, + human_description: param.description || param.label, + type: param.type, + form: param.type, + llm_description: JSON.stringify(param.description || {}), + required: param.required || false, + default: param.default || '', + options: param.options?.map(option => ({ + label: option.label, + value: option.value, + })) || [], + multiple: param.multiple || false, + })), + labels: provider.tags || [], + output_schema: event.output_schema || {}, + })), + + // Trigger-specific schema fields + subscription_constructor: provider.subscription_constructor, + subscription_schema: provider.subscription_schema, + supported_creation_methods: provider.supported_creation_methods, + + meta: { + version: '1.0', + }, + } +} + +export const useAllTriggerPlugins = (enabled = true) => { + return useQuery<TriggerWithProvider[]>({ + queryKey: [NAME_SPACE, 'all'], + queryFn: async () => { + const response = await get<TriggerProviderApiEntity[]>('/workspaces/current/triggers') + return response.map(convertToTriggerWithProvider) + }, + enabled, + staleTime: 0, + gcTime: 0, + }) +} + +export const useTriggerPluginsByType = (triggerType: string, enabled = true) => { + return useQuery<TriggerWithProvider[]>({ + queryKey: [NAME_SPACE, 'byType', triggerType], + queryFn: async () => { + const response = await get<TriggerProviderApiEntity[]>(`/workspaces/current/triggers?type=${triggerType}`) + return response.map(convertToTriggerWithProvider) + }, + enabled: enabled && !!triggerType, + }) +} + +export const useInvalidateAllTriggerPlugins = () => { + return useInvalid([NAME_SPACE, 'all']) +} + +// ===== Trigger Subscriptions Management ===== + +export const useTriggerProviderInfo = (provider: string, enabled = true) => { + return useQuery<TriggerProviderApiEntity>({ + queryKey: [NAME_SPACE, 'provider-info', provider], + queryFn: () => get<TriggerProviderApiEntity>(`/workspaces/current/trigger-provider/${provider}/info`), + enabled: enabled && !!provider, + staleTime: 0, + gcTime: 0, + }) +} + +export const useTriggerSubscriptions = (provider: string, enabled = true) => { + return useQuery<TriggerSubscription[]>({ + queryKey: [NAME_SPACE, 'list-subscriptions', provider], + queryFn: () => get<TriggerSubscription[]>(`/workspaces/current/trigger-provider/${provider}/subscriptions/list`), + enabled: enabled && !!provider, + }) +} + +export const useInvalidateTriggerSubscriptions = () => { + const queryClient = useQueryClient() + return (provider: string) => { + queryClient.invalidateQueries({ + queryKey: [NAME_SPACE, 'subscriptions', provider], + }) + } +} + +export const useCreateTriggerSubscriptionBuilder = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'create-subscription-builder'], + mutationFn: (payload: { + provider: string + credential_type?: string + }) => { + const { provider, ...body } = payload + return post<{ subscription_builder: TriggerSubscriptionBuilder }>( + `/workspaces/current/trigger-provider/${provider}/subscriptions/builder/create`, + { body }, + ) + }, + }) +} + +export const useUpdateTriggerSubscriptionBuilder = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'update-subscription-builder'], + mutationFn: (payload: { + provider: string + subscriptionBuilderId: string + name?: string + properties?: Record<string, any> + parameters?: Record<string, any> + credentials?: Record<string, any> + }) => { + const { provider, subscriptionBuilderId, ...body } = payload + return post<TriggerSubscriptionBuilder>( + `/workspaces/current/trigger-provider/${provider}/subscriptions/builder/update/${subscriptionBuilderId}`, + { body }, + ) + }, + }) +} + +export const useVerifyTriggerSubscriptionBuilder = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'verify-subscription-builder'], + mutationFn: (payload: { + provider: string + subscriptionBuilderId: string + credentials?: Record<string, any> + }) => { + const { provider, subscriptionBuilderId, ...body } = payload + return post<{ verified: boolean }>( + `/workspaces/current/trigger-provider/${provider}/subscriptions/builder/verify/${subscriptionBuilderId}`, + { body }, + { silent: true }, + ) + }, + }) +} + +export type BuildTriggerSubscriptionPayload = { + provider: string + subscriptionBuilderId: string + name?: string + parameters?: Record<string, any> +} + +export const useBuildTriggerSubscription = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'build-subscription'], + mutationFn: (payload: BuildTriggerSubscriptionPayload) => { + const { provider, subscriptionBuilderId, ...body } = payload + return post( + `/workspaces/current/trigger-provider/${provider}/subscriptions/builder/build/${subscriptionBuilderId}`, + { body }, + ) + }, + }) +} + +export const useDeleteTriggerSubscription = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'delete-subscription'], + mutationFn: (subscriptionId: string) => { + return post<{ result: string }>( + `/workspaces/current/trigger-provider/${subscriptionId}/subscriptions/delete`, + ) + }, + }) +} + +export const useTriggerSubscriptionBuilderLogs = ( + provider: string, + subscriptionBuilderId: string, + options: { + enabled?: boolean + refetchInterval?: number | false + } = {}, +) => { + const { enabled = true, refetchInterval = false } = options + + return useQuery<{ logs: TriggerLogEntity[] }>({ + queryKey: [NAME_SPACE, 'subscription-builder-logs', provider, subscriptionBuilderId], + queryFn: () => get( + `/workspaces/current/trigger-provider/${provider}/subscriptions/builder/logs/${subscriptionBuilderId}`, + ), + enabled: enabled && !!provider && !!subscriptionBuilderId, + refetchInterval, + }) +} + +// ===== OAuth Management ===== +export const useTriggerOAuthConfig = (provider: string, enabled = true) => { + return useQuery<TriggerOAuthConfig>({ + queryKey: [NAME_SPACE, 'oauth-config', provider], + queryFn: () => get<TriggerOAuthConfig>(`/workspaces/current/trigger-provider/${provider}/oauth/client`), + enabled: enabled && !!provider, + }) +} + +export type ConfigureTriggerOAuthPayload = { + provider: string + client_params?: TriggerOAuthClientParams + enabled: boolean +} + +export const useConfigureTriggerOAuth = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'configure-oauth'], + mutationFn: (payload: ConfigureTriggerOAuthPayload) => { + const { provider, ...body } = payload + return post<{ result: string }>( + `/workspaces/current/trigger-provider/${provider}/oauth/client`, + { body }, + ) + }, + }) +} + +export const useDeleteTriggerOAuth = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'delete-oauth'], + mutationFn: (provider: string) => { + return del<{ result: string }>( + `/workspaces/current/trigger-provider/${provider}/oauth/client`, + ) + }, + }) +} + +export const useInitiateTriggerOAuth = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'initiate-oauth'], + mutationFn: (provider: string) => { + return get<{ authorization_url: string; subscription_builder: TriggerSubscriptionBuilder }>( + `/workspaces/current/trigger-provider/${provider}/subscriptions/oauth/authorize`, + {}, + { silent: true }, + ) + }, + }) +} + +// ===== Dynamic Options Support ===== +export const useTriggerPluginDynamicOptions = (payload: { + plugin_id: string + provider: string + action: string + parameter: string + credential_id: string + extra?: Record<string, any> +}, enabled = true) => { + return useQuery<{ options: Array<{ value: string; label: any }> }>({ + queryKey: [NAME_SPACE, 'dynamic-options', payload.plugin_id, payload.provider, payload.action, payload.parameter, payload.credential_id, payload.extra], + queryFn: () => get<{ options: Array<{ value: string; label: any }> }>( + '/workspaces/current/plugin/parameters/dynamic-options', + { + params: { + ...payload, + provider_type: 'trigger', // Add required provider_type parameter + }, + }, + { silent: true }, + ), + enabled: enabled && !!payload.plugin_id && !!payload.provider && !!payload.action && !!payload.parameter && !!payload.credential_id, + retry: 0, + }) +} + +// ===== Cache Invalidation Helpers ===== + +export const useInvalidateTriggerOAuthConfig = () => { + const queryClient = useQueryClient() + return (provider: string) => { + queryClient.invalidateQueries({ + queryKey: [NAME_SPACE, 'oauth-config', provider], + }) + } +} diff --git a/web/service/workflow-payload.ts b/web/service/workflow-payload.ts new file mode 100644 index 0000000000..b80c4a3731 --- /dev/null +++ b/web/service/workflow-payload.ts @@ -0,0 +1,152 @@ +import { produce } from 'immer' +import type { Edge, Node } from '@/app/components/workflow/types' +import { BlockEnum } from '@/app/components/workflow/types' +import type { PluginTriggerNodeType } from '@/app/components/workflow/nodes/trigger-plugin/types' +import type { FetchWorkflowDraftResponse } from '@/types/workflow' + +export type TriggerPluginNodePayload = { + title: string + desc: string + plugin_id: string + provider_id: string + event_name: string + subscription_id: string + plugin_unique_identifier: string + event_parameters: Record<string, unknown> +} + +export type WorkflowDraftSyncParams = Pick< + FetchWorkflowDraftResponse, + 'graph' | 'features' | 'environment_variables' | 'conversation_variables' +> + +const removeTempProperties = (data: Record<string, unknown>): void => { + Object.keys(data).forEach((key) => { + if (key.startsWith('_')) + delete data[key] + }) +} + +type TriggerParameterSchema = Record<string, unknown> + +type TriggerPluginHydratePayload = (PluginTriggerNodeType & { + paramSchemas?: TriggerParameterSchema[] + parameters_schema?: TriggerParameterSchema[] +}) + +const sanitizeTriggerPluginNode = (node: Node<TriggerPluginNodePayload>): Node<TriggerPluginNodePayload> => { + const data = node.data + + if (!data || data.type !== BlockEnum.TriggerPlugin) + return node + + const sanitizedData: TriggerPluginNodePayload & { type: BlockEnum.TriggerPlugin } = { + type: BlockEnum.TriggerPlugin, + title: data.title ?? '', + desc: data.desc ?? '', + plugin_id: data.plugin_id ?? '', + provider_id: data.provider_id ?? '', + event_name: data.event_name ?? '', + subscription_id: data.subscription_id ?? '', + plugin_unique_identifier: data.plugin_unique_identifier ?? '', + event_parameters: (typeof data.event_parameters === 'object' && data.event_parameters !== null) + ? data.event_parameters as Record<string, unknown> + : {}, + } + + return { + ...node, + data: sanitizedData, + } +} + +export const sanitizeWorkflowDraftPayload = (params: WorkflowDraftSyncParams): WorkflowDraftSyncParams => { + const { graph } = params + + if (!graph?.nodes?.length) + return params + + const sanitizedNodes = graph.nodes.map(node => sanitizeTriggerPluginNode(node as Node<TriggerPluginNodePayload>)) + + return { + ...params, + graph: { + ...graph, + nodes: sanitizedNodes, + }, + } +} + +const isTriggerPluginNode = (node: Node): node is Node<TriggerPluginHydratePayload> => { + const data = node.data as unknown + + if (!data || typeof data !== 'object') + return false + + const payload = data as Partial<TriggerPluginHydratePayload> & { type?: BlockEnum } + + if (payload.type !== BlockEnum.TriggerPlugin) + return false + + return 'event_parameters' in payload +} + +const hydrateTriggerPluginNode = (node: Node): Node => { + if (!isTriggerPluginNode(node)) + return node + + const typedNode = node as Node<TriggerPluginHydratePayload> + const data = typedNode.data + const eventParameters = data.event_parameters ?? {} + const parametersSchema = data.parameters_schema ?? data.paramSchemas ?? [] + const config = data.config ?? eventParameters ?? {} + + const nextData: typeof data = { + ...data, + config, + paramSchemas: data.paramSchemas ?? parametersSchema, + parameters_schema: parametersSchema, + } + + return { + ...typedNode, + data: nextData, + } +} + +export const hydrateWorkflowDraftResponse = (draft: FetchWorkflowDraftResponse): FetchWorkflowDraftResponse => { + return produce(draft, (mutableDraft) => { + if (!mutableDraft?.graph) + return + + if (mutableDraft.graph.nodes) { + mutableDraft.graph.nodes = mutableDraft.graph.nodes + .filter((node: Node) => !node.data?._isTempNode) + .map((node: Node) => { + if (node.data) + removeTempProperties(node.data as Record<string, unknown>) + + return hydrateTriggerPluginNode(node) + }) + } + + if (mutableDraft.graph.edges) { + mutableDraft.graph.edges = mutableDraft.graph.edges + .filter((edge: Edge) => !edge.data?._isTemp) + .map((edge: Edge) => { + if (edge.data) + removeTempProperties(edge.data as Record<string, unknown>) + + return edge + }) + } + + if (mutableDraft.environment_variables) { + mutableDraft.environment_variables = mutableDraft.environment_variables.map(env => + env.value_type === 'secret' + ? { ...env, value: '[__HIDDEN__]' } + : env, + ) + } + }) +} diff --git a/web/themes/dark.css b/web/themes/dark.css index cd1a016f75..dae2add2b1 100644 --- a/web/themes/dark.css +++ b/web/themes/dark.css @@ -435,7 +435,7 @@ html[data-theme="dark"] { --color-workflow-block-bg: #27272b; --color-workflow-block-bg-transparent: rgb(39 39 43 / 0.96); --color-workflow-block-border-highlight: rgb(200 206 218 / 0.2); - --color-workflow-block-wrapper-bg-1: #27272b; + --color-workflow-block-wrapper-bg-1: #323236; --color-workflow-block-wrapper-bg-2: rgb(39 39 43 / 0.2); --color-workflow-canvas-workflow-dot-color: rgb(133 133 173 / 0.11); diff --git a/web/tsconfig.json b/web/tsconfig.json index 3b022e4708..1d03daa576 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -40,6 +40,11 @@ "app/components/develop/Prose.jsx" ], "exclude": [ - "node_modules" + "node_modules", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.spec.ts", + "**/*.spec.tsx", + "__tests__/**" ] } diff --git a/web/types/app.ts b/web/types/app.ts index 591bbf5e31..b7a7f6a48d 100644 --- a/web/types/app.ts +++ b/web/types/app.ts @@ -60,8 +60,14 @@ export type VariableInput = { /** * App modes */ -export const AppModes = ['advanced-chat', 'agent-chat', 'chat', 'completion', 'workflow'] as const -export type AppMode = typeof AppModes[number] +export enum AppModeEnum { + COMPLETION = 'completion', + WORKFLOW = 'workflow', + CHAT = 'chat', + ADVANCED_CHAT = 'advanced-chat', + AGENT_CHAT = 'agent-chat', +} +export const AppModes = [AppModeEnum.COMPLETION, AppModeEnum.WORKFLOW, AppModeEnum.CHAT, AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT] as const /** * Variable type @@ -339,7 +345,7 @@ export type App = { use_icon_as_answer_icon: boolean /** Mode */ - mode: AppMode + mode: AppModeEnum /** Enable web app */ enable_site: boolean /** Enable web API */ @@ -388,7 +394,7 @@ export type AppTemplate = { /** Description */ description: string /** Mode */ - mode: AppMode + mode: AppModeEnum /** Model */ model_config: ModelConfig } diff --git a/web/types/i18n.d.ts b/web/types/i18n.d.ts index a6ed8f0a1e..826fcc1613 100644 --- a/web/types/i18n.d.ts +++ b/web/types/i18n.d.ts @@ -27,6 +27,7 @@ type LoginMessages = typeof import('../i18n/en-US/login').default type OauthMessages = typeof import('../i18n/en-US/oauth').default type PipelineMessages = typeof import('../i18n/en-US/pipeline').default type PluginTagsMessages = typeof import('../i18n/en-US/plugin-tags').default +type PluginTriggerMessages = typeof import('../i18n/en-US/plugin-trigger').default type PluginMessages = typeof import('../i18n/en-US/plugin').default type RegisterMessages = typeof import('../i18n/en-US/register').default type RunLogMessages = typeof import('../i18n/en-US/run-log').default @@ -59,6 +60,7 @@ export type Messages = { oauth: OauthMessages; pipeline: PipelineMessages; pluginTags: PluginTagsMessages; + pluginTrigger: PluginTriggerMessages; plugin: PluginMessages; register: RegisterMessages; runLog: RunLogMessages; diff --git a/web/utils/app-redirection.ts b/web/utils/app-redirection.ts index dfecbd17d4..5ed8419e05 100644 --- a/web/utils/app-redirection.ts +++ b/web/utils/app-redirection.ts @@ -1,14 +1,14 @@ -import type { AppMode } from '@/types/app' +import { AppModeEnum } from '@/types/app' export const getRedirectionPath = ( isCurrentWorkspaceEditor: boolean, - app: { id: string, mode: AppMode }, + app: { id: string, mode: AppModeEnum }, ) => { if (!isCurrentWorkspaceEditor) { return `/app/${app.id}/overview` } else { - if (app.mode === 'workflow' || app.mode === 'advanced-chat') + if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT) return `/app/${app.id}/workflow` else return `/app/${app.id}/configuration` @@ -17,7 +17,7 @@ export const getRedirectionPath = ( export const getRedirection = ( isCurrentWorkspaceEditor: boolean, - app: { id: string, mode: AppMode }, + app: { id: string, mode: AppModeEnum }, redirectionFunc: (href: string) => void, ) => { const redirectionPath = getRedirectionPath(isCurrentWorkspaceEditor, app) diff --git a/web/utils/error-parser.ts b/web/utils/error-parser.ts new file mode 100644 index 0000000000..311505521f --- /dev/null +++ b/web/utils/error-parser.ts @@ -0,0 +1,52 @@ +/** + * Parse plugin error message from nested error structure + * Extracts the real error message from PluginInvokeError JSON string + * + * @example + * Input: { message: "req_id: xxx PluginInvokeError: {\"message\":\"Bad credentials\"}" } + * Output: "Bad credentials" + * + * @param error - Error object (can be Response object or error with message property) + * @returns Promise<string> or string - Parsed error message + */ +export const parsePluginErrorMessage = async (error: any): Promise<string> => { + let rawMessage = '' + + // Handle Response object from fetch/ky + if (error instanceof Response) { + try { + const body = await error.clone().json() + rawMessage = body?.message || error.statusText || 'Unknown error' + } + catch { + rawMessage = error.statusText || 'Unknown error' + } + } + else { + rawMessage = error?.message || error?.toString() || 'Unknown error' + } + + console.log('rawMessage', rawMessage) + + // Try to extract nested JSON from PluginInvokeError + // Use greedy match .+ to capture the complete JSON object with nested braces + const pluginErrorPattern = /PluginInvokeError:\s*(\{.+\})/ + const match = rawMessage.match(pluginErrorPattern) + + if (match) { + try { + const errorData = JSON.parse(match[1]) + // Return the inner message if exists + if (errorData.message) + return errorData.message + // Fallback to error_type if message not available + if (errorData.error_type) + return errorData.error_type + } + catch (parseError) { + console.warn('Failed to parse plugin error JSON:', parseError) + } + } + + return rawMessage +} diff --git a/web/utils/urlValidation.ts b/web/utils/urlValidation.ts index abc15a1365..db6de5275a 100644 --- a/web/utils/urlValidation.ts +++ b/web/utils/urlValidation.ts @@ -21,3 +21,44 @@ export function validateRedirectUrl(url: string): void { throw new Error(`Invalid URL: ${url}`) } } + +/** + * Check if URL is a private/local network address or cloud debug URL + * @param url - The URL string to check + * @returns true if the URL is a private/local address or cloud debug URL + */ +export function isPrivateOrLocalAddress(url: string): boolean { + try { + const urlObj = new URL(url) + const hostname = urlObj.hostname.toLowerCase() + + // Check for localhost + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') + return true + + // Check for private IP ranges + const ipv4Regex = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/ + const ipv4Match = hostname.match(ipv4Regex) + if (ipv4Match) { + const [, a, b] = ipv4Match.map(Number) + // 10.0.0.0/8 + if (a === 10) + return true + // 172.16.0.0/12 + if (a === 172 && b >= 16 && b <= 31) + return true + // 192.168.0.0/16 + if (a === 192 && b === 168) + return true + // 169.254.0.0/16 (link-local) + if (a === 169 && b === 254) + return true + } + + // Check for .local domains + return hostname.endsWith('.local') + } + catch { + return false + } +} From 1369119a0c77842e337e8bf1fabc16f9c6c9f96c Mon Sep 17 00:00:00 2001 From: Bowen Liang <liang.bowen.123@qq.com> Date: Wed, 12 Nov 2025 19:27:27 +0800 Subject: [PATCH 18/23] fix: determine cpu cores determination in baseedpyright-check script on macos (#28058) --- api/app.py | 2 +- dev/basedpyright-check | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/api/app.py b/api/app.py index 4ed743dcb4..99f70f32d5 100644 --- a/api/app.py +++ b/api/app.py @@ -1,7 +1,7 @@ import sys -def is_db_command(): +def is_db_command() -> bool: if len(sys.argv) > 1 and sys.argv[0].endswith("flask") and sys.argv[1] == "db": return True return False diff --git a/dev/basedpyright-check b/dev/basedpyright-check index 1c87b27d6f..1b3d1df7ad 100755 --- a/dev/basedpyright-check +++ b/dev/basedpyright-check @@ -8,9 +8,14 @@ cd "$SCRIPT_DIR/.." # Get the path argument if provided PATH_TO_CHECK="$1" -# run basedpyright checks -if [ -n "$PATH_TO_CHECK" ]; then - uv run --directory api --dev -- basedpyright --threads $(nproc) "$PATH_TO_CHECK" -else - uv run --directory api --dev -- basedpyright --threads $(nproc) -fi +# Determine CPU core count based on OS +CPU_CORES=$( + if [[ "$(uname -s)" == "Darwin" ]]; then + sysctl -n hw.ncpu 2>/dev/null + else + nproc + fi +) + +# Run basedpyright checks +uv run --directory api --dev -- basedpyright --threads "$CPU_CORES" $PATH_TO_CHECK From 6026bd873b376f9ec2cc6c8c269f5a24f120754a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= <hjlarry@163.com> Date: Wed, 12 Nov 2025 19:27:36 +0800 Subject: [PATCH 19/23] fix: variable assigner can't assign float number (#28068) --- api/services/workflow_draft_variable_service.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/services/workflow_draft_variable_service.py b/api/services/workflow_draft_variable_service.py index 2690b55dbc..c5d1f6ab13 100644 --- a/api/services/workflow_draft_variable_service.py +++ b/api/services/workflow_draft_variable_service.py @@ -808,7 +808,11 @@ class DraftVariableSaver: # We only save conversation variable here. if selector[0] != CONVERSATION_VARIABLE_NODE_ID: continue - segment = WorkflowDraftVariable.build_segment_with_type(segment_type=item.value_type, value=item.new_value) + # Conversation variables are exposed as NUMBER in the UI even if their + # persisted type is INTEGER. Allow float updates by loosening the type + # to NUMBER here so downstream storage infers the precise subtype. + segment_type = SegmentType.NUMBER if item.value_type == SegmentType.INTEGER else item.value_type + segment = WorkflowDraftVariable.build_segment_with_type(segment_type=segment_type, value=item.new_value) draft_vars.append( WorkflowDraftVariable.new_conversation_variable( app_id=self._app_id, From 19c92fd670bab121cd8d4c3f92ddefd95c98bf17 Mon Sep 17 00:00:00 2001 From: Gen Sato <52241300+halogen22@users.noreply.github.com> Date: Wed, 12 Nov 2025 20:27:56 +0900 Subject: [PATCH 20/23] Add file type validation to paste upload (#28017) --- web/app/components/base/file-uploader/hooks.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/web/app/components/base/file-uploader/hooks.ts b/web/app/components/base/file-uploader/hooks.ts index 9675123fe7..521ecdbafd 100644 --- a/web/app/components/base/file-uploader/hooks.ts +++ b/web/app/components/base/file-uploader/hooks.ts @@ -305,9 +305,23 @@ export const useFile = (fileConfig: FileUpload) => { const text = e.clipboardData?.getData('text/plain') if (file && !text) { e.preventDefault() + + const allowedFileTypes = fileConfig.allowed_file_types || [] + const fileType = getSupportFileType(file.name, file.type, allowedFileTypes?.includes(SupportUploadFileTypes.custom)) + const isFileTypeAllowed = allowedFileTypes.includes(fileType) + + // Check if file type is in allowed list + if (!isFileTypeAllowed || !fileConfig.enabled) { + notify({ + type: 'error', + message: t('common.fileUploader.fileExtensionNotSupport'), + }) + return + } + handleLocalFileUpload(file) } - }, [handleLocalFileUpload]) + }, [handleLocalFileUpload, fileConfig, notify, t]) const [isDragActive, setIsDragActive] = useState(false) const handleDragFileEnter = useCallback((e: React.DragEvent<HTMLElement>) => { From 5c06e285ecb7ed2e6ab7aa0284bf3c8b2c2a6c0a Mon Sep 17 00:00:00 2001 From: Gritty_dev <101377478+codomposer@users.noreply.github.com> Date: Wed, 12 Nov 2025 08:47:06 -0500 Subject: [PATCH 21/23] test: create some hooks and utils test script, modified clipboard test script (#27928) --- web/hooks/use-breakpoints.spec.ts | 44 +- web/hooks/use-document-title.spec.ts | 46 ++ web/hooks/use-format-time-from-now.spec.ts | 376 ++++++++++ web/hooks/use-tab-searchparams.spec.ts | 543 ++++++++++++++ web/service/utils.spec.ts | 170 +++++ web/utils/clipboard.spec.ts | 39 + web/utils/context.spec.ts | 253 +++++++ web/utils/model-config.spec.ts | 819 +++++++++++++++++++++ web/utils/model-config.ts | 2 +- 9 files changed, 2289 insertions(+), 3 deletions(-) create mode 100644 web/hooks/use-format-time-from-now.spec.ts create mode 100644 web/hooks/use-tab-searchparams.spec.ts create mode 100644 web/service/utils.spec.ts create mode 100644 web/utils/context.spec.ts create mode 100644 web/utils/model-config.spec.ts diff --git a/web/hooks/use-breakpoints.spec.ts b/web/hooks/use-breakpoints.spec.ts index 315e514f0f..8b29fe486c 100644 --- a/web/hooks/use-breakpoints.spec.ts +++ b/web/hooks/use-breakpoints.spec.ts @@ -1,10 +1,27 @@ +/** + * Test suite for useBreakpoints hook + * + * This hook provides responsive breakpoint detection based on window width. + * It listens to window resize events and returns the current media type. + * + * Breakpoint definitions: + * - mobile: width <= 640px + * - tablet: 640px < width <= 768px + * - pc: width > 768px + * + * The hook automatically updates when the window is resized and cleans up + * event listeners on unmount to prevent memory leaks. + */ import { act, renderHook } from '@testing-library/react' import useBreakpoints, { MediaType } from './use-breakpoints' describe('useBreakpoints', () => { const originalInnerWidth = window.innerWidth - // Mock the window resize event + /** + * Helper function to simulate window resize events + * Updates window.innerWidth and dispatches a resize event + */ const fireResize = (width: number) => { window.innerWidth = width act(() => { @@ -12,11 +29,18 @@ describe('useBreakpoints', () => { }) } - // Restore the original innerWidth after tests + /** + * Restore the original innerWidth after all tests + * Ensures tests don't affect each other or the test environment + */ afterAll(() => { window.innerWidth = originalInnerWidth }) + /** + * Test mobile breakpoint detection + * Mobile devices have width <= 640px + */ it('should return mobile for width <= 640px', () => { // Mock window.innerWidth for mobile Object.defineProperty(window, 'innerWidth', { @@ -29,6 +53,10 @@ describe('useBreakpoints', () => { expect(result.current).toBe(MediaType.mobile) }) + /** + * Test tablet breakpoint detection + * Tablet devices have width between 640px and 768px + */ it('should return tablet for width > 640px and <= 768px', () => { // Mock window.innerWidth for tablet Object.defineProperty(window, 'innerWidth', { @@ -41,6 +69,10 @@ describe('useBreakpoints', () => { expect(result.current).toBe(MediaType.tablet) }) + /** + * Test desktop/PC breakpoint detection + * Desktop devices have width > 768px + */ it('should return pc for width > 768px', () => { // Mock window.innerWidth for pc Object.defineProperty(window, 'innerWidth', { @@ -53,6 +85,10 @@ describe('useBreakpoints', () => { expect(result.current).toBe(MediaType.pc) }) + /** + * Test dynamic breakpoint updates on window resize + * The hook should react to window resize events and update the media type + */ it('should update media type when window resizes', () => { // Start with desktop Object.defineProperty(window, 'innerWidth', { @@ -73,6 +109,10 @@ describe('useBreakpoints', () => { expect(result.current).toBe(MediaType.mobile) }) + /** + * Test proper cleanup of event listeners + * Ensures no memory leaks by removing resize listeners on unmount + */ it('should clean up event listeners on unmount', () => { // Spy on addEventListener and removeEventListener const addEventListenerSpy = jest.spyOn(window, 'addEventListener') diff --git a/web/hooks/use-document-title.spec.ts b/web/hooks/use-document-title.spec.ts index a8d3d56cff..fbc82a0cdf 100644 --- a/web/hooks/use-document-title.spec.ts +++ b/web/hooks/use-document-title.spec.ts @@ -1,3 +1,15 @@ +/** + * Test suite for useDocumentTitle hook + * + * This hook manages the browser document title with support for: + * - Custom branding (when enabled in system features) + * - Default "Dify" branding + * - Pending state handling (prevents title flicker during loading) + * - Page-specific titles with automatic suffix + * + * Title format: "[Page Title] - [Brand Name]" + * If no page title: "[Brand Name]" + */ import { defaultSystemFeatures } from '@/types/feature' import { act, renderHook } from '@testing-library/react' import useDocumentTitle from './use-document-title' @@ -7,6 +19,10 @@ jest.mock('@/service/common', () => ({ getSystemFeatures: jest.fn(() => ({ ...defaultSystemFeatures })), })) +/** + * Test behavior when system features are still loading + * Title should remain empty to prevent flicker + */ describe('title should be empty if systemFeatures is pending', () => { act(() => { useGlobalPublicStore.setState({ @@ -14,16 +30,26 @@ describe('title should be empty if systemFeatures is pending', () => { isGlobalPending: true, }) }) + /** + * Test that title stays empty during loading even when a title is provided + */ it('document title should be empty if set title', () => { renderHook(() => useDocumentTitle('test')) expect(document.title).toBe('') }) + /** + * Test that title stays empty during loading when no title is provided + */ it('document title should be empty if not set title', () => { renderHook(() => useDocumentTitle('')) expect(document.title).toBe('') }) }) +/** + * Test default Dify branding behavior + * When custom branding is disabled, should use "Dify" as the brand name + */ describe('use default branding', () => { beforeEach(() => { act(() => { @@ -33,17 +59,29 @@ describe('use default branding', () => { }) }) }) + /** + * Test title format with page title and default branding + * Format: "[page] - Dify" + */ it('document title should be test-Dify if set title', () => { renderHook(() => useDocumentTitle('test')) expect(document.title).toBe('test - Dify') }) + /** + * Test title with only default branding (no page title) + * Format: "Dify" + */ it('document title should be Dify if not set title', () => { renderHook(() => useDocumentTitle('')) expect(document.title).toBe('Dify') }) }) +/** + * Test custom branding behavior + * When custom branding is enabled, should use the configured application_title + */ describe('use specific branding', () => { beforeEach(() => { act(() => { @@ -53,11 +91,19 @@ describe('use specific branding', () => { }) }) }) + /** + * Test title format with page title and custom branding + * Format: "[page] - [Custom Brand]" + */ it('document title should be test-Test if set title', () => { renderHook(() => useDocumentTitle('test')) expect(document.title).toBe('test - Test') }) + /** + * Test title with only custom branding (no page title) + * Format: "[Custom Brand]" + */ it('document title should be Test if not set title', () => { renderHook(() => useDocumentTitle('')) expect(document.title).toBe('Test') diff --git a/web/hooks/use-format-time-from-now.spec.ts b/web/hooks/use-format-time-from-now.spec.ts new file mode 100644 index 0000000000..92ed37515c --- /dev/null +++ b/web/hooks/use-format-time-from-now.spec.ts @@ -0,0 +1,376 @@ +/** + * Test suite for useFormatTimeFromNow hook + * + * This hook provides internationalized relative time formatting (e.g., "2 hours ago", "3 days ago") + * using dayjs with the relativeTime plugin. It automatically uses the correct locale based on + * the user's i18n settings. + * + * Key features: + * - Supports 20+ locales with proper translations + * - Automatically syncs with user's interface language + * - Uses dayjs for consistent time calculations + * - Returns human-readable relative time strings + */ +import { renderHook } from '@testing-library/react' +import { useFormatTimeFromNow } from './use-format-time-from-now' + +// Mock the i18n context +jest.mock('@/context/i18n', () => ({ + useI18N: jest.fn(() => ({ + locale: 'en-US', + })), +})) + +// Import after mock to get the mocked version +import { useI18N } from '@/context/i18n' + +describe('useFormatTimeFromNow', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Basic functionality', () => { + /** + * Test that the hook returns a formatTimeFromNow function + * This is the primary interface of the hook + */ + it('should return formatTimeFromNow function', () => { + const { result } = renderHook(() => useFormatTimeFromNow()) + + expect(result.current).toHaveProperty('formatTimeFromNow') + expect(typeof result.current.formatTimeFromNow).toBe('function') + }) + + /** + * Test basic relative time formatting with English locale + * Should return human-readable relative time strings + */ + it('should format time from now in English', () => { + ;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' }) + + const { result } = renderHook(() => useFormatTimeFromNow()) + + const now = Date.now() + const oneHourAgo = now - (60 * 60 * 1000) + const formatted = result.current.formatTimeFromNow(oneHourAgo) + + // Should contain "hour" or "hours" and "ago" + expect(formatted).toMatch(/hour|hours/) + expect(formatted).toMatch(/ago/) + }) + + /** + * Test that recent times are formatted as "a few seconds ago" + * Very recent timestamps should show seconds + */ + it('should format very recent times', () => { + ;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' }) + + const { result } = renderHook(() => useFormatTimeFromNow()) + + const now = Date.now() + const fiveSecondsAgo = now - (5 * 1000) + const formatted = result.current.formatTimeFromNow(fiveSecondsAgo) + + expect(formatted).toMatch(/second|seconds|few seconds/) + }) + + /** + * Test formatting of times in the past (days ago) + * Should handle day-level granularity + */ + it('should format times from days ago', () => { + ;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' }) + + const { result } = renderHook(() => useFormatTimeFromNow()) + + const now = Date.now() + const threeDaysAgo = now - (3 * 24 * 60 * 60 * 1000) + const formatted = result.current.formatTimeFromNow(threeDaysAgo) + + expect(formatted).toMatch(/day|days/) + expect(formatted).toMatch(/ago/) + }) + + /** + * Test formatting of future times + * dayjs fromNow also supports future times (e.g., "in 2 hours") + */ + it('should format future times', () => { + ;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' }) + + const { result } = renderHook(() => useFormatTimeFromNow()) + + const now = Date.now() + const twoHoursFromNow = now + (2 * 60 * 60 * 1000) + const formatted = result.current.formatTimeFromNow(twoHoursFromNow) + + expect(formatted).toMatch(/in/) + expect(formatted).toMatch(/hour|hours/) + }) + }) + + describe('Locale support', () => { + /** + * Test Chinese (Simplified) locale formatting + * Should use Chinese characters for time units + */ + it('should format time in Chinese (Simplified)', () => { + ;(useI18N as jest.Mock).mockReturnValue({ locale: 'zh-Hans' }) + + const { result } = renderHook(() => useFormatTimeFromNow()) + + const now = Date.now() + const oneHourAgo = now - (60 * 60 * 1000) + const formatted = result.current.formatTimeFromNow(oneHourAgo) + + // Chinese should contain Chinese characters + expect(formatted).toMatch(/[\u4E00-\u9FA5]/) + }) + + /** + * Test Spanish locale formatting + * Should use Spanish words for relative time + */ + it('should format time in Spanish', () => { + ;(useI18N as jest.Mock).mockReturnValue({ locale: 'es-ES' }) + + const { result } = renderHook(() => useFormatTimeFromNow()) + + const now = Date.now() + const oneHourAgo = now - (60 * 60 * 1000) + const formatted = result.current.formatTimeFromNow(oneHourAgo) + + // Spanish should contain "hace" (ago) + expect(formatted).toMatch(/hace/) + }) + + /** + * Test French locale formatting + * Should use French words for relative time + */ + it('should format time in French', () => { + ;(useI18N as jest.Mock).mockReturnValue({ locale: 'fr-FR' }) + + const { result } = renderHook(() => useFormatTimeFromNow()) + + const now = Date.now() + const oneHourAgo = now - (60 * 60 * 1000) + const formatted = result.current.formatTimeFromNow(oneHourAgo) + + // French should contain "il y a" (ago) + expect(formatted).toMatch(/il y a/) + }) + + /** + * Test Japanese locale formatting + * Should use Japanese characters + */ + it('should format time in Japanese', () => { + ;(useI18N as jest.Mock).mockReturnValue({ locale: 'ja-JP' }) + + const { result } = renderHook(() => useFormatTimeFromNow()) + + const now = Date.now() + const oneHourAgo = now - (60 * 60 * 1000) + const formatted = result.current.formatTimeFromNow(oneHourAgo) + + // Japanese should contain Japanese characters + expect(formatted).toMatch(/[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/) + }) + + /** + * Test Portuguese (Brazil) locale formatting + * Should use pt-br locale mapping + */ + it('should format time in Portuguese (Brazil)', () => { + ;(useI18N as jest.Mock).mockReturnValue({ locale: 'pt-BR' }) + + const { result } = renderHook(() => useFormatTimeFromNow()) + + const now = Date.now() + const oneHourAgo = now - (60 * 60 * 1000) + const formatted = result.current.formatTimeFromNow(oneHourAgo) + + // Portuguese should contain "há" (ago) + expect(formatted).toMatch(/há/) + }) + + /** + * Test fallback to English for unsupported locales + * Unknown locales should default to English + */ + it('should fallback to English for unsupported locale', () => { + ;(useI18N as jest.Mock).mockReturnValue({ locale: 'xx-XX' as any }) + + const { result } = renderHook(() => useFormatTimeFromNow()) + + const now = Date.now() + const oneHourAgo = now - (60 * 60 * 1000) + const formatted = result.current.formatTimeFromNow(oneHourAgo) + + // Should still return a valid string (in English) + expect(typeof formatted).toBe('string') + expect(formatted.length).toBeGreaterThan(0) + }) + }) + + describe('Edge cases', () => { + /** + * Test handling of timestamp 0 (Unix epoch) + * Should format as a very old date + */ + it('should handle timestamp 0', () => { + ;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' }) + + const { result } = renderHook(() => useFormatTimeFromNow()) + + const formatted = result.current.formatTimeFromNow(0) + + expect(typeof formatted).toBe('string') + expect(formatted.length).toBeGreaterThan(0) + expect(formatted).toMatch(/year|years/) + }) + + /** + * Test handling of very large timestamps + * Should handle dates far in the future + */ + it('should handle very large timestamps', () => { + ;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' }) + + const { result } = renderHook(() => useFormatTimeFromNow()) + + const farFuture = Date.now() + (365 * 24 * 60 * 60 * 1000) // 1 year from now + const formatted = result.current.formatTimeFromNow(farFuture) + + expect(typeof formatted).toBe('string') + expect(formatted).toMatch(/in/) + }) + + /** + * Test that the function is memoized based on locale + * Changing locale should update the function + */ + it('should update when locale changes', () => { + const { result, rerender } = renderHook(() => useFormatTimeFromNow()) + + const now = Date.now() + const oneHourAgo = now - (60 * 60 * 1000) + + // First render with English + ;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' }) + rerender() + const englishResult = result.current.formatTimeFromNow(oneHourAgo) + + // Second render with Spanish + ;(useI18N as jest.Mock).mockReturnValue({ locale: 'es-ES' }) + rerender() + const spanishResult = result.current.formatTimeFromNow(oneHourAgo) + + // Results should be different + expect(englishResult).not.toBe(spanishResult) + }) + }) + + describe('Time granularity', () => { + /** + * Test different time granularities (seconds, minutes, hours, days, months, years) + * dayjs should automatically choose the appropriate unit + */ + it('should use appropriate time units for different durations', () => { + ;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' }) + + const { result } = renderHook(() => useFormatTimeFromNow()) + + const now = Date.now() + + // Seconds + const seconds = result.current.formatTimeFromNow(now - 30 * 1000) + expect(seconds).toMatch(/second/) + + // Minutes + const minutes = result.current.formatTimeFromNow(now - 5 * 60 * 1000) + expect(minutes).toMatch(/minute/) + + // Hours + const hours = result.current.formatTimeFromNow(now - 3 * 60 * 60 * 1000) + expect(hours).toMatch(/hour/) + + // Days + const days = result.current.formatTimeFromNow(now - 5 * 24 * 60 * 60 * 1000) + expect(days).toMatch(/day/) + + // Months + const months = result.current.formatTimeFromNow(now - 60 * 24 * 60 * 60 * 1000) + expect(months).toMatch(/month/) + }) + }) + + describe('Locale mapping', () => { + /** + * Test that all supported locales in the localeMap are handled correctly + * This ensures the mapping from app locales to dayjs locales works + */ + it('should handle all mapped locales', () => { + const locales = [ + 'en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR', + 'de-DE', 'ja-JP', 'ko-KR', 'ru-RU', 'it-IT', 'th-TH', + 'id-ID', 'uk-UA', 'vi-VN', 'ro-RO', 'pl-PL', 'hi-IN', + 'tr-TR', 'fa-IR', 'sl-SI', + ] + + const now = Date.now() + const oneHourAgo = now - (60 * 60 * 1000) + + locales.forEach((locale) => { + ;(useI18N as jest.Mock).mockReturnValue({ locale }) + + const { result } = renderHook(() => useFormatTimeFromNow()) + const formatted = result.current.formatTimeFromNow(oneHourAgo) + + // Should return a non-empty string for each locale + expect(typeof formatted).toBe('string') + expect(formatted.length).toBeGreaterThan(0) + }) + }) + }) + + describe('Performance', () => { + /** + * Test that the hook doesn't create new functions on every render + * The formatTimeFromNow function should be memoized with useCallback + */ + it('should memoize formatTimeFromNow function', () => { + ;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' }) + + const { result, rerender } = renderHook(() => useFormatTimeFromNow()) + + const firstFunction = result.current.formatTimeFromNow + rerender() + const secondFunction = result.current.formatTimeFromNow + + // Same locale should return the same function reference + expect(firstFunction).toBe(secondFunction) + }) + + /** + * Test that changing locale creates a new function + * This ensures the memoization dependency on locale works correctly + */ + it('should create new function when locale changes', () => { + const { result, rerender } = renderHook(() => useFormatTimeFromNow()) + + ;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' }) + rerender() + const englishFunction = result.current.formatTimeFromNow + + ;(useI18N as jest.Mock).mockReturnValue({ locale: 'es-ES' }) + rerender() + const spanishFunction = result.current.formatTimeFromNow + + // Different locale should return different function reference + expect(englishFunction).not.toBe(spanishFunction) + }) + }) +}) diff --git a/web/hooks/use-tab-searchparams.spec.ts b/web/hooks/use-tab-searchparams.spec.ts new file mode 100644 index 0000000000..62adea529f --- /dev/null +++ b/web/hooks/use-tab-searchparams.spec.ts @@ -0,0 +1,543 @@ +/** + * Test suite for useTabSearchParams hook + * + * This hook manages tab state through URL search parameters, enabling: + * - Bookmarkable tab states (users can share URLs with specific tabs active) + * - Browser history integration (back/forward buttons work with tabs) + * - Configurable routing behavior (push vs replace) + * - Optional search parameter syncing (can disable URL updates) + * + * The hook syncs a local tab state with URL search parameters, making tab + * navigation persistent and shareable across sessions. + */ +import { act, renderHook } from '@testing-library/react' +import { useTabSearchParams } from './use-tab-searchparams' + +// Mock Next.js navigation hooks +const mockPush = jest.fn() +const mockReplace = jest.fn() +const mockPathname = '/test-path' +const mockSearchParams = new URLSearchParams() + +jest.mock('next/navigation', () => ({ + usePathname: jest.fn(() => mockPathname), + useRouter: jest.fn(() => ({ + push: mockPush, + replace: mockReplace, + })), + useSearchParams: jest.fn(() => mockSearchParams), +})) + +// Import after mocks +import { usePathname } from 'next/navigation' + +describe('useTabSearchParams', () => { + beforeEach(() => { + jest.clearAllMocks() + mockSearchParams.delete('category') + mockSearchParams.delete('tab') + }) + + describe('Basic functionality', () => { + /** + * Test that the hook returns a tuple with activeTab and setActiveTab + * This is the primary interface matching React's useState pattern + */ + it('should return activeTab and setActiveTab function', () => { + const { result } = renderHook(() => + useTabSearchParams({ defaultTab: 'overview' }), + ) + + const [activeTab, setActiveTab] = result.current + + expect(typeof activeTab).toBe('string') + expect(typeof setActiveTab).toBe('function') + }) + + /** + * Test that the hook initializes with the default tab + * When no search param is present, should use defaultTab + */ + it('should initialize with default tab when no search param exists', () => { + const { result } = renderHook(() => + useTabSearchParams({ defaultTab: 'overview' }), + ) + + const [activeTab] = result.current + expect(activeTab).toBe('overview') + }) + + /** + * Test that the hook reads from URL search parameters + * When a search param exists, it should take precedence over defaultTab + */ + it('should initialize with search param value when present', () => { + mockSearchParams.set('category', 'settings') + + const { result } = renderHook(() => + useTabSearchParams({ defaultTab: 'overview' }), + ) + + const [activeTab] = result.current + expect(activeTab).toBe('settings') + }) + + /** + * Test that setActiveTab updates the local state + * The active tab should change when setActiveTab is called + */ + it('should update active tab when setActiveTab is called', () => { + const { result } = renderHook(() => + useTabSearchParams({ defaultTab: 'overview' }), + ) + + act(() => { + const [, setActiveTab] = result.current + setActiveTab('settings') + }) + + const [activeTab] = result.current + expect(activeTab).toBe('settings') + }) + }) + + describe('Routing behavior', () => { + /** + * Test default push routing behavior + * By default, tab changes should use router.push (adds to history) + */ + it('should use push routing by default', () => { + const { result } = renderHook(() => + useTabSearchParams({ defaultTab: 'overview' }), + ) + + act(() => { + const [, setActiveTab] = result.current + setActiveTab('settings') + }) + + expect(mockPush).toHaveBeenCalledWith('/test-path?category=settings') + expect(mockReplace).not.toHaveBeenCalled() + }) + + /** + * Test replace routing behavior + * When routingBehavior is 'replace', should use router.replace (no history) + */ + it('should use replace routing when specified', () => { + const { result } = renderHook(() => + useTabSearchParams({ + defaultTab: 'overview', + routingBehavior: 'replace', + }), + ) + + act(() => { + const [, setActiveTab] = result.current + setActiveTab('settings') + }) + + expect(mockReplace).toHaveBeenCalledWith('/test-path?category=settings') + expect(mockPush).not.toHaveBeenCalled() + }) + + /** + * Test that URL encoding is applied to tab values + * Special characters in tab names should be properly encoded + */ + it('should encode special characters in tab values', () => { + const { result } = renderHook(() => + useTabSearchParams({ defaultTab: 'overview' }), + ) + + act(() => { + const [, setActiveTab] = result.current + setActiveTab('settings & config') + }) + + expect(mockPush).toHaveBeenCalledWith( + '/test-path?category=settings%20%26%20config', + ) + }) + + /** + * Test that URL decoding is applied when reading from search params + * Encoded values in the URL should be properly decoded + */ + it('should decode encoded values from search params', () => { + mockSearchParams.set('category', 'settings%20%26%20config') + + const { result } = renderHook(() => + useTabSearchParams({ defaultTab: 'overview' }), + ) + + const [activeTab] = result.current + expect(activeTab).toBe('settings & config') + }) + }) + + describe('Custom search parameter name', () => { + /** + * Test using a custom search parameter name + * Should support different param names instead of default 'category' + */ + it('should use custom search param name', () => { + mockSearchParams.set('tab', 'profile') + + const { result } = renderHook(() => + useTabSearchParams({ + defaultTab: 'overview', + searchParamName: 'tab', + }), + ) + + const [activeTab] = result.current + expect(activeTab).toBe('profile') + }) + + /** + * Test that setActiveTab uses the custom param name in the URL + */ + it('should update URL with custom param name', () => { + const { result } = renderHook(() => + useTabSearchParams({ + defaultTab: 'overview', + searchParamName: 'tab', + }), + ) + + act(() => { + const [, setActiveTab] = result.current + setActiveTab('profile') + }) + + expect(mockPush).toHaveBeenCalledWith('/test-path?tab=profile') + }) + }) + + describe('Disabled search params mode', () => { + /** + * Test that disableSearchParams prevents URL updates + * When disabled, tab state should be local only + */ + it('should not update URL when disableSearchParams is true', () => { + const { result } = renderHook(() => + useTabSearchParams({ + defaultTab: 'overview', + disableSearchParams: true, + }), + ) + + act(() => { + const [, setActiveTab] = result.current + setActiveTab('settings') + }) + + expect(mockPush).not.toHaveBeenCalled() + expect(mockReplace).not.toHaveBeenCalled() + }) + + /** + * Test that local state still updates when search params are disabled + * The tab state should work even without URL syncing + */ + it('should still update local state when search params disabled', () => { + const { result } = renderHook(() => + useTabSearchParams({ + defaultTab: 'overview', + disableSearchParams: true, + }), + ) + + act(() => { + const [, setActiveTab] = result.current + setActiveTab('settings') + }) + + const [activeTab] = result.current + expect(activeTab).toBe('settings') + }) + + /** + * Test that disabled mode always uses defaultTab + * Search params should be ignored when disabled + */ + it('should use defaultTab when search params disabled even if URL has value', () => { + mockSearchParams.set('category', 'settings') + + const { result } = renderHook(() => + useTabSearchParams({ + defaultTab: 'overview', + disableSearchParams: true, + }), + ) + + const [activeTab] = result.current + expect(activeTab).toBe('overview') + }) + }) + + describe('Edge cases', () => { + /** + * Test handling of empty string tab values + * Empty strings should be handled gracefully + */ + it('should handle empty string tab values', () => { + const { result } = renderHook(() => + useTabSearchParams({ defaultTab: 'overview' }), + ) + + act(() => { + const [, setActiveTab] = result.current + setActiveTab('') + }) + + const [activeTab] = result.current + expect(activeTab).toBe('') + expect(mockPush).toHaveBeenCalledWith('/test-path?category=') + }) + + /** + * Test that special characters in tab names are properly encoded + * This ensures URLs remain valid even with unusual tab names + */ + it('should handle tabs with various special characters', () => { + const { result } = renderHook(() => + useTabSearchParams({ defaultTab: 'overview' }), + ) + + // Test tab with slashes + act(() => result.current[1]('tab/with/slashes')) + expect(result.current[0]).toBe('tab/with/slashes') + + // Test tab with question marks + act(() => result.current[1]('tab?with?questions')) + expect(result.current[0]).toBe('tab?with?questions') + + // Test tab with hash symbols + act(() => result.current[1]('tab#with#hash')) + expect(result.current[0]).toBe('tab#with#hash') + + // Test tab with equals signs + act(() => result.current[1]('tab=with=equals')) + expect(result.current[0]).toBe('tab=with=equals') + }) + + /** + * Test fallback when pathname is not available + * Should use window.location.pathname as fallback + */ + it('should fallback to window.location.pathname when hook pathname is null', () => { + ;(usePathname as jest.Mock).mockReturnValue(null) + + // Mock window.location.pathname + Object.defineProperty(window, 'location', { + value: { pathname: '/fallback-path' }, + writable: true, + }) + + const { result } = renderHook(() => + useTabSearchParams({ defaultTab: 'overview' }), + ) + + act(() => { + const [, setActiveTab] = result.current + setActiveTab('settings') + }) + + expect(mockPush).toHaveBeenCalledWith('/fallback-path?category=settings') + + // Restore mock + ;(usePathname as jest.Mock).mockReturnValue(mockPathname) + }) + }) + + describe('Multiple instances', () => { + /** + * Test that multiple instances with different param names work independently + * Different hooks should not interfere with each other + */ + it('should support multiple independent tab states', () => { + mockSearchParams.set('category', 'overview') + mockSearchParams.set('subtab', 'details') + + const { result: result1 } = renderHook(() => + useTabSearchParams({ + defaultTab: 'home', + searchParamName: 'category', + }), + ) + + const { result: result2 } = renderHook(() => + useTabSearchParams({ + defaultTab: 'info', + searchParamName: 'subtab', + }), + ) + + const [activeTab1] = result1.current + const [activeTab2] = result2.current + + expect(activeTab1).toBe('overview') + expect(activeTab2).toBe('details') + }) + }) + + describe('Integration scenarios', () => { + /** + * Test typical usage in a tabbed interface + * Simulates real-world tab switching behavior + */ + it('should handle sequential tab changes', () => { + const { result } = renderHook(() => + useTabSearchParams({ defaultTab: 'overview' }), + ) + + // Change to settings tab + act(() => { + const [, setActiveTab] = result.current + setActiveTab('settings') + }) + + expect(result.current[0]).toBe('settings') + expect(mockPush).toHaveBeenCalledWith('/test-path?category=settings') + + // Change to profile tab + act(() => { + const [, setActiveTab] = result.current + setActiveTab('profile') + }) + + expect(result.current[0]).toBe('profile') + expect(mockPush).toHaveBeenCalledWith('/test-path?category=profile') + + // Verify push was called twice + expect(mockPush).toHaveBeenCalledTimes(2) + }) + + /** + * Test that the hook works with complex pathnames + * Should handle nested routes and existing query params + */ + it('should work with complex pathnames', () => { + ;(usePathname as jest.Mock).mockReturnValue('/app/123/settings') + + const { result } = renderHook(() => + useTabSearchParams({ defaultTab: 'overview' }), + ) + + act(() => { + const [, setActiveTab] = result.current + setActiveTab('advanced') + }) + + expect(mockPush).toHaveBeenCalledWith('/app/123/settings?category=advanced') + + // Restore mock + ;(usePathname as jest.Mock).mockReturnValue(mockPathname) + }) + }) + + describe('Type safety', () => { + /** + * Test that the return type is a const tuple + * TypeScript should infer [string, (tab: string) => void] as const + */ + it('should return a const tuple type', () => { + const { result } = renderHook(() => + useTabSearchParams({ defaultTab: 'overview' }), + ) + + // The result should be a tuple with exactly 2 elements + expect(result.current).toHaveLength(2) + expect(typeof result.current[0]).toBe('string') + expect(typeof result.current[1]).toBe('function') + }) + }) + + describe('Performance', () => { + /** + * Test that the hook creates a new function on each render + * Note: The current implementation doesn't use useCallback, + * so setActiveTab is recreated on each render. This could lead to + * unnecessary re-renders in child components that depend on this function. + * TODO: Consider memoizing setActiveTab with useCallback for better performance. + */ + it('should create new setActiveTab function on each render', () => { + const { result, rerender } = renderHook(() => + useTabSearchParams({ defaultTab: 'overview' }), + ) + + const [, firstSetActiveTab] = result.current + rerender() + const [, secondSetActiveTab] = result.current + + // Function reference changes on re-render (not memoized) + expect(firstSetActiveTab).not.toBe(secondSetActiveTab) + + // But both functions should work correctly + expect(typeof firstSetActiveTab).toBe('function') + expect(typeof secondSetActiveTab).toBe('function') + }) + }) + + describe('Browser history integration', () => { + /** + * Test that push behavior adds to browser history + * This enables back/forward navigation through tabs + */ + it('should add to history with push behavior', () => { + const { result } = renderHook(() => + useTabSearchParams({ + defaultTab: 'overview', + routingBehavior: 'push', + }), + ) + + act(() => { + const [, setActiveTab] = result.current + setActiveTab('tab1') + }) + + act(() => { + const [, setActiveTab] = result.current + setActiveTab('tab2') + }) + + act(() => { + const [, setActiveTab] = result.current + setActiveTab('tab3') + }) + + // Each tab change should create a history entry + expect(mockPush).toHaveBeenCalledTimes(3) + }) + + /** + * Test that replace behavior doesn't add to history + * This prevents cluttering browser history with tab changes + */ + it('should not add to history with replace behavior', () => { + const { result } = renderHook(() => + useTabSearchParams({ + defaultTab: 'overview', + routingBehavior: 'replace', + }), + ) + + act(() => { + const [, setActiveTab] = result.current + setActiveTab('tab1') + }) + + act(() => { + const [, setActiveTab] = result.current + setActiveTab('tab2') + }) + + // Should use replace instead of push + expect(mockReplace).toHaveBeenCalledTimes(2) + expect(mockPush).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/service/utils.spec.ts b/web/service/utils.spec.ts new file mode 100644 index 0000000000..fc5385c309 --- /dev/null +++ b/web/service/utils.spec.ts @@ -0,0 +1,170 @@ +/** + * Test suite for service utility functions + * + * This module provides utilities for working with different flow types in the application. + * Flow types determine the API endpoint prefix used for various operations. + * + * Key concepts: + * - FlowType.appFlow: Standard application workflows (prefix: 'apps') + * - FlowType.ragPipeline: RAG (Retrieval-Augmented Generation) pipelines (prefix: 'rag/pipelines') + * + * The getFlowPrefix function maps flow types to their corresponding API path prefixes, + * with a fallback to 'apps' for undefined or unknown flow types. + */ +import { flowPrefixMap, getFlowPrefix } from './utils' +import { FlowType } from '@/types/common' + +describe('Service Utils', () => { + describe('flowPrefixMap', () => { + /** + * Test that the flowPrefixMap object contains the expected mappings + * This ensures the mapping configuration is correct + */ + it('should have correct flow type to prefix mappings', () => { + expect(flowPrefixMap[FlowType.appFlow]).toBe('apps') + expect(flowPrefixMap[FlowType.ragPipeline]).toBe('rag/pipelines') + }) + + /** + * Test that the map only contains the expected flow types + * This helps catch unintended additions to the mapping + */ + it('should contain exactly two flow type mappings', () => { + const keys = Object.keys(flowPrefixMap) + expect(keys).toHaveLength(2) + }) + }) + + describe('getFlowPrefix', () => { + /** + * Test that appFlow type returns the correct prefix + * This is the most common flow type for standard application workflows + */ + it('should return "apps" for appFlow type', () => { + const result = getFlowPrefix(FlowType.appFlow) + expect(result).toBe('apps') + }) + + /** + * Test that ragPipeline type returns the correct prefix + * RAG pipelines have a different API structure with nested paths + */ + it('should return "rag/pipelines" for ragPipeline type', () => { + const result = getFlowPrefix(FlowType.ragPipeline) + expect(result).toBe('rag/pipelines') + }) + + /** + * Test fallback behavior when no flow type is provided + * Should default to 'apps' prefix for backward compatibility + */ + it('should return "apps" when flow type is undefined', () => { + const result = getFlowPrefix(undefined) + expect(result).toBe('apps') + }) + + /** + * Test fallback behavior for unknown flow types + * Any unrecognized flow type should default to 'apps' + */ + it('should return "apps" for unknown flow type', () => { + // Cast to FlowType to test the fallback behavior + const unknownType = 'unknown' as FlowType + const result = getFlowPrefix(unknownType) + expect(result).toBe('apps') + }) + + /** + * Test that the function handles null gracefully + * Null should be treated the same as undefined + */ + it('should return "apps" when flow type is null', () => { + const result = getFlowPrefix(null as any) + expect(result).toBe('apps') + }) + + /** + * Test consistency with flowPrefixMap + * The function should return the same values as direct map access + */ + it('should return values consistent with flowPrefixMap', () => { + expect(getFlowPrefix(FlowType.appFlow)).toBe(flowPrefixMap[FlowType.appFlow]) + expect(getFlowPrefix(FlowType.ragPipeline)).toBe(flowPrefixMap[FlowType.ragPipeline]) + }) + }) + + describe('Integration scenarios', () => { + /** + * Test typical usage pattern in API path construction + * This demonstrates how the function is used in real application code + */ + it('should construct correct API paths for different flow types', () => { + const appId = '123' + + // App flow path construction + const appFlowPath = `/${getFlowPrefix(FlowType.appFlow)}/${appId}` + expect(appFlowPath).toBe('/apps/123') + + // RAG pipeline path construction + const ragPipelinePath = `/${getFlowPrefix(FlowType.ragPipeline)}/${appId}` + expect(ragPipelinePath).toBe('/rag/pipelines/123') + }) + + /** + * Test that the function can be used in conditional logic + * Common pattern for determining which API endpoint to use + */ + it('should support conditional API routing logic', () => { + const determineEndpoint = (flowType?: FlowType, resourceId?: string) => { + const prefix = getFlowPrefix(flowType) + return `/${prefix}/${resourceId || 'default'}` + } + + expect(determineEndpoint(FlowType.appFlow, 'app-1')).toBe('/apps/app-1') + expect(determineEndpoint(FlowType.ragPipeline, 'pipeline-1')).toBe('/rag/pipelines/pipeline-1') + expect(determineEndpoint(undefined, 'fallback')).toBe('/apps/fallback') + }) + + /** + * Test behavior with empty string flow type + * Empty strings should fall back to default + */ + it('should handle empty string as flow type', () => { + const result = getFlowPrefix('' as any) + expect(result).toBe('apps') + }) + }) + + describe('Type safety', () => { + /** + * Test that all FlowType enum values are handled + * This ensures we don't miss any flow types in the mapping + */ + it('should handle all FlowType enum values', () => { + // Get all enum values + const flowTypes = Object.values(FlowType) + + // Each flow type should return a valid prefix + flowTypes.forEach((flowType) => { + const prefix = getFlowPrefix(flowType) + expect(prefix).toBeTruthy() + expect(typeof prefix).toBe('string') + expect(prefix.length).toBeGreaterThan(0) + }) + }) + + /** + * Test that returned prefixes are valid path segments + * Prefixes should not contain leading/trailing slashes or invalid characters + */ + it('should return valid path segments without leading/trailing slashes', () => { + const appFlowPrefix = getFlowPrefix(FlowType.appFlow) + const ragPipelinePrefix = getFlowPrefix(FlowType.ragPipeline) + + expect(appFlowPrefix).not.toMatch(/^\//) + expect(appFlowPrefix).not.toMatch(/\/$/) + expect(ragPipelinePrefix).not.toMatch(/^\//) + expect(ragPipelinePrefix).not.toMatch(/\/$/) + }) + }) +}) diff --git a/web/utils/clipboard.spec.ts b/web/utils/clipboard.spec.ts index ccdafe83f4..be64cbbe13 100644 --- a/web/utils/clipboard.spec.ts +++ b/web/utils/clipboard.spec.ts @@ -1,3 +1,13 @@ +/** + * Test suite for clipboard utilities + * + * This module provides cross-browser clipboard functionality with automatic fallback: + * 1. Modern Clipboard API (navigator.clipboard.writeText) - preferred method + * 2. Legacy execCommand('copy') - fallback for older browsers + * + * The implementation ensures clipboard operations work across all supported browsers + * while gracefully handling permissions and API availability. + */ import { writeTextToClipboard } from './clipboard' describe('Clipboard Utilities', () => { @@ -6,6 +16,10 @@ describe('Clipboard Utilities', () => { jest.restoreAllMocks() }) + /** + * Test modern Clipboard API usage + * When navigator.clipboard is available, should use the modern API + */ it('should use navigator.clipboard.writeText when available', async () => { const mockWriteText = jest.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { @@ -18,6 +32,11 @@ describe('Clipboard Utilities', () => { expect(mockWriteText).toHaveBeenCalledWith('test text') }) + /** + * Test fallback to legacy execCommand method + * When Clipboard API is unavailable, should use document.execCommand('copy') + * This involves creating a temporary textarea element + */ it('should fallback to execCommand when clipboard API not available', async () => { Object.defineProperty(navigator, 'clipboard', { value: undefined, @@ -38,6 +57,10 @@ describe('Clipboard Utilities', () => { expect(removeChildSpy).toHaveBeenCalled() }) + /** + * Test error handling when execCommand returns false + * execCommand returns false when the operation fails + */ it('should handle execCommand failure', async () => { Object.defineProperty(navigator, 'clipboard', { value: undefined, @@ -51,6 +74,10 @@ describe('Clipboard Utilities', () => { await expect(writeTextToClipboard('fail text')).rejects.toThrow() }) + /** + * Test error handling when execCommand throws an exception + * Should propagate the error to the caller + */ it('should handle execCommand exception', async () => { Object.defineProperty(navigator, 'clipboard', { value: undefined, @@ -66,6 +93,10 @@ describe('Clipboard Utilities', () => { await expect(writeTextToClipboard('error text')).rejects.toThrow('execCommand error') }) + /** + * Test proper cleanup of temporary DOM elements + * The temporary textarea should be removed after copying + */ it('should clean up textarea after fallback', async () => { Object.defineProperty(navigator, 'clipboard', { value: undefined, @@ -81,6 +112,10 @@ describe('Clipboard Utilities', () => { expect(removeChildSpy).toHaveBeenCalled() }) + /** + * Test copying empty strings + * Should handle edge case of empty clipboard content + */ it('should handle empty string', async () => { const mockWriteText = jest.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { @@ -93,6 +128,10 @@ describe('Clipboard Utilities', () => { expect(mockWriteText).toHaveBeenCalledWith('') }) + /** + * Test copying text with special characters + * Should preserve newlines, tabs, quotes, unicode, and emojis + */ it('should handle special characters', async () => { const mockWriteText = jest.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { diff --git a/web/utils/context.spec.ts b/web/utils/context.spec.ts new file mode 100644 index 0000000000..fb72e4f4de --- /dev/null +++ b/web/utils/context.spec.ts @@ -0,0 +1,253 @@ +/** + * Test suite for React context creation utilities + * + * This module provides helper functions to create React contexts with better type safety + * and automatic error handling when context is used outside of its provider. + * + * Two variants are provided: + * - createCtx: Standard React context using useContext/createContext + * - createSelectorCtx: Context with selector support using use-context-selector library + */ +import React from 'react' +import { renderHook } from '@testing-library/react' +import { createCtx, createSelectorCtx } from './context' + +describe('Context Utilities', () => { + describe('createCtx', () => { + /** + * Test that createCtx creates a valid context with provider and hook + * The function should return a tuple with [Provider, useContextValue, Context] + * plus named properties for easier access + */ + it('should create context with provider and hook', () => { + type TestContextValue = { value: string } + const [Provider, useTestContext, Context] = createCtx<TestContextValue>({ + name: 'Test', + }) + + expect(Provider).toBeDefined() + expect(useTestContext).toBeDefined() + expect(Context).toBeDefined() + }) + + /** + * Test that the context hook returns the provided value correctly + * when used within the context provider + */ + it('should provide and consume context value', () => { + type TestContextValue = { value: string } + const [Provider, useTestContext] = createCtx<TestContextValue>({ + name: 'Test', + }) + + const testValue = { value: 'test-value' } + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(Provider, { value: testValue }, children) + + const { result } = renderHook(() => useTestContext(), { wrapper }) + + expect(result.current).toEqual(testValue) + }) + + /** + * Test that accessing context outside of provider throws an error + * This ensures developers are notified when they forget to wrap components + */ + it('should throw error when used outside provider', () => { + type TestContextValue = { value: string } + const [, useTestContext] = createCtx<TestContextValue>({ + name: 'Test', + }) + + // Suppress console.error for this test + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => { /* suppress error */ }) + + expect(() => { + renderHook(() => useTestContext()) + }).toThrow('No Test context found.') + + consoleError.mockRestore() + }) + + /** + * Test that context works with default values + * When a default value is provided, it should be accessible without a provider + */ + it('should use default value when provided', () => { + type TestContextValue = { value: string } + const defaultValue = { value: 'default' } + const [, useTestContext] = createCtx<TestContextValue>({ + name: 'Test', + defaultValue, + }) + + const { result } = renderHook(() => useTestContext()) + + expect(result.current).toEqual(defaultValue) + }) + + /** + * Test that the returned tuple has named properties for convenience + * This allows destructuring or property access based on preference + */ + it('should expose named properties', () => { + type TestContextValue = { value: string } + const result = createCtx<TestContextValue>({ name: 'Test' }) + + expect(result.provider).toBe(result[0]) + expect(result.useContextValue).toBe(result[1]) + expect(result.context).toBe(result[2]) + }) + + /** + * Test context with complex data types + * Ensures type safety is maintained with nested objects and arrays + */ + it('should handle complex context values', () => { + type ComplexContext = { + user: { id: string; name: string } + settings: { theme: string; locale: string } + actions: Array<() => void> + } + + const [Provider, useComplexContext] = createCtx<ComplexContext>({ + name: 'Complex', + }) + + const complexValue: ComplexContext = { + user: { id: '123', name: 'Test User' }, + settings: { theme: 'dark', locale: 'en-US' }, + actions: [ + () => { /* empty action 1 */ }, + () => { /* empty action 2 */ }, + ], + } + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(Provider, { value: complexValue }, children) + + const { result } = renderHook(() => useComplexContext(), { wrapper }) + + expect(result.current).toEqual(complexValue) + expect(result.current.user.id).toBe('123') + expect(result.current.settings.theme).toBe('dark') + expect(result.current.actions).toHaveLength(2) + }) + + /** + * Test that context updates propagate to consumers + * When provider value changes, hooks should receive the new value + */ + it('should update when context value changes', () => { + type TestContextValue = { count: number } + const [Provider, useTestContext] = createCtx<TestContextValue>({ + name: 'Test', + }) + + let value = { count: 0 } + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(Provider, { value }, children) + + const { result, rerender } = renderHook(() => useTestContext(), { wrapper }) + + expect(result.current.count).toBe(0) + + value = { count: 5 } + rerender() + + expect(result.current.count).toBe(5) + }) + }) + + describe('createSelectorCtx', () => { + /** + * Test that createSelectorCtx creates a valid context with selector support + * This variant uses use-context-selector for optimized re-renders + */ + it('should create selector context with provider and hook', () => { + type TestContextValue = { value: string } + const [Provider, useTestContext, Context] = createSelectorCtx<TestContextValue>({ + name: 'SelectorTest', + }) + + expect(Provider).toBeDefined() + expect(useTestContext).toBeDefined() + expect(Context).toBeDefined() + }) + + /** + * Test that selector context provides and consumes values correctly + * The API should be identical to createCtx for basic usage + */ + it('should provide and consume context value with selector', () => { + type TestContextValue = { value: string } + const [Provider, useTestContext] = createSelectorCtx<TestContextValue>({ + name: 'SelectorTest', + }) + + const testValue = { value: 'selector-test' } + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(Provider, { value: testValue }, children) + + const { result } = renderHook(() => useTestContext(), { wrapper }) + + expect(result.current).toEqual(testValue) + }) + + /** + * Test error handling for selector context + * Should throw error when used outside provider, same as createCtx + */ + it('should throw error when used outside provider', () => { + type TestContextValue = { value: string } + const [, useTestContext] = createSelectorCtx<TestContextValue>({ + name: 'SelectorTest', + }) + + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => { /* suppress error */ }) + + expect(() => { + renderHook(() => useTestContext()) + }).toThrow('No SelectorTest context found.') + + consoleError.mockRestore() + }) + + /** + * Test that selector context works with default values + */ + it('should use default value when provided', () => { + type TestContextValue = { value: string } + const defaultValue = { value: 'selector-default' } + const [, useTestContext] = createSelectorCtx<TestContextValue>({ + name: 'SelectorTest', + defaultValue, + }) + + const { result } = renderHook(() => useTestContext()) + + expect(result.current).toEqual(defaultValue) + }) + }) + + describe('Context without name', () => { + /** + * Test that contexts can be created without a name + * The error message should use a generic fallback + */ + it('should create context without name and show generic error', () => { + type TestContextValue = { value: string } + const [, useTestContext] = createCtx<TestContextValue>() + + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => { /* suppress error */ }) + + expect(() => { + renderHook(() => useTestContext()) + }).toThrow('No related context found.') + + consoleError.mockRestore() + }) + }) +}) diff --git a/web/utils/model-config.spec.ts b/web/utils/model-config.spec.ts new file mode 100644 index 0000000000..2cccaabc61 --- /dev/null +++ b/web/utils/model-config.spec.ts @@ -0,0 +1,819 @@ +/** + * Test suite for model configuration transformation utilities + * + * This module handles the conversion between two different representations of user input forms: + * 1. UserInputFormItem: The form structure used in the UI + * 2. PromptVariable: The variable structure used in prompts and model configuration + * + * Key functions: + * - userInputsFormToPromptVariables: Converts UI form items to prompt variables + * - promptVariablesToUserInputsForm: Converts prompt variables back to form items + * - formatBooleanInputs: Ensures boolean inputs are properly typed + */ +import { + formatBooleanInputs, + promptVariablesToUserInputsForm, + userInputsFormToPromptVariables, +} from './model-config' +import type { UserInputFormItem } from '@/types/app' +import type { PromptVariable } from '@/models/debug' + +describe('Model Config Utilities', () => { + describe('userInputsFormToPromptVariables', () => { + /** + * Test handling of null or undefined input + * Should return empty array when no inputs provided + */ + it('should return empty array for null input', () => { + const result = userInputsFormToPromptVariables(null) + expect(result).toEqual([]) + }) + + /** + * Test conversion of text-input (string) type + * Text inputs are the most common form field type + */ + it('should convert text-input to string prompt variable', () => { + const userInputs: UserInputFormItem[] = [ + { + 'text-input': { + label: 'User Name', + variable: 'user_name', + required: true, + max_length: 100, + default: '', + hide: false, + }, + }, + ] + + const result = userInputsFormToPromptVariables(userInputs) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + key: 'user_name', + name: 'User Name', + required: true, + type: 'string', + max_length: 100, + options: [], + is_context_var: false, + hide: false, + default: '', + }) + }) + + /** + * Test conversion of paragraph type + * Paragraphs are multi-line text inputs + */ + it('should convert paragraph to paragraph prompt variable', () => { + const userInputs: UserInputFormItem[] = [ + { + paragraph: { + label: 'Description', + variable: 'description', + required: false, + max_length: 500, + default: '', + hide: false, + }, + }, + ] + + const result = userInputsFormToPromptVariables(userInputs) + + expect(result[0]).toEqual({ + key: 'description', + name: 'Description', + required: false, + type: 'paragraph', + max_length: 500, + options: [], + is_context_var: false, + hide: false, + default: '', + }) + }) + + /** + * Test conversion of number type + * Number inputs should preserve numeric constraints + */ + it('should convert number input to number prompt variable', () => { + const userInputs: UserInputFormItem[] = [ + { + number: { + label: 'Age', + variable: 'age', + required: true, + default: '', + hide: false, + }, + } as any, + ] + + const result = userInputsFormToPromptVariables(userInputs) + + expect(result[0]).toEqual({ + key: 'age', + name: 'Age', + required: true, + type: 'number', + options: [], + hide: false, + default: '', + }) + }) + + /** + * Test conversion of checkbox (boolean) type + * Checkboxes are converted to 'checkbox' type in prompt variables + */ + it('should convert checkbox to checkbox prompt variable', () => { + const userInputs: UserInputFormItem[] = [ + { + checkbox: { + label: 'Accept Terms', + variable: 'accept_terms', + required: true, + default: '', + hide: false, + }, + } as any, + ] + + const result = userInputsFormToPromptVariables(userInputs) + + expect(result[0]).toEqual({ + key: 'accept_terms', + name: 'Accept Terms', + required: true, + type: 'checkbox', + options: [], + hide: false, + default: '', + }) + }) + + /** + * Test conversion of select (dropdown) type + * Select inputs include options array + */ + it('should convert select input to select prompt variable', () => { + const userInputs: UserInputFormItem[] = [ + { + select: { + label: 'Country', + variable: 'country', + required: true, + options: ['USA', 'Canada', 'Mexico'], + default: 'USA', + hide: false, + }, + }, + ] + + const result = userInputsFormToPromptVariables(userInputs) + + expect(result[0]).toEqual({ + key: 'country', + name: 'Country', + required: true, + type: 'select', + options: ['USA', 'Canada', 'Mexico'], + is_context_var: false, + hide: false, + default: 'USA', + }) + }) + + /** + * Test conversion of file upload type + * File inputs include configuration for allowed types and upload methods + */ + it('should convert file input to file prompt variable', () => { + const userInputs: UserInputFormItem[] = [ + { + file: { + label: 'Profile Picture', + variable: 'profile_pic', + required: false, + allowed_file_types: ['image'], + allowed_file_extensions: ['.jpg', '.png'], + allowed_file_upload_methods: ['local_file', 'remote_url'], + default: '', + hide: false, + }, + } as any, + ] + + const result = userInputsFormToPromptVariables(userInputs) + + expect(result[0]).toEqual({ + key: 'profile_pic', + name: 'Profile Picture', + required: false, + type: 'file', + config: { + allowed_file_types: ['image'], + allowed_file_extensions: ['.jpg', '.png'], + allowed_file_upload_methods: ['local_file', 'remote_url'], + number_limits: 1, + }, + hide: false, + default: '', + }) + }) + + /** + * Test conversion of file-list type + * File lists allow multiple file uploads with a max_length constraint + */ + it('should convert file-list input to file-list prompt variable', () => { + const userInputs: UserInputFormItem[] = [ + { + 'file-list': { + label: 'Documents', + variable: 'documents', + required: true, + allowed_file_types: ['document'], + allowed_file_extensions: ['.pdf', '.docx'], + allowed_file_upload_methods: ['local_file'], + max_length: 5, + default: '', + hide: false, + }, + } as any, + ] + + const result = userInputsFormToPromptVariables(userInputs) + + expect(result[0]).toEqual({ + key: 'documents', + name: 'Documents', + required: true, + type: 'file-list', + config: { + allowed_file_types: ['document'], + allowed_file_extensions: ['.pdf', '.docx'], + allowed_file_upload_methods: ['local_file'], + number_limits: 5, + }, + hide: false, + default: '', + }) + }) + + /** + * Test conversion of external_data_tool type + * External data tools have custom configuration and icons + */ + it('should convert external_data_tool to prompt variable', () => { + const userInputs: UserInputFormItem[] = [ + { + external_data_tool: { + label: 'API Data', + variable: 'api_data', + type: 'api', + enabled: true, + required: false, + config: { endpoint: 'https://api.example.com' }, + icon: 'api-icon', + icon_background: '#FF5733', + hide: false, + }, + } as any, + ] + + const result = userInputsFormToPromptVariables(userInputs) + + expect(result[0]).toEqual({ + key: 'api_data', + name: 'API Data', + required: false, + type: 'api', + enabled: true, + config: { endpoint: 'https://api.example.com' }, + icon: 'api-icon', + icon_background: '#FF5733', + is_context_var: false, + hide: false, + }) + }) + + /** + * Test handling of dataset_query_variable + * When a variable matches the dataset_query_variable, is_context_var should be true + */ + it('should mark variable as context var when matching dataset_query_variable', () => { + const userInputs: UserInputFormItem[] = [ + { + 'text-input': { + label: 'Query', + variable: 'query', + required: true, + max_length: 200, + default: '', + hide: false, + }, + }, + ] + + const result = userInputsFormToPromptVariables(userInputs, 'query') + + expect(result[0].is_context_var).toBe(true) + }) + + /** + * Test conversion of multiple mixed input types + * Should handle an array with different input types correctly + */ + it('should convert multiple mixed input types', () => { + const userInputs: UserInputFormItem[] = [ + { + 'text-input': { + label: 'Name', + variable: 'name', + required: true, + max_length: 50, + default: '', + hide: false, + }, + }, + { + number: { + label: 'Age', + variable: 'age', + required: false, + default: '', + hide: false, + }, + } as any, + { + select: { + label: 'Gender', + variable: 'gender', + required: true, + options: ['Male', 'Female', 'Other'], + default: '', + hide: false, + }, + }, + ] + + const result = userInputsFormToPromptVariables(userInputs) + + expect(result).toHaveLength(3) + expect(result[0].type).toBe('string') + expect(result[1].type).toBe('number') + expect(result[2].type).toBe('select') + }) + }) + + describe('promptVariablesToUserInputsForm', () => { + /** + * Test conversion of string prompt variable back to text-input + */ + it('should convert string prompt variable to text-input', () => { + const promptVariables: PromptVariable[] = [ + { + key: 'user_name', + name: 'User Name', + required: true, + type: 'string', + max_length: 100, + options: [], + }, + ] + + const result = promptVariablesToUserInputsForm(promptVariables) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + 'text-input': { + label: 'User Name', + variable: 'user_name', + required: true, + max_length: 100, + default: '', + hide: undefined, + }, + }) + }) + + /** + * Test conversion of paragraph prompt variable + */ + it('should convert paragraph prompt variable to paragraph input', () => { + const promptVariables: PromptVariable[] = [ + { + key: 'description', + name: 'Description', + required: false, + type: 'paragraph', + max_length: 500, + options: [], + }, + ] + + const result = promptVariablesToUserInputsForm(promptVariables) + + expect(result[0]).toEqual({ + paragraph: { + label: 'Description', + variable: 'description', + required: false, + max_length: 500, + default: '', + hide: undefined, + }, + }) + }) + + /** + * Test conversion of number prompt variable + */ + it('should convert number prompt variable to number input', () => { + const promptVariables: PromptVariable[] = [ + { + key: 'age', + name: 'Age', + required: true, + type: 'number', + options: [], + }, + ] + + const result = promptVariablesToUserInputsForm(promptVariables) + + expect(result[0]).toEqual({ + number: { + label: 'Age', + variable: 'age', + required: true, + default: '', + hide: undefined, + }, + }) + }) + + /** + * Test conversion of checkbox prompt variable + */ + it('should convert checkbox prompt variable to checkbox input', () => { + const promptVariables: PromptVariable[] = [ + { + key: 'accept_terms', + name: 'Accept Terms', + required: true, + type: 'checkbox', + options: [], + }, + ] + + const result = promptVariablesToUserInputsForm(promptVariables) + + expect(result[0]).toEqual({ + checkbox: { + label: 'Accept Terms', + variable: 'accept_terms', + required: true, + default: '', + hide: undefined, + }, + }) + }) + + /** + * Test conversion of select prompt variable + */ + it('should convert select prompt variable to select input', () => { + const promptVariables: PromptVariable[] = [ + { + key: 'country', + name: 'Country', + required: true, + type: 'select', + options: ['USA', 'Canada', 'Mexico'], + default: 'USA', + }, + ] + + const result = promptVariablesToUserInputsForm(promptVariables) + + expect(result[0]).toEqual({ + select: { + label: 'Country', + variable: 'country', + required: true, + options: ['USA', 'Canada', 'Mexico'], + default: 'USA', + hide: undefined, + }, + }) + }) + + /** + * Test filtering of invalid prompt variables + * Variables without key or name should be filtered out + */ + it('should filter out variables with empty key or name', () => { + const promptVariables: PromptVariable[] = [ + { + key: '', + name: 'Empty Key', + required: true, + type: 'string', + options: [], + }, + { + key: 'valid', + name: '', + required: true, + type: 'string', + options: [], + }, + { + key: ' ', + name: 'Whitespace Key', + required: true, + type: 'string', + options: [], + }, + { + key: 'valid_key', + name: 'Valid Name', + required: true, + type: 'string', + options: [], + }, + ] + + const result = promptVariablesToUserInputsForm(promptVariables) + + expect(result).toHaveLength(1) + expect((result[0] as any)['text-input']?.variable).toBe('valid_key') + }) + + /** + * Test conversion of external data tool prompt variable + */ + it('should convert external data tool prompt variable', () => { + const promptVariables: PromptVariable[] = [ + { + key: 'api_data', + name: 'API Data', + required: false, + type: 'api', + enabled: true, + config: { endpoint: 'https://api.example.com' }, + icon: 'api-icon', + icon_background: '#FF5733', + }, + ] + + const result = promptVariablesToUserInputsForm(promptVariables) + + expect(result[0]).toEqual({ + external_data_tool: { + label: 'API Data', + variable: 'api_data', + enabled: true, + type: 'api', + config: { endpoint: 'https://api.example.com' }, + required: false, + icon: 'api-icon', + icon_background: '#FF5733', + hide: undefined, + }, + }) + }) + + /** + * Test that required defaults to true when not explicitly set to false + */ + it('should default required to true when not false', () => { + const promptVariables: PromptVariable[] = [ + { + key: 'test1', + name: 'Test 1', + required: undefined, + type: 'string', + options: [], + }, + { + key: 'test2', + name: 'Test 2', + required: false, + type: 'string', + options: [], + }, + ] + + const result = promptVariablesToUserInputsForm(promptVariables) + + expect((result[0] as any)['text-input']?.required).toBe(true) + expect((result[1] as any)['text-input']?.required).toBe(false) + }) + }) + + describe('formatBooleanInputs', () => { + /** + * Test that null or undefined inputs are handled gracefully + */ + it('should return inputs unchanged when useInputs is null', () => { + const inputs = { key1: 'value1', key2: 'value2' } + const result = formatBooleanInputs(null, inputs) + expect(result).toEqual(inputs) + }) + + it('should return inputs unchanged when useInputs is undefined', () => { + const inputs = { key1: 'value1', key2: 'value2' } + const result = formatBooleanInputs(undefined, inputs) + expect(result).toEqual(inputs) + }) + + /** + * Test conversion of boolean input values to actual boolean type + * This is important for proper type handling in the backend + * Note: checkbox inputs are converted to type 'checkbox' by userInputsFormToPromptVariables + */ + it('should convert boolean inputs to boolean type', () => { + const useInputs: PromptVariable[] = [ + { + key: 'accept_terms', + name: 'Accept Terms', + required: true, + type: 'checkbox', + options: [], + }, + { + key: 'subscribe', + name: 'Subscribe', + required: false, + type: 'checkbox', + options: [], + }, + ] + + const inputs = { + accept_terms: 'true', + subscribe: '', + other_field: 'value', + } + + const result = formatBooleanInputs(useInputs, inputs) + + expect(result).toEqual({ + accept_terms: true, + subscribe: false, + other_field: 'value', + }) + }) + + /** + * Test that non-boolean inputs are not affected + */ + it('should not modify non-boolean inputs', () => { + const useInputs: PromptVariable[] = [ + { + key: 'name', + name: 'Name', + required: true, + type: 'string', + options: [], + }, + { + key: 'age', + name: 'Age', + required: true, + type: 'number', + options: [], + }, + ] + + const inputs = { + name: 'John Doe', + age: 30, + } + + const result = formatBooleanInputs(useInputs, inputs) + + expect(result).toEqual(inputs) + }) + + /** + * Test handling of truthy and falsy values for boolean conversion + * Note: checkbox inputs are converted to type 'checkbox' by userInputsFormToPromptVariables + */ + it('should handle various truthy and falsy values', () => { + const useInputs: PromptVariable[] = [ + { + key: 'bool1', + name: 'Bool 1', + required: true, + type: 'checkbox', + options: [], + }, + { + key: 'bool2', + name: 'Bool 2', + required: true, + type: 'checkbox', + options: [], + }, + { + key: 'bool3', + name: 'Bool 3', + required: true, + type: 'checkbox', + options: [], + }, + { + key: 'bool4', + name: 'Bool 4', + required: true, + type: 'checkbox', + options: [], + }, + ] + + const inputs = { + bool1: 1, + bool2: 0, + bool3: 'yes', + bool4: null as any, + } + + const result = formatBooleanInputs(useInputs, inputs) + + expect(result?.bool1).toBe(true) + expect(result?.bool2).toBe(false) + expect(result?.bool3).toBe(true) + expect(result?.bool4).toBe(false) + }) + + /** + * Test that the function creates a new object and doesn't mutate the original + * Note: checkbox inputs are converted to type 'checkbox' by userInputsFormToPromptVariables + */ + it('should not mutate original inputs object', () => { + const useInputs: PromptVariable[] = [ + { + key: 'flag', + name: 'Flag', + required: true, + type: 'checkbox', + options: [], + }, + ] + + const inputs = { flag: 'true', other: 'value' } + const originalInputs = { ...inputs } + + formatBooleanInputs(useInputs, inputs) + + expect(inputs).toEqual(originalInputs) + }) + }) + + describe('Round-trip conversion', () => { + /** + * Test that converting from UserInputForm to PromptVariable and back + * preserves the essential data (though some fields may have defaults applied) + */ + it('should preserve data through round-trip conversion', () => { + const originalUserInputs: UserInputFormItem[] = [ + { + 'text-input': { + label: 'Name', + variable: 'name', + required: true, + max_length: 50, + default: '', + hide: false, + }, + }, + { + select: { + label: 'Type', + variable: 'type', + required: false, + options: ['A', 'B', 'C'], + default: 'A', + hide: false, + }, + }, + ] + + const promptVars = userInputsFormToPromptVariables(originalUserInputs) + const backToUserInputs = promptVariablesToUserInputsForm(promptVars) + + expect(backToUserInputs).toHaveLength(2) + expect((backToUserInputs[0] as any)['text-input']?.variable).toBe('name') + expect((backToUserInputs[1] as any).select?.variable).toBe('type') + expect((backToUserInputs[1] as any).select?.options).toEqual(['A', 'B', 'C']) + }) + }) +}) diff --git a/web/utils/model-config.ts b/web/utils/model-config.ts index 3f655ce036..707a3685b9 100644 --- a/web/utils/model-config.ts +++ b/web/utils/model-config.ts @@ -200,7 +200,7 @@ export const formatBooleanInputs = (useInputs?: PromptVariable[] | null, inputs? return inputs const res = { ...inputs } useInputs.forEach((item) => { - const isBooleanInput = item.type === 'boolean' + const isBooleanInput = item.type === 'checkbox' if (isBooleanInput) { // Convert boolean inputs to boolean type res[item.key] = !!res[item.key] From 1bbb9d664451180aed73b92b9f5def9565fa6f61 Mon Sep 17 00:00:00 2001 From: Asuka Minato <i@asukaminato.eu.org> Date: Wed, 12 Nov 2025 22:50:13 +0900 Subject: [PATCH 22/23] convert to TypeBase (#27935) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/models/provider.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/api/models/provider.py b/api/models/provider.py index e9365adb93..4de17a7fd5 100644 --- a/api/models/provider.py +++ b/api/models/provider.py @@ -6,7 +6,7 @@ import sqlalchemy as sa from sqlalchemy import DateTime, String, func, text from sqlalchemy.orm import Mapped, mapped_column -from .base import Base +from .base import Base, TypeBase from .engine import db from .types import StringUUID @@ -41,7 +41,7 @@ class ProviderQuotaType(StrEnum): raise ValueError(f"No matching enum found for value '{value}'") -class Provider(Base): +class Provider(TypeBase): """ Provider model representing the API providers and their configurations. """ @@ -55,25 +55,27 @@ class Provider(Base): ), ) - id: Mapped[str] = mapped_column(StringUUID, server_default=text("uuid_generate_v4()")) + id: Mapped[str] = mapped_column(StringUUID, primary_key=True, server_default=text("uuidv7()"), init=False) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) provider_name: Mapped[str] = mapped_column(String(255), nullable=False) provider_type: Mapped[str] = mapped_column( - String(40), nullable=False, server_default=text("'custom'::character varying") + String(40), nullable=False, server_default=text("'custom'::character varying"), default="custom" ) - is_valid: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=text("false")) - last_used: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - credential_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + is_valid: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=text("false"), default=False) + last_used: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, init=False) + credential_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) quota_type: Mapped[str | None] = mapped_column( - String(40), nullable=True, server_default=text("''::character varying") + String(40), nullable=True, server_default=text("''::character varying"), default="" ) - quota_limit: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True) - quota_used: Mapped[int | None] = mapped_column(sa.BigInteger, default=0) + quota_limit: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True, default=None) + quota_used: Mapped[int] = mapped_column(sa.BigInteger, nullable=False, default=0) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) updated_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp() + DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp(), init=False ) def __repr__(self): From fe6538b08d786299a5c13c99f8593f8eae83a522 Mon Sep 17 00:00:00 2001 From: -LAN- <laipz8200@outlook.com> Date: Wed, 12 Nov 2025 22:55:02 +0800 Subject: [PATCH 23/23] chore: disable workflow logs auto-cleanup by default (#28136) This PR changes the default value of `WORKFLOW_LOG_CLEANUP_ENABLED` from `true` to `false` across all configuration files. ## Motivation Setting the default to `false` provides safer default behavior by: - Preventing unintended data loss for new installations - Giving users explicit control over when to enable log cleanup - Following the opt-in principle for data deletion features Users who need automatic cleanup can enable it by setting `WORKFLOW_LOG_CLEANUP_ENABLED=true` in their configuration. --- api/.env.example | 2 +- api/configs/feature/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/.env.example b/api/.env.example index 64fe20aa27..b1ac15d25b 100644 --- a/api/.env.example +++ b/api/.env.example @@ -527,7 +527,7 @@ API_WORKFLOW_NODE_EXECUTION_REPOSITORY=repositories.sqlalchemy_api_workflow_node API_WORKFLOW_RUN_REPOSITORY=repositories.sqlalchemy_api_workflow_run_repository.DifyAPISQLAlchemyWorkflowRunRepository # Workflow log cleanup configuration # Enable automatic cleanup of workflow run logs to manage database size -WORKFLOW_LOG_CLEANUP_ENABLED=true +WORKFLOW_LOG_CLEANUP_ENABLED=false # Number of days to retain workflow run logs (default: 30 days) WORKFLOW_LOG_RETENTION_DAYS=30 # Batch size for workflow log cleanup operations (default: 100) diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 65f07d65c3..ff1f983f94 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -1190,7 +1190,7 @@ class AccountConfig(BaseSettings): class WorkflowLogConfig(BaseSettings): - WORKFLOW_LOG_CLEANUP_ENABLED: bool = Field(default=True, description="Enable workflow run log cleanup") + WORKFLOW_LOG_CLEANUP_ENABLED: bool = Field(default=False, description="Enable workflow run log cleanup") WORKFLOW_LOG_RETENTION_DAYS: int = Field(default=30, description="Retention days for workflow run logs") WORKFLOW_LOG_CLEANUP_BATCH_SIZE: int = Field( default=100, description="Batch size for workflow run log cleanup operations"