feat: Publish Flow, API code update and UI components (#6140)

* refactor: Update flowToolbarComponent with FlowToolbarOptions component and ENABLE_PUBLISH feature flag

* [autofix.ci] apply automated fixes

* refactor: Update ENABLE_PUBLISH feature flag in feature-flags.ts

* add external link for hover state

* Refactor routes.tsx to enable PlaygroundPage

* Refactor deploy-dropdown.tsx to add ShadTooltipComponent and handle cases where there is no IO

* [autofix.ci] apply automated fixes

* add colorfull langflow icon

* Add playgroundPage prop to IOModalPropsType

* Refactor IOModal in PlaygroundPage component

* Refactor IOModal component and add publish options

* Add LangflowButtonRedirectTarget utility function

* Refactor IOModal component and add LangflowButtonRedirectTarget utility function

* fix: remove feature flag for playground button name

* fix: rename DeployDropdown to PublishDropdown in FlowToolbarOptions

* fix: rename DeployDropdown to PublishDropdown and update related functionality

* fix: update classNames utility import and refactor class assignment in FlowToolbar

* [autofix.ci] apply automated fixes

* fix: enhance hover effects and accessibility in PublishDropdown component

* [autofix.ci] apply automated fixes

* fix: update Playground title in IOModal and ChatViewWrapper components

* fix: improve layout and visibility of session information in ChatViewWrapper component

* add neutral icon to playground

* fix: add playgroundPage prop to ContentBlockDisplay and conditionally render elements in ChatMessage

* fix: pass playgroundPage prop to ContentDisplay and conditionally render duration

* fix: remove playgroundTitle display from ChatViewWrapper component

* fix: adjust padding and alignment in ChatViewWrapper and IOModal components based on playgroundPage prop

* fix: update alignment and responsiveness in ChatViewWrapper component based on playgroundPage and sidebarOpen states

* fix: update document title based on currentSavedFlow in PlaygroundPage component

* [autofix.ci] apply automated fixes

* feat: add ENABLE_WIDGET flag to conditionally render embed option in PublishDropdown

* feat: add EmbedModal component for copying embed code

* feat: integrate EmbedModal in PublishDropdown for embed code sharing

* feat: enhance EmbedModal integration in PublishDropdown with dynamic embed code generation

* feat: add switch for publishing state in PublishDropdown component and update FlowType

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes

* feat: add optional copy property to GetCodeType for enhanced tweak management

* feat: make description property optional in modalHeaderType for improved flexibility

* feat: update background color in code styles for improved visibility

* feat: add support for 'retangular' size in switchCaseModalSize helper

* feat: make description property optional in Header component and add 'retangular' size option in BaseModalProps

* feat: add optional copy parameter to getWidgetCode for customizable script output

* feat: refactor EmbedModal props for improved clarity and consistency

* feat: add ApiModal component for custom API code generation

* feat: adjust icon size in EmbedModal for better visual consistency

* feat: integrate ApiModal into PublishDropdown for enhanced API code generation

* feat: update ApiModal header to include API key creation instructions

* feat: add APITabsComponent for displaying code snippets in tabs

* feat: add APITabType and tabsArrayType for enhanced API tab management

* feat: replace CodeTabsComponent with APITabsComponent in ApiModal for improved tab management

* feat: add JSIcon component for JavaScript icon representation

* feat: add JSIcon to nodeIconsLucide for JavaScript representation

* style: format code for consistency in EmbedModal component

* feat: change ApiModal size from x-large to medium for better usability

* feat: enhance APITabsComponent with light theme support and improve button positioning

* feat: adjust APITabsComponent tab styling for improved layout and readability

* feat: add BWPython icon and update references in APITabsComponent and styleUtils

* fix: correct export name for BWSvgPython icon component

* feat: add new column 'public' to flow table

* raw: Sample code for creating a new unauthenticated endpoint as a PoCh

Note: this code is blatantly copied from another code snippet.

* feat: add dark mode support to JSIcon component

* feat: add dark mode support to BWPython icon component

* feat: add new column 'access_type' to flow

* chore: update code

* chore: remove unnecessary migration

* feat: add getNewCurlCode function for generating cURL commands with dynamic payloads

* feat: integrate dynamic code generation for Python, JavaScript, and cURL in APITabsComponent

* feat: add getNewJsApiCode function for generating JavaScript API code with dynamic payloads

* feat: add getNewPythonApiCode function for generating Python API code with dynamic payloads

* fix color on sintax highlight

* feat: enhance APITabsComponent with dynamic streaming and authentication state

* feat: update APITabsComponent to handle dynamic input and output types based on flow data

* [autofix.ci] apply automated fixes

* feat: add input and output validation in PlaygroundPage to redirect if none exist

* [autofix.ci] apply automated fixes

* fix: ensure navigation only occurs when currentSavedFlow data is present

* feat: add access_type field to FlowUpdate model

* feat: add playgroundPage parameter to buildFlowVertices for conditional URL construction

* feat: add playgroundPage state and setter to FlowStoreType

* feat: add playgroundPage state and setter to FlowStoreType

* feat: add access_type field to FlowType for improved access control

* feat: modify useGetMessagesQuery to handle playgroundPage state for conditional message retrieval

* feat: update IPatchUpdateFlow interface to make fields optional and add access_type for enhanced flexibility

* feat: implement publish toggle functionality in PublishDropdown component for dynamic access control

* feat: integrate playgroundPage state management in IOModal for improved session handling

* add: new endpoint to public_flow

* refactor: remove unused current_user parameter from read_public_flow function

* feat: add ContextWrapper to PlaygroundPage route for improved context management

* refactor: simplify flow retrieval logic in PlaygroundPage component

* feat: add support for public flow retrieval in useGetFlow hook

* feat: add PUBLIC_FLOW constant to URLs for public flow retrieval

* fix: add whitespace for improved code readability in AuthSettingsGuard component

* fix: add whitespace for improved code readability in ProtectedAdminRoute component

* [autofix.ci] apply automated fixes

* fix: update redirect condition in PlaygroundPage for non-public access types

* [autofix.ci] apply automated fixes

* persist session name update

* fix: enhance message update logic to handle playground state and local storage

* fix: remove debugger statement from PlaygroundPage initialization

* fix: manage dark mode class in App component and remove redundant logic from AppInitPage

* [autofix.ci] apply automated fixes

* feat: add access_type field to FlowHeader model for flow access control

* fix: refactor flow access handling in PublishDropdown component for improved readability and async operation

* feat: enhance FlowMenu component with swatch color display based on flow gradient

* feat: add swatch color display in IOModal based on flow gradient

* [autofix.ci] apply automated fixes

* Update copyCode to use dynamic API code generation for Python, JavaScript, and cURL tabs

* Add optional session tracking to JavaScript API code generation

* Enhance Python API code generation with detailed comments and error handling

* Fix SVG attribute casing and update dark mode state handling in Python icon components

* Fix SVG clip-path casing and ensure dark mode state is a string in JS icon components

* Fix SVG fill color handling for dark mode in Python icon components

* Fix SVG filter handling for dark mode in JS icon components

* Add tweaks management and update functionality in tweaks store

* Add normal font style to line numbers in CSS

* Add debug log for flow retrieval in FlowPage component

* Refactor APITabsComponent to remove unused props and integrate tweaks management

* Enhance ApiModal to support tweaks management and improve API access UI

* Remove flow prop from ApiModal in PublishDropdown component

* update package lock

* [autofix.ci] apply automated fixes

* Update ChatViewWrapper to adjust layout based on visibleSession state

* [autofix.ci] apply automated fixes

* Update icon fallback in FlowMenu to use "Workflow"

* Refactor EmbedModal button styles for consistency and clarity

* Add useEffect to reset copied state on active tab change and clean up button styles

* Comment out DropdownMenuItem in PublishDropdown for future reference

* refactor: remove duplicated code from route

* refactor: remove duplicated code from route

* [autofix.ci] apply automated fixes

* Increase font size for code blocks in classes.css for better readability

* Adjust padding and height for deploy dropdown items for improved layout

* Update minWidth for medium modal size to include max-width constraint

* Refactor ApiModal to conditionally render button and adjust styles for improved layout

* Add margin-top to API modal tabs content for improved spacing

* Refactor deploy dropdown to include API access and Embed options, and rename 'Standalone app' to 'Shareable Playground'

* Enhance API code generation to include environment variable checks for API key in curl, JavaScript, and Python examples

* Fix authentication check logic and clean up modal class names

* Refactor authentication logic in APITabsComponent to improve clarity and functionality

* [autofix.ci] apply automated fixes

* Update environment variable references in API code examples to use LANGFLOW_API_KEY

* [autofix.ci] apply automated fixes

* Update API key references to use LANGFLOW_API_KEY in curl and JS code examples

* Remove streaming parameter from API code examples in JavaScript and Python

* Adjust button padding and separator margin in API modal for improved layout

* Add transparent background to scrollbar corner in Tailwind config

* Update publish dropdown to display sharing status based on flow publication state

* Add playgroundPage prop to ChatInput and conditionally render file upload button

* Refactor ChatViewWrapper layout logic for improved responsiveness

* [autofix.ci] apply automated fixes

* Add closeButtonClassName prop to BaseModal and DialogContent for customization

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes

* feat: use new deterministic flow id for public flow calls (#6314)

* Enhance public flow building with deterministic flow ID and name handling

* Handle asyncio.CancelledError in message table commit operation

* Add UUID v5 generation for flow IDs in chat view and modal components

* Add comment explaining CancelledError handling in message table commit

* Simplify public flow ID and name generation logic

* Refactor UUID v5 import and flow ID generation in IOModal components

* Update Alembic migration down_revision identifier

---------

Co-authored-by: anovazzi1 <otavio2204@gmail.com>

* feat: add playgroundPage prop to SessionSelector and SidebarOpenView components

* feat: switch from localStorage to sessionStorage for message handling in playground mode

* feat: Add public flow settings with cleanup and expiration configurations

Add new settings for managing public temporary flows, including:
- Configurable cleanup interval (default 1 hour)
- Configurable flow expiration time (default 24 hours)
- Minimum interval of 10 minutes for both settings

* feat: Add public flow expiration settings to ConfigResponse

Update ConfigResponse schema to include new configuration parameters for public flow management:
- public_flow_cleanup_interval: Interval for cleaning up public flows
- public_flow_expiration: Duration for public flow retention

* feat: Add temporary public flow cleanup worker

Implement a background worker to manage and clean up expired public flows:
- Add CleanupWorker class to handle periodic cleanup tasks
- Integrate cleanup worker into application lifespan
- Implement cleanup logic for removing expired public flow data from database and storage
- Add start and stop methods for graceful worker management

* feat: implement client ID management using cookies in Playground and update flow ID generation

* refactor: Optimize public flow cleanup worker with targeted execution

Improve the temporary public flow cleanup process by:
- Adding a pre-check to only run cleanup when public flows exist
- Passing public flows and session directly to cleanup function
- Simplifying the cleanup logic to reduce nested session management

* refactor: Enhance database cleanup worker with comprehensive record management

Improve the cleanup worker to handle both expired public flows and orphaned records:
- Add function to clean up expired public flows with detailed logging
- Implement orphaned record cleanup across multiple database tables
- Enhance error handling and logging for storage file deletion
- Simplify worker run method to execute both cleanup tasks sequentially

* feat: Add session cookie validation for public flow generation

Enhance public flow building by:
- Requiring a session cookie for generating temporary public flows
- Incorporating the session cookie into the flow ID generation process
- Adding explicit error handling for missing session cookies

* fix: Update session cookie retrieval in public flow generation

Change cookie key from "session" to "client_id" to align with recent client ID management implementation

* fix: Correct flow ID generation by adding an underscore separator between client ID and real flow ID

* [autofix.ci] apply automated fixes

* fix: add options to the fetch call and add docs

Update getNewJsApiCode function to:
- Add comprehensive JSDoc documentation
- Include fetch options in API call
- Simplify code generation logic
- Ensure proper payload and options handling

* fix: update label for temporary overrides to tweaks in API modal

* update package lock

* ensure individual instances of contexts

* fix: add data-testid attributes for testing in PublishDropdown component

* fix: handle authentication errors for public API requests in ApiInterceptor

* test: add publish feature test using Playwright

* [autofix.ci] apply automated fixes

* refactor: optimize temp flow cleanup with improved file and logging management

* test: add unit tests for temp flow cleanup service

* chore: remove unnecessary console logs and comments for cleaner code

* Update src/frontend/tests/core/features/publish-flow.spec.ts

Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com>

* Update src/frontend/tests/core/features/publish-flow.spec.ts

Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com>

* Update src/frontend/tests/core/features/publish-flow.spec.ts

Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com>

* Update src/frontend/tests/core/features/publish-flow.spec.ts

Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com>

* Update src/frontend/tests/core/features/publish-flow.spec.ts

Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com>

* Update src/frontend/tests/core/features/publish-flow.spec.ts

Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com>

* Update src/frontend/tests/core/features/publish-flow.spec.ts

Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com>

* Update src/frontend/src/modals/apiModal/utils/get-python-api-code.tsx

Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com>

* Update src/frontend/tests/core/features/publish-flow.spec.ts

Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com>

* Update src/frontend/tests/core/features/publish-flow.spec.ts

Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com>

* Update src/frontend/src/modals/apiModal/utils/get-curl-code.tsx

Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com>

* Update src/frontend/src/modals/apiModal/utils/get-curl-code.tsx

Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com>

* Update src/frontend/src/modals/apiModal/utils/get-curl-code.tsx

Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com>

* Update src/frontend/src/modals/apiModal/utils/get-python-api-code.tsx

Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com>

* Update src/frontend/src/modals/apiModal/utils/get-python-api-code.tsx

Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com>

* fix: adds edge filtering only to parameters not hidden (#6270)

Adds advanced filter to only filter by showing fields on click of edge

* Add config parameter to Graph run method with configuration application

* fix: remove unnecessary await from flow directory existence check

* refactor: Remove expired public flows cleanup method

* refactor: Move data_dir initialization to base StorageService

* fix: Update temp flow cleanup to use async file operations

- Add explicit type hint for storage_service
- Use async methods for file and directory existence checks
- Improve error handling for file deletion during flow cleanup

* refactor: Add explicit type hint for tables in temp flow cleanup

- Improve type annotations for tables list in cleanup_orphaned_records
- Explicitly define the types of tables to be processed

* [autofix.ci] apply automated fixes

* refactor: Remove tests for expired public flows from temp flow cleanup

* test: Update test_cleanup_orphaned_records_no_orphans to use fixtures

* feat: Add utility function for verifying public flow access

Implement async function to validate public flow requests with:
- Client ID verification
- Flow existence and public access check
- Deterministic flow ID generation
- User retrieval for permission handling

* feat: build_public_tmp to use the jobqueue

Refactored the build_public_tmp endpoint to:
- Add comprehensive docstring explaining endpoint functionality
- Improve error handling and logging
- Simplify flow verification and user retrieval process
- Use new verify_public_flow_and_get_user utility function
- Streamline job creation and error management

* chore: Add anyio import to local storage service

Import anyio library in preparation for potential async storage operations

* style: run formatter

* changed endpoint

* [autofix.ci] apply automated fixes

* Add size for tweaks

* Add size for tweaks

* Change tweaks modal

* Fix switch design

* [autofix.ci] apply automated fixes

* fix: mypy erros

* fix: alembic multiple heads error

* fix: ruff error

* refactor: update test cleanup for orphaned records to use fixtures

Changed the test for cleanup of orphaned records to utilize the "client" fixture instead of the "asyncio" marker, enhancing test organization and clarity.

* fix: cli test

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: italojohnny <italojohnnydosanjos@gmail.com>
Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@langflow.org>
Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com>
Co-authored-by: Lucas Oliveira <62335616+lucaseduoli@users.noreply.github.com>
Co-authored-by: Lucas Oliveira <lucas.edu.oli@hotmail.com>
This commit is contained in:
anovazzi1 2025-03-17 11:03:59 -03:00 committed by GitHub
commit 7aca264fec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
86 changed files with 2252 additions and 1465 deletions

View file

@ -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')

View file

@ -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,

View file

@ -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

View file

@ -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}

View file

@ -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(
*,

View file

@ -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"]

View file

@ -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)

View file

@ -274,6 +274,7 @@ def create_app():
FastAPIInstrumentor.instrument_app(app)
add_pagination(app)
return app

View file

@ -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

View file

@ -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")

View file

@ -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'."""

View file

@ -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:

View file

@ -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:

View file

@ -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()

View file

@ -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)

File diff suppressed because it is too large Load diff

View file

@ -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 (
<Suspense fallback={<LoadingPage />}>
<RouterProvider router={router} />

View file

@ -0,0 +1,9 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="langflow-icon-color-black-transparent">
<g id="Group 3">
<path id="Vector" d="M13.121 9.31941H14.9266C15.2808 9.31941 15.5677 9.60632 15.5677 9.96049V11.0257C15.5677 11.3798 15.2808 11.6667 14.9266 11.6667H13.3518C13.1816 11.6667 13.0185 11.7345 12.8982 11.8547L10.1958 14.5567C10.0756 14.677 9.91251 14.7447 9.74233 14.7447H8.44325C8.09577 14.7447 7.81109 14.4676 7.80218 14.1201L7.77412 13.0326C7.76476 12.6722 8.05433 12.3746 8.41519 12.3746H9.53339C9.70357 12.3746 9.86662 12.3069 9.98691 12.1866L12.6666 9.50697C12.7869 9.38669 12.9499 9.31897 13.1201 9.31897L13.121 9.31941Z" fill="#7528FC"/>
<path id="Vector_2" d="M7.75986 3.25531H9.56546C9.91963 3.25531 10.2065 3.54221 10.2065 3.89638V4.96157C10.2065 5.31574 9.91963 5.60264 9.56546 5.60264H7.99063C7.82045 5.60264 7.65739 5.67036 7.53711 5.79064L4.83472 8.49303C4.71443 8.61332 4.55138 8.68104 4.3812 8.68104H3.08212C2.73464 8.68104 2.44996 8.40394 2.44105 8.05645L2.41299 6.96898C2.40363 6.60858 2.6932 6.31143 3.05406 6.31143H4.17226C4.34244 6.31143 4.50549 6.24371 4.62578 6.12343L7.30545 3.44376C7.42573 3.32347 7.58879 3.25576 7.75897 3.25576L7.75986 3.25531Z" fill="#FF3276"/>
<path id="Vector_3" d="M13.121 4.62744H14.9266C15.2808 4.62744 15.5677 4.91434 15.5677 5.26851V6.3337C15.5677 6.68787 15.2808 6.97477 14.9266 6.97477H13.3518C13.1816 6.97477 13.0185 7.04249 12.8982 7.16277L10.1958 9.86517C10.0756 9.98545 9.91251 10.0532 9.74233 10.0532H8.16393C7.99865 10.0532 7.8396 10.1169 7.72021 10.2314L4.68637 13.1387C4.56697 13.2532 4.40793 13.3169 4.24265 13.3169H3.13692C2.78275 13.3169 2.49585 13.0295 2.49585 12.6758V11.5812C2.49585 11.2271 2.78275 10.9402 3.13692 10.9402H4.23463C4.40481 10.9402 4.56786 10.8724 4.68815 10.7522L7.56918 7.87112C7.68947 7.75083 7.85252 7.68312 8.0227 7.68312H9.53339C9.70357 7.68312 9.86662 7.6154 9.98691 7.49511L12.6666 4.81544C12.7869 4.69516 12.9499 4.62744 13.1201 4.62744H13.121Z" fill="#F480FF"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -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 ? (
<div
className="flex w-full items-center justify-center gap-2"
@ -261,6 +268,12 @@ export const MenuBar = ({}: {}): JSX.Element => {
>
/
</div>
<div className={cn(`flex rounded p-1`, swatchColors[swatchIndex])}>
<IconComponent
name={currentFlow?.icon ?? "Workflow"}
className="h-3.5 w-3.5"
/>
</div>
<div
className="shrink-0 overflow-hidden text-sm sm:whitespace-normal"

View file

@ -18,6 +18,7 @@ interface ContentBlockDisplayProps {
isLoading?: boolean;
state?: string;
chatId: string;
playgroundPage?: boolean;
}
export function ContentBlockDisplay({
@ -25,6 +26,7 @@ export function ContentBlockDisplay({
isLoading,
state,
chatId,
playgroundPage,
}: ContentBlockDisplayProps) {
const [isExpanded, setIsExpanded] = useState(false);
@ -41,7 +43,7 @@ export function ContentBlockDisplay({
const lastContent =
contentBlocks[0]?.contents[contentBlocks[0]?.contents.length - 1];
const headerIcon =
state === "partial" ? lastContent?.header?.icon || "Bot" : "Bot";
state === "partial" ? lastContent?.header?.icon || "Bot" : "Check";
const headerTitle =
state === "partial" ? (lastContent?.header?.title ?? "Steps") : "Finished";
@ -76,11 +78,14 @@ export function ContentBlockDisplay({
className="flex cursor-pointer items-center justify-between p-4"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 align-baseline">
{headerIcon && (
<ForwardedIconComponent
name={headerIcon}
className="h-4 w-4"
className={cn(
"h-4 w-4",
state !== "partial" && "text-status-green",
)}
strokeWidth={1.5}
/>
)}
@ -102,7 +107,9 @@ export function ContentBlockDisplay({
</div>
</div>
<div className="flex items-center gap-2">
<DurationDisplay duration={totalDuration} chatId={chatId} />
{!playgroundPage && (
<DurationDisplay duration={totalDuration} chatId={chatId} />
)}
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
@ -193,6 +200,7 @@ export function ContentBlockDisplay({
)}
</AnimatePresence>
<ContentDisplay
playgroundPage={playgroundPage}
content={content}
chatId={`${chatId}-${index}`}
/>

View file

@ -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({
</div>
</>
);
const renderDuration = content.duration !== undefined && (
const renderDuration = content.duration !== undefined && !playgroundPage && (
<div className="absolute right-2 top-4">
<DurationDisplay duration={content.duration} chatId={chatId} />
</div>

View file

@ -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 (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="default"
className="!h-8 !w-[95px] font-medium"
data-testid="publish-button"
>
Publish
<IconComponent
name="ChevronDown"
className="icon-size font-medium"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
forceMount
sideOffset={10}
alignOffset={-10}
align="end"
className="min-w-[300px] max-w-[400px]"
>
<DropdownMenuItem
className="deploy-dropdown-item group"
onClick={() => setOpenApiModal(true)}
>
<div
className="group-hover:bg-accent"
data-testid="api-access-item"
>
<IconComponent
name="Code2"
className={`${groupStyle} icon-size mr-2`}
/>
<span>API access</span>
</div>
</DropdownMenuItem>
{ENABLE_WIDGET && (
<DropdownMenuItem
onClick={() => setOpenEmbedModal(true)}
className="deploy-dropdown-item group"
>
<div className="group-hover:bg-accent">
<IconComponent
name="Columns2"
className={`${groupStyle} icon-size mr-2`}
/>
<span>Embed into site</span>
</div>
</DropdownMenuItem>
)}
<ShadTooltipComponent
styleClasses="truncate"
side="left"
content={
hasIO
? isPublished
? encodeURI(`${domain}/playground/${flowId}`)
: "Active to share a public version of this Playground"
: "Add a Chat Input or Chat Output to access your flow"
}
>
<div
className={
!hasIO ? "cursor-not-allowed" : "" + "flex items-center"
}
>
<DropdownMenuItem
data-testid="shareable-playground"
disabled={!hasIO || !isPublished}
className="deploy-dropdown-item group flex-1"
onClick={() => {
if (hasIO) {
if (isPublished) {
window.open(`${domain}/playground/${flowId}`, "_blank");
}
}
}}
>
<div className="group-hover:bg-accent">
<IconComponent
name="Globe"
className={`${groupStyle} icon-size mr-2`}
/>
<span>Shareable Playground</span>
</div>
</DropdownMenuItem>
<div className={`z-50 mr-2 text-foreground`}>
<Switch
data-testid="publish-switch"
className="scale-[85%]"
checked={isPublished}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handlePublishedSwitch(isPublished);
}}
/>
</div>
</div>
</ShadTooltipComponent>
{/* <DropdownMenuItem className="deploy-dropdown-item group">
<div className="group-hover:bg-accent">
<IconComponent
name="FileCode2"
className={`${groupStyle} icon-size mr-2`}
/>
<span>Langflow SDK</span>
<IconComponent
name="ExternalLink"
className={`icon-size ml-auto mr-3 ${externalUrlStyle} text-foreground`}
/>
</div>
</DropdownMenuItem> */}
</DropdownMenuContent>
</DropdownMenu>
<ApiModal open={openApiModal} setOpen={setOpenApiModal}>
<></>
</ApiModal>
<EmbedModal
open={openEmbedModal}
setOpen={setOpenEmbedModal}
flowId={flowId ?? ""}
flowName={flowName ?? ""}
isAuth={isAuth}
tweaksBuildedObject={{}}
activeTweaks={false}
></EmbedModal>
</>
);
}

View file

@ -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<boolean>(false);
const hasIO = useFlowStore((state) => state.hasIO);
return (
<div className="flex items-center gap-1.5">
<div className="flex h-full w-full gap-1.5 rounded-sm transition-all">
<PlaygroundButton
hasIO={hasIO}
open={open}
setOpen={setOpen}
canvasOpen
/>
</div>
<PublishDropdown />
</div>
);
}

View file

@ -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 = () => (
<ForwardedIconComponent name="Play" className="h-4 w-4 transition-all" />
<ForwardedIconComponent
name="Play"
className="h-4 w-4 transition-all"
strokeWidth={ENABLE_PUBLISH ? 2 : 1.5}
/>
);
const ButtonLabel = () => <span className="hidden md:block">Playground</span>;
const ButtonLabel = () => (
<span className="hidden md:block">{PLAYGROUND_BUTTON_NAME}</span>
);
const ActiveButton = () => (
<div

View file

@ -3,6 +3,7 @@ import PlaygroundButton from "@/components/core/flowToolbarComponent/components/
import {
ENABLE_API,
ENABLE_LANGFLOW_STORE,
ENABLE_PUBLISH,
} from "@/customization/feature-flags";
import { track } from "@/customization/utils/analytics";
import { Panel } from "@xyflow/react";
@ -13,8 +14,9 @@ import ShareModal from "../../../modals/shareModal";
import useFlowStore from "../../../stores/flowStore";
import { useShortcutsStore } from "../../../stores/shortcuts";
import { useStoreStore } from "../../../stores/storeStore";
import { classNames, isThereModal } from "../../../utils/utils";
import { classNames, cn, isThereModal } from "../../../utils/utils";
import ForwardedIconComponent from "../../common/genericIconComponent";
import FlowToolbarOptions from "./components/flow-toolbar-options";
export default function FlowToolbar(): JSX.Element {
const preventDefault = true;
@ -119,62 +121,67 @@ export default function FlowToolbar(): JSX.Element {
<>
<Panel className="!m-2" position="top-right">
<div
className={
"hover:shadow-round-btn-shadow flex items-center justify-center gap-7 rounded-md border bg-background p-1.5 shadow transition-all"
}
className={cn(
"hover:shadow-round-btn-shadow flex items-center justify-center gap-7 rounded-md border bg-background px-1.5 shadow transition-all",
ENABLE_PUBLISH ? "h-11" : "",
)}
>
<div className="flex gap-1.5">
<div className="flex h-full w-full gap-1.5 rounded-sm transition-all">
<PlaygroundButton
hasIO={hasIO}
open={open}
setOpen={setOpen}
canvasOpen
/>
</div>
{ENABLE_API && (
<>
<div
className="flex cursor-pointer items-center gap-2"
data-testid="api_button_modal"
id="api_button_modal"
>
{currentFlow && currentFlow.data && (
<ApiModal
flow={currentFlow}
open={openCodeModal}
setOpen={setOpenCodeModal}
>
<div
className={classNames(
"relative inline-flex h-8 w-full items-center justify-center gap-1.5 rounded px-3 py-1.5 text-sm font-semibold text-foreground transition-all duration-150 ease-in-out hover:bg-accent",
)}
>
<ForwardedIconComponent
name="Code2"
className={"h-4 w-4"}
/>
<span className="hidden md:block">API</span>
</div>
</ApiModal>
)}
</div>
</>
)}
{ENABLE_LANGFLOW_STORE && (
<div className="flex items-center gap-2">
<div
className={`side-bar-button ${
!hasApiKey || !validApiKey || !hasStore
? "cursor-not-allowed"
: "cursor-pointer"
}`}
>
{ModalMemo}
</div>
{ENABLE_PUBLISH ? (
<FlowToolbarOptions />
) : (
<div className="flex gap-1.5">
<div className="flex h-full w-full gap-1.5 rounded-sm transition-all">
<PlaygroundButton
hasIO={hasIO}
open={open}
setOpen={setOpen}
canvasOpen
/>
</div>
)}
</div>
{ENABLE_API && (
<>
<div
className="flex cursor-pointer items-center gap-2"
data-testid="api_button_modal"
id="api_button_modal"
>
{currentFlow && currentFlow.data && (
<ApiModal
flow={currentFlow}
open={openCodeModal}
setOpen={setOpenCodeModal}
>
<div
className={classNames(
"relative inline-flex h-8 w-full items-center justify-center gap-1.5 rounded px-3 py-1.5 text-sm font-semibold text-foreground transition-all duration-150 ease-in-out hover:bg-accent",
)}
>
<ForwardedIconComponent
name="Code2"
className={"h-4 w-4"}
/>
<span className="hidden md:block">API</span>
</div>
</ApiModal>
)}
</div>
</>
)}
{ENABLE_LANGFLOW_STORE && (
<div className="flex items-center gap-2">
<div
className={`side-bar-button ${
!hasApiKey || !validApiKey || !hasStore
? "cursor-not-allowed"
: "cursor-pointer"
}`}
>
{ModalMemo}
</div>
</div>
)}
</div>
)}
</div>
</Panel>
</>

View file

@ -55,45 +55,56 @@ const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
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 (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 flex w-full max-w-lg flex-col gap-4 rounded-xl border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]",
className,
)}
{...props}
>
{!hasDialogTitle && (
<VisuallyHidden>
<DialogTitle>Dialog</DialogTitle>
</VisuallyHidden>
)}
{children}
<ShadTooltip
styleClasses="z-50"
content="Close"
side="bottom"
avoidCollisions
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 flex w-full max-w-lg flex-col gap-4 rounded-xl border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]",
className,
)}
{...props}
>
<DialogPrimitive.Close className="absolute right-2 top-2 flex h-8 w-8 items-center justify-center rounded-sm ring-offset-background transition-opacity hover:bg-secondary-hover hover:text-accent-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-[18px] w-[18px]" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</ShadTooltip>
</DialogPrimitive.Content>
</DialogPortal>
);
});
{!hasDialogTitle && (
<VisuallyHidden>
<DialogTitle>Dialog</DialogTitle>
</VisuallyHidden>
)}
{children}
<ShadTooltip
styleClasses="z-50"
content="Close"
side="bottom"
avoidCollisions
>
<DialogPrimitive.Close
className={cn(
"absolute right-2 top-2 flex h-8 w-8 items-center justify-center rounded-sm ring-offset-background transition-opacity hover:bg-secondary-hover hover:text-accent-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
closeButtonClassName,
)}
>
<Cross2Icon className="h-[18px] w-[18px]" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</ShadTooltip>
</DialogPrimitive.Content>
</DialogPortal>
);
},
);
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({

View file

@ -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",

View file

@ -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();

View file

@ -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 = {}) {

View file

@ -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<undefined, IGetFlow> = (
const getFlowFn = async (payload: IGetFlow): Promise<FlowType> => {
const response = await api.get<FlowType>(
`${getURL("FLOWS")}/${payload.id}`,
`${getURL(payload.public ? "PUBLIC_FLOW" : "FLOWS")}/${payload.id}`,
);
const flowsArrayToProcess = [response.data];

View file

@ -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<any> => {
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<any> => {
const response = await api.patch(`${getURL("FLOWS")}/${id}`, payload);
return response.data;
};

View file

@ -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<any>(`${getURL("MESSAGES")}`, config);
if (!isPlaygroundPage) {
return await api.get<any>(`${getURL("MESSAGES")}`, config);
} else {
return {
data: JSON.parse(window.sessionStorage.getItem(id ?? "") || "[]"),
};
}
};
const responseFn = async () => {

View file

@ -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<Message, any, UpdateMessageParams> = mutate(

View file

@ -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<Message[], any, UpdateSessionParams> =

View file

@ -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;

View file

@ -0,0 +1,3 @@
export const LangflowButtonRedirectTarget = () => {
return "https://langflow.org";
};

View file

@ -0,0 +1,33 @@
export const BWSvgPython = (props) => (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g id="method-card / logo" clipPath="url(#clip0_2342_182)">
<path
id="path1948"
d="M7.73326 0.000129445C7.08782 0.00312847 6.47144 0.0581749 5.92909 0.154143C4.3314 0.436402 4.04133 1.0272 4.04133 2.11672V3.55565H7.81686V4.03529H4.04133H2.6244C1.52712 4.03529 0.566317 4.69482 0.265789 5.94946C-0.0808665 7.38757 -0.0962426 8.28498 0.265789 9.7866C0.534167 10.9044 1.17509 11.7008 2.27237 11.7008H3.57048V9.97582C3.57048 8.72964 4.64871 7.63041 5.92909 7.63041H9.70023C10.75 7.63041 11.588 6.76608 11.588 5.71184V2.11672C11.588 1.09353 10.7248 0.32491 9.70023 0.154143C9.05165 0.0461786 8.37869 -0.00286962 7.73326 0.000129445ZM5.69147 1.15743C6.08146 1.15743 6.39994 1.48111 6.39994 1.8791C6.39994 2.27567 6.08146 2.59636 5.69147 2.59636C5.30009 2.59636 4.98301 2.27567 4.98301 1.8791C4.98301 1.48111 5.30009 1.15743 5.69147 1.15743Z"
fill={props.isdark === "true" ? "white" : "black"}
/>
<path
id="path1950"
d="M12.0589 4.03528V5.71183C12.0589 7.01163 10.9569 8.10564 9.70029 8.10564H5.92915C4.89617 8.10564 4.04138 8.98973 4.04138 10.0242V13.6193C4.04138 14.6425 4.93112 15.2444 5.92915 15.5379C7.12427 15.8893 8.27033 15.9528 9.70029 15.5379C10.6508 15.2627 11.5881 14.7089 11.5881 13.6193V12.1804H7.81692V11.7008H11.5881H13.4758C14.5731 11.7008 14.982 10.9354 15.3636 9.78659C15.7578 8.60392 15.741 7.4666 15.3636 5.94945C15.0924 4.8571 14.5745 4.03528 13.4758 4.03528H12.0589ZM9.93791 13.1397C10.3293 13.1397 10.6464 13.4604 10.6464 13.857C10.6464 14.2549 10.3293 14.5786 9.93791 14.5786C9.54792 14.5786 9.22944 14.2549 9.22944 13.857C9.22945 13.4604 9.54792 13.1397 9.93791 13.1397Z"
fill={props.isdark === "true" ? "white" : "black"}
/>
</g>
<defs>
<clipPath id="clip0_2342_182">
<rect
width="16"
height="16"
fill={props.isdark === "true" ? "black" : "white"}
/>
</clipPath>
</defs>
</svg>
);
export default BWSvgPython;

View file

@ -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 <BWSvgPython ref={ref} {...props} isdark={isdark} />;
});

View file

@ -0,0 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="method-card / logo" clip-path="url(#clip0_2342_182)">
<path id="path1948" d="M7.73326 0.000129445C7.08782 0.00312847 6.47144 0.0581749 5.92909 0.154143C4.3314 0.436402 4.04133 1.0272 4.04133 2.11672V3.55565H7.81686V4.03529H4.04133H2.6244C1.52712 4.03529 0.566317 4.69482 0.265789 5.94946C-0.0808665 7.38757 -0.0962426 8.28498 0.265789 9.7866C0.534167 10.9044 1.17509 11.7008 2.27237 11.7008H3.57048V9.97582C3.57048 8.72964 4.64871 7.63041 5.92909 7.63041H9.70023C10.75 7.63041 11.588 6.76608 11.588 5.71184V2.11672C11.588 1.09353 10.7248 0.32491 9.70023 0.154143C9.05165 0.0461786 8.37869 -0.00286962 7.73326 0.000129445ZM5.69147 1.15743C6.08146 1.15743 6.39994 1.48111 6.39994 1.8791C6.39994 2.27567 6.08146 2.59636 5.69147 2.59636C5.30009 2.59636 4.98301 2.27567 4.98301 1.8791C4.98301 1.48111 5.30009 1.15743 5.69147 1.15743Z" fill="black"/>
<path id="path1950" d="M12.0589 4.03528V5.71183C12.0589 7.01163 10.9569 8.10564 9.70029 8.10564H5.92915C4.89617 8.10564 4.04138 8.98973 4.04138 10.0242V13.6193C4.04138 14.6425 4.93112 15.2444 5.92915 15.5379C7.12427 15.8893 8.27033 15.9528 9.70029 15.5379C10.6508 15.2627 11.5881 14.7089 11.5881 13.6193V12.1804H7.81692V11.7008H11.5881H13.4758C14.5731 11.7008 14.982 10.9354 15.3636 9.78659C15.7578 8.60392 15.741 7.4666 15.3636 5.94945C15.0924 4.8571 14.5745 4.03528 13.4758 4.03528H12.0589ZM9.93791 13.1397C10.3293 13.1397 10.6464 13.4604 10.6464 13.857C10.6464 14.2549 10.3293 14.5786 9.93791 14.5786C9.54792 14.5786 9.22944 14.2549 9.22944 13.857C9.22945 13.4604 9.54792 13.1397 9.93791 13.1397Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_2342_182">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame" clip-path="url(#clip0_2046_939)">
<path id="Vector" d="M16 0H0V16H16V0Z" fill="black"/>
<path id="Vector_2" d="M10.7479 12.5001C11.0702 13.0263 11.4895 13.4131 12.2311 13.4131C12.854 13.4131 13.252 13.1017 13.252 12.6715C13.252 12.1559 12.8431 11.9733 12.1574 11.6734L11.7815 11.5121C10.6966 11.0499 9.97582 10.4709 9.97582 9.24674C9.97582 8.11912 10.835 7.26071 12.1777 7.26071C13.1337 7.26071 13.8209 7.59341 14.3161 8.46452L13.1453 9.21627C12.8876 8.75405 12.6095 8.57195 12.1777 8.57195C11.7373 8.57195 11.4582 8.85131 11.4582 9.21627C11.4582 9.66731 11.7376 9.84992 12.3827 10.1293L12.7585 10.2903C14.036 10.8381 14.7573 11.3966 14.7573 12.6522C14.7573 14.0059 13.6939 14.7474 12.2658 14.7474C10.8695 14.7474 9.96743 14.082 9.52604 13.2099L10.7479 12.5001ZM5.43664 12.6304C5.67283 13.0494 5.88769 13.4037 6.40426 13.4037C6.89823 13.4037 7.20985 13.2104 7.20985 12.4589V7.34655H8.71334V12.4793C8.71334 14.0361 7.80058 14.7446 6.46826 14.7446C5.26445 14.7446 4.56731 14.1217 4.21277 13.3713L5.43664 12.6304Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_2046_939">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,26 @@
const SvgJSIcon = (props) => (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
filter={props.isdark === "true" ? "invert(100%)" : "invert(0%)"}
>
<g id="Frame" clipPath="url(#clip0_2046_939)">
<path id="Vector" d="M16 0H0V16H16V0Z" fill="black" />
<path
id="Vector_2"
d="M10.7479 12.5001C11.0702 13.0263 11.4895 13.4131 12.2311 13.4131C12.854 13.4131 13.252 13.1017 13.252 12.6715C13.252 12.1559 12.8431 11.9733 12.1574 11.6734L11.7815 11.5121C10.6966 11.0499 9.97582 10.4709 9.97582 9.24674C9.97582 8.11912 10.835 7.26071 12.1777 7.26071C13.1337 7.26071 13.8209 7.59341 14.3161 8.46452L13.1453 9.21627C12.8876 8.75405 12.6095 8.57195 12.1777 8.57195C11.7373 8.57195 11.4582 8.85131 11.4582 9.21627C11.4582 9.66731 11.7376 9.84992 12.3827 10.1293L12.7585 10.2903C14.036 10.8381 14.7573 11.3966 14.7573 12.6522C14.7573 14.0059 13.6939 14.7474 12.2658 14.7474C10.8695 14.7474 9.96743 14.082 9.52604 13.2099L10.7479 12.5001ZM5.43664 12.6304C5.67283 13.0494 5.88769 13.4037 6.40426 13.4037C6.89823 13.4037 7.20985 13.2104 7.20985 12.4589V7.34655H8.71334V12.4793C8.71334 14.0361 7.80058 14.7446 6.46826 14.7446C5.26445 14.7446 4.56731 14.1217 4.21277 13.3713L5.43664 12.6304Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_2046_939">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
);
export default SvgJSIcon;

View file

@ -0,0 +1,10 @@
import { useDarkStore } from "@/stores/darkStore";
import React, { forwardRef } from "react";
import SvgJSIcon from "./JSIcon";
export const JSIcon = forwardRef<SVGSVGElement, React.PropsWithChildren<{}>>(
(props, ref) => {
const isdark = useDarkStore((state) => state.dark.toString());
return <SvgJSIcon ref={ref} {...props} isdark={isdark} />;
},
);

View file

@ -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<boolean>(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 (
<BaseModal open={open} setOpen={setOpen} size="retangular">
<BaseModal.Header>
<div className="flex items-center gap-2 text-[16px] font-semibold">
<IconComponent name="Columns2" className="icon-size" />
Embed into site
</div>
</BaseModal.Header>
<BaseModal.Content className="">
<div className="relative flex h-full w-full">
<Button
variant="ghost"
size="icon"
onClick={copyToClipboard}
data-testid="btn-copy-code"
className="!hover:bg-foreground group absolute right-2 top-2"
>
{isCopied ? (
<IconComponent
name="Check"
className="h-5 w-5 text-muted-foreground"
/>
) : (
<IconComponent
name="Copy"
className="!h-5 !w-5 text-muted-foreground"
/>
)}
</Button>
<SyntaxHighlighter
showLineNumbers={true}
wrapLongLines={true}
language="html"
style={isDark ? oneDark : oneLight}
className="!mt-0 h-full w-full overflow-scroll !rounded-b-md border border-border text-left !custom-scroll"
>
{embedCode}
</SyntaxHighlighter>
</div>
</BaseModal.Content>
</BaseModal>
);
}

View file

@ -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();

View file

@ -21,23 +21,23 @@ export const ChatViewWrapper = ({
sendMessage,
canvasOpen,
setOpen,
playgroundTitle,
playgroundPage,
}: ChatViewWrapperProps) => {
return (
<div
className={cn(
"flex h-full w-full flex-col justify-between p-4",
"flex h-full w-full flex-col justify-between px-4 pb-4 pt-2",
selectedViewField ? "hidden" : "",
)}
>
<div className="mb-4 h-[5%] text-[16px] font-semibold">
{visibleSession && sessions.length > 0 && sidebarOpen && (
<div className="hidden lg:block">
{visibleSession === currentFlowId
? "Default Session"
: `${visibleSession}`}
</div>
<div
className={cn(
"mb-4 flex h-[5%] items-center text-[16px] font-semibold",
playgroundPage ? "justify-between" : "lg:justify-start",
)}
<div className={cn(sidebarOpen ? "lg:hidden" : "")}>
>
<div className={cn(sidebarOpen ? "lg:hidden" : "left-4")}>
<div className="flex items-center gap-2">
<Button
variant="ghost"
@ -50,14 +50,26 @@ export const ChatViewWrapper = ({
className="h-[18px] w-[18px] text-ring"
/>
</Button>
<div className="font-semibold">Playground</div>
</div>
</div>
{visibleSession && sessions.length > 0 && (
<div
className={cn(
"truncate text-center font-semibold",
playgroundPage ? "" : "mr-12 flex-grow lg:mr-0",
sidebarOpen ? "blur-sm lg:blur-0" : "",
)}
>
{visibleSession === currentFlowId
? "Default Session"
: `${visibleSession}`}
</div>
)}
<div
className={cn(
sidebarOpen ? "pointer-events-none opacity-0" : "",
"absolute flex h-8 items-center justify-center rounded-sm ring-offset-background transition-opacity focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
isPlayground ? "right-2 top-4" : "right-12 top-2",
"flex items-center justify-center rounded-sm ring-offset-background transition-opacity focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
playgroundPage ? "right-2 top-4" : "absolute right-12 top-2 h-8",
)}
>
<ShadTooltip side="bottom" styleClasses="z-50" content="New Chat">
@ -76,7 +88,7 @@ export const ChatViewWrapper = ({
/>
</Button>
</ShadTooltip>
{!isPlayground && <Separator orientation="vertical" />}
{!playgroundPage && <Separator orientation="vertical" />}
</div>
</div>
<div
@ -99,6 +111,7 @@ export const ChatViewWrapper = ({
setOpen(false);
}
}
playgroundPage={playgroundPage}
/>
)}
</div>

View file

@ -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<HTMLDivElement | null>(null);
const [chatHistory, setChatHistory] = useState<ChatMessageType[] | undefined>(
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({
</div>
<div className="m-auto w-full max-w-[768px] md:w-5/6">
<ChatInput
playgroundPage={!!playgroundPage}
noInput={!inputTypes.includes("ChatInput")}
sendMessage={({ repeat, files }) => {
sendMessage({ repeat, files });

View file

@ -32,6 +32,7 @@ export default function ChatInput({
files,
setFiles,
isDragging,
playgroundPage,
}: ChatInputType): JSX.Element {
const currentFlowId = useFlowsManagerStore((state) => state.currentFlowId);
const fileInputRef = useRef<HTMLInputElement>(null);
@ -49,6 +50,10 @@ export default function ChatInput({
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement> | ClipboardEvent,
) => {
if (playgroundPage) {
return;
}
let file: File | null = null;
if ("clipboardData" in event) {
@ -238,15 +243,17 @@ export default function ChatInput({
))}
</div>
<div className="flex w-full items-end justify-between">
<div className={isBuilding ? "cursor-not-allowed" : ""}>
<UploadFileButton
isBuilding={isBuilding}
fileInputRef={fileInputRef}
handleFileChange={handleFileChange}
handleButtonClick={handleButtonClick}
/>
</div>
<div className="">
{!playgroundPage && (
<div className={isBuilding ? "cursor-not-allowed" : ""}>
<UploadFileButton
isBuilding={isBuilding}
fileInputRef={fileInputRef}
handleFileChange={handleFileChange}
handleButtonClick={handleButtonClick}
/>
</div>
)}
<div className={playgroundPage ? "ml-auto" : ""}>
<ButtonSendWrapper
send={send}
noInput={noInput}

View file

@ -2,7 +2,10 @@ import { ProfileIcon } from "@/components/core/appHeaderComponent/components/Pro
import { ContentBlockDisplay } from "@/components/core/chatComponents/ContentBlockDisplay";
import { useUpdateMessage } from "@/controllers/API/queries/messages";
import { CustomProfileIcon } from "@/customization/components/custom-profile-icon";
import { ENABLE_DATASTAX_LANGFLOW } from "@/customization/feature-flags";
import {
ENABLE_DATASTAX_LANGFLOW,
ENABLE_PUBLISH,
} from "@/customization/feature-flags";
import useFlowsManagerStore from "@/stores/flowsManagerStore";
import useFlowStore from "@/stores/flowStore";
import { useUtilityStore } from "@/stores/utilityStore";
@ -31,6 +34,7 @@ export default function ChatMessage({
lastMessage,
updateChat,
closeChat,
playgroundPage,
}: chatMessagePropsType): JSX.Element {
const convert = new Convert({ newline: true });
const [hidden, setHidden] = useState(true);
@ -277,8 +281,10 @@ export default function ChatMessage({
) : (
<ForwardedIconComponent name={chat.properties.icon} />
)
) : !ENABLE_DATASTAX_LANGFLOW ? (
) : !ENABLE_DATASTAX_LANGFLOW && !playgroundPage ? (
<ProfileIcon />
) : playgroundPage ? (
<ForwardedIconComponent name="User" />
) : (
<CustomProfileIcon />
)}
@ -301,7 +307,7 @@ export default function ChatMessage({
}
>
{chat.sender_name}
{chat.properties?.source && (
{chat.properties?.source && !playgroundPage && (
<div className="text-[13px] font-normal text-muted-foreground">
{chat.properties?.source.source}
</div>
@ -310,6 +316,7 @@ export default function ChatMessage({
</div>
{chat.content_blocks && chat.content_blocks.length > 0 && (
<ContentBlockDisplay
playgroundPage={playgroundPage}
contentBlocks={chat.content_blocks}
isLoading={
chatMessage === "" &&

View file

@ -11,6 +11,7 @@ export const SidebarOpenView = ({
handleDeleteSession,
visibleSession,
selectedViewField,
playgroundPage,
}: SidebarOpenViewProps) => {
return (
<>
@ -51,6 +52,7 @@ export const SidebarOpenView = ({
selectedView={selectedViewField}
key={index}
session={session}
playgroundPage={playgroundPage}
deleteSession={(session) => {
handleDeleteSession(session);
if (selectedViewField?.id === session) {

View file

@ -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<string | undefined>(
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 (
<BaseModal
open={open}
@ -261,8 +298,31 @@ export default function IOModal({
: "w-0",
)}
>
<div className="flex h-full flex-col overflow-y-auto border-r border-border bg-muted p-4 text-center custom-scroll dark:bg-canvas">
<div className="flex items-center gap-2 pb-8">
<div
className={cn(
"relative flex h-full flex-col overflow-y-auto border-r border-border bg-muted p-4 text-center custom-scroll dark:bg-canvas",
playgroundPage ? "pt-[15px]" : "pt-3.5",
)}
>
<div className="flex items-center justify-between gap-2 pb-8 align-middle">
<div className="flex items-center gap-2">
<div
className={cn(
`flex rounded p-1`,
swatchColors[swatchIndex],
)}
>
<IconComponent
name={currentFlow?.icon ?? "Workflow"}
className="h-3.5 w-3.5"
/>
</div>
{sidebarOpen && (
<div className="truncate font-semibold">
{PlaygroundTitle}
</div>
)}
</div>
<ShadTooltip
styleClasses="z-50"
side="right"
@ -279,9 +339,6 @@ export default function IOModal({
/>
</Button>
</ShadTooltip>
{sidebarOpen && (
<div className="font-semibold">Playground</div>
)}
</div>
{sidebarOpen && (
<SidebarOpenView
@ -291,10 +348,44 @@ export default function IOModal({
handleDeleteSession={handleDeleteSession}
visibleSession={visibleSession}
selectedViewField={selectedViewField}
playgroundPage={!!playgroundPage}
/>
)}
{sidebarOpen && showPublishOptions && (
<div className="absolute bottom-2 left-0 flex w-full flex-col gap-8 border-t border-border px-2 py-4 transition-all">
<div className="flex items-center justify-between px-2">
<div className="text-sm">Theme</div>
<ThemeButtons />
</div>
<Button
onClick={LangflowButtonClick}
variant="primary"
className="w-full !rounded-xl shadow-lg"
>
<LangflowLogoColor />
<div className="text-sm">Built with Langflow</div>
</Button>
</div>
)}
</div>
</div>
{!sidebarOpen && showPublishOptions && (
<div className="absolute bottom-6 left-4 hidden transition-all md:block">
<ShadTooltip
styleClasses="z-50"
side="right"
content="Built with Langflow"
>
<Button
variant="primary"
className="h-12 w-12 !rounded-xl !p-4 shadow-lg"
onClick={LangflowButtonClick}
>
<LangflowLogoColor className="h-[18px] w-[18px] scale-150" />
</Button>
</ShadTooltip>
</div>
)}
<div className="flex h-full min-w-96 flex-grow bg-background">
{selectedViewField && (
<SelectedViewField
@ -309,6 +400,7 @@ export default function IOModal({
/>
)}
<ChatViewWrapper
playgroundPage={playgroundPage}
selectedViewField={selectedViewField}
visibleSession={visibleSession}
sessions={sessions}
@ -324,6 +416,7 @@ export default function IOModal({
sendMessage={sendMessage}
canvasOpen={canvasOpen}
setOpen={setOpen}
playgroundTitle={PlaygroundTitle}
/>
</div>
</div>

View file

@ -16,4 +16,6 @@ export type ChatViewWrapperProps = {
sendMessage: (options: { repeat: number; files?: string[] }) => Promise<void>;
canvasOpen: boolean | undefined;
setOpen: (open: boolean) => void;
playgroundTitle: string;
playgroundPage?: boolean;
};

View file

@ -7,4 +7,5 @@ export type SidebarOpenViewProps = {
handleDeleteSession: (session: string) => void;
visibleSession: string | undefined;
selectedViewField: { type: string; id: string } | undefined;
playgroundPage: boolean;
};

View file

@ -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<Boolean>(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<number>(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 (
<Tabs
value={activeTab.toString()}
className={"api-modal-tabs inset-0 m-0"}
onValueChange={(value) => {
setActiveTab(parseInt(value));
}}
>
<div className="flex items-center justify-between">
{tabsList.length > 0 && tabsList[0].title !== "" ? (
<TabsList className="flex w-fit items-center rounded bg-muted p-1">
{tabsList.map((tab, index) => (
<TabsTrigger
key={index}
value={index.toString()}
className="flex items-center gap-2.5 rounded-md !border-0 px-4 py-2 !text-[14px] data-[state=active]:bg-background"
>
<IconComponent name={tab.icon} className="h-4 w-4" />
{tab.title}
</TabsTrigger>
))}
</TabsList>
) : (
<div></div>
)}
</div>
{tabsList.map((tab, idx) => (
<TabsContent
value={idx.toString()}
className="api-modal-tabs-content mt-4 overflow-hidden"
key={idx}
>
<div className="relative flex h-full w-full">
<Button
variant="ghost"
size="icon"
onClick={copyToClipboard}
data-testid="btn-copy-code"
className="!hover:bg-foreground group absolute right-2 top-2"
>
{isCopied ? (
<IconComponent
name="Check"
className="h-5 w-5 text-muted-foreground"
/>
) : (
<IconComponent
name="Copy"
className="!h-5 !w-5 text-muted-foreground"
/>
)}
</Button>
<SyntaxHighlighter
showLineNumbers={true}
wrapLongLines={true}
language={tab.language}
style={dark ? oneDark : oneLight}
className="!mt-0 h-full w-full overflow-scroll !rounded-b-md border border-border text-left !custom-scroll"
>
{tab.code}
</SyntaxHighlighter>
</div>
</TabsContent>
))}
</Tabs>
);
}

View file

@ -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 (
<>
<BaseModal
closeButtonClassName="!top-3"
open={open}
setOpen={setOpen}
size="medium"
className="pt-4"
>
<BaseModal.Trigger asChild>{children}</BaseModal.Trigger>
<BaseModal.Header
description={
autoLogin ? undefined : (
<>
<span className="pr-2">
API access requires an API key. You can{" "}
<a
href="/settings/api-keys"
className="text-accent-pink-foreground"
>
{" "}
create an API key
</a>{" "}
in settings.
</span>
</>
)
}
>
<IconComponent
name="Code2"
className="h-6 w-6 text-gray-800 dark:text-white"
aria-hidden="true"
/>
<span className="pl-2">API access</span>
{nodes.length > 0 && (
<div className="border-r-1 absolute right-12 flex items-center text-[13px] font-medium leading-[16px]">
<Button
variant="ghost"
size="icon"
className="h-8 px-3"
onClick={() => setOpenTweaks(true)}
>
<IconComponent
name="SlidersHorizontal"
className="h-3.5 w-3.5"
/>
<span>Tweaks ({Object.keys(tweaks)?.length}) </span>
</Button>
<Separator orientation="vertical" className="ml-2 h-8" />
</div>
)}
</BaseModal.Header>
<BaseModal.Content overflowHidden>
{open && (
<>
<CustomAPIGenerator isOpen={open} />
<APITabsComponent />
</>
)}
</BaseModal.Content>
</BaseModal>
<BaseModal
open={openTweaks}
setOpen={setOpenTweaks}
size="medium-small-tall"
>
<BaseModal.Header
description={
autoLogin ? undefined : (
<>
<span className="pr-2">
API access requires an API key. You can{" "}
<a
href="/settings/api-keys"
className="text-accent-pink-foreground"
>
{" "}
create an API key
</a>{" "}
in settings.
</span>
</>
)
}
>
<IconComponent
name="SlidersHorizontal"
className="h-6 w-6 text-gray-800 dark:text-white"
/>
<span className="pl-2">Tweaks</span>
</BaseModal.Header>
<BaseModal.Content overflowHidden>
<div className="h-full w-full overflow-y-auto overflow-x-hidden rounded-lg bg-muted custom-scroll">
<TweaksComponent open={openTweaks} />
</div>
</BaseModal.Content>
</BaseModal>
</>
);
}

View file

@ -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)}'`;
}

View file

@ -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));
`;
}

View file

@ -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}")
`;
}

View file

@ -9,9 +9,18 @@ export default function getWidgetCode({
flowId,
flowName,
isAuth,
copy = false,
}: GetCodeType): string {
return `<script src="https://cdn.jsdelivr.net/gh/logspace-ai/langflow-embedded-chat@v1.0.7/dist/build/static/js/bundle.min.js"></script>
const source = copy
? `<script
src="https://cdn.jsdelivr.net/gh/logspace-ai/langflow-embedded-chat@v1.0.7/dist/build/static/js/bundle.min.js">
</script>`
: `<script
src="https://cdn.jsdelivr.net/gh/logspace-ai/langflow-embedded-chat@v1.0.7/dist/
build/static/js/bundle.min.js">
</script>`;
return `${source}
<langflow-chat
window_title="${flowName}"
flow_id="${flowId}"
@ -20,7 +29,6 @@ export default function getWidgetCode({
? `
api_key="..."`
: ""
}
></langflow-chat>`;
}>
</langflow-chat>`;
}

View file

@ -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]";

View file

@ -73,7 +73,7 @@ const Trigger: React.FC<TriggerProps> = ({
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<{
<DialogTitle className="line-clamp-1 flex items-center pb-0.5 text-base">
{children}
</DialogTitle>
<DialogDescription
className={`line-clamp-${clampDescription ?? 2} text-sm`}
>
{description}
</DialogDescription>
{description && (
<DialogDescription
className={`line-clamp-${clampDescription ?? 2} text-sm`}
>
{description}
</DialogDescription>
)}
</DialogHeader>
);
};
@ -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 ? (
<Form.Root

View file

@ -40,14 +40,6 @@ export function AppInitPage() {
}
}, [isFetched]);
useEffect(() => {
if (!dark) {
document.getElementById("body")!.classList.remove("dark");
} else {
document.getElementById("body")!.classList.add("dark");
}
}, [dark]);
return (
//need parent component with width and height
<>

View file

@ -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 (
<div className="flex h-full w-full flex-col items-center justify-center align-middle">
{currentSavedFlow && (
<IOModal open={true} setOpen={() => {}} isPlayground>
<IOModal open={true} setOpen={() => {}} isPlayground playgroundPage>
<></>
</IOModal>
)}

View file

@ -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([
<Route path="/playground/:id/">
<Route
path=""
element={
<ContextWrapper key={1}>
<PlaygroundPage />
</ContextWrapper>
}
/>
</Route>,
<Route
path={ENABLE_CUSTOM_PARAM ? "/:customParam?" : "/"}
element={
<ContextWrapper>
<ContextWrapper key={2}>
<Outlet />
</ContextWrapper>
}
@ -151,9 +161,6 @@ const router = createBrowserRouter(
</Route>
<Route path="view" element={<ViewPage />} />
</Route>
{/* <Route path="playground/:id/">
<Route path="" element={<PlaygroundPage />} />
</Route> */}
</Route>
</Route>
<Route

View file

@ -58,6 +58,10 @@ import { useTypesStore } from "./typesStore";
// this is our useStore hook that we can use in our components to get parts of the store and call actions
const useFlowStore = create<FlowStoreType>((set, get) => ({
playgroundPage: false,
setPlaygroundPage: (playgroundPage) => {
set({ playgroundPage });
},
positionDictionary: {},
setPositionDictionary: (positionDictionary) => {
set({ positionDictionary });
@ -604,6 +608,7 @@ const useFlowStore = create<FlowStoreType>((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<FlowStoreType>((set, get) => ({
nodes: get().nodes || undefined,
edges: get().edges || undefined,
logBuilds: get().onFlowPage,
playgroundPage,
stream,
});
get().setIsBuilding(false);

View file

@ -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<TweaksStoreType>((set, get) => ({
activeTweaks: false,
tweaks: {},
setActiveTweaks: (activeTweaks: boolean) => {
set({ activeTweaks }), get().refreshTabs();
},
@ -21,6 +22,7 @@ export const useTweaksStore = create<TweaksStoreType>((set, get) => ({
nodes: newChange,
});
get().refreshTabs();
get().updateTweaks();
},
setNode: (id, change) => {
let newChange =
@ -59,6 +61,13 @@ export const useTweaksStore = create<TweaksStoreType>((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<TweaksStoreType>((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,
});
},
}));

View file

@ -3,6 +3,8 @@ import { UtilityStoreType } from "@/types/zustand/utility";
import { create } from "zustand";
export const useUtilityStore = create<UtilityStoreType>((set, get) => ({
clientId: "",
setClientId: (clientId: string) => set({ clientId }),
dismissAll: false,
setDismissAll: (dismissAll: boolean) => set({ dismissAll }),
chatValueStore: "",

View file

@ -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;

View file

@ -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;
}

View file

@ -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 = {

View file

@ -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<NodeDataType, "genericNode">;

View file

@ -69,3 +69,13 @@ export type errorsVarType = {
title: string;
list?: Array<string>;
};
export type APITabType = {
title: string;
language: string;
icon: string;
code: string;
copyCode: string;
};
export type tabsArrayType = Array<APITabType>;

View file

@ -14,4 +14,5 @@ export type GetCodeType = {
tweaksBuildedObject?: {};
endpointName?: string | null;
activeTweaks?: boolean;
copy?: boolean;
};

View file

@ -139,6 +139,8 @@ export type FlowStoreType = {
getFilterEdge: any[];
onConnect: (connection: Connection) => void;
unselectAll: () => void;
playgroundPage: boolean;
setPlaygroundPage: (playgroundPage: boolean) => void;
buildFlow: ({
startNodeId,
stopNodeId,

View file

@ -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;
};
};
};

View file

@ -21,4 +21,6 @@ export type UtilityStoreType = {
setChatValueStore: (value: string) => void;
dismissAll: boolean;
setDismissAll: (dismissAll: boolean) => void;
setClientId: (clientId: string) => void;
clientId: string;
};

View file

@ -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) {

View file

@ -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);
}

View file

@ -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,

View file

@ -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;
}

View file

@ -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": {

View file

@ -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();

View file

@ -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();
},
);

View file

@ -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(() =>

View file

@ -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);

View file

@ -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(),