Merge remote-tracking branch 'origin/release-1.8.4'

# Conflicts:
#	src/backend/base/langflow/api/v1/chat.py
#	src/backend/tests/unit/test_chat_endpoint.py
This commit is contained in:
vijay kumar katuri
2026-04-06 15:28:41 -04:00
14 changed files with 916 additions and 419 deletions

View File

@ -1,6 +1,6 @@
[project]
name = "langflow"
version = "1.8.3"
version = "1.8.4"
description = "A Python package with a built-in web application"
requires-python = ">=3.10,<3.14"
license = "MIT"
@ -17,7 +17,7 @@ maintainers = [
]
# Define your main dependencies here
dependencies = [
"langflow-base[complete]~=0.8.3",
"langflow-base[complete]~=0.8.4",
]

View File

@ -69,9 +69,26 @@ async def start_flow_build(
current_user: CurrentActiveUser,
queue_service: JobQueueService,
flow_name: str | None = None,
source_flow_id: uuid.UUID | None = None,
) -> str:
"""Start the flow build process by setting up the queue and starting the build task.
Args:
flow_id: The flow ID used for tracking, sessions, and messages.
background_tasks: FastAPI background tasks handler.
inputs: Optional input values for the flow.
data: Optional flow data request.
files: Optional list of file paths.
stop_component_id: Optional component ID to stop the build at.
start_component_id: Optional component ID to start the build from.
log_builds: Whether to log builds.
current_user: The current authenticated user.
queue_service: The job queue service.
flow_name: Optional flow name override.
source_flow_id: If provided, the actual flow ID to load from DB.
Used by public flows where flow_id is a virtual UUID for session isolation
but the flow data must be loaded from the original flow in the database.
Returns:
the job_id.
"""
@ -90,6 +107,7 @@ async def start_flow_build(
log_builds=log_builds,
current_user=current_user,
flow_name=flow_name,
source_flow_id=source_flow_id,
)
queue_service.start_job(job_id, task_coro)
except Exception as e:
@ -209,6 +227,7 @@ async def generate_flow_events(
log_builds: bool,
current_user: CurrentActiveUser,
flow_name: str | None = None,
source_flow_id: uuid.UUID | None = None,
) -> None:
"""Generate events for flow building process.
@ -283,13 +302,19 @@ async def generate_flow_events(
effective_session_id = flow_id_str
if not data:
return await build_graph_from_db(
flow_id=flow_id,
# For public flows, source_flow_id is the real DB ID, flow_id is virtual.
# Load from DB using the real ID, then override graph.flow_id with virtual.
db_flow_id = source_flow_id if source_flow_id is not None else flow_id
graph = await build_graph_from_db(
flow_id=db_flow_id,
session=fresh_session,
chat_service=chat_service,
user_id=str(current_user.id),
session_id=effective_session_id,
)
if source_flow_id is not None:
graph.flow_id = str(flow_id)
return graph
if not flow_name:
result = await fresh_session.exec(select(Flow.name).where(Flow.id == flow_id))

View File

@ -583,6 +583,7 @@ 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,
@ -597,16 +598,10 @@ async def build_public_tmp(
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.
Security Note:
- The 'data' parameter is NOT accepted to prevent flow definition tampering
- Public flows must execute the stored flow definition only
- The flow definition is always loaded from the database
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
4. Always loads the flow definition from the database
Requirements:
- The flow must be marked as PUBLIC in the database
@ -616,6 +611,7 @@ async def build_public_tmp(
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
@ -633,13 +629,14 @@ async def build_public_tmp(
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
# data is always None for public flows - they load from database only
# flow_id=new_flow_id for tracking/sessions/messages (virtual, per-user isolation).
# source_flow_id=flow_id to load the actual flow data from the database.
job_id = await start_flow_build(
flow_id=new_flow_id,
source_flow_id=flow_id,
background_tasks=background_tasks,
inputs=inputs,
data=None, # Always None - public flows load from database only
data=data,
files=files,
stop_component_id=stop_component_id,
start_component_id=start_component_id,
@ -660,3 +657,54 @@ async def build_public_tmp(
queue_service=queue_service,
event_delivery=event_delivery,
)
@router.get("/build_public_tmp/{job_id}/events")
async def get_build_events_public(
job_id: str,
queue_service: Annotated[JobQueueService, Depends(get_queue_service)],
*,
event_delivery: EventDeliveryType = EventDeliveryType.STREAMING,
):
"""Get events for a public flow build job.
This endpoint does not require authentication, matching the public build endpoint.
It is used by the shareable playground to consume build events.
"""
return await get_flow_events_response(
job_id=job_id,
queue_service=queue_service,
event_delivery=event_delivery,
)
@router.post(
"/build_public_tmp/{job_id}/cancel",
response_model=CancelFlowResponse,
)
async def cancel_build_public(
job_id: str,
queue_service: Annotated[JobQueueService, Depends(get_queue_service)],
):
"""Cancel a public flow build job.
This endpoint does not require authentication, matching the public build endpoint.
It is used by the shareable playground to cancel builds.
"""
try:
cancellation_success = await cancel_flow_build(job_id=job_id, queue_service=queue_service)
if cancellation_success:
return CancelFlowResponse(success=True, message="Flow build cancelled successfully")
return CancelFlowResponse(success=False, message="Failed to cancel flow build")
except asyncio.CancelledError:
await logger.aerror(f"Failed to cancel public flow build for job_id {job_id} (CancelledError caught)")
return CancelFlowResponse(success=False, message="Failed to cancel flow build")
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except JobQueueNotFoundError as exc:
await logger.aerror(f"Public job not found: {job_id}. Error: {exc!s}")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Job not found: {exc!s}") from exc
except Exception as exc:
await logger.aexception(f"Error cancelling public flow build for job_id {job_id}: {exc}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc

View File

@ -1,6 +1,6 @@
[project]
name = "langflow-base"
version = "0.8.3"
version = "0.8.4"
description = "A Python package with a built-in web application"
requires-python = ">=3.10,<3.14"
license = "MIT"
@ -17,7 +17,7 @@ maintainers = [
]
dependencies = [
"lfx~=0.3.3",
"lfx~=0.3.4",
"fastapi>=0.135.0,<1.0.0",
"httpx[http2]>=0.27,<1.0.0",
"aiofile>=3.9.0,<4.0.0",

View File

@ -434,6 +434,54 @@ async def test_cancel_build_with_cancelled_error(client, json_memory_chatbot_no_
monkeypatch.setattr(langflow.api.v1.chat, "cancel_flow_build", original_cancel_flow_build)
@pytest.mark.benchmark
async def test_should_have_public_events_endpoint_accessible_without_auth(client, logged_in_headers): # noqa: ARG001
"""Test that public events endpoint exists and is accessible without authentication.
Bug: After sending a message in the Shareable Playground, the chat input resets
but no response is rendered. The root cause is that the events endpoint
(/build/{job_id}/events) requires authentication, which the unauthenticated
shareable playground user does not have.
This test proves:
1. The PUBLIC events endpoint exists and responds without auth (404 = route exists, job not found)
2. The AUTHENTICATED events endpoint rejects unauthenticated requests (403)
"""
fake_job_id = str(uuid.uuid4())
# Assert 1 — the PUBLIC events endpoint is accessible without auth
# Returns 404 "Job not found" (route exists, but job doesn't) — NOT 401/403
events_response = await client.get(
f"api/v1/build_public_tmp/{fake_job_id}/events?event_delivery=polling",
headers={"Accept": "application/x-ndjson"},
)
assert events_response.status_code == codes.NOT_FOUND
# The key proof: the public endpoint responded with 404 (route exists, job not found)
# rather than 401/403 (authentication required). Before the fix, this endpoint
# didn't exist at all and would return 404 for the route, not the job.
assert "Job not found" in events_response.json()["detail"]
@pytest.mark.benchmark
async def test_should_have_public_cancel_endpoint_accessible_without_auth(client, logged_in_headers): # noqa: ARG001
"""Test that public cancel endpoint exists and is accessible without authentication.
Same root cause as the events bug: the cancel endpoint requires auth
but the shareable playground user is unauthenticated.
"""
fake_job_id = str(uuid.uuid4())
# The PUBLIC cancel endpoint is accessible without auth
# Returns 404 "Job not found" (route exists, but job doesn't) — NOT 401/403
cancel_response = await client.post(
f"api/v1/build_public_tmp/{fake_job_id}/cancel",
headers={"Content-Type": "application/json"},
)
assert cancel_response.status_code == codes.NOT_FOUND
assert "Job not found" in cancel_response.json()["detail"]
@pytest.mark.benchmark
async def test_build_public_tmp_ignores_data_parameter(client, json_memory_chatbot_no_llm, logged_in_headers):
"""Test that build_public_tmp endpoint silently ignores data parameter for security.

