diff --git a/src/backend/base/langflow/alembic/versions/f3b2d1f1002d_add_column_access_type_to_flow.py b/src/backend/base/langflow/alembic/versions/f3b2d1f1002d_add_column_access_type_to_flow.py new file mode 100644 index 000000000..575a151b7 --- /dev/null +++ b/src/backend/base/langflow/alembic/versions/f3b2d1f1002d_add_column_access_type_to_flow.py @@ -0,0 +1,33 @@ +"""add column 'access_type' to flow + +Revision ID: f3b2d1f1002d +Revises: 93e2705fa8d6 +Create Date: 2025-02-05 14:35:29.658101 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from langflow.utils import migration + + +# revision identifiers, used by Alembic. +revision: str = 'f3b2d1f1002d' +down_revision: Union[str, None] = '93e2705fa8d6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + with op.batch_alter_table('flow', schema=None) as batch_op: + if not migration.column_exists(table_name='flow', column_name='access_type', conn=conn): + batch_op.add_column(sa.Column('access_type', sa.Enum('PRIVATE', 'PUBLIC', name='access_type_enum'), server_default='private', nullable=False)) + + +def downgrade() -> None: + conn = op.get_bind() + with op.batch_alter_table('flow', schema=None) as batch_op: + if migration.column_exists(table_name='flow', column_name='access_type', conn=conn): + batch_op.drop_column('access_type') diff --git a/src/backend/base/langflow/api/build.py b/src/backend/base/langflow/api/build.py index 586d1112f..a68b538c0 100644 --- a/src/backend/base/langflow/api/build.py +++ b/src/backend/base/langflow/api/build.py @@ -50,6 +50,7 @@ async def start_flow_build( log_builds: bool, current_user: CurrentActiveUser, queue_service: JobQueueService, + flow_name: str | None = None, ) -> str: """Start the flow build process by setting up the queue and starting the build task. @@ -70,6 +71,7 @@ async def start_flow_build( start_component_id=start_component_id, log_builds=log_builds, current_user=current_user, + flow_name=flow_name, ) queue_service.start_job(job_id, task_coro) except Exception as e: @@ -154,6 +156,7 @@ async def generate_flow_events( start_component_id: str | None, log_builds: bool, current_user: CurrentActiveUser, + flow_name: str | None = None, ) -> None: """Generate events for flow building process. @@ -175,7 +178,7 @@ async def generate_flow_events( flow_id_str = str(flow_id) # Create a fresh session for database operations async with session_scope() as fresh_session: - graph = await create_graph(fresh_session, flow_id_str) + graph = await create_graph(fresh_session, flow_id_str, flow_name) graph.validate_stream() first_layer = sort_vertices(graph) @@ -214,7 +217,7 @@ async def generate_flow_events( ), ) - async def create_graph(fresh_session, flow_id_str: str) -> Graph: + async def create_graph(fresh_session, flow_id_str: str, flow_name: str | None) -> Graph: if inputs is not None and getattr(inputs, "session", None) is not None: effective_session_id = inputs.session else: @@ -229,8 +232,9 @@ async def generate_flow_events( session_id=effective_session_id, ) - result = await fresh_session.exec(select(Flow.name).where(Flow.id == flow_id)) - flow_name = result.first() + if not flow_name: + result = await fresh_session.exec(select(Flow.name).where(Flow.id == flow_id)) + flow_name = result.first() return await build_graph_from_data( flow_id=flow_id_str, diff --git a/src/backend/base/langflow/api/utils.py b/src/backend/base/langflow/api/utils.py index 48549fd57..6a8258f0a 100644 --- a/src/backend/base/langflow/api/utils.py +++ b/src/backend/base/langflow/api/utils.py @@ -303,3 +303,63 @@ def custom_params( if page is None and size is None: return None return Params(page=page or MIN_PAGE_SIZE, size=size or MAX_PAGE_SIZE) + + +async def verify_public_flow_and_get_user(flow_id: uuid.UUID, client_id: str | None) -> tuple[User, uuid.UUID]: + """Verify a public flow request and generate a deterministic flow ID. + + This utility function: + 1. Checks that a client_id cookie is provided + 2. Verifies the flow exists and is marked as PUBLIC + 3. Creates a deterministic UUID based on client_id and original flow_id + 4. Retrieves the flow owner user for permission purposes + + This function is used to support public flow endpoints that don't require + authentication but still need to operate within the permission model. + + Args: + flow_id: The original flow ID to verify + client_id: The client ID from the request cookie + + Returns: + tuple: (flow owner user, deterministic flow ID for tracking) + + Raises: + HTTPException: + - 400 if no client_id is provided + - 403 if flow doesn't exist or isn't public + - 403 if unable to retrieve the flow owner user + - 403 if user is not found for public flow + """ + if not client_id: + raise HTTPException(status_code=400, detail="No client_id cookie found") + + # Check if the flow is public + async with session_scope() as session: + from sqlmodel import select + + from langflow.services.database.models.flow.model import AccessTypeEnum, Flow + + flow = (await session.exec(select(Flow).where(Flow.id == flow_id))).first() + if not flow or flow.access_type is not AccessTypeEnum.PUBLIC: + raise HTTPException(status_code=403, detail="Flow is not public") + + # Create a new flow ID using the client_id and flow_id + new_id = f"{client_id}_{flow_id}" + new_flow_id = uuid.uuid5(uuid.NAMESPACE_DNS, new_id) + + # Get the user associated with the flow + try: + from langflow.helpers.user import get_user_by_flow_id_or_endpoint_name + + user = await get_user_by_flow_id_or_endpoint_name(str(flow_id)) + + except Exception as exc: + logger.exception(f"Error getting user for public flow {flow_id}") + raise HTTPException(status_code=403, detail="Flow is not accessible") from exc + + if not user: + msg = f"User not found for public flow {flow_id}" + raise HTTPException(status_code=403, detail=msg) + + return user, new_flow_id diff --git a/src/backend/base/langflow/api/v1/chat.py b/src/backend/base/langflow/api/v1/chat.py index 0f2fed846..0af57def0 100644 --- a/src/backend/base/langflow/api/v1/chat.py +++ b/src/backend/base/langflow/api/v1/chat.py @@ -6,7 +6,15 @@ import traceback import uuid from typing import TYPE_CHECKING, Annotated -from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, status +from fastapi import ( + APIRouter, + BackgroundTasks, + Body, + Depends, + HTTPException, + Request, + status, +) from fastapi.responses import StreamingResponse from loguru import logger @@ -25,6 +33,7 @@ from langflow.api.utils import ( format_exception_message, get_top_level_vertices, parse_exception, + verify_public_flow_and_get_user, ) from langflow.api.v1.schemas import ( CancelFlowResponse, @@ -142,8 +151,29 @@ async def build_flow( log_builds: bool = True, current_user: CurrentActiveUser, queue_service: Annotated[JobQueueService, Depends(get_queue_service)], + flow_name: str | None = None, ): - """Build and process a flow, returning a job ID for event polling.""" + """Build and process a flow, returning a job ID for event polling. + + This endpoint requires authentication through the CurrentActiveUser dependency. + For public flows that don't require authentication, use the /build_public_tmp/{flow_id}/flow endpoint. + + Args: + flow_id: UUID of the flow to build + background_tasks: Background tasks manager + inputs: Optional input values for the flow + data: Optional flow data + files: Optional files to include + stop_component_id: Optional ID of component to stop at + start_component_id: Optional ID of component to start from + log_builds: Whether to log the build process + current_user: The authenticated user + queue_service: Queue service for job management + flow_name: Optional name for the flow + + Returns: + Dict with job_id that can be used to poll for build status + """ # First verify the flow exists async with session_scope() as session: flow = await session.get(Flow, flow_id) @@ -161,6 +191,7 @@ async def build_flow( log_builds=log_builds, current_user=current_user, queue_service=queue_service, + flow_name=flow_name, ) return {"job_id": job_id} @@ -254,7 +285,9 @@ async def build_vertex( # If there's no cache logger.warning(f"No cache found for {flow_id_str}. Building graph starting at {vertex_id}") graph = await build_graph_from_db( - flow_id=flow_id, session=await anext(get_session()), chat_service=chat_service + flow_id=flow_id, + session=await anext(get_session()), + chat_service=chat_service, ) else: graph = cache.get("result") @@ -450,7 +483,11 @@ async def _stream_vertex(flow_id: str, vertex_id: str, chat_service: ChatService yield str(StreamData(event="close", data={"message": "Stream closed"})) -@router.get("/build/{flow_id}/{vertex_id}/stream", response_class=StreamingResponse, deprecated=True) +@router.get( + "/build/{flow_id}/{vertex_id}/stream", + response_class=StreamingResponse, + deprecated=True, +) async def build_vertex_stream( flow_id: uuid.UUID, vertex_id: str, @@ -482,7 +519,80 @@ async def build_vertex_stream( """ try: return StreamingResponse( - _stream_vertex(str(flow_id), vertex_id, get_chat_service()), media_type="text/event-stream" + _stream_vertex(str(flow_id), vertex_id, get_chat_service()), + media_type="text/event-stream", ) except Exception as exc: raise HTTPException(status_code=500, detail="Error building Component") from exc + + +@router.post("/build_public_tmp/{flow_id}/flow") +async def build_public_tmp( + *, + background_tasks: LimitVertexBuildBackgroundTasks, + flow_id: uuid.UUID, + inputs: Annotated[InputValueRequest | None, Body(embed=True)] = None, + data: Annotated[FlowDataRequest | None, Body(embed=True)] = None, + files: list[str] | None = None, + stop_component_id: str | None = None, + start_component_id: str | None = None, + log_builds: bool | None = True, + flow_name: str | None = None, + request: Request, + queue_service: Annotated[JobQueueService, Depends(get_queue_service)], +): + """Build a public flow without requiring authentication. + + This endpoint is specifically for public flows that don't require authentication. + It uses a client_id cookie to create a deterministic flow ID for tracking purposes. + + The endpoint: + 1. Verifies the requested flow is marked as public in the database + 2. Creates a deterministic UUID based on client_id and flow_id + 3. Uses the flow owner's permissions to build the flow + + Requirements: + - The flow must be marked as PUBLIC in the database + - The request must include a client_id cookie + + Args: + flow_id: UUID of the public flow to build + background_tasks: Background tasks manager + inputs: Optional input values for the flow + data: Optional flow data + files: Optional files to include + stop_component_id: Optional ID of component to stop at + start_component_id: Optional ID of component to start from + log_builds: Whether to log the build process + flow_name: Optional name for the flow + request: FastAPI request object (needed for cookie access) + queue_service: Queue service for job management + + Returns: + Dict with job_id that can be used to poll for build status + """ + try: + # Verify this is a public flow and get the associated user + client_id = request.cookies.get("client_id") + owner_user, new_flow_id = await verify_public_flow_and_get_user(flow_id=flow_id, client_id=client_id) + + # Start the flow build using the new flow ID + job_id = await start_flow_build( + flow_id=new_flow_id, + background_tasks=background_tasks, + inputs=inputs, + data=data, + files=files, + stop_component_id=stop_component_id, + start_component_id=start_component_id, + log_builds=log_builds or False, + current_user=owner_user, + queue_service=queue_service, + flow_name=flow_name or f"{client_id}_{flow_id}", + ) + except Exception as exc: + logger.exception("Error building public flow") + if isinstance(exc, HTTPException): + raise + raise HTTPException(status_code=500, detail=str(exc)) from exc + return {"job_id": job_id} diff --git a/src/backend/base/langflow/api/v1/flows.py b/src/backend/base/langflow/api/v1/flows.py index 3995788a6..8519f9be6 100644 --- a/src/backend/base/langflow/api/v1/flows.py +++ b/src/backend/base/langflow/api/v1/flows.py @@ -21,10 +21,11 @@ from sqlmodel.ext.asyncio.session import AsyncSession from langflow.api.utils import CurrentActiveUser, DbSession, cascade_delete_flow, remove_api_keys, validate_is_component from langflow.api.v1.schemas import FlowListCreate +from langflow.helpers.user import get_user_by_flow_id_or_endpoint_name from langflow.initial_setup.constants import STARTER_FOLDER_NAME from langflow.logging import logger from langflow.services.database.models.flow import Flow, FlowCreate, FlowRead, FlowUpdate -from langflow.services.database.models.flow.model import FlowHeader +from langflow.services.database.models.flow.model import AccessTypeEnum, FlowHeader from langflow.services.database.models.flow.utils import get_webhook_component_in_flow from langflow.services.database.models.folder.constants import DEFAULT_FOLDER_NAME from langflow.services.database.models.folder.model import Folder @@ -278,6 +279,21 @@ async def read_flow( raise HTTPException(status_code=404, detail="Flow not found") +@router.get("/public_flow/{flow_id}", response_model=FlowRead, status_code=200) +async def read_public_flow( + *, + session: DbSession, + flow_id: UUID, +): + """Read a public flow.""" + access_type = (await session.exec(select(Flow.access_type).where(Flow.id == flow_id))).first() + if access_type is not AccessTypeEnum.PUBLIC: + raise HTTPException(status_code=403, detail="Flow is not public") + + current_user = await get_user_by_flow_id_or_endpoint_name(str(flow_id)) + return await read_flow(session=session, flow_id=flow_id, current_user=current_user) + + @router.patch("/{flow_id}", response_model=FlowRead, status_code=200) async def update_flow( *, diff --git a/src/backend/base/langflow/api/v1/schemas.py b/src/backend/base/langflow/api/v1/schemas.py index 777390a12..08e0ade10 100644 --- a/src/backend/base/langflow/api/v1/schemas.py +++ b/src/backend/base/langflow/api/v1/schemas.py @@ -4,7 +4,14 @@ from pathlib import Path from typing import Any, Literal from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator, model_serializer +from pydantic import ( + BaseModel, + ConfigDict, + Field, + field_serializer, + field_validator, + model_serializer, +) from langflow.graph.schema import RunOutputs from langflow.schema import dotdict @@ -377,6 +384,8 @@ class ConfigResponse(BaseModel): health_check_max_retries: int max_file_size_upload: int webhook_polling_interval: int + public_flow_cleanup_interval: int + public_flow_expiration: int event_delivery: Literal["polling", "streaming"] diff --git a/src/backend/base/langflow/graph/graph/base.py b/src/backend/base/langflow/graph/graph/base.py index df79ac575..32a613a5d 100644 --- a/src/backend/base/langflow/graph/graph/base.py +++ b/src/backend/base/langflow/graph/graph/base.py @@ -341,6 +341,7 @@ class Graph: self, inputs: list[dict] | None = None, max_iterations: int | None = None, + config: StartConfigDict | None = None, event_manager: EventManager | None = None, ): if not self._prepared: @@ -348,6 +349,8 @@ class Graph: raise ValueError(msg) # The idea is for this to return a generator that yields the result of # each step call and raise StopIteration when the graph is done + if config is not None: + self.__apply_config(config) for _input in inputs or []: for key, value in _input.items(): vertex = self.get_vertex(key) diff --git a/src/backend/base/langflow/main.py b/src/backend/base/langflow/main.py index 684564ae0..290284cbe 100644 --- a/src/backend/base/langflow/main.py +++ b/src/backend/base/langflow/main.py @@ -274,6 +274,7 @@ def create_app(): FastAPIInstrumentor.instrument_app(app) add_pagination(app) + return app diff --git a/src/backend/base/langflow/memory.py b/src/backend/base/langflow/memory.py index cdb438fbe..66cfabd87 100644 --- a/src/backend/base/langflow/memory.py +++ b/src/backend/base/langflow/memory.py @@ -1,3 +1,4 @@ +import asyncio import json from collections.abc import Sequence from uuid import UUID @@ -154,9 +155,19 @@ async def aadd_messagetables(messages: list[MessageTable], session: AsyncSession try: for message in messages: session.add(message) - await session.commit() + try: + await session.commit() + # This is a hack. + # We are doing this because build_public_tmp causes the CancelledError to be raised + # while build_flow does not. + except asyncio.CancelledError: + await session.commit() for message in messages: await session.refresh(message) + except asyncio.CancelledError as e: + logger.exception(e) + error_msg = "Operation cancelled" + raise ValueError(error_msg) from e except Exception as e: logger.exception(e) raise diff --git a/src/backend/base/langflow/services/database/models/flow/model.py b/src/backend/base/langflow/services/database/models/flow/model.py index 1dbbd54ec..13ec269df 100644 --- a/src/backend/base/langflow/services/database/models/flow/model.py +++ b/src/backend/base/langflow/services/database/models/flow/model.py @@ -2,6 +2,7 @@ import re from datetime import datetime, timezone +from enum import Enum from typing import TYPE_CHECKING, Optional from uuid import UUID, uuid4 @@ -15,7 +16,8 @@ from pydantic import ( field_serializer, field_validator, ) -from sqlalchemy import Text, UniqueConstraint +from sqlalchemy import Enum as SQLEnum +from sqlalchemy import Text, UniqueConstraint, text from sqlmodel import JSON, Column, Field, Relationship, SQLModel from langflow.schema import Data @@ -30,6 +32,11 @@ if TYPE_CHECKING: HEX_COLOR_LENGTH = 7 +class AccessTypeEnum(str, Enum): + PRIVATE = "private" + PUBLIC = "public" + + class FlowBase(SQLModel): name: str = Field(index=True) description: str | None = Field(default=None, sa_column=Column(Text, index=True, nullable=True)) @@ -43,6 +50,18 @@ class FlowBase(SQLModel): endpoint_name: str | None = Field(default=None, nullable=True, index=True) tags: list[str] | None = None locked: bool | None = Field(default=False, nullable=True) + access_type: AccessTypeEnum = Field( + default=AccessTypeEnum.PRIVATE, + sa_column=Column( + SQLEnum( + AccessTypeEnum, + name="access_type_enum", + values_callable=lambda enum: [member.value for member in enum], + ), + nullable=False, + server_default=text("'private'"), + ), + ) @field_validator("endpoint_name") @classmethod @@ -218,6 +237,7 @@ class FlowHeader(BaseModel): endpoint_name: str | None = Field(None, description="The name of the endpoint associated with this flow") description: str | None = Field(None, description="A description of the flow") data: dict | None = Field(None, description="The data of the component, if is_component is True") + access_type: AccessTypeEnum | None = Field(None, description="The access type of the flow") tags: list[str] | None = Field(None, description="The tags of the flow") @field_validator("data", mode="before") @@ -235,6 +255,7 @@ class FlowUpdate(SQLModel): folder_id: UUID | None = None endpoint_name: str | None = None locked: bool | None = None + access_type: AccessTypeEnum | None = None fs_path: str | None = None @field_validator("endpoint_name") diff --git a/src/backend/base/langflow/services/settings/base.py b/src/backend/base/langflow/services/settings/base.py index c97f37fab..c2e930f42 100644 --- a/src/backend/base/langflow/services/settings/base.py +++ b/src/backend/base/langflow/services/settings/base.py @@ -10,9 +10,14 @@ import orjson import yaml from aiofile import async_open from loguru import logger -from pydantic import field_validator +from pydantic import Field, field_validator from pydantic.fields import FieldInfo -from pydantic_settings import BaseSettings, EnvSettingsSource, PydanticBaseSettingsSource, SettingsConfigDict +from pydantic_settings import ( + BaseSettings, + EnvSettingsSource, + PydanticBaseSettingsSource, + SettingsConfigDict, +) from typing_extensions import override from langflow.services.settings.constants import VARIABLES_TO_GET_FROM_ENVIRONMENT @@ -224,6 +229,13 @@ class Settings(BaseSettings): mcp_server_enable_progress_notifications: bool = False """If set to False, Langflow will not send progress notifications in the MCP server.""" + # Public Flow Settings + public_flow_cleanup_interval: int = Field(default=3600, gt=600) + """The interval in seconds at which public temporary flows will be cleaned up. + Default is 1 hour (3600 seconds). Minimum is 600 seconds (10 minutes).""" + public_flow_expiration: int = Field(default=86400, gt=600) + """The time in seconds after which a public temporary flow will be considered expired and eligible for cleanup. + Default is 24 hours (86400 seconds). Minimum is 600 seconds (10 minutes).""" event_delivery: Literal["polling", "streaming"] = "polling" """How to deliver build events to the frontend. Can be 'polling' or 'streaming'.""" diff --git a/src/backend/base/langflow/services/storage/local.py b/src/backend/base/langflow/services/storage/local.py index fed79b354..428593f81 100644 --- a/src/backend/base/langflow/services/storage/local.py +++ b/src/backend/base/langflow/services/storage/local.py @@ -11,7 +11,6 @@ class LocalStorageService(StorageService): def __init__(self, session_service, settings_service) -> None: """Initialize the local storage service with session and settings services.""" super().__init__(session_service, settings_service) - self.data_dir = anyio.Path(settings_service.settings.config_dir) self.set_ready() def build_full_path(self, flow_id: str, file_name: str) -> str: diff --git a/src/backend/base/langflow/services/storage/service.py b/src/backend/base/langflow/services/storage/service.py index e139b73c4..4cdd2696b 100644 --- a/src/backend/base/langflow/services/storage/service.py +++ b/src/backend/base/langflow/services/storage/service.py @@ -3,6 +3,8 @@ from __future__ import annotations from abc import abstractmethod from typing import TYPE_CHECKING +import anyio + from langflow.services.base import Service if TYPE_CHECKING: @@ -16,6 +18,7 @@ class StorageService(Service): def __init__(self, session_service: SessionService, settings_service: SettingsService): self.settings_service = settings_service self.session_service = session_service + self.data_dir: anyio.Path = anyio.Path(settings_service.settings.config_dir) self.set_ready() def build_full_path(self, flow_id: str, file_name: str) -> str: diff --git a/src/backend/base/langflow/services/task/temp_flow_cleanup.py b/src/backend/base/langflow/services/task/temp_flow_cleanup.py new file mode 100644 index 000000000..3f7317c10 --- /dev/null +++ b/src/backend/base/langflow/services/task/temp_flow_cleanup.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import asyncio +import contextlib +from typing import TYPE_CHECKING + +from loguru import logger +from sqlmodel import col, delete, select + +from langflow.services.database.models.message.model import MessageTable +from langflow.services.database.models.transactions.model import TransactionTable +from langflow.services.database.models.vertex_builds.model import VertexBuildTable +from langflow.services.deps import get_settings_service, get_storage_service, session_scope + +if TYPE_CHECKING: + from langflow.services.storage.service import StorageService + + +async def cleanup_orphaned_records() -> None: + """Clean up all records that reference non-existent flows.""" + from langflow.services.database.models.flow.model import Flow + + async with session_scope() as session: + # Create a subquery of existing flow IDs + flow_ids_subquery = select(Flow.id) + + # Tables that have flow_id foreign keys + tables: list[type[VertexBuildTable | MessageTable | TransactionTable]] = [ + MessageTable, + VertexBuildTable, + TransactionTable, + ] + + for table in tables: + try: + # Get distinct orphaned flow IDs from the table + orphaned_flow_ids = ( + await session.exec( + select(col(table.flow_id).distinct()).where(col(table.flow_id).not_in(flow_ids_subquery)) + ) + ).all() + + if orphaned_flow_ids: + logger.debug(f"Found {len(orphaned_flow_ids)} orphaned flow IDs in {table.__name__}") + + # Delete all orphaned records in a single query + await session.exec(delete(table).where(col(table.flow_id).in_(orphaned_flow_ids))) + + # Clean up any associated storage files + storage_service: StorageService = get_storage_service() + for flow_id in orphaned_flow_ids: + try: + files = await storage_service.list_files(str(flow_id)) + for file in files: + try: + await storage_service.delete_file(str(flow_id), file) + except Exception as exc: # noqa: BLE001 + logger.error(f"Failed to delete file {file} for flow {flow_id}: {exc!s}") + # Delete the flow directory after all files are deleted + flow_dir = storage_service.data_dir / str(flow_id) + if await flow_dir.exists(): + await flow_dir.rmdir() + except Exception as exc: # noqa: BLE001 + logger.error(f"Failed to list files for flow {flow_id}: {exc!s}") + + await session.commit() + logger.debug(f"Successfully deleted orphaned records from {table.__name__}") + + except Exception as exc: # noqa: BLE001 + logger.error(f"Error cleaning up orphaned records in {table.__name__}: {exc!s}") + await session.rollback() + + +class CleanupWorker: + def __init__(self) -> None: + self._stop_event = asyncio.Event() + self._task: asyncio.Task | None = None + + async def start(self): + """Start the cleanup worker.""" + if self._task is not None: + logger.warning("Cleanup worker is already running") + return + + self._task = asyncio.create_task(self._run()) + logger.debug("Started database cleanup worker") + + async def stop(self): + """Stop the cleanup worker gracefully.""" + if self._task is None: + logger.warning("Cleanup worker is not running") + return + + logger.debug("Stopping database cleanup worker...") + self._stop_event.set() + await self._task + self._task = None + logger.debug("Database cleanup worker stopped") + + async def _run(self): + """Run the cleanup worker until stopped.""" + settings = get_settings_service().settings + while not self._stop_event.is_set(): + try: + # Clean up any orphaned records + await cleanup_orphaned_records() + except Exception as exc: # noqa: BLE001 + logger.error(f"Error in cleanup worker: {exc!s}") + + try: + # Create a task for the timeout + sleep_task = asyncio.create_task(asyncio.sleep(settings.public_flow_cleanup_interval)) + # Create a task for the stop event + stop_task = asyncio.create_task(self._stop_event.wait()) + + # Wait for either the timeout or the stop event + done, pending = await asyncio.wait([sleep_task, stop_task], return_when=asyncio.FIRST_COMPLETED) + + # Cancel any pending tasks + for task in pending: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + # If the stop event completed, break the loop + if stop_task in done: + break + + except Exception as exc: # noqa: BLE001 + logger.error(f"Error in cleanup worker sleep: {exc!s}") + # Sleep a minimum amount in case of errors + await asyncio.sleep(60) + + +# Create a global instance of the worker +cleanup_worker = CleanupWorker() diff --git a/src/backend/tests/unit/services/tasks/__init__.py b/src/backend/tests/unit/services/tasks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/backend/tests/unit/services/tasks/test_temp_flow_cleanup.py b/src/backend/tests/unit/services/tasks/test_temp_flow_cleanup.py new file mode 100644 index 000000000..5b1b0a8c9 --- /dev/null +++ b/src/backend/tests/unit/services/tasks/test_temp_flow_cleanup.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import datetime +from datetime import timezone +from uuid import uuid4 + +import pytest +from langflow.services.database.models.flow import Flow as FlowTable +from langflow.services.database.models.message.model import MessageTable +from langflow.services.deps import get_settings_service, get_storage_service, session_scope +from langflow.services.task.temp_flow_cleanup import ( + CleanupWorker, + cleanup_orphaned_records, +) + + +@pytest.mark.usefixtures("client") +async def test_cleanup_orphaned_records_no_orphans(): + """Test cleanup when there are no orphaned records.""" + storage_service = get_storage_service() + flow_id = uuid4() + + async with session_scope() as session: + # Create a flow and associated message + flow = FlowTable( + id=flow_id, + name="Test Flow", + data="null", + updated_at=datetime.datetime.now(timezone.utc), + ) + message = MessageTable( + id=uuid4(), + flow_id=flow_id, + sender="test_user", + sender_name="Test User", + timestamp=datetime.datetime.now(timezone.utc), + session_id=str(uuid4()), + ) + session.add(flow) + session.add(message) + await session.commit() + + # Write a file for the flow + await storage_service.save_file(str(flow_id), "test.json", b"test data") + + # Run cleanup + async with session_scope() as session: + await cleanup_orphaned_records() + + # Verify message still exists + async with session_scope() as session: + message = await session.get(MessageTable, message.id) + assert message is not None + + +@pytest.mark.usefixtures("client") +async def test_cleanup_orphaned_records_with_orphans(): + """Test cleanup when there are orphaned records.""" + orphaned_flow_id = uuid4() + + async with session_scope() as session: + # Create orphaned records without an associated flow + message = MessageTable( + id=uuid4(), + flow_id=orphaned_flow_id, + sender="test_user", + sender_name="Test User", + timestamp=datetime.datetime.now(timezone.utc), + session_id=str(uuid4()), + ) + session.add(message) + await session.commit() + + # Run cleanup + async with session_scope() as session: + await cleanup_orphaned_records() + + # Verify orphaned message was deleted + async with session_scope() as session: + message = await session.get(MessageTable, message.id) + assert message is None + + +@pytest.mark.asyncio +async def test_cleanup_worker_start_stop(): + """Test CleanupWorker start and stop functionality.""" + worker = CleanupWorker() + await worker.start() + assert worker._task is not None + assert not worker._stop_event.is_set() + await worker.stop() + assert worker._task is None + assert worker._stop_event.is_set() + + +@pytest.mark.asyncio +async def test_cleanup_worker_run_with_exception(caplog): + """Test CleanupWorker handles exceptions gracefully.""" + settings = get_settings_service().settings + settings.public_flow_cleanup_interval = 601 # Minimum valid interval + worker = CleanupWorker() + + # Start worker and let it run briefly + await worker.start() + await worker.stop() + + # Check logs for expected messages + assert any("Started database cleanup worker" in record.message for record in caplog.records) + assert any("Stopping database cleanup worker" in record.message for record in caplog.records) diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 44a4d1e1f..a6ae05e95 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -862,246 +862,6 @@ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "peer": true }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@esbuild/linux-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", @@ -1117,96 +877,6 @@ "node": ">=12" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.0.tgz", @@ -1768,246 +1438,6 @@ "node": ">=6.9.0" } }, - "node_modules/@million/lint/node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@million/lint/node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@million/lint/node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@million/lint/node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@million/lint/node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@million/lint/node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@million/lint/node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@million/lint/node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@million/lint/node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@million/lint/node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@million/lint/node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@million/lint/node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", - "cpu": [ - "loong64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@million/lint/node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", - "cpu": [ - "mips64el" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@million/lint/node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@million/lint/node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", - "cpu": [ - "riscv64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@million/lint/node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@million/lint/node_modules/@esbuild/linux-x64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", @@ -2023,96 +1453,6 @@ "node": ">=12" } }, - "node_modules/@million/lint/node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@million/lint/node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@million/lint/node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@million/lint/node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@million/lint/node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@million/lint/node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@million/lint/node_modules/esbuild": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", @@ -2206,171 +1546,6 @@ "@napi-rs/nice-win32-x64-msvc": "1.0.1" } }, - "node_modules/@napi-rs/nice-android-arm-eabi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.1.tgz", - "integrity": "sha512-5qpvOu5IGwDo7MEKVqqyAxF90I6aLj4n07OzpARdgDRfz8UbBztTByBp0RC59r3J1Ij8uzYi6jI7r5Lws7nn6w==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-android-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.0.1.tgz", - "integrity": "sha512-GqvXL0P8fZ+mQqG1g0o4AO9hJjQaeYG84FRfZaYjyJtZZZcMjXW5TwkL8Y8UApheJgyE13TQ4YNUssQaTgTyvA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-91k3HEqUl2fsrz/sKkuEkscj6EAj3/eZNCLqzD2AA0TtVbkQi8nqxZCZDMkfklULmxLkMxuUdKe7RvG/T6s2AA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.0.1.tgz", - "integrity": "sha512-jXnMleYSIR/+TAN/p5u+NkCA7yidgswx5ftqzXdD5wgy/hNR92oerTXHc0jrlBisbd7DpzoaGY4cFD7Sm5GlgQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-freebsd-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.0.1.tgz", - "integrity": "sha512-j+iJ/ezONXRQsVIB/FJfwjeQXX7A2tf3gEXs4WUGFrJjpe/z2KB7sOv6zpkm08PofF36C9S7wTNuzHZ/Iiccfw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.0.1.tgz", - "integrity": "sha512-G8RgJ8FYXYkkSGQwywAUh84m946UTn6l03/vmEXBYNJxQJcD+I3B3k5jmjFG/OPiU8DfvxutOP8bi+F89MCV7Q==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-arm64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.0.1.tgz", - "integrity": "sha512-IMDak59/W5JSab1oZvmNbrms3mHqcreaCeClUjwlwDr0m3BoR09ZiN8cKFBzuSlXgRdZ4PNqCYNeGQv7YMTjuA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-arm64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.0.1.tgz", - "integrity": "sha512-wG8fa2VKuWM4CfjOjjRX9YLIbysSVV1S3Kgm2Fnc67ap/soHBeYZa6AGMeR5BJAylYRjnoVOzV19Cmkco3QEPw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-ppc64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.0.1.tgz", - "integrity": "sha512-lxQ9WrBf0IlNTCA9oS2jg/iAjQyTI6JHzABV664LLrLA/SIdD+I1i3Mjf7TsnoUbgopBcCuDztVLfJ0q9ubf6Q==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-riscv64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.0.1.tgz", - "integrity": "sha512-3xs69dO8WSWBb13KBVex+yvxmUeEsdWexxibqskzoKaWx9AIqkMbWmE2npkazJoopPKX2ULKd8Fm9veEn0g4Ig==", - "cpu": [ - "riscv64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-s390x-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.0.1.tgz", - "integrity": "sha512-lMFI3i9rlW7hgToyAzTaEybQYGbQHDrpRkg+1gJWEpH0PLAQoZ8jiY0IzakLfNWnVda1eTYYlxxFYzW8Rqczkg==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@napi-rs/nice-linux-x64-gnu": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.0.1.tgz", @@ -2401,51 +1576,6 @@ "node": ">= 10" } }, - "node_modules/@napi-rs/nice-win32-arm64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.0.1.tgz", - "integrity": "sha512-rEcz9vZymaCB3OqEXoHnp9YViLct8ugF+6uO5McifTedjq4QMQs3DHz35xBEGhH3gJWEsXMUbzazkz5KNM5YUg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-win32-ia32-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.0.1.tgz", - "integrity": "sha512-t7eBAyPUrWL8su3gDxw9xxxqNwZzAqKo0Szv3IjVQd1GpXXVkb6vBBQUuxfIYaXMzZLwlxRQ7uzM2vdUE9ULGw==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-win32-x64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.0.1.tgz", - "integrity": "sha512-JlF+uDcatt3St2ntBG8H02F1mM45i5SF9W+bIKiReVE6wiy3o16oBP/yxt+RZ+N6LbCImJXJ6bXNO2kn9AXicg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3906,174 +3036,6 @@ } } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.35.0.tgz", - "integrity": "sha512-uYQ2WfPaqz5QtVgMxfN6NpLD+no0MYHDBywl7itPYd3K5TjjSghNKmX8ic9S8NU8w81NVhJv/XojcHptRly7qQ==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.35.0.tgz", - "integrity": "sha512-FtKddj9XZudurLhdJnBl9fl6BwCJ3ky8riCXjEw3/UIbjmIY58ppWwPEvU3fNu+W7FUsAsB1CdH+7EQE6CXAPA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.35.0.tgz", - "integrity": "sha512-Uk+GjOJR6CY844/q6r5DR/6lkPFOw0hjfOIzVx22THJXMxktXG6CbejseJFznU8vHcEBLpiXKY3/6xc+cBm65Q==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.35.0.tgz", - "integrity": "sha512-3IrHjfAS6Vkp+5bISNQnPogRAW5GAV1n+bNCrDwXmfMHbPl5EhTmWtfmwlJxFRUCBZ+tZ/OxDyU08aF6NI/N5Q==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.35.0.tgz", - "integrity": "sha512-sxjoD/6F9cDLSELuLNnY0fOrM9WA0KrM0vWm57XhrIMf5FGiN8D0l7fn+bpUeBSU7dCgPV2oX4zHAsAXyHFGcQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.35.0.tgz", - "integrity": "sha512-2mpHCeRuD1u/2kruUiHSsnjWtHjqVbzhBkNVQ1aVD63CcexKVcQGwJ2g5VphOd84GvxfSvnnlEyBtQCE5hxVVw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.35.0.tgz", - "integrity": "sha512-mrA0v3QMy6ZSvEuLs0dMxcO2LnaCONs1Z73GUDBHWbY8tFFocM6yl7YyMu7rz4zS81NDSqhrUuolyZXGi8TEqg==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.35.0.tgz", - "integrity": "sha512-DnYhhzcvTAKNexIql8pFajr0PiDGrIsBYPRvCKlA5ixSS3uwo/CWNZxB09jhIapEIg945KOzcYEAGGSmTSpk7A==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.35.0.tgz", - "integrity": "sha512-uagpnH2M2g2b5iLsCTZ35CL1FgyuzzJQ8L9VtlJ+FckBXroTwNOaD0z0/UF+k5K3aNQjbm8LIVpxykUOQt1m/A==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.35.0.tgz", - "integrity": "sha512-XQxVOCd6VJeHQA/7YcqyV0/88N6ysSVzRjJ9I9UA/xXpEsjvAgDTgH3wQYz5bmr7SPtVK2TsP2fQ2N9L4ukoUg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.35.0.tgz", - "integrity": "sha512-5pMT5PzfgwcXEwOaSrqVsz/LvjDZt+vQ8RT/70yhPU06PTuq8WaHhfT1LW+cdD7mW6i/J5/XIkX/1tCAkh1W6g==", - "cpu": [ - "loong64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.35.0.tgz", - "integrity": "sha512-c+zkcvbhbXF98f4CtEIP1EBA/lCic5xB0lToneZYvMeKu5Kamq3O8gqrxiYYLzlZH6E3Aq+TSW86E4ay8iD8EA==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.35.0.tgz", - "integrity": "sha512-s91fuAHdOwH/Tad2tzTtPX7UZyytHIRR6V4+2IGlV0Cej5rkG0R61SX4l4y9sh0JBibMiploZx3oHKPnQBKe4g==", - "cpu": [ - "riscv64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.35.0.tgz", - "integrity": "sha512-hQRkPQPLYJZYGP+Hj4fR9dDBMIM7zrzJDWFEMPdTnTy95Ljnv0/4w/ixFw3pTBMEuuEuoqtBINYND4M7ujcuQw==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ] - }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.35.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.35.0.tgz", @@ -4098,42 +3060,6 @@ "linux" ] }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.35.0.tgz", - "integrity": "sha512-OUOlGqPkVJCdJETKOCEf1mw848ZyJ5w50/rZ/3IBQVdLfR5jk/6Sr5m3iO2tdPgwo0x7VcncYuOvMhBWZq8ayg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.35.0.tgz", - "integrity": "sha512-2/lsgejMrtwQe44glq7AFFHLfJBPafpsTa6JvP2NGef/ifOa4KBoglVf7AKN7EV9o32evBPRqfg96fEHzWo5kw==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.35.0.tgz", - "integrity": "sha512-PIQeY5XDkrOysbQblSW7v3l1MDZzkTEzAfTPkj5VAu3FW8fS4ynyLg2sINp0fp3SjZ8xkRYpLqoKcYqAkhU1dw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@rrweb/types": { "version": "2.0.0-alpha.16", "resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.0.0-alpha.16.tgz", @@ -4494,86 +3420,6 @@ } } }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.11.8", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.8.tgz", - "integrity": "sha512-rrSsunyJWpHN+5V1zumndwSSifmIeFQBK9i2RMQQp15PgbgUNxHK5qoET1n20pcUrmZeT6jmJaEWlQchkV//Og==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.11.8", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.8.tgz", - "integrity": "sha512-44goLqQuuo0HgWnG8qC+ZFw/qnjCVVeqffhzFr9WAXXotogVaxM8ze6egE58VWrfEc8me8yCcxOYL9RbtjhS/Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.11.8", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.8.tgz", - "integrity": "sha512-Mzo8umKlhTWwF1v8SLuTM1z2A+P43UVhf4R8RZDhzIRBuB2NkeyE+c0gexIOJBuGSIATryuAF4O4luDu727D1w==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.11.8", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.8.tgz", - "integrity": "sha512-EyhO6U+QdoGYC1MeHOR0pyaaSaKYyNuT4FQNZ1eZIbnuueXpuICC7iNmLIOfr3LE5bVWcZ7NKGVPlM2StJEcgA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.11.8", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.8.tgz", - "integrity": "sha512-QU6wOkZnS6/QuBN1MHD6G2BgFxB0AclvTVGbqYkRA7MsVkcC29PffESqzTXnypzB252/XkhQjoB2JIt9rPYf6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, "node_modules/@swc/core-linux-x64-gnu": { "version": "1.11.8", "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.8.tgz", @@ -4606,54 +3452,6 @@ "node": ">=10" } }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.11.8", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.8.tgz", - "integrity": "sha512-EbjOzQ+B85rumHyeesBYxZ+hq3ZQn+YAAT1ZNE9xW1/8SuLoBmHy/K9YniRGVDq/2NRmp5kI5+5h5TX0asIS9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.11.8", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.8.tgz", - "integrity": "sha512-Z+FF5kgLHfQWIZ1KPdeInToXLzbY0sMAashjd/igKeP1Lz0qKXVAK+rpn6ASJi85Fn8wTftCGCyQUkRVn0bTDg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.11.8", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.8.tgz", - "integrity": "sha512-j6B6N0hChCeAISS6xp/hh6zR5CSCr037BAjCxNLsT8TGe5D+gYZ57heswUWXRH8eMKiRDGiLCYpPB2pkTqxCSw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -8208,19 +7006,6 @@ "optional": true, "peer": true }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -15237,19 +14022,6 @@ } } }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 9c7a659e5..a30557cdc 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,10 +1,19 @@ import "@xyflow/react/dist/style.css"; -import { Suspense } from "react"; +import { Suspense, useEffect } from "react"; import { RouterProvider } from "react-router-dom"; import { LoadingPage } from "./pages/LoadingPage"; import router from "./routes"; +import { useDarkStore } from "./stores/darkStore"; export default function App() { + const dark = useDarkStore((state) => state.dark); + useEffect(() => { + if (!dark) { + document.getElementById("body")!.classList.remove("dark"); + } else { + document.getElementById("body")!.classList.add("dark"); + } + }, [dark]); return ( }> diff --git a/src/frontend/src/assets/LangflowLogoColor.svg b/src/frontend/src/assets/LangflowLogoColor.svg new file mode 100644 index 000000000..4eaa8203d --- /dev/null +++ b/src/frontend/src/assets/LangflowLogoColor.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/frontend/src/components/core/appHeaderComponent/components/FlowMenu/index.tsx b/src/frontend/src/components/core/appHeaderComponent/components/FlowMenu/index.tsx index 5f3873558..6e0af694f 100644 --- a/src/frontend/src/components/core/appHeaderComponent/components/FlowMenu/index.tsx +++ b/src/frontend/src/components/core/appHeaderComponent/components/FlowMenu/index.tsx @@ -30,7 +30,8 @@ import useAlertStore from "@/stores/alertStore"; import useFlowsManagerStore from "@/stores/flowsManagerStore"; import useFlowStore from "@/stores/flowStore"; import { useShortcutsStore } from "@/stores/shortcuts"; -import { cn } from "@/utils/utils"; +import { swatchColors } from "@/utils/styleUtils"; +import { cn, getNumberFromString } from "@/utils/utils"; import { useQueryClient } from "@tanstack/react-query"; export const MenuBar = ({}: {}): JSX.Element => { @@ -228,6 +229,12 @@ export const MenuBar = ({}: {}): JSX.Element => { } }, [flowName]); + const swatchIndex = + (currentFlow?.gradient && !isNaN(parseInt(currentFlow?.gradient)) + ? parseInt(currentFlow?.gradient) + : getNumberFromString(currentFlow?.gradient ?? currentFlow?.id ?? "")) % + swatchColors.length; + return currentFlow && onFlowPage ? (
{ > /
+
+ +
setIsExpanded(!isExpanded)} > -
+
{headerIcon && ( )} @@ -102,7 +107,9 @@ export function ContentBlockDisplay({
- + {!playgroundPage && ( + + )} diff --git a/src/frontend/src/components/core/chatComponents/ContentDisplay.tsx b/src/frontend/src/components/core/chatComponents/ContentDisplay.tsx index 1100e0255..23d9a5e06 100644 --- a/src/frontend/src/components/core/chatComponents/ContentDisplay.tsx +++ b/src/frontend/src/components/core/chatComponents/ContentDisplay.tsx @@ -10,9 +10,11 @@ import DurationDisplay from "./DurationDisplay"; export default function ContentDisplay({ content, chatId, + playgroundPage, }: { content: ContentType; chatId: string; + playgroundPage?: boolean; }) { // First render the common BaseContent elements if they exist const renderHeader = content.header && ( @@ -39,7 +41,7 @@ export default function ContentDisplay({
); - const renderDuration = content.duration !== undefined && ( + const renderDuration = content.duration !== undefined && !playgroundPage && (
diff --git a/src/frontend/src/components/core/flowToolbarComponent/components/deploy-dropdown.tsx b/src/frontend/src/components/core/flowToolbarComponent/components/deploy-dropdown.tsx new file mode 100644 index 000000000..cb2d7148c --- /dev/null +++ b/src/frontend/src/components/core/flowToolbarComponent/components/deploy-dropdown.tsx @@ -0,0 +1,208 @@ +import IconComponent from "@/components/common/genericIconComponent"; +import ShadTooltipComponent from "@/components/common/shadTooltipComponent"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Switch } from "@/components/ui/switch"; +import { usePatchUpdateFlow } from "@/controllers/API/queries/flows/use-patch-update-flow"; +import { ENABLE_WIDGET } from "@/customization/feature-flags"; +import ApiModal from "@/modals/apiModal/new-api-modal"; +import EmbedModal from "@/modals/EmbedModal/embed-modal"; +import useAlertStore from "@/stores/alertStore"; +import useAuthStore from "@/stores/authStore"; +import useFlowsManagerStore from "@/stores/flowsManagerStore"; +import useFlowStore from "@/stores/flowStore"; +import { useState } from "react"; + +export default function PublishDropdown() { + const domain = window.location.origin; + const [openEmbedModal, setOpenEmbedModal] = useState(false); + const currentFlow = useFlowsManagerStore((state) => state.currentFlow); + const flowId = currentFlow?.id; + const flowName = currentFlow?.name; + const setErrorData = useAlertStore((state) => state.setErrorData); + const { mutateAsync } = usePatchUpdateFlow(); + const flows = useFlowsManagerStore((state) => state.flows); + const setFlows = useFlowsManagerStore((state) => state.setFlows); + const setCurrentFlow = useFlowStore((state) => state.setCurrentFlow); + const isPublished = currentFlow?.access_type === "public"; + const hasIO = useFlowStore((state) => state.hasIO); + const isAuth = useAuthStore((state) => !!state.autoLogin); + const [openApiModal, setOpenApiModal] = useState(false); + + const handlePublishedSwitch = async (checked: boolean) => { + mutateAsync( + { + id: flowId ?? "", + access_type: checked ? "private" : "public", + }, + { + onSuccess: (updatedFlow) => { + if (flows) { + setFlows( + flows.map((flow) => { + if (flow.id === updatedFlow.id) { + return updatedFlow; + } + return flow; + }), + ); + setCurrentFlow(updatedFlow); + } else { + setErrorData({ + title: "Failed to save flow", + list: ["Flows variable undefined"], + }); + } + }, + onError: (e) => { + setErrorData({ + title: "Failed to save flow", + list: [e.message], + }); + }, + }, + ); + }; + + // using js const instead of applies.css because of group tag + const groupStyle = "text-muted-foreground group-hover:text-foreground"; + const externalUrlStyle = + "opacity-0 transition-all duration-300 group-hover:translate-x-3 group-hover:opacity-100 group-focus-visible:translate-x-3 group-focus-visible:opacity-100"; + + return ( + <> + + + + + + setOpenApiModal(true)} + > +
+ + API access +
+
+ {ENABLE_WIDGET && ( + setOpenEmbedModal(true)} + className="deploy-dropdown-item group" + > +
+ + Embed into site +
+
+ )} + +
+ { + if (hasIO) { + if (isPublished) { + window.open(`${domain}/playground/${flowId}`, "_blank"); + } + } + }} + > +
+ + Shareable Playground +
+
+
+ { + e.preventDefault(); + e.stopPropagation(); + handlePublishedSwitch(isPublished); + }} + /> +
+
+
+ {/* +
+ + Langflow SDK + +
+
*/} +
+
+ + <> + + + + ); +} diff --git a/src/frontend/src/components/core/flowToolbarComponent/components/flow-toolbar-options.tsx b/src/frontend/src/components/core/flowToolbarComponent/components/flow-toolbar-options.tsx new file mode 100644 index 000000000..c9e6cf724 --- /dev/null +++ b/src/frontend/src/components/core/flowToolbarComponent/components/flow-toolbar-options.tsx @@ -0,0 +1,23 @@ +import useFlowStore from "@/stores/flowStore"; +import { useState } from "react"; +import PublishDropdown from "./deploy-dropdown"; +import PlaygroundButton from "./playground-button"; + +export default function FlowToolbarOptions() { + const [open, setOpen] = useState(false); + const hasIO = useFlowStore((state) => state.hasIO); + + return ( +
+
+ +
+ +
+ ); +} diff --git a/src/frontend/src/components/core/flowToolbarComponent/components/playground-button.tsx b/src/frontend/src/components/core/flowToolbarComponent/components/playground-button.tsx index 384942fa5..13b63f784 100644 --- a/src/frontend/src/components/core/flowToolbarComponent/components/playground-button.tsx +++ b/src/frontend/src/components/core/flowToolbarComponent/components/playground-button.tsx @@ -1,13 +1,21 @@ import ForwardedIconComponent from "@/components/common/genericIconComponent"; import ShadTooltip from "@/components/common/shadTooltipComponent"; +import { PLAYGROUND_BUTTON_NAME } from "@/constants/constants"; +import { ENABLE_PUBLISH } from "@/customization/feature-flags"; import IOModal from "@/modals/IOModal/new-modal"; const PlaygroundButton = ({ hasIO, open, setOpen, canvasOpen }) => { const PlayIcon = () => ( - + ); - const ButtonLabel = () => Playground; + const ButtonLabel = () => ( + {PLAYGROUND_BUTTON_NAME} + ); const ActiveButton = () => (
-
-
- -
- {ENABLE_API && ( - <> -
- {currentFlow && currentFlow.data && ( - -
- - API -
-
- )} -
- - )} - {ENABLE_LANGFLOW_STORE && ( -
-
- {ModalMemo} -
+ {ENABLE_PUBLISH ? ( + + ) : ( +
+
+
- )} -
+ {ENABLE_API && ( + <> +
+ {currentFlow && currentFlow.data && ( + +
+ + API +
+
+ )} +
+ + )} + {ENABLE_LANGFLOW_STORE && ( +
+
+ {ModalMemo} +
+
+ )} +
+ )}
diff --git a/src/frontend/src/components/ui/dialog.tsx b/src/frontend/src/components/ui/dialog.tsx index a1fa23a4f..8cf0e05bc 100644 --- a/src/frontend/src/components/ui/dialog.tsx +++ b/src/frontend/src/components/ui/dialog.tsx @@ -55,45 +55,56 @@ const DialogContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { hideTitle?: boolean; + closeButtonClassName?: string; } ->(({ className, children, hideTitle = false, ...props }, ref) => { - // Check if DialogTitle is included in children - const hasDialogTitle = React.Children.toArray(children).some( - (child) => React.isValidElement(child) && child.type === DialogTitle, - ); +>( + ( + { className, children, hideTitle = false, closeButtonClassName, ...props }, + ref, + ) => { + // Check if DialogTitle is included in children + const hasDialogTitle = React.Children.toArray(children).some( + (child) => React.isValidElement(child) && child.type === DialogTitle, + ); - return ( - - - - {!hasDialogTitle && ( - - Dialog - - )} - {children} - + + - - - Close - - - - - ); -}); + {!hasDialogTitle && ( + + Dialog + + )} + {children} + + + + Close + + + + + ); + }, +); DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ diff --git a/src/frontend/src/constants/constants.ts b/src/frontend/src/constants/constants.ts index 2d12b941a..18ef1075c 100644 --- a/src/frontend/src/constants/constants.ts +++ b/src/frontend/src/constants/constants.ts @@ -1004,6 +1004,7 @@ export const DEFAULT_PLACEHOLDER = "Type something..."; export const DEFAULT_TOOLSET_PLACEHOLDER = "Used as a tool"; +export const PLAYGROUND_BUTTON_NAME = "Playground"; export const POLLING_MESSAGES = { ENDPOINT_NOT_AVAILABLE: "Endpoint not available", STREAMING_NOT_SUPPORTED: "Streaming not supported", diff --git a/src/frontend/src/controllers/API/api.tsx b/src/frontend/src/controllers/API/api.tsx index 2fc6a886c..faa35f05b 100644 --- a/src/frontend/src/controllers/API/api.tsx +++ b/src/frontend/src/controllers/API/api.tsx @@ -67,7 +67,10 @@ function ApiInterceptor() { if (isAuthenticationError && !IS_AUTO_LOGIN) { if (autoLogin !== undefined && !autoLogin) { - if (error?.config?.url?.includes("github")) { + if ( + error?.config?.url?.includes("github") || + error?.config?.url?.includes("public") + ) { return Promise.reject(error); } const stillRefresh = checkErrorCount(); diff --git a/src/frontend/src/controllers/API/helpers/constants.ts b/src/frontend/src/controllers/API/helpers/constants.ts index 2d8809cc1..54bfca6c4 100644 --- a/src/frontend/src/controllers/API/helpers/constants.ts +++ b/src/frontend/src/controllers/API/helpers/constants.ts @@ -23,6 +23,7 @@ export const URLs = { STARTER_PROJECTS: `starter-projects`, SIDEBAR_CATEGORIES: `sidebar_categories`, ALL: `all`, + PUBLIC_FLOW: `/flows/public_flow`, } as const; export function getURL(key: keyof typeof URLs, params: any = {}) { diff --git a/src/frontend/src/controllers/API/queries/flows/use-get-flow.ts b/src/frontend/src/controllers/API/queries/flows/use-get-flow.ts index e059db1d5..85334fd2e 100644 --- a/src/frontend/src/controllers/API/queries/flows/use-get-flow.ts +++ b/src/frontend/src/controllers/API/queries/flows/use-get-flow.ts @@ -8,6 +8,7 @@ import { UseRequestProcessor } from "../../services/request-processor"; interface IGetFlow { id: string; + public?: boolean; } // add types for error handling and success @@ -19,7 +20,7 @@ export const useGetFlow: useMutationFunctionType = ( const getFlowFn = async (payload: IGetFlow): Promise => { const response = await api.get( - `${getURL("FLOWS")}/${payload.id}`, + `${getURL(payload.public ? "PUBLIC_FLOW" : "FLOWS")}/${payload.id}`, ); const flowsArrayToProcess = [response.data]; diff --git a/src/frontend/src/controllers/API/queries/flows/use-patch-update-flow.ts b/src/frontend/src/controllers/API/queries/flows/use-patch-update-flow.ts index 2a27edab2..c5100678f 100644 --- a/src/frontend/src/controllers/API/queries/flows/use-patch-update-flow.ts +++ b/src/frontend/src/controllers/API/queries/flows/use-patch-update-flow.ts @@ -7,12 +7,13 @@ import { UseRequestProcessor } from "../../services/request-processor"; interface IPatchUpdateFlow { id: string; - name: string; - data: ReactFlowJsonObject; - description: string; - folder_id: string | null | undefined; - endpoint_name: string | null | undefined; + name?: string; + data?: ReactFlowJsonObject; + description?: string; + folder_id?: string | null | undefined; + endpoint_name?: string | null | undefined; locked?: boolean | null | undefined; + access_type?: "public" | "private" | "protected"; } export const usePatchUpdateFlow: useMutationFunctionType< @@ -21,15 +22,11 @@ export const usePatchUpdateFlow: useMutationFunctionType< > = (options?) => { const { mutate, queryClient } = UseRequestProcessor(); - const PatchUpdateFlowFn = async (payload: IPatchUpdateFlow): Promise => { - const response = await api.patch(`${getURL("FLOWS")}/${payload.id}`, { - name: payload.name, - data: payload.data, - description: payload.description, - folder_id: payload.folder_id || null, - endpoint_name: payload.endpoint_name || null, - locked: payload.locked || false, - }); + const PatchUpdateFlowFn = async ({ + id, + ...payload + }: IPatchUpdateFlow): Promise => { + const response = await api.patch(`${getURL("FLOWS")}/${id}`, payload); return response.data; }; diff --git a/src/frontend/src/controllers/API/queries/messages/use-get-messages.ts b/src/frontend/src/controllers/API/queries/messages/use-get-messages.ts index c0be11627..52d5408fd 100644 --- a/src/frontend/src/controllers/API/queries/messages/use-get-messages.ts +++ b/src/frontend/src/controllers/API/queries/messages/use-get-messages.ts @@ -1,3 +1,4 @@ +import useFlowStore from "@/stores/flowStore"; import { useMessagesStore } from "@/stores/messagesStore"; import { keepPreviousData } from "@tanstack/react-query"; import { ColDef, ColGroupDef } from "ag-grid-community"; @@ -26,6 +27,7 @@ export const useGetMessagesQuery: useQueryFunctionType< const { query } = UseRequestProcessor(); const getMessagesFn = async (id?: string, params = {}) => { + const isPlaygroundPage = useFlowStore.getState().playgroundPage; const config = {}; if (id) { config["params"] = { flow_id: id }; @@ -33,7 +35,13 @@ export const useGetMessagesQuery: useQueryFunctionType< if (params) { config["params"] = { ...config["params"], ...params }; } - return await api.get(`${getURL("MESSAGES")}`, config); + if (!isPlaygroundPage) { + return await api.get(`${getURL("MESSAGES")}`, config); + } else { + return { + data: JSON.parse(window.sessionStorage.getItem(id ?? "") || "[]"), + }; + } }; const responseFn = async () => { diff --git a/src/frontend/src/controllers/API/queries/messages/use-put-update-messages.ts b/src/frontend/src/controllers/API/queries/messages/use-put-update-messages.ts index 1ea24cda5..229210d20 100644 --- a/src/frontend/src/controllers/API/queries/messages/use-put-update-messages.ts +++ b/src/frontend/src/controllers/API/queries/messages/use-put-update-messages.ts @@ -1,4 +1,5 @@ import useFlowsManagerStore from "@/stores/flowsManagerStore"; +import useFlowStore from "@/stores/flowStore"; import { useMutationFunctionType } from "@/types/api"; import { Message } from "@/types/messages"; import { UseMutationResult } from "@tanstack/react-query"; @@ -18,15 +19,29 @@ export const useUpdateMessage: useMutationFunctionType< const { mutate, queryClient } = UseRequestProcessor(); const updateMessageApi = async (data: UpdateMessageParams) => { + const isPlayground = useFlowStore.getState().playgroundPage; + const flowId = useFlowsManagerStore.getState().currentFlowId; const message = data.message; if (message.files && typeof message.files === "string") { message.files = JSON.parse(message.files); } - const result = await api.put( - `${getURL("MESSAGES")}/${message.id}`, - message, - ); - return result.data; + if (isPlayground && flowId) { + const messages = JSON.parse(sessionStorage.getItem(flowId) || ""); + const messageIndex = messages.findIndex( + (m: Message) => m.id === message.id, + ); + messages[messageIndex] = message; + sessionStorage.setItem(flowId, JSON.stringify(messages)); + return { + data: message, + }; + } else { + const result = await api.put( + `${getURL("MESSAGES")}/${message.id}`, + message, + ); + return result.data; + } }; const mutation: UseMutationResult = mutate( diff --git a/src/frontend/src/controllers/API/queries/messages/use-rename-session.ts b/src/frontend/src/controllers/API/queries/messages/use-rename-session.ts index 51f088eda..c4cc5e142 100644 --- a/src/frontend/src/controllers/API/queries/messages/use-rename-session.ts +++ b/src/frontend/src/controllers/API/queries/messages/use-rename-session.ts @@ -1,3 +1,4 @@ +import useFlowStore from "@/stores/flowStore"; import { useMutationFunctionType } from "@/types/api"; import { Message } from "@/types/messages"; import { UseMutationResult } from "@tanstack/react-query"; @@ -17,14 +18,31 @@ export const useUpdateSessionName: useMutationFunctionType< const { mutate, queryClient } = UseRequestProcessor(); const updateSessionApi = async (data: UpdateSessionParams) => { - const result = await api.patch( - `${getURL("MESSAGES")}/session/${data.old_session_id}`, - null, - { - params: { new_session_id: data.new_session_id }, - }, - ); - return result.data; + const isPlayground = useFlowStore.getState().playgroundPage; + const flowId = useFlowStore.getState().currentFlow?.id; + // if we are in playground we will edit the local storage instead of the API + if (isPlayground && flowId) { + const messages = JSON.parse(sessionStorage.getItem(flowId) || ""); + const messagesWithNewSessionId = messages.map((message: Message) => { + if (message.session_id === data.old_session_id) { + message.session_id = data.new_session_id; + } + return message; + }); + sessionStorage.setItem(flowId, JSON.stringify(messagesWithNewSessionId)); + return { + data: messagesWithNewSessionId, + }; + } else { + const result = await api.patch( + `${getURL("MESSAGES")}/session/${data.old_session_id}`, + null, + { + params: { new_session_id: data.new_session_id }, + }, + ); + return result.data; + } }; const mutation: UseMutationResult = diff --git a/src/frontend/src/customization/feature-flags.ts b/src/frontend/src/customization/feature-flags.ts index e54902a85..746c12ff6 100644 --- a/src/frontend/src/customization/feature-flags.ts +++ b/src/frontend/src/customization/feature-flags.ts @@ -8,3 +8,5 @@ export const ENABLE_MVPS = false; export const ENABLE_CUSTOM_PARAM = false; export const ENABLE_INTEGRATIONS = false; export const ENABLE_DATASTAX_LANGFLOW = false; +export const ENABLE_PUBLISH = true; +export const ENABLE_WIDGET = true; diff --git a/src/frontend/src/customization/utils/urls.ts b/src/frontend/src/customization/utils/urls.ts new file mode 100644 index 000000000..8546eb7b8 --- /dev/null +++ b/src/frontend/src/customization/utils/urls.ts @@ -0,0 +1,3 @@ +export const LangflowButtonRedirectTarget = () => { + return "https://langflow.org"; +}; diff --git a/src/frontend/src/icons/BW python/Python.jsx b/src/frontend/src/icons/BW python/Python.jsx new file mode 100644 index 000000000..da085dfa0 --- /dev/null +++ b/src/frontend/src/icons/BW python/Python.jsx @@ -0,0 +1,33 @@ +export const BWSvgPython = (props) => ( + + + + + + + + +); +export default BWSvgPython; diff --git a/src/frontend/src/icons/BW python/index.tsx b/src/frontend/src/icons/BW python/index.tsx new file mode 100644 index 000000000..25c2a9c4b --- /dev/null +++ b/src/frontend/src/icons/BW python/index.tsx @@ -0,0 +1,11 @@ +import { useDarkStore } from "@/stores/darkStore"; +import React, { forwardRef } from "react"; +import BWSvgPython from "./Python"; + +export const BWPythonIcon = forwardRef< + SVGSVGElement, + React.PropsWithChildren<{}> +>((props, ref) => { + const isdark = useDarkStore((state) => state.dark.toString()); + return ; +}); diff --git a/src/frontend/src/icons/BW python/logo.svg b/src/frontend/src/icons/BW python/logo.svg new file mode 100644 index 000000000..bc7170f9a --- /dev/null +++ b/src/frontend/src/icons/BW python/logo.svg @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/src/frontend/src/icons/JSicon/Frame.svg b/src/frontend/src/icons/JSicon/Frame.svg new file mode 100644 index 000000000..12d4cef86 --- /dev/null +++ b/src/frontend/src/icons/JSicon/Frame.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/frontend/src/icons/JSicon/JSIcon.jsx b/src/frontend/src/icons/JSicon/JSIcon.jsx new file mode 100644 index 000000000..42459bc09 --- /dev/null +++ b/src/frontend/src/icons/JSicon/JSIcon.jsx @@ -0,0 +1,26 @@ +const SvgJSIcon = (props) => ( + + + + + + + + + + + +); +export default SvgJSIcon; diff --git a/src/frontend/src/icons/JSicon/index.tsx b/src/frontend/src/icons/JSicon/index.tsx new file mode 100644 index 000000000..a66fd867b --- /dev/null +++ b/src/frontend/src/icons/JSicon/index.tsx @@ -0,0 +1,10 @@ +import { useDarkStore } from "@/stores/darkStore"; +import React, { forwardRef } from "react"; +import SvgJSIcon from "./JSIcon"; + +export const JSIcon = forwardRef>( + (props, ref) => { + const isdark = useDarkStore((state) => state.dark.toString()); + return ; + }, +); diff --git a/src/frontend/src/modals/EmbedModal/embed-modal.tsx b/src/frontend/src/modals/EmbedModal/embed-modal.tsx new file mode 100644 index 000000000..66d38d325 --- /dev/null +++ b/src/frontend/src/modals/EmbedModal/embed-modal.tsx @@ -0,0 +1,99 @@ +import { useDarkStore } from "@/stores/darkStore"; +import { useState } from "react"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { + oneDark, + oneLight, +} from "react-syntax-highlighter/dist/cjs/styles/prism"; +import IconComponent from "../../components/common/genericIconComponent"; +import { Button } from "../../components/ui/button"; +import getWidgetCode from "../apiModal/utils/get-widget-code"; +import BaseModal from "../baseModal"; + +interface EmbedModalProps { + open: boolean; + setOpen: (open: boolean) => void; + flowId: string; + flowName: string; + isAuth: boolean; + tweaksBuildedObject: {}; + activeTweaks: boolean; +} + +export default function EmbedModal({ + open, + setOpen, + flowId, + flowName, + isAuth, + tweaksBuildedObject, + activeTweaks, +}: EmbedModalProps) { + const isDark = useDarkStore((state) => state.dark); + const [isCopied, setIsCopied] = useState(false); + const widgetProps = { + flowId: flowId, + flowName: flowName, + isAuth: isAuth, + tweaksBuildedObject: tweaksBuildedObject, + activeTweaks: activeTweaks, + }; + const embedCode = getWidgetCode({ ...widgetProps, copy: false }); + const copyCode = getWidgetCode({ ...widgetProps, copy: true }); + const copyToClipboard = () => { + if (!navigator.clipboard || !navigator.clipboard.writeText) { + return; + } + + navigator.clipboard.writeText(copyCode).then(() => { + setIsCopied(true); + + setTimeout(() => { + setIsCopied(false); + }, 2000); + }); + }; + + return ( + + +
+ + Embed into site +
+
+ +
+ + + {embedCode} + +
+
+
+ ); +} diff --git a/src/frontend/src/modals/IOModal/components/IOFieldView/components/session-selector.tsx b/src/frontend/src/modals/IOModal/components/IOFieldView/components/session-selector.tsx index 40fecf1c5..db8450225 100644 --- a/src/frontend/src/modals/IOModal/components/IOFieldView/components/session-selector.tsx +++ b/src/frontend/src/modals/IOModal/components/IOFieldView/components/session-selector.tsx @@ -8,9 +8,12 @@ import { SelectTrigger, } from "@/components/ui/select-custom"; import { useUpdateSessionName } from "@/controllers/API/queries/messages/use-rename-session"; +import useFlowsManagerStore from "@/stores/flowsManagerStore"; import useFlowStore from "@/stores/flowStore"; +import { useUtilityStore } from "@/stores/utilityStore"; import { cn } from "@/utils/utils"; import React, { useEffect, useRef, useState } from "react"; +import { v5 as uuidv5 } from "uuid"; export default function SessionSelector({ deleteSession, @@ -21,6 +24,7 @@ export default function SessionSelector({ updateVisibleSession, selectedView, setSelectedView, + playgroundPage, }: { deleteSession: (session: string) => void; session: string; @@ -30,8 +34,13 @@ export default function SessionSelector({ updateVisibleSession: (session: string) => void; selectedView?: { type: string; id: string }; setSelectedView: (view: { type: string; id: string } | undefined) => void; + playgroundPage: boolean; }) { - const currentFlowId = useFlowStore((state) => state.currentFlow?.id); + const clientId = useUtilityStore((state) => state.clientId); + let realFlowId = useFlowsManagerStore((state) => state.currentFlowId); + const currentFlowId = playgroundPage + ? uuidv5(`${clientId}_${realFlowId}`, uuidv5.DNS) + : realFlowId; const [isEditing, setIsEditing] = useState(false); const [editedSession, setEditedSession] = useState(session); const { mutate: updateSessionName } = useUpdateSessionName(); diff --git a/src/frontend/src/modals/IOModal/components/chat-view-wrapper.tsx b/src/frontend/src/modals/IOModal/components/chat-view-wrapper.tsx index 6bcf576fe..2cdb76e51 100644 --- a/src/frontend/src/modals/IOModal/components/chat-view-wrapper.tsx +++ b/src/frontend/src/modals/IOModal/components/chat-view-wrapper.tsx @@ -21,23 +21,23 @@ export const ChatViewWrapper = ({ sendMessage, canvasOpen, setOpen, + playgroundTitle, + playgroundPage, }: ChatViewWrapperProps) => { return (
-
- {visibleSession && sessions.length > 0 && sidebarOpen && ( -
- {visibleSession === currentFlowId - ? "Default Session" - : `${visibleSession}`} -
+
+ > +
-
Playground
+ {visibleSession && sessions.length > 0 && ( +
+ {visibleSession === currentFlowId + ? "Default Session" + : `${visibleSession}`} +
+ )}
@@ -76,7 +88,7 @@ export const ChatViewWrapper = ({ /> - {!isPlayground && } + {!playgroundPage && }
)}
diff --git a/src/frontend/src/modals/IOModal/components/chatView/chat-view.tsx b/src/frontend/src/modals/IOModal/components/chatView/chat-view.tsx index 4ee3b3b1f..bde65dc85 100644 --- a/src/frontend/src/modals/IOModal/components/chatView/chat-view.tsx +++ b/src/frontend/src/modals/IOModal/components/chatView/chat-view.tsx @@ -4,6 +4,7 @@ import { track } from "@/customization/utils/analytics"; import { useMessagesStore } from "@/stores/messagesStore"; import { useUtilityStore } from "@/stores/utilityStore"; import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { v5 as uuidv5 } from "uuid"; import useTabVisibility from "../../../../shared/hooks/use-tab-visibility"; import useFlowsManagerStore from "../../../../stores/flowsManagerStore"; import useFlowStore from "../../../../stores/flowStore"; @@ -31,10 +32,15 @@ export default function ChatView({ visibleSession, focusChat, closeChat, + playgroundPage, }: chatViewProps): JSX.Element { const flowPool = useFlowStore((state) => state.flowPool); const inputs = useFlowStore((state) => state.inputs); - const currentFlowId = useFlowsManagerStore((state) => state.currentFlowId); + const clientId = useUtilityStore((state) => state.clientId); + let realFlowId = useFlowsManagerStore((state) => state.currentFlowId); + const currentFlowId = playgroundPage + ? uuidv5(`${clientId}_${realFlowId}`, uuidv5.DNS) + : realFlowId; const messagesRef = useRef(null); const [chatHistory, setChatHistory] = useState( undefined, @@ -171,6 +177,7 @@ export default function ChatView({ key={`${chat.id}-${index}`} updateChat={updateChat} closeChat={closeChat} + playgroundPage={playgroundPage} /> ))} @@ -212,6 +219,7 @@ export default function ChatView({
{ sendMessage({ repeat, files }); diff --git a/src/frontend/src/modals/IOModal/components/chatView/chatInput/chat-input.tsx b/src/frontend/src/modals/IOModal/components/chatView/chatInput/chat-input.tsx index 7de0862bc..c28b0ab3b 100644 --- a/src/frontend/src/modals/IOModal/components/chatView/chatInput/chat-input.tsx +++ b/src/frontend/src/modals/IOModal/components/chatView/chatInput/chat-input.tsx @@ -32,6 +32,7 @@ export default function ChatInput({ files, setFiles, isDragging, + playgroundPage, }: ChatInputType): JSX.Element { const currentFlowId = useFlowsManagerStore((state) => state.currentFlowId); const fileInputRef = useRef(null); @@ -49,6 +50,10 @@ export default function ChatInput({ const handleFileChange = async ( event: React.ChangeEvent | ClipboardEvent, ) => { + if (playgroundPage) { + return; + } + let file: File | null = null; if ("clipboardData" in event) { @@ -238,15 +243,17 @@ export default function ChatInput({ ))}
-
- -
-
+ {!playgroundPage && ( +
+ +
+ )} +
) - ) : !ENABLE_DATASTAX_LANGFLOW ? ( + ) : !ENABLE_DATASTAX_LANGFLOW && !playgroundPage ? ( + ) : playgroundPage ? ( + ) : ( )} @@ -301,7 +307,7 @@ export default function ChatMessage({ } > {chat.sender_name} - {chat.properties?.source && ( + {chat.properties?.source && !playgroundPage && (
{chat.properties?.source.source}
@@ -310,6 +316,7 @@ export default function ChatMessage({
{chat.content_blocks && chat.content_blocks.length > 0 && ( { return ( <> @@ -51,6 +52,7 @@ export const SidebarOpenView = ({ selectedView={selectedViewField} key={index} session={session} + playgroundPage={playgroundPage} deleteSession={(session) => { handleDeleteSession(session); if (selectedViewField?.id === session) { diff --git a/src/frontend/src/modals/IOModal/new-modal.tsx b/src/frontend/src/modals/IOModal/new-modal.tsx index 71dd9478f..4a3abe3ba 100644 --- a/src/frontend/src/modals/IOModal/new-modal.tsx +++ b/src/frontend/src/modals/IOModal/new-modal.tsx @@ -1,11 +1,19 @@ +//import LangflowLogoColor from "@/assets/LangflowLogocolor.svg?react"; +import ThemeButtons from "@/components/core/appHeaderComponent/components/ThemeButtons"; import { EventDeliveryType } from "@/constants/enums"; import { useGetConfig } from "@/controllers/API/queries/config/use-get-config"; import { useDeleteMessages, useGetMessagesQuery, } from "@/controllers/API/queries/messages"; +import { ENABLE_PUBLISH } from "@/customization/feature-flags"; +import { track } from "@/customization/utils/analytics"; +import { LangflowButtonRedirectTarget } from "@/customization/utils/urls"; import { useUtilityStore } from "@/stores/utilityStore"; +import { swatchColors } from "@/utils/styleUtils"; import { useCallback, useEffect, useState } from "react"; +import { v5 as uuidv5 } from "uuid"; +import LangflowLogoColor from "../../assets/LangflowLogoColor.svg?react"; import IconComponent from "../../components/common/genericIconComponent"; import ShadTooltip from "../../components/common/shadTooltipComponent"; import { Button } from "../../components/ui/button"; @@ -14,12 +22,11 @@ import useFlowStore from "../../stores/flowStore"; import useFlowsManagerStore from "../../stores/flowsManagerStore"; import { useMessagesStore } from "../../stores/messagesStore"; import { IOModalPropsType } from "../../types/components"; -import { cn } from "../../utils/utils"; +import { cn, getNumberFromString } from "../../utils/utils"; import BaseModal from "../baseModal"; import { ChatViewWrapper } from "./components/chat-view-wrapper"; import { SelectedViewField } from "./components/selected-view-field"; import { SidebarOpenView } from "./components/sidebar-open-view"; - export default function IOModal({ children, open, @@ -27,6 +34,7 @@ export default function IOModal({ disable, isPlayground, canvasOpen, + playgroundPage, }: IOModalPropsType): JSX.Element { const allNodes = useFlowStore((state) => state.nodes); const setIOModalOpen = useFlowsManagerStore((state) => state.setIOModalOpen); @@ -54,13 +62,23 @@ export default function IOModal({ const setErrorData = useAlertStore((state) => state.setErrorData); const setSuccessData = useAlertStore((state) => state.setSuccessData); const deleteSession = useMessagesStore((state) => state.deleteSession); - const currentFlowId = useFlowsManagerStore((state) => state.currentFlowId); + const clientId = useUtilityStore((state) => state.clientId); + let realFlowId = useFlowsManagerStore((state) => state.currentFlowId); + const currentFlowId = playgroundPage + ? uuidv5(`${clientId}_${realFlowId}`, uuidv5.DNS) + : realFlowId; + const currentFlow = useFlowsManagerStore((state) => state.currentFlow); const [sidebarOpen, setSidebarOpen] = useState(true); + const setPlaygroundPage = useFlowStore((state) => state.setPlaygroundPage); + setPlaygroundPage(!!playgroundPage); const { mutate: deleteSessionFunction } = useDeleteMessages(); const [visibleSession, setvisibleSession] = useState( currentFlowId, ); + const flowName = useFlowStore((state) => state.currentFlow?.name); + const PlaygroundTitle = + playgroundPage && ENABLE_PUBLISH && flowName ? flowName : "Playground"; useEffect(() => { setIOModalOpen(open); @@ -238,6 +256,25 @@ export default function IOModal({ }; }, []); + const showPublishOptions = playgroundPage && ENABLE_PUBLISH; + + const LangflowButtonClick = () => { + track("LangflowButtonClick"); + window.open(LangflowButtonRedirectTarget(), "_blank"); + }; + + useEffect(() => { + if (playgroundPage && messages.length > 0) { + window.sessionStorage.setItem(currentFlowId, JSON.stringify(messages)); + } + }, [playgroundPage, messages]); + + const swatchIndex = + (currentFlow?.gradient && !isNaN(parseInt(currentFlow?.gradient)) + ? parseInt(currentFlow?.gradient) + : getNumberFromString(currentFlow?.gradient ?? currentFlow?.id ?? "")) % + swatchColors.length; + return ( -
-
+
+
+
+
+ +
+ {sidebarOpen && ( +
+ {PlaygroundTitle} +
+ )} +
- {sidebarOpen && ( -
Playground
- )}
{sidebarOpen && ( )} + {sidebarOpen && showPublishOptions && ( +
+
+
Theme
+ +
+ +
+ )}
+ {!sidebarOpen && showPublishOptions && ( +
+ + + +
+ )}
{selectedViewField && ( )}
diff --git a/src/frontend/src/modals/IOModal/types/chat-view-wrapper.ts b/src/frontend/src/modals/IOModal/types/chat-view-wrapper.ts index 31b101a4a..35a629172 100644 --- a/src/frontend/src/modals/IOModal/types/chat-view-wrapper.ts +++ b/src/frontend/src/modals/IOModal/types/chat-view-wrapper.ts @@ -16,4 +16,6 @@ export type ChatViewWrapperProps = { sendMessage: (options: { repeat: number; files?: string[] }) => Promise; canvasOpen: boolean | undefined; setOpen: (open: boolean) => void; + playgroundTitle: string; + playgroundPage?: boolean; }; diff --git a/src/frontend/src/modals/IOModal/types/sidebar-open-view.ts b/src/frontend/src/modals/IOModal/types/sidebar-open-view.ts index 047c6e431..0e299a499 100644 --- a/src/frontend/src/modals/IOModal/types/sidebar-open-view.ts +++ b/src/frontend/src/modals/IOModal/types/sidebar-open-view.ts @@ -7,4 +7,5 @@ export type SidebarOpenViewProps = { handleDeleteSession: (session: string) => void; visibleSession: string | undefined; selectedViewField: { type: string; id: string } | undefined; + playgroundPage: boolean; }; diff --git a/src/frontend/src/modals/apiModal/codeTabs/code-tabs.tsx b/src/frontend/src/modals/apiModal/codeTabs/code-tabs.tsx new file mode 100644 index 000000000..9d3b8c748 --- /dev/null +++ b/src/frontend/src/modals/apiModal/codeTabs/code-tabs.tsx @@ -0,0 +1,164 @@ +import IconComponent from "@/components/common/genericIconComponent"; +import ShadTooltip from "@/components/common/shadTooltipComponent"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import useAuthStore from "@/stores/authStore"; +import useFlowStore from "@/stores/flowStore"; +import { useTweaksStore } from "@/stores/tweaksStore"; +import { AllNodeType } from "@/types/flow"; +import { tabsArrayType } from "@/types/tabs"; +import { hasStreaming } from "@/utils/reactflowUtils"; +import { useEffect, useState } from "react"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { + oneDark, + oneLight, +} from "react-syntax-highlighter/dist/cjs/styles/prism"; +import { useDarkStore } from "../../../stores/darkStore"; +import { getNewCurlCode } from "../utils/get-curl-code"; +import { getNewJsApiCode } from "../utils/get-js-api-code"; +import { getNewPythonApiCode } from "../utils/get-python-api-code"; + +export default function APITabsComponent() { + const [isCopied, setIsCopied] = useState(false); + const dark = useDarkStore((state) => state.dark); + const nodes = useFlowStore((state) => state.nodes); + const flowId = useFlowStore((state) => state.currentFlow?.id); + const autologin = useAuthStore((state) => state.autoLogin); + const inputs = useFlowStore((state) => state.inputs); + const outputs = useFlowStore((state) => state.outputs); + const hasChatInput = inputs.some((input) => input.type === "ChatInput"); + const hasChatOutput = outputs.some((output) => output.type === "ChatOutput"); + let input_value = "hello world!"; + if (hasChatInput) { + const chatInputId = inputs.find((input) => input.type === "ChatInput")?.id; + const inputNode = nodes.find((node) => node.id === chatInputId); + if (inputNode && inputNode?.data.node?.template?.input_value?.value) { + input_value = inputNode?.data.node?.template.input_value?.value; + } + } + const streaming = hasStreaming(nodes); + const tweaks = useTweaksStore((state) => state.tweaks); + const codeOptions = { + streaming: streaming, + flowId: flowId || "", + isAuthenticated: !autologin || false, + input_value: input_value, + input_type: hasChatInput ? "chat" : "text", + output_type: hasChatOutput ? "chat" : "text", + tweaksObject: tweaks, + activeTweaks: Object.values(tweaks).some( + (tweak) => Object.keys(tweak).length > 0, + ), + }; + const tabsList: tabsArrayType = [ + { + title: "Python", + icon: "BWPython", + language: "python", + code: getNewPythonApiCode(codeOptions), + copyCode: getNewPythonApiCode(codeOptions), + }, + { + title: "JavaScript", + icon: "javascript", + language: "javascript", + code: getNewJsApiCode(codeOptions), + copyCode: getNewJsApiCode(codeOptions), + }, + { + title: "cURL", + icon: "TerminalSquare", + language: "shell", + code: getNewCurlCode(codeOptions), + copyCode: getNewCurlCode(codeOptions), + }, + ]; + const [activeTab, setActiveTab] = useState(0); + + const copyToClipboard = () => { + if (!navigator.clipboard || !navigator.clipboard.writeText) { + return; + } + + navigator.clipboard.writeText(tabsList[activeTab].code).then(() => { + setIsCopied(true); + + setTimeout(() => { + setIsCopied(false); + }, 2000); + }); + }; + + useEffect(() => { + setIsCopied(false); + }, [activeTab]); + + return ( + { + setActiveTab(parseInt(value)); + }} + > +
+ {tabsList.length > 0 && tabsList[0].title !== "" ? ( + + {tabsList.map((tab, index) => ( + + + {tab.title} + + ))} + + ) : ( +
+ )} +
+ + {tabsList.map((tab, idx) => ( + +
+ + + {tab.code} + +
+
+ ))} +
+ ); +} diff --git a/src/frontend/src/modals/apiModal/new-api-modal.tsx b/src/frontend/src/modals/apiModal/new-api-modal.tsx new file mode 100644 index 000000000..26d379890 --- /dev/null +++ b/src/frontend/src/modals/apiModal/new-api-modal.tsx @@ -0,0 +1,141 @@ +import { TweaksComponent } from "@/components/core/codeTabsComponent/components/tweaksComponent"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { CustomAPIGenerator } from "@/customization/components/custom-api-generator"; +import useAuthStore from "@/stores/authStore"; +import useFlowStore from "@/stores/flowStore"; +import "ace-builds/src-noconflict/ext-language_tools"; +import "ace-builds/src-noconflict/mode-python"; +import "ace-builds/src-noconflict/theme-github"; +import "ace-builds/src-noconflict/theme-twilight"; +import { ReactNode, useEffect, useState } from "react"; +import IconComponent from "../../components/common/genericIconComponent"; +import { useTweaksStore } from "../../stores/tweaksStore"; +import BaseModal from "../baseModal"; +import APITabsComponent from "./codeTabs/code-tabs"; + +export default function ApiModal({ + children, + open: myOpen, + setOpen: mySetOpen, +}: { + children: ReactNode; + open?: boolean; + setOpen?: (a: boolean | ((o?: boolean) => boolean)) => void; +}) { + const autoLogin = useAuthStore((state) => state.autoLogin); + const nodes = useFlowStore((state) => state.nodes); + const [openTweaks, setOpenTweaks] = useState(false); + const tweaks = useTweaksStore((state) => state.tweaks); + const [open, setOpen] = + mySetOpen !== undefined && myOpen !== undefined + ? [myOpen, mySetOpen] + : useState(false); + const newInitialSetup = useTweaksStore((state) => state.newInitialSetup); + + useEffect(() => { + if (open) newInitialSetup(nodes); + }, [open]); + + return ( + <> + + {children} + + + API access requires an API key. You can{" "} + + {" "} + create an API key + {" "} + in settings. + + + ) + } + > + + + {open && ( + <> + + + + )} + + + + + + + API access requires an API key. You can{" "} + + {" "} + create an API key + {" "} + in settings. + + + ) + } + > + + Tweaks + + +
+ +
+
+
+ + ); +} diff --git a/src/frontend/src/modals/apiModal/utils/get-curl-code.tsx b/src/frontend/src/modals/apiModal/utils/get-curl-code.tsx index 877620300..27446cd5b 100644 --- a/src/frontend/src/modals/apiModal/utils/get-curl-code.tsx +++ b/src/frontend/src/modals/apiModal/utils/get-curl-code.tsx @@ -68,3 +68,56 @@ export function getCurlWebhookCode({ -d '{"any": "data"}' `.trim(); } + +export function getNewCurlCode({ + flowId, + isAuthenticated, + input_value, + input_type, + output_type, + tweaksObject, + activeTweaks, +}: { + flowId: string; + isAuthenticated: boolean; + input_value: string; + input_type: string; + output_type: string; + tweaksObject: any; + activeTweaks: boolean; +}): string { + const host = window.location.host; + const protocol = window.location.protocol; + const apiUrl = `${protocol}//${host}/api/v1/run/${flowId}`; + + const tweaksString = + tweaksObject && activeTweaks ? JSON.stringify(tweaksObject, null, 2) : "{}"; + + // Construct the payload + const payload = { + input_value: input_value, + output_type: output_type, + input_type: input_type, + ...(activeTweaks && tweaksObject + ? { tweaks: JSON.parse(tweaksString) } + : {}), + }; + + return `${ + isAuthenticated + ? `# Get API key from environment variable +if [ -z "$LANGFLOW_API_KEY" ]; then + echo "Error: LANGFLOW_API_KEY environment variable not found. Please set your API key in the environment variables." +fi +` + : "" + }curl --request POST \\ + --url '${apiUrl}?stream=false' \\ + --header 'Content-Type: application/json' \\${ + isAuthenticated + ? ` + --header "x-api-key: $LANGFLOW_API_KEY" \\` + : "" + } + --data '${JSON.stringify(payload, null, 2)}'`; +} diff --git a/src/frontend/src/modals/apiModal/utils/get-js-api-code.tsx b/src/frontend/src/modals/apiModal/utils/get-js-api-code.tsx index 67246647e..617477fef 100644 --- a/src/frontend/src/modals/apiModal/utils/get-js-api-code.tsx +++ b/src/frontend/src/modals/apiModal/utils/get-js-api-code.tsx @@ -44,3 +44,75 @@ export default function getJsApiCode({ .catch(error => console.error('Error:', error)); `; } +/** + * Generates JavaScript code for making API calls to a Langflow endpoint. + * + * @param {Object} params - The parameters for generating the API code + * @param {string} params.flowId - The ID of the flow to run + * @param {boolean} params.isAuthenticated - Whether authentication is required + * @param {string} params.input_value - The input value to send to the flow + * @param {string} params.input_type - The type of input (e.g. "text", "chat") + * @param {string} params.output_type - The type of output (e.g. "text", "chat") + * @param {Object} params.tweaksObject - Optional tweaks to customize flow behavior + * @param {boolean} params.activeTweaks - Whether tweaks should be included + * @returns {string} Generated JavaScript code as a string + */ +export function getNewJsApiCode({ + flowId, + isAuthenticated, + input_value, + input_type, + output_type, + tweaksObject, + activeTweaks, +}: { + flowId: string; + isAuthenticated: boolean; + input_value: string; + input_type: string; + output_type: string; + tweaksObject: any; + activeTweaks: boolean; +}): string { + const host = window.location.host; + const protocol = window.location.protocol; + const apiUrl = `${protocol}//${host}/api/v1/run/${flowId}`; + + const tweaksString = + tweaksObject && activeTweaks ? JSON.stringify(tweaksObject, null, 2) : "{}"; + + return `${ + isAuthenticated + ? `// Get API key from environment variable +if (!process.env.LANGFLOW_API_KEY) { + throw new Error('LANGFLOW_API_KEY environment variable not found. Please set your API key in the environment variables.'); +} +` + : "" + }const payload = { + "input_value": "${input_value}", + "output_type": "${output_type}", + "input_type": "${input_type}", + // Optional: Use session tracking if needed + "session_id": "user_1"${ + activeTweaks && tweaksObject + ? `, + "tweaks": ${tweaksString}` + : "" + } +}; + +const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json'${isAuthenticated ? ',\n "x-api-key": process.env.LANGFLOW_API_KEY' : ""} + }, + body: JSON.stringify(payload) +}; + +fetch('${apiUrl}', options) + .then(response => response.json()) + .then(response => console.log(response)) + .catch(err => console.error(err)); + `; +} diff --git a/src/frontend/src/modals/apiModal/utils/get-python-api-code.tsx b/src/frontend/src/modals/apiModal/utils/get-python-api-code.tsx index 0931d1eb2..79bffd92b 100644 --- a/src/frontend/src/modals/apiModal/utils/get-python-api-code.tsx +++ b/src/frontend/src/modals/apiModal/utils/get-python-api-code.tsx @@ -114,3 +114,76 @@ if __name__ == "__main__": main() `; } + +export function getNewPythonApiCode({ + flowId, + isAuthenticated, + input_value, + input_type, + output_type, + tweaksObject, + activeTweaks, +}: { + flowId: string; + isAuthenticated: boolean; + input_value: string; + input_type: string; + output_type: string; + tweaksObject: any; + activeTweaks: boolean; +}): string { + const host = window.location.host; + const protocol = window.location.protocol; + const apiUrl = `${protocol}//${host}/api/v1/run/${flowId}`; + + const tweaksString = + tweaksObject && activeTweaks + ? JSON.stringify(tweaksObject, null, 4) + .replace(/true/g, "True") + .replace(/false/g, "False") + .replace(/null/g, "None") + : "{}"; + + return `import requests +${ + isAuthenticated + ? `import os +# API Configuration +try: + api_key = os.environ["LANGFLOW_API_KEY"] +except KeyError: + raise ValueError("LANGFLOW_API_KEY environment variable not found. Please set your API key in the environment variables.")\n` + : "" +}url = "${apiUrl}" # The complete API endpoint URL for this flow + +# Request payload configuration +payload = { + "input_value": "${input_value}", # The input value to be processed by the flow + "output_type": "${output_type}", # Specifies the expected output format + "input_type": "${input_type}" # Specifies the input format${ + activeTweaks && tweaksObject + ? `, + "tweaks": ${tweaksString} # Custom tweaks to modify flow behavior` + : "" + } +} + +# Request headers +headers = { + "Content-Type": "application/json"${isAuthenticated ? ',\n "x-api-key": api_key # Authentication key from environment variable' : ""} +} + +try: + # Send API request + response = requests.request("POST", url, json=payload, headers=headers) + response.raise_for_status() # Raise exception for bad status codes + + # Print response + print(response.text) + +except requests.exceptions.RequestException as e: + print(f"Error making API request: {e}") +except ValueError as e: + print(f"Error parsing response: {e}") + `; +} diff --git a/src/frontend/src/modals/apiModal/utils/get-widget-code.tsx b/src/frontend/src/modals/apiModal/utils/get-widget-code.tsx index dd3e76bf6..a1ab03ebb 100644 --- a/src/frontend/src/modals/apiModal/utils/get-widget-code.tsx +++ b/src/frontend/src/modals/apiModal/utils/get-widget-code.tsx @@ -9,9 +9,18 @@ export default function getWidgetCode({ flowId, flowName, isAuth, + copy = false, }: GetCodeType): string { - return ` + const source = copy + ? `` + : ``; + return `${source} `; + }> +`; } diff --git a/src/frontend/src/modals/baseModal/helpers/switch-case-size.ts b/src/frontend/src/modals/baseModal/helpers/switch-case-size.ts index 23196ce8f..8882dd9d8 100644 --- a/src/frontend/src/modals/baseModal/helpers/switch-case-size.ts +++ b/src/frontend/src/modals/baseModal/helpers/switch-case-size.ts @@ -18,12 +18,16 @@ export const switchCaseModalSize = (size: string) => { minWidth = "min-w-[40vw]"; height = "h-[40vh]"; break; + case "medium-small-tall": + minWidth = "min-w-[50vw]"; + height = "h-[70vh]"; + break; case "small-h-full": minWidth = "min-w-[40vw]"; height = ""; break; case "medium": - minWidth = "min-w-[60vw]"; + minWidth = "min-w-[60vw] max-w-[720px]"; height = "h-[60vh]"; break; case "medium-tall": @@ -72,6 +76,11 @@ export const switchCaseModalSize = (size: string) => { height = "h-[95vh]"; break; + case "retangular": + minWidth = "!min-w-[900px]"; + height = "min-h-[232px]"; + break; + default: minWidth = "min-w-[80vw]"; height = "h-[90vh]"; diff --git a/src/frontend/src/modals/baseModal/index.tsx b/src/frontend/src/modals/baseModal/index.tsx index 65ba7f986..566e067f9 100644 --- a/src/frontend/src/modals/baseModal/index.tsx +++ b/src/frontend/src/modals/baseModal/index.tsx @@ -73,7 +73,7 @@ const Trigger: React.FC = ({ const Header: React.FC<{ children: ReactNode; - description: string | JSX.Element | null; + description?: string | JSX.Element | null; clampDescription?: number; }> = ({ children, @@ -85,11 +85,13 @@ const Header: React.FC<{ {children} - - {description} - + {description && ( + + {description} + + )} ); }; @@ -166,6 +168,7 @@ interface BaseModalProps { setOpen?: (open: boolean) => void; size?: | "x-small" + | "retangular" | "smaller" | "small" | "medium" @@ -176,6 +179,7 @@ interface BaseModalProps { | "large-h-full" | "templates" | "small-h-full" + | "medium-small-tall" | "medium-h-full" | "md-thin" | "sm-thin" @@ -188,6 +192,7 @@ interface BaseModalProps { type?: "modal" | "dialog" | "full-screen"; onSubmit?: () => void; onEscapeKeyDown?: (e: KeyboardEvent) => void; + closeButtonClassName?: string; } function BaseModal({ className, @@ -199,6 +204,7 @@ function BaseModal({ type = "dialog", onSubmit, onEscapeKeyDown, + closeButtonClassName, }: BaseModalProps) { const headerChild = React.Children.toArray(children).find( (child) => (child as React.ReactElement).type === Header, @@ -253,6 +259,7 @@ function BaseModal({ onOpenAutoFocus={(event) => event.preventDefault()} onEscapeKeyDown={onEscapeKeyDown} className={contentClasses} + closeButtonClassName={closeButtonClassName} > {onSubmit ? ( { - if (!dark) { - document.getElementById("body")!.classList.remove("dark"); - } else { - document.getElementById("body")!.classList.add("dark"); - } - }, [dark]); - return ( //need parent component with width and height <> diff --git a/src/frontend/src/pages/Playground/index.tsx b/src/frontend/src/pages/Playground/index.tsx index 9d3834658..3de0a1e0f 100644 --- a/src/frontend/src/pages/Playground/index.tsx +++ b/src/frontend/src/pages/Playground/index.tsx @@ -2,17 +2,23 @@ import { useGetFlow } from "@/controllers/API/queries/flows/use-get-flow"; import { useCustomNavigate } from "@/customization/hooks/use-custom-navigate"; import { track } from "@/customization/utils/analytics"; import IOModal from "@/modals/IOModal/new-modal"; +import useFlowStore from "@/stores/flowStore"; import { useStoreStore } from "@/stores/storeStore"; +import { useUtilityStore } from "@/stores/utilityStore"; +import { CookieOptions, getCookie, setCookie } from "@/utils/utils"; import { useEffect } from "react"; import { useParams } from "react-router-dom"; +import { v4 as uuid } from "uuid"; import { getComponent } from "../../controllers/API"; import useFlowsManagerStore from "../../stores/flowsManagerStore"; -import cloneFLowWithParent from "../../utils/storeUtils"; - +import cloneFLowWithParent, { + getInputsAndOutputs, +} from "../../utils/storeUtils"; export default function PlaygroundPage() { const setCurrentFlow = useFlowsManagerStore((state) => state.setCurrentFlow); const currentSavedFlow = useFlowsManagerStore((state) => state.currentFlow); - const validApiKey = useStoreStore((state) => state.validApiKey); + const setClientId = useUtilityStore((state) => state.setClientId); + const { id } = useParams(); const { mutateAsync: getFlow } = useGetFlow(); @@ -23,22 +29,11 @@ export default function PlaygroundPage() { async function getFlowData() { try { - const flow = await getFlow({ id: id! }); + const flow = await getFlow({ id: id!, public: true }); return flow; } catch (error: any) { - if (error?.response?.status === 404) { - if (!validApiKey) { - return null; - } - try { - const res = await getComponent(id!); - const newFlow = cloneFLowWithParent(res, res.id, false, true); - return newFlow; - } catch (componentError) { - return null; - } - } - return null; + console.log(error); + navigate("/"); } } @@ -57,16 +52,48 @@ export default function PlaygroundPage() { initializeFlow(); setIsLoading(false); - }, [id, validApiKey]); + }, [id]); useEffect(() => { if (id) track("Playground Page Loaded", { flowId: id }); }, []); + useEffect(() => { + document.title = currentSavedFlow?.name || "Langflow"; + if (currentSavedFlow?.data) { + const { inputs, outputs } = getInputsAndOutputs( + currentSavedFlow?.data?.nodes || [], + ); + if ( + (inputs.length === 0 && outputs.length === 0) || + currentSavedFlow?.access_type !== "public" + ) { + // redirect to the home page + navigate("/"); + } + } + }, [currentSavedFlow]); + + useEffect(() => { + // Get client ID from cookie or create new one + const clientId = getCookie("client_id"); + if (!clientId) { + const newClientId = uuid(); + const cookieOptions: CookieOptions = { + secure: window.location.protocol === "https:", + sameSite: "Strict", + }; + setCookie("client_id", newClientId, cookieOptions); + setClientId(newClientId); + } else { + setClientId(clientId); + } + }, []); + return (
{currentSavedFlow && ( - {}} isPlayground> + {}} isPlayground playgroundPage> <> )} diff --git a/src/frontend/src/routes.tsx b/src/frontend/src/routes.tsx index 6e133b99a..801ceed5e 100644 --- a/src/frontend/src/routes.tsx +++ b/src/frontend/src/routes.tsx @@ -36,15 +36,25 @@ const AdminPage = lazy(() => import("./pages/AdminPage")); const LoginAdminPage = lazy(() => import("./pages/AdminPage/LoginPage")); const DeleteAccountPage = lazy(() => import("./pages/DeleteAccountPage")); -// const PlaygroundPage = lazy(() => import("./pages/Playground")); +const PlaygroundPage = lazy(() => import("./pages/Playground")); const SignUp = lazy(() => import("./pages/SignUpPage")); const router = createBrowserRouter( createRoutesFromElements([ + + + + + } + /> + , + } @@ -151,9 +161,6 @@ const router = createBrowserRouter( } /> - {/* - } /> - */} ((set, get) => ({ + playgroundPage: false, + setPlaygroundPage: (playgroundPage) => { + set({ playgroundPage }); + }, positionDictionary: {}, setPositionDictionary: (positionDictionary) => { set({ positionDictionary }); @@ -604,6 +608,7 @@ const useFlowStore = create((set, get) => ({ session?: string; stream?: boolean; }) => { + const playgroundPage = get().playgroundPage; get().setIsBuilding(true); const currentFlow = useFlowsManagerStore.getState().currentFlow; const setSuccessData = useAlertStore.getState().setSuccessData; @@ -827,6 +832,7 @@ const useFlowStore = create((set, get) => ({ nodes: get().nodes || undefined, edges: get().edges || undefined, logBuilds: get().onFlowPage, + playgroundPage, stream, }); get().setIsBuilding(false); diff --git a/src/frontend/src/stores/tweaksStore.ts b/src/frontend/src/stores/tweaksStore.ts index 290ddffbe..df36f29a6 100644 --- a/src/frontend/src/stores/tweaksStore.ts +++ b/src/frontend/src/stores/tweaksStore.ts @@ -1,7 +1,7 @@ import { getChangesType } from "@/modals/apiModal/utils/get-changes-types"; import { getNodesWithDefaultValue } from "@/modals/apiModal/utils/get-nodes-with-default-value"; import { createTabsArray } from "@/modals/apiModal/utils/tabs-array"; -import { FlowType, NodeDataType } from "@/types/flow"; +import { AllNodeType, FlowType, NodeDataType } from "@/types/flow"; import { GetCodesType } from "@/types/tweaks"; import { customStringify } from "@/utils/reactflowUtils"; import { create } from "zustand"; @@ -10,6 +10,7 @@ import useFlowStore from "./flowStore"; export const useTweaksStore = create((set, get) => ({ activeTweaks: false, + tweaks: {}, setActiveTweaks: (activeTweaks: boolean) => { set({ activeTweaks }), get().refreshTabs(); }, @@ -21,6 +22,7 @@ export const useTweaksStore = create((set, get) => ({ nodes: newChange, }); get().refreshTabs(); + get().updateTweaks(); }, setNode: (id, change) => { let newChange = @@ -59,6 +61,13 @@ export const useTweaksStore = create((set, get) => ({ }); get().refreshTabs(); }, + newInitialSetup: (nodes: AllNodeType[]) => { + useFlowStore.getState().unselectAll(); + set({ + nodes: getNodesWithDefaultValue(nodes), + }); + get().updateTweaks(); + }, tabs: [], refreshTabs: () => { const autoLogin = get().autoLogin; @@ -126,4 +135,34 @@ export const useTweaksStore = create((set, get) => ({ tabs: createTabsArray(codesObj, nodes.length > 0), }); }, + updateTweaks: () => { + const nodes = get().nodes; + const originalNodes = useFlowStore.getState().nodes; + const tweak = {}; + nodes.forEach((node) => { + const originalNodeTemplate = originalNodes?.find((n) => n.id === node.id) + ?.data?.node?.template; + const nodeTemplate = node.data?.node?.template; + if (originalNodeTemplate && nodeTemplate && node.type === "genericNode") { + const currentTweak = {}; + Object.keys(nodeTemplate).forEach((name) => { + if ( + customStringify(nodeTemplate[name]) !== + customStringify(originalNodeTemplate[name]) + ) { + currentTweak[name] = getChangesType( + nodeTemplate[name].value, + nodeTemplate[name], + ); + } + }); + if (Object.keys(currentTweak).length > 0) { + tweak[node.id] = currentTweak; + } + } + }); + set({ + tweaks: tweak, + }); + }, })); diff --git a/src/frontend/src/stores/utilityStore.ts b/src/frontend/src/stores/utilityStore.ts index e71b3d864..d9d800f58 100644 --- a/src/frontend/src/stores/utilityStore.ts +++ b/src/frontend/src/stores/utilityStore.ts @@ -3,6 +3,8 @@ import { UtilityStoreType } from "@/types/zustand/utility"; import { create } from "zustand"; export const useUtilityStore = create((set, get) => ({ + clientId: "", + setClientId: (clientId: string) => set({ clientId }), dismissAll: false, setDismissAll: (dismissAll: boolean) => set({ dismissAll }), chatValueStore: "", diff --git a/src/frontend/src/style/applies.css b/src/frontend/src/style/applies.css index 9aca73461..f202240b6 100644 --- a/src/frontend/src/style/applies.css +++ b/src/frontend/src/style/applies.css @@ -1294,6 +1294,14 @@ align-items: center; } +.deploy-dropdown-item { + @apply px-2.5 py-1 hover:bg-background cursor-pointer h-11 font-normal text-[14px] !important; +} + +.deploy-dropdown-item > :first-child { + @apply flex items-center w-full px-2 h-8 py-1 rounded-md !important; +} + :root { --color-bg1: rgb(255, 255, 255); --color1: 255, 50, 118; diff --git a/src/frontend/src/style/classes.css b/src/frontend/src/style/classes.css index 3334c5223..edbe5b7ab 100644 --- a/src/frontend/src/style/classes.css +++ b/src/frontend/src/style/classes.css @@ -250,13 +250,17 @@ pre code { display: inline-block; width: 100%; /* Background color */ - background-color: hsl(var(--code-background)) !important; + background-color: hsl(var(--muted)) !important; + font-size: 12px !important; +} + +.dark pre code { color: hsl(var(--code-foreground)) !important; } pre { /* Background color */ - background-color: hsl(var(--code-background)) !important; + background-color: hsl(var(--muted)) !important; } .prose li::marker { @@ -286,3 +290,7 @@ input[type="search"]::-webkit-search-cancel-button { .markdown td { min-width: 78px; } + +.linenumber { + font-style: normal !important; +} diff --git a/src/frontend/src/types/components/index.ts b/src/frontend/src/types/components/index.ts index 58e607790..f73d13dcd 100644 --- a/src/frontend/src/types/components/index.ts +++ b/src/frontend/src/types/components/index.ts @@ -535,6 +535,7 @@ export type ChatInputType = { repeat: number; files?: string[]; }) => void; + playgroundPage: boolean; }; export type editNodeToggleType = { @@ -595,7 +596,7 @@ export type iconsType = { export type modalHeaderType = { children: ReactNode; - description: string | JSX.Element | null; + description?: string | JSX.Element | null; clampDescription?: number; }; @@ -622,6 +623,7 @@ export type chatMessagePropsType = { stream_url?: string, ) => void; closeChat?: () => void; + playgroundPage?: boolean; }; export type genericModalPropsType = { @@ -679,6 +681,7 @@ export type IOModalPropsType = { isPlayground?: boolean; cleanOnClose?: boolean; canvasOpen?: boolean; + playgroundPage?: boolean; }; export type buttonBoxPropsType = { @@ -796,6 +799,7 @@ export type chatViewProps = { visibleSession?: string; focusChat?: string; closeChat?: () => void; + playgroundPage?: boolean; }; export type IOFileInputProps = { diff --git a/src/frontend/src/types/flow/index.ts b/src/frontend/src/types/flow/index.ts index 0fe74b8bd..c6aee4b50 100644 --- a/src/frontend/src/types/flow/index.ts +++ b/src/frontend/src/types/flow/index.ts @@ -31,6 +31,8 @@ export type FlowType = { folder_id?: string; webhook?: boolean; locked?: boolean | null; + public?: boolean; + access_type?: "public" | "private" | "protected"; }; export type GenericNodeType = Node; diff --git a/src/frontend/src/types/tabs/index.ts b/src/frontend/src/types/tabs/index.ts index 4b8c5d188..bf06fa973 100644 --- a/src/frontend/src/types/tabs/index.ts +++ b/src/frontend/src/types/tabs/index.ts @@ -69,3 +69,13 @@ export type errorsVarType = { title: string; list?: Array; }; + +export type APITabType = { + title: string; + language: string; + icon: string; + code: string; + copyCode: string; +}; + +export type tabsArrayType = Array; diff --git a/src/frontend/src/types/tweaks/index.ts b/src/frontend/src/types/tweaks/index.ts index d6a672379..c6b584655 100644 --- a/src/frontend/src/types/tweaks/index.ts +++ b/src/frontend/src/types/tweaks/index.ts @@ -14,4 +14,5 @@ export type GetCodeType = { tweaksBuildedObject?: {}; endpointName?: string | null; activeTweaks?: boolean; + copy?: boolean; }; diff --git a/src/frontend/src/types/zustand/flow/index.ts b/src/frontend/src/types/zustand/flow/index.ts index bcfa59374..ef2db91fe 100644 --- a/src/frontend/src/types/zustand/flow/index.ts +++ b/src/frontend/src/types/zustand/flow/index.ts @@ -139,6 +139,8 @@ export type FlowStoreType = { getFilterEdge: any[]; onConnect: (connection: Connection) => void; unselectAll: () => void; + playgroundPage: boolean; + setPlaygroundPage: (playgroundPage: boolean) => void; buildFlow: ({ startNodeId, stopNodeId, diff --git a/src/frontend/src/types/zustand/tweaks/index.ts b/src/frontend/src/types/zustand/tweaks/index.ts index 018b7cbb5..78feb71dd 100644 --- a/src/frontend/src/types/zustand/tweaks/index.ts +++ b/src/frontend/src/types/zustand/tweaks/index.ts @@ -1,4 +1,4 @@ -import { AllNodeType, FlowType } from "@/types/flow"; +import { AllNodeType, FlowType, TweaksType } from "@/types/flow"; import { GetCodesType } from "@/types/tweaks"; import { tabsArrayType } from "../../components"; @@ -22,7 +22,14 @@ export type TweaksStoreType = { flow: FlowType, getCodes: GetCodesType, ) => void; + newInitialSetup: (nodes: AllNodeType[]) => void; refreshTabs: () => void; autoLogin: boolean; flow: FlowType | null; + updateTweaks: () => void; + tweaks: { + [key: string]: { + [key: string]: any; + }; + }; }; diff --git a/src/frontend/src/types/zustand/utility/index.ts b/src/frontend/src/types/zustand/utility/index.ts index dc4b53c8b..788ba193e 100644 --- a/src/frontend/src/types/zustand/utility/index.ts +++ b/src/frontend/src/types/zustand/utility/index.ts @@ -21,4 +21,6 @@ export type UtilityStoreType = { setChatValueStore: (value: string) => void; dismissAll: boolean; setDismissAll: (dismissAll: boolean) => void; + setClientId: (clientId: string) => void; + clientId: string; }; diff --git a/src/frontend/src/utils/buildUtils.ts b/src/frontend/src/utils/buildUtils.ts index 44e5c1460..62aeeacdc 100644 --- a/src/frontend/src/utils/buildUtils.ts +++ b/src/frontend/src/utils/buildUtils.ts @@ -39,6 +39,7 @@ type BuildVerticesParams = { edges?: Edge[]; logBuilds?: boolean; session?: string; + playgroundPage?: boolean; stream?: boolean; }; @@ -226,10 +227,13 @@ export async function buildFlowVertices({ edges, logBuilds, session, + playgroundPage, stream = true, }: BuildVerticesParams) { const inputs = {}; - let buildUrl = `${BASE_URL_API}build/${flowId}/flow`; + + let buildUrl = `${BASE_URL_API}${playgroundPage ? "build_public_tmp" : "build"}/${flowId}/flow`; + const queryParams = new URLSearchParams(); if (startNodeId) { diff --git a/src/frontend/src/utils/reactflowUtils.ts b/src/frontend/src/utils/reactflowUtils.ts index 699a9e3d4..0a05abd99 100644 --- a/src/frontend/src/utils/reactflowUtils.ts +++ b/src/frontend/src/utils/reactflowUtils.ts @@ -1983,3 +1983,7 @@ export function buildPositionDictionary(nodes: AllNodeType[]) { }); return positionDictionary; } + +export function hasStreaming(nodes: AllNodeType[]) { + return nodes.some((node) => node.data.node?.template?.stream?.value); +} diff --git a/src/frontend/src/utils/styleUtils.ts b/src/frontend/src/utils/styleUtils.ts index 2aabf2f90..ae90a9d58 100644 --- a/src/frontend/src/utils/styleUtils.ts +++ b/src/frontend/src/utils/styleUtils.ts @@ -1,7 +1,9 @@ import { AIMLIcon } from "@/icons/AIML"; +import { BWPythonIcon } from "@/icons/BW python"; import { DuckDuckGoIcon } from "@/icons/DuckDuckGo"; import { ExaIcon } from "@/icons/Exa"; import { GleanIcon } from "@/icons/Glean"; +import { JSIcon } from "@/icons/JSicon"; import { LangwatchIcon } from "@/icons/Langwatch"; import { MilvusIcon } from "@/icons/Milvus"; import Perplexity from "@/icons/Perplexity/Perplexity"; @@ -60,6 +62,7 @@ import { Code2, CodeXml, Cog, + Columns2, Combine, Command, Compass, @@ -82,6 +85,7 @@ import { EyeOff, File, FileClock, + FileCode2, FileDown, FileQuestion, FileSearch, @@ -612,6 +616,7 @@ export const nodeIconsLucide: iconsType = { ListFlows: Group, ClearMessageHistory: FileClock, Python: PythonIcon, + BWPython: BWPythonIcon, AzureChatOpenAi: AzureIcon, Ollama: OllamaIcon, ChatOllama: OllamaIcon, @@ -972,6 +977,9 @@ export const nodeIconsLucide: iconsType = { ThumbDownIconCustom, ThumbUpIconCustom, Serper: SerperIcon, + javascript: JSIcon, + FileCode2, + Columns2, ScrapeGraphAI: ScrapeGraph, ScrapeGraph: ScrapeGraph, ScrapeGraphSmartScraperApi: ScrapeGraph, diff --git a/src/frontend/src/utils/utils.ts b/src/frontend/src/utils/utils.ts index 7ad16d018..95b1897ca 100644 --- a/src/frontend/src/utils/utils.ts +++ b/src/frontend/src/utils/utils.ts @@ -795,3 +795,63 @@ export const stringToBool = (str) => (str === "false" ? false : true); export const filterNullOptions = (opts: any[]): any[] => { return opts.filter((opt) => opt !== null && opt !== undefined); }; + +/** + * Gets a cookie value by its name + * @param {string} name - The name of the cookie to retrieve + * @returns {string | undefined} The cookie value if found, undefined otherwise + */ +export function getCookie(name: string): string | undefined { + const cookie = document.cookie + .split("; ") + .find((row) => row.startsWith(`${name}=`)); + + if (cookie) { + return cookie.split("=")[1]; + } + return undefined; +} + +/** + * Interface for cookie options + */ +export interface CookieOptions { + path?: string; + domain?: string; + maxAge?: number; + expires?: Date; + secure?: boolean; + sameSite?: "Strict" | "Lax" | "None"; +} + +/** + * Sets a cookie with the specified name, value, and optional configuration + * @param {string} name - The name of the cookie + * @param {string} value - The value to store in the cookie + * @param {CookieOptions} options - Optional configuration for the cookie + */ +export function setCookie( + name: string, + value: string, + options: CookieOptions = {}, +): void { + const { + path = "/", + domain, + maxAge, + expires, + secure = true, + sameSite = "Strict", + } = options; + + let cookieString = `${name}=${encodeURIComponent(value)}`; + + if (path) cookieString += `; path=${path}`; + if (domain) cookieString += `; domain=${domain}`; + if (maxAge) cookieString += `; max-age=${maxAge}`; + if (expires) cookieString += `; expires=${expires.toUTCString()}`; + if (secure) cookieString += "; secure"; + if (sameSite) cookieString += `; SameSite=${sameSite}`; + + document.cookie = cookieString; +} diff --git a/src/frontend/tailwind.config.mjs b/src/frontend/tailwind.config.mjs index 28087fefa..743574f43 100644 --- a/src/frontend/tailwind.config.mjs +++ b/src/frontend/tailwind.config.mjs @@ -366,6 +366,9 @@ const config = { "&::-webkit-scrollbar-thumb:hover": { backgroundColor: "hsl(var(--placeholder-foreground))", }, + "&::-webkit-scrollbar-corner": { + backgroundColor: "transparent", + }, cursor: "auto", }, ".dark .theme-attribution .react-flow__attribution": { diff --git a/src/frontend/tests/core/features/logs.spec.ts b/src/frontend/tests/core/features/logs.spec.ts index b74272920..fcc78d0db 100644 --- a/src/frontend/tests/core/features/logs.spec.ts +++ b/src/frontend/tests/core/features/logs.spec.ts @@ -39,7 +39,7 @@ test( filledApiKey = await page.getByTestId("remove-icon-badge").count(); } - await page.getByTestId("icon-ChevronDown").click(); + await page.getByTestId("icon-ChevronDown").first().click(); await page.getByText("Logs").click(); await page.getByText("No Data Available", { exact: true }).isVisible(); await page.keyboard.press("Escape"); @@ -68,7 +68,7 @@ test( await page .getByText("Chat Output built successfully", { exact: true }) .isVisible(); - await page.getByTestId("icon-ChevronDown").click(); + await page.getByTestId("icon-ChevronDown").first().click(); await page.getByText("Logs").click(); await page.getByText("timestamp").first().isVisible(); diff --git a/src/frontend/tests/core/features/publish-flow.spec.ts b/src/frontend/tests/core/features/publish-flow.spec.ts new file mode 100644 index 000000000..d43c37f55 --- /dev/null +++ b/src/frontend/tests/core/features/publish-flow.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from "@playwright/test"; +import { adjustScreenView } from "../../utils/adjust-screen-view"; +import { awaitBootstrapTest } from "../../utils/await-bootstrap-test"; + +test( + "user should be able to publish a flow", + { tag: ["@release", "@workspace", "@api"] }, + async ({ page, context }) => { + await awaitBootstrapTest(page); + + await page.waitForSelector('[data-testid="blank-flow"]', { + timeout: 3000, + }); + const flowId = page.url().split("/").pop(); + expect(flowId).toBeDefined(); + expect(flowId).not.toBeNull(); + expect(flowId!.length).toBeGreaterThan(0); + await page.getByTestId("blank-flow").click(); + await page.waitForSelector('[data-testid="sidebar-search-input"]', { + timeout: 3000, + }); + + await page.getByTestId("sidebar-search-input").click(); + await page.getByTestId("sidebar-search-input").fill("chat input"); + + await page.waitForSelector('[data-testid="inputsChat Input"]', { + timeout: 3000, + }); + await page.getByTestId("inputsChat Input").hover({ timeout: 3000 }); + await page.getByTestId("add-component-button-chat-input").click(); + + await adjustScreenView(page); + await page.getByTestId("publish-button").click(); + await page.waitForSelector('[data-testid="shareable-playground"]', { + timeout: 3000, + }); + await expect( + page.waitForResponse( + (response) => + response.url().includes(flowId!) && response.status() === 200, + ), + ).resolves.toBeTruthy(); + + await page.getByTestId("publish-switch").click(); + await page.getByTestId("shareable-playground").click(); + await expect(page.getByTestId("rf__wrapper")).toBeVisible(); + await page.getByTestId("publish-button").click(); + await page.getByTestId("publish-switch").click(); + await expect(page.getByTestId("rf__wrapper")).toBeVisible(); + await expect(page.getByTestId("publish-switch")).toBeChecked(); + const pagePromise = context.waitForEvent("page"); + await page.getByTestId("shareable-playground").click(); + const newPage = await pagePromise; + await newPage.waitForTimeout(3000); + const newUrl = newPage.url(); + await newPage.getByPlaceholder("Send a message...").fill("Hello"); + await newPage.getByTestId("button-send").click(); + await expect(newPage.getByText("Hello")).toBeVisible(); + await newPage.close(); + await page.bringToFront(); + // check if deactivate the publishworks + await page.getByTestId("publish-button").click(); + await page.getByTestId("publish-switch").click(); + await expect(page.getByTestId("rf__wrapper")).toBeVisible(); + await expect(page.getByTestId("publish-switch")).toBeChecked({ + checked: false, + }); + await page.getByTestId("shareable-playground").click(); + await expect(page.getByTestId("rf__wrapper")).toBeVisible(); + await page.goto(newUrl); + await expect(page.getByTestId("mainpage_title")).toBeVisible(); + }, +); diff --git a/src/frontend/tests/extended/features/curlApiGeneration.spec.ts b/src/frontend/tests/extended/features/curlApiGeneration.spec.ts index f811f2033..52208f8cc 100644 --- a/src/frontend/tests/extended/features/curlApiGeneration.spec.ts +++ b/src/frontend/tests/extended/features/curlApiGeneration.spec.ts @@ -9,7 +9,8 @@ test( await page.getByTestId("side_nav_options_all-templates").click(); await page.getByRole("heading", { name: "Basic Prompting" }).click(); - await page.getByText("API", { exact: true }).click(); + await page.getByTestId("publish-button").click(); + await page.getByTestId("api-access-item").click(); await page.getByRole("tab", { name: "cURL" }).click(); await page.getByTestId("icon-Copy").last().click(); const handle = await page.evaluateHandle(() => diff --git a/src/frontend/tests/extended/features/edit-name-description-node.spec.ts b/src/frontend/tests/extended/features/edit-name-description-node.spec.ts index 7c042f976..6ec8555a7 100644 --- a/src/frontend/tests/extended/features/edit-name-description-node.spec.ts +++ b/src/frontend/tests/extended/features/edit-name-description-node.spec.ts @@ -44,9 +44,9 @@ test( await page.getByTestId("textarea").fill(randomDescription); - await page.getByTestId("api_button_modal").click(); + await page.getByTestId("publish-button").click(); - await page.getByText("Close").last().click(); + await page.keyboard.press("Escape"); expect(await page.getByText(randomName).count()).toBe(1); expect(await page.getByText(randomDescription).count()).toBe(1); diff --git a/src/frontend/tests/extended/features/pythonApiGeneration.spec.ts b/src/frontend/tests/extended/features/pythonApiGeneration.spec.ts index b3efc5bda..91dc4dfbc 100644 --- a/src/frontend/tests/extended/features/pythonApiGeneration.spec.ts +++ b/src/frontend/tests/extended/features/pythonApiGeneration.spec.ts @@ -9,8 +9,9 @@ test( await page.getByTestId("side_nav_options_all-templates").click(); await page.getByRole("heading", { name: "Basic Prompting" }).click(); - await page.getByText("API", { exact: true }).click(); - await page.getByRole("tab", { name: "Python API" }).click(); + await page.getByTestId("publish-button").click(); + await page.getByTestId("api-access-item").click(); + await page.getByRole("tab", { name: "Python" }).click(); await page.getByTestId("icon-Copy").click(); const handle = await page.evaluateHandle(() => navigator.clipboard.readText(),