View File

@ -1,12 +1,12 @@
{
"name": "langflow",
"version": "1.8.3",
"version": "1.8.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "langflow",
"version": "1.8.3",
"version": "1.8.4",
"dependencies": {
"@chakra-ui/number-input": "^2.1.2",
"@chakra-ui/system": "^2.6.2",

View File

@ -1,12 +1,13 @@
{
"name": "langflow",
"version": "1.8.3",
"version": "1.8.4",
"private": true,
"engines": {
"node": ">=20.19.0"
},
"overrides": {
"tar": "^7.5.7",
"tar-fs": ">=2.1.4",
"glob": "^11.1.0",
"test-exclude": "^7.0.0"
},

View File

@ -1,9 +1,11 @@
import { useMessagesStore } from "@/stores/messagesStore";
import type { Message } from "@/types/messages";
import { removeMessages, updateMessage } from "./message-utils";
/**
* Handles message-related events from the build process.
* This keeps all chat message logic within the chat-view scope.
* Updates both React Query cache (used by the internal playground)
* and useMessagesStore (used by the shareable playground / IOModal).
*/
export const handleMessageEvent = (
eventType: string,
@ -11,37 +13,48 @@ export const handleMessageEvent = (
): boolean => {
switch (eventType) {
case "add_message": {
// Add/update message in React Query cache (replaces placeholder if exists)
// Update React Query cache (internal playground)
updateMessage(data as Message);
// Update Zustand store (shareable playground / IOModal)
useMessagesStore.getState().addMessage(data as Message);
return true;
}
case "token": {
// Update message text in React Query cache for streaming
updateMessage({
id: data.id,
flow_id: data.flow_id || "",
session_id: data.session_id || "",
text: data.chunk || "",
sender: data.sender || "Machine",
sender_name: data.sender_name || "AI",
timestamp: data.timestamp || new Date().toISOString(),
files: data.files || [],
edit: data.edit || false,
background_color: data.background_color || "",
text_color: data.text_color || "",
properties: { ...data.properties, state: "partial" },
} as Message);
const d = data as Record<string, unknown>;
const tokenMessage = {
id: d.id,
flow_id: d.flow_id || "",
session_id: d.session_id || "",
text: d.chunk || "",
sender: d.sender || "Machine",
sender_name: d.sender_name || "AI",
timestamp: d.timestamp || new Date().toISOString(),
files: d.files || [],
edit: d.edit || false,
background_color: d.background_color || "",
text_color: d.text_color || "",
properties: { ...(d.properties as object), state: "partial" },
} as Message;
// Update React Query cache (internal playground)
updateMessage(tokenMessage);
// Update Zustand store (shareable playground / IOModal)
useMessagesStore.getState().addMessage(tokenMessage);
return true;
}
case "remove_message": {
// Remove message from React Query cache
removeMessages([data.id], data.session_id || "", data.flow_id || "");
const rm = data as Record<string, string>;
// Remove from React Query cache
removeMessages([rm.id], rm.session_id || "", rm.flow_id || "");
// Remove from Zustand store
useMessagesStore.getState().removeMessage(data as Message);
return true;
}
case "error": {
if (data?.category === "error") {
// Add error message to React Query cache
if ((data as Record<string, unknown>)?.category === "error") {
// Update React Query cache
updateMessage(data as Message);
// Update Zustand store
useMessagesStore.getState().addMessage(data as Message);
}
return true;
}

View File

@ -4,10 +4,13 @@ export const customBuildUrl = (flowId: string, playgroundPage?: boolean) => {
return `${getBaseUrl()}${playgroundPage ? "build_public_tmp" : "build"}/${flowId}/flow`;
};
export const customCancelBuildUrl = (jobId: string) => {
return `${getBaseUrl()}build/${jobId}/cancel`;
export const customCancelBuildUrl = (
jobId: string,
playgroundPage?: boolean,
) => {
return `${getBaseUrl()}${playgroundPage ? "build_public_tmp" : "build"}/${jobId}/cancel`;
};
export const customEventsUrl = (jobId: string) => {
return `${getBaseUrl()}build/${jobId}/events`;
export const customEventsUrl = (jobId: string, playgroundPage?: boolean) => {
return `${getBaseUrl()}${playgroundPage ? "build_public_tmp" : "build"}/${jobId}/events`;
};

View File

@ -344,7 +344,7 @@ export async function buildFlowVertices({
const { job_id } = await buildResponse.json();
const cancelBuildUrl = customCancelBuildUrl(job_id);
const cancelBuildUrl = customCancelBuildUrl(job_id, playgroundPage);
// Get the buildController from flowStore
const buildController = new AbortController();
@ -363,7 +363,7 @@ export async function buildFlowVertices({
});
useFlowStore.getState().setBuildController(buildController);
// Then stream the events
const eventsUrl = customEventsUrl(job_id);
const eventsUrl = customEventsUrl(job_id, playgroundPage);
const buildResults: Array<boolean> = [];
if (eventDelivery === EventDeliveryType.STREAMING) {

View File

@ -1,6 +1,6 @@
[project]
name = "lfx"
version = "0.3.3"
version = "0.3.4"
description = "Langflow Executor - A lightweight CLI tool for executing and serving Langflow AI flows"
readme = "README.md"
authors = [

View File

@ -73359,7 +73359,7 @@
"dependencies": [
{
"name": "google",
"version": "2.30.0"
"version": "2.8.0"
},
{
"name": "lfx",
@ -73507,7 +73507,7 @@
"dependencies": [
{
"name": "google",
"version": "2.30.0"
"version": "2.8.0"
},
{
"name": "googleapiclient",
@ -73668,7 +73668,7 @@
"dependencies": [
{
"name": "google",
"version": "2.30.0"
"version": "2.8.0"
},
{
"name": "langchain_core",
@ -73797,7 +73797,7 @@
"dependencies": [
{
"name": "google",
"version": "2.30.0"
"version": "2.8.0"
},
{
"name": "langchain_google_community",
@ -73926,7 +73926,7 @@
"dependencies": [
{
"name": "google",
"version": "2.30.0"
"version": "2.8.0"
},
{
"name": "googleapiclient",
@ -74216,7 +74216,7 @@
},
{
"name": "google",
"version": "2.30.0"
"version": "2.8.0"
},
{
"name": "langchain_google_genai",
@ -74576,7 +74576,7 @@
"dependencies": [
{
"name": "google",
"version": "2.30.0"
"version": "2.8.0"
},
{
"name": "google_auth_oauthlib",
@ -113549,7 +113549,7 @@
},
{
"name": "google",
"version": "2.30.0"
"version": "2.8.0"
}
],
"total_dependencies": 3
@ -113933,7 +113933,7 @@
},
{
"name": "google",
"version": "2.30.0"
"version": "2.8.0"
}
],
"total_dependencies": 3
@ -118191,6 +118191,6 @@
"num_components": 359,
"num_modules": 97
},
"sha256": "8fc3c7cb1bdf0873d2374de24e6e6e9a0bed598adbd0ef95964db455b2c937b6",
"version": "0.3.3"
"sha256": "c794ac17e44941714c12c00a023c7384137ea10c84b679f48949dba1005ecd13",
"version": "0.3.4"
}

File diff suppressed because it is too large Load Diff

6
uv.lock generated
View File

@ -5741,7 +5741,7 @@ wheels = [
[[package]]
name = "langflow"
version = "1.8.3"
version = "1.8.4"
source = { editable = "." }
dependencies = [
{ name = "langflow-base", extra = ["complete"] },
@ -5919,7 +5919,7 @@ dev = [
[[package]]
name = "langflow-base"
version = "0.8.3"
version = "0.8.4"
source = { editable = "src/backend/base" }
dependencies = [
{ name = "aiofile" },
@ -7099,7 +7099,7 @@ wheels = [
[[package]]
name = "lfx"
version = "0.3.3"
version = "0.3.4"
source = { editable = "src/lfx" }
dependencies = [
{ name = "ag-ui-protocol" },