feat: add sessions endpoint with session management enhancements (#8596)

* 📝 (monitor.py): Add endpoint to get sessions and handle session_id encoding for API requests
📝 (use-get-messages-mutation.ts): Implement a mutation function to fetch messages with query parameters and handle session_id encoding for API requests
📝 (use-get-messages-polling.ts): Ensure proper encoding of session_id for API requests in polling mutation
📝 (use-get-messages.ts): Handle session_id encoding for API requests in messages query
📝 (new-modal.tsx): Implement functions to handle session deletion and proper encoding of session_id for API requests
📝 (utils.ts): Add functions to encode, decode, validate, format, and prepare session IDs for API requests

* 📝 (constants.ts): Add SESSIONS constant to API URLs for monitoring sessions
🔧 (use-delete-messages.ts): Add queryClient to UseRequestProcessor to invalidate sessions query
✨ (use-get-sessions-from-flow.ts): Introduce useGetSessionsFromFlowQuery to fetch sessions from flow
🔧 (use-rename-session.ts): Change refetchQueries to invalidateQueries for useGetSessionsFromFlowQuery
🔧 (custom-new-modal.tsx): Update import path for IOModal to playground-modal
🔧 (session-selector.tsx): Add setActiveSession function to handle setting active session
🔧 (sidebar-open-view.tsx): Add setActiveSession function to handle setting active session
♻️ (new-modal.tsx): Refactor IOModal into playground-modal and update functionality
♻️ (playground-modal.tsx): Refactor IOModal to handle playground-specific functionality
⬆️ (flowStore.ts): Add newChatOnPlayground state and setNewChatOnPlayground function
⬆️ (index.ts): Update FlowStoreType to include newChatOnPlayground and setNewChatOnPlayground

* 🔧 (pyproject.toml): update testpaths to point to the correct directory for tests
✨ (test_session_endpoint.py): add unit tests for sessions endpoint with flow_id filtering
♻️ (session-selector.tsx): refactor to trim editedSession before setting it
♻️ (sidebar-open-view.tsx): refactor to set visibleSession instead of activeSession

* ✨ (use-get-sessions-from-flow.ts): Always include the flow ID as the default session if it's not already present
♻️ (playground-modal.tsx): Refactor setting sessions to include currentFlowId as the default session if not present, and handle visibility of sessions more efficiently

* ♻️ (use-get-messages-mutation.ts): remove unused imports and refactor code for better readability and maintainability

* ✨ (test_session_endpoint.py): refactor test function names for better clarity and consistency

* ✨ (create-new-session-name.ts): add function to generate a new session name based on the current date and time
🔧 (playground-modal.tsx): import createNewSessionName function to dynamically set a new session name when no session is visible

* [autofix.ci] apply automated fixes

* ✨ (monitor.py): rename get_sessions endpoint to get_message_sessions for clarity and consistency
🔧 (constants.ts): remove unused SESSIONS constant from API URLs
🔧 (use-delete-messages.ts): remove commented out code and unnecessary comments
✨ (use-delete-sessions.ts): add functionality to delete sessions in frontend
🔧 (use-get-sessions-from-flow.ts): update API endpoint for getting sessions to match backend changes
🔧 (playground-modal.tsx): add functionality to delete sessions and associated messages in the UI, update UI optimistically, and handle errors appropriately

* [autofix.ci] apply automated fixes

* 🐛 (monitor.py): Fix type hinting issue in delete_messages function
📝 (monitor.py): Add comments and improve readability in test_messages_endpoints.py
📝 (session_endpoint.py): Update endpoint paths for consistency and clarity in test_session_endpoint.py

* [autofix.ci] apply automated fixes

* fix: update SQL statement to use col() for session_id filtering in get_message_sessions function

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@langflow.org>
This commit is contained in:
Cristhian Zanforlin Lousa 2025-06-23 11:11:07 -03:00 • committed by GitHub
commit 043ba55718
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 672 additions and 65 deletions

View file

@ -233,7 +233,7 @@ ignore-regex = '.*(Stati Uniti|Tense=Pres).*'
timeout = 120
timeout_method = "signal"
minversion = "6.0"
testpaths = ["tests", "integration"]
testpaths = ["src/backend/tests"]
console_output_style = "progress"
filterwarnings = ["ignore::DeprecationWarning", "ignore::ResourceWarning"]
log_cli = true

View file

@ -40,6 +40,24 @@ async def delete_vertex_builds(flow_id: Annotated[UUID, Query()], session: DbSes
raise HTTPException(status_code=500, detail=str(e)) from e
@router.get("/messages/sessions", dependencies=[Depends(get_current_active_user)])
async def get_message_sessions(
session: DbSession,
flow_id: Annotated[UUID | None, Query()] = None,
) -> list[str]:
try:
stmt = select(MessageTable.session_id).distinct()
stmt = stmt.where(col(MessageTable.session_id).isnot(None))
if flow_id:
stmt = stmt.where(MessageTable.flow_id == flow_id)
session_ids = await session.exec(stmt)
return list(session_ids)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
@router.get("/messages")
async def get_messages(
session: DbSession,
@ -54,7 +72,10 @@ async def get_messages(
if flow_id:
stmt = stmt.where(MessageTable.flow_id == flow_id)
if session_id:
stmt = stmt.where(MessageTable.session_id == session_id)
from urllib.parse import unquote
decoded_session_id = unquote(session_id)
stmt = stmt.where(MessageTable.session_id == decoded_session_id)
if sender:
stmt = stmt.where(MessageTable.sender == sender)
if sender_name:

View file

@ -1,4 +1,5 @@
from datetime import datetime, timezone
from urllib.parse import quote
from uuid import UUID
import pytest
@ -32,6 +33,20 @@ async def created_messages(session): # noqa: ARG001
return await aadd_messagetables(messagetables, _session)
@pytest.fixture
async def messages_with_datetime_session_id(session): # noqa: ARG001
"""Create messages with datetime-like session IDs that contain characters requiring URL encoding."""
datetime_session_id = "2024-01-15 10:30:45 UTC" # Contains spaces and colons
async with session_scope() as _session:
messages = [
MessageCreate(text="Datetime message 1", sender="User", sender_name="User", session_id=datetime_session_id),
MessageCreate(text="Datetime message 2", sender="AI", sender_name="AI", session_id=datetime_session_id),
]
messagetables = [MessageTable.model_validate(message, from_attributes=True) for message in messages]
created_messages = await aadd_messagetables(messagetables, _session)
return created_messages, datetime_session_id
@pytest.mark.api_key_required
async def test_delete_messages(client: AsyncClient, created_messages, logged_in_headers):
response = await client.request(
@ -127,3 +142,96 @@ async def test_no_messages_found_with_given_session_id(client, logged_in_headers
assert response.status_code == 404, response.text
assert response.json()["detail"] == "Not Found"
# Test for URL-encoded datetime session ID
@pytest.mark.api_key_required
async def test_get_messages_with_url_encoded_datetime_session_id(
client: AsyncClient, messages_with_datetime_session_id, logged_in_headers
):
"""Test that URL-encoded datetime session IDs are properly decoded and matched."""
created_messages, datetime_session_id = messages_with_datetime_session_id
# URL encode the datetime session ID (spaces become %20, colons become %3A)
encoded_session_id = quote(datetime_session_id)
# Test with URL-encoded session ID
response = await client.get(
"api/v1/monitor/messages", params={"session_id": encoded_session_id}, headers=logged_in_headers
)
assert response.status_code == 200, response.text
messages = response.json()
assert len(messages) == 2
# Verify all messages have the correct (decoded) session ID
for message in messages:
assert message["session_id"] == datetime_session_id
# Verify message content
assert messages[0]["text"] == "Datetime message 1"
assert messages[1]["text"] == "Datetime message 2"
@pytest.mark.api_key_required
async def test_get_messages_with_non_encoded_datetime_session_id(
client: AsyncClient, messages_with_datetime_session_id, logged_in_headers
):
"""Test that non-URL-encoded datetime session IDs also work correctly."""
created_messages, datetime_session_id = messages_with_datetime_session_id
# Test with non-encoded session ID (should still work due to unquote being safe for non-encoded strings)
response = await client.get(
"api/v1/monitor/messages", params={"session_id": datetime_session_id}, headers=logged_in_headers
)
assert response.status_code == 200, response.text
messages = response.json()
assert len(messages) == 2
# Verify all messages have the correct session ID
for message in messages:
assert message["session_id"] == datetime_session_id
@pytest.mark.api_key_required
async def test_get_messages_with_various_encoded_characters(client: AsyncClient, logged_in_headers):
"""Test various URL-encoded characters in session IDs."""
# Create a session ID with various special characters
special_session_id = "test+session:2024@domain.com"
async with session_scope() as session:
message = MessageCreate(
text="Special chars message", sender="User", sender_name="User", session_id=special_session_id
)
messagetable = MessageTable.model_validate(message, from_attributes=True)
await aadd_messagetables([messagetable], session)
# URL encode the session ID
encoded_session_id = quote(special_session_id)
# Test with URL-encoded session ID
response = await client.get(
"api/v1/monitor/messages", params={"session_id": encoded_session_id}, headers=logged_in_headers
)
assert response.status_code == 200, response.text
messages = response.json()
assert len(messages) == 1
assert messages[0]["session_id"] == special_session_id
assert messages[0]["text"] == "Special chars message"
@pytest.mark.api_key_required
async def test_get_messages_empty_result_with_encoded_nonexistent_session(client: AsyncClient, logged_in_headers):
"""Test that URL-encoded non-existent session IDs return empty results."""
nonexistent_session_id = "2024-12-31 23:59:59 UTC"
encoded_session_id = quote(nonexistent_session_id)
response = await client.get(
"api/v1/monitor/messages", params={"session_id": encoded_session_id}, headers=logged_in_headers
)
assert response.status_code == 200, response.text
messages = response.json()
assert len(messages) == 0

View file

@ -0,0 +1,142 @@
from uuid import uuid4
import pytest
from httpx import AsyncClient
from langflow.memory import aadd_messagetables
from langflow.services.database.models.message.model import MessageTable
from langflow.services.deps import session_scope
@pytest.fixture
async def messages_with_flow_ids(session): # noqa: ARG001
"""Create messages with different session_ids and flow_ids for testing sessions endpoint."""
async with session_scope() as _session:
flow_id_1 = uuid4()
flow_id_2 = uuid4()
# Create MessageTable objects directly since MessageCreate doesn't have flow_id field
messagetables = [
MessageTable(
text="Message 1", sender="User", sender_name="User", session_id="session_A", flow_id=flow_id_1
),
MessageTable(text="Message 2", sender="AI", sender_name="AI", session_id="session_A", flow_id=flow_id_1),
MessageTable(
text="Message 3", sender="User", sender_name="User", session_id="session_B", flow_id=flow_id_1
),
MessageTable(
text="Message 4", sender="User", sender_name="User", session_id="session_C", flow_id=flow_id_2
),
MessageTable(text="Message 5", sender="AI", sender_name="AI", session_id="session_D", flow_id=flow_id_2),
MessageTable(
text="Message 6",
sender="User",
sender_name="User",
session_id="session_E",
flow_id=None, # No flow_id
),
]
created_messages = await aadd_messagetables(messagetables, _session)
return {
"messages": created_messages,
"flow_id_1": flow_id_1,
"flow_id_2": flow_id_2,
"expected_sessions_flow_1": {"session_A", "session_B"},
"expected_sessions_flow_2": {"session_C", "session_D"},
"expected_all_sessions": {"session_A", "session_B", "session_C", "session_D", "session_E"},
}
# Tests for /sessions endpoint
@pytest.mark.api_key_required
async def test_get_sessions_all(client: AsyncClient, logged_in_headers, messages_with_flow_ids):
"""Test getting all sessions without any filter."""
response = await client.get("api/v1/monitor/messages/sessions", headers=logged_in_headers)
assert response.status_code == 200, response.text
sessions = response.json()
assert isinstance(sessions, list)
# Convert to set for easier comparison since order doesn't matter
returned_sessions = set(sessions)
expected_sessions = messages_with_flow_ids["expected_all_sessions"]
assert returned_sessions == expected_sessions
assert len(sessions) == len(expected_sessions)
@pytest.mark.api_key_required
async def test_get_sessions_with_flow_id_filter(client: AsyncClient, logged_in_headers, messages_with_flow_ids):
"""Test getting sessions filtered by flow_id."""
flow_id_1 = messages_with_flow_ids["flow_id_1"]
response = await client.get(
"api/v1/monitor/messages/sessions", params={"flow_id": str(flow_id_1)}, headers=logged_in_headers
)
assert response.status_code == 200, response.text
sessions = response.json()
assert isinstance(sessions, list)
returned_sessions = set(sessions)
expected_sessions = messages_with_flow_ids["expected_sessions_flow_1"]
assert returned_sessions == expected_sessions
assert len(sessions) == len(expected_sessions)
@pytest.mark.api_key_required
async def test_get_sessions_with_different_flow_id(client: AsyncClient, logged_in_headers, messages_with_flow_ids):
"""Test getting sessions filtered by a different flow_id."""
flow_id_2 = messages_with_flow_ids["flow_id_2"]
response = await client.get(
"api/v1/monitor/messages/sessions", params={"flow_id": str(flow_id_2)}, headers=logged_in_headers
)
assert response.status_code == 200, response.text
sessions = response.json()
assert isinstance(sessions, list)
returned_sessions = set(sessions)
expected_sessions = messages_with_flow_ids["expected_sessions_flow_2"]
assert returned_sessions == expected_sessions
assert len(sessions) == len(expected_sessions)
@pytest.mark.api_key_required
async def test_get_sessions_with_non_existent_flow_id(client: AsyncClient, logged_in_headers):
"""Test getting sessions with a non-existent flow_id returns empty list."""
non_existent_flow_id = uuid4()
response = await client.get(
"api/v1/monitor/messages/sessions", params={"flow_id": str(non_existent_flow_id)}, headers=logged_in_headers
)
assert response.status_code == 200, response.text
sessions = response.json()
assert isinstance(sessions, list)
assert len(sessions) == 0
@pytest.mark.api_key_required
async def test_get_sessions_empty_database(client: AsyncClient, logged_in_headers):
"""Test getting sessions when no messages exist in database."""
response = await client.get("api/v1/monitor/messages/sessions", headers=logged_in_headers)
assert response.status_code == 200, response.text
sessions = response.json()
assert isinstance(sessions, list)
assert len(sessions) == 0
@pytest.mark.api_key_required
async def test_get_sessions_invalid_flow_id_format(client: AsyncClient, logged_in_headers):
"""Test getting sessions with invalid flow_id format returns 422."""
response = await client.get(
"api/v1/monitor/messages/sessions", params={"flow_id": "invalid-uuid"}, headers=logged_in_headers
)
assert response.status_code == 422, response.text
assert "detail" in response.json()

View file

@ -12,7 +12,7 @@ export const useDeleteMessages: useMutationFunctionType<
undefined,
DeleteMessagesParams
> = (options?) => {
const { mutate } = UseRequestProcessor();
const { mutate, queryClient } = UseRequestProcessor();
const deleteMessage = async ({ ids }: DeleteMessagesParams): Promise<any> => {
const response = await api.delete(`${getURL("MESSAGES")}`, {
@ -26,7 +26,15 @@ export const useDeleteMessages: useMutationFunctionType<
DeleteMessagesParams,
any,
DeleteMessagesParams
> = mutate(["useDeleteMessages"], deleteMessage, options);
> = mutate(["useDeleteMessages"], deleteMessage, {
...options,
onSettled: (data, error, variables, context) => {
queryClient.invalidateQueries({
queryKey: ["useGetSessionsFromFlowQuery"],
});
options?.onSettled?.(data, error, variables, context);
},
});
return mutation;
};

View file

@ -0,0 +1,41 @@
import { useMutationFunctionType } from "@/types/api";
import { UseMutationResult } from "@tanstack/react-query";
import { api } from "../../api";
import { getURL } from "../../helpers/constants";
import { UseRequestProcessor } from "../../services/request-processor";
interface DeleteSessionParams {
sessionId: string;
}
export const useDeleteSession: useMutationFunctionType<
undefined,
DeleteSessionParams
> = (options?) => {
const { mutate, queryClient } = UseRequestProcessor();
const deleteSession = async ({
sessionId,
}: DeleteSessionParams): Promise<any> => {
const response = await api.delete(
`${getURL("MESSAGES")}/session/${sessionId}`,
);
return response.data;
};
const mutation: UseMutationResult<
DeleteSessionParams,
any,
DeleteSessionParams
> = mutate(["useDeleteSession"], deleteSession, {
...options,
onSettled: (data, error, variables, context) => {
queryClient.invalidateQueries({
queryKey: ["useGetSessionsFromFlowQuery"],
});
options?.onSettled?.(data, error, variables, context);
},
});
return mutation;
};

View file

@ -2,7 +2,10 @@ import { useMessagesStore } from "@/stores/messagesStore";
import { UseMutationResult } from "@tanstack/react-query";
import { ColDef, ColGroupDef } from "ag-grid-community";
import { useEffect, useRef } from "react";
import { extractColumnsFromRows } from "../../../../utils/utils";
import {
extractColumnsFromRows,
prepareSessionIdForAPI,
} from "../../../../utils/utils";
import { api } from "../../api";
import { getURL } from "../../helpers/constants";
import { UseRequestProcessor } from "../../services/request-processor";
@ -109,7 +112,14 @@ export const useGetMessagesPollingMutation = (
}
if (params) {
config["params"] = { ...config["params"], ...params };
// Process params to ensure session_id is properly encoded
const processedParams = { ...params } as any;
if (processedParams.session_id) {
processedParams.session_id = prepareSessionIdForAPI(
processedParams.session_id,
);
}
config["params"] = { ...config["params"], ...processedParams };
}
const data = await api.get<any>(`${getURL("MESSAGES")}`, config);

View file

@ -3,7 +3,10 @@ import { useMessagesStore } from "@/stores/messagesStore";
import { keepPreviousData } from "@tanstack/react-query";
import { ColDef, ColGroupDef } from "ag-grid-community";
import { useQueryFunctionType } from "../../../../types/api";
import { extractColumnsFromRows } from "../../../../utils/utils";
import {
extractColumnsFromRows,
prepareSessionIdForAPI,
} from "../../../../utils/utils";
import { api } from "../../api";
import { getURL } from "../../helpers/constants";
import { UseRequestProcessor } from "../../services/request-processor";
@ -33,7 +36,14 @@ export const useGetMessagesQuery: useQueryFunctionType<
config["params"] = { flow_id: id };
}
if (params) {
config["params"] = { ...config["params"], ...params };
// Process params to ensure session_id is properly encoded
const processedParams = { ...params } as any;
if (processedParams.session_id) {
processedParams.session_id = prepareSessionIdForAPI(
processedParams.session_id,
);
}
config["params"] = { ...config["params"], ...processedParams };
}
if (!isPlaygroundPage) {
return await api.get<any>(`${getURL("MESSAGES")}`, config);

View file

@ -0,0 +1,66 @@
import useFlowStore from "@/stores/flowStore";
import { keepPreviousData } from "@tanstack/react-query";
import { useQueryFunctionType } from "../../../../types/api";
import { api } from "../../api";
import { getURL } from "../../helpers/constants";
import { UseRequestProcessor } from "../../services/request-processor";
interface SessionsQueryParams {
id?: string;
}
interface SessionsResponse {
sessions: string[];
}
export const useGetSessionsFromFlowQuery: useQueryFunctionType<
SessionsQueryParams,
SessionsResponse
> = ({ id }, options) => {
const { query } = UseRequestProcessor();
const getSessionsFn = async (id?: string) => {
const isPlaygroundPage = useFlowStore.getState().playgroundPage;
const config = {};
if (id) {
config["params"] = { flow_id: id };
}
if (!isPlaygroundPage) {
return await api.get<string[]>(`${getURL("MESSAGES")}/sessions`, config);
} else {
// For playground mode, get sessions from sessionStorage
const data = JSON.parse(window.sessionStorage.getItem(id ?? "") || "[]");
// Extract unique session IDs from stored messages
const sessionIdsSet = new Set(
data.map((msg: any) => msg.session_id).filter(Boolean),
);
const sessionIds = Array.from(sessionIdsSet);
// Always include the flow ID as the default session if it's not already present
if (id && !sessionIds.includes(id)) {
sessionIds.unshift(id);
}
return {
data: sessionIds,
};
}
};
const responseFn = async () => {
const response = await getSessionsFn(id);
return { sessions: response.data };
};
const queryResult = query(
["useGetSessionsFromFlowQuery", { id }],
responseFn,
{
placeholderData: keepPreviousData,
...options,
},
);
return queryResult;
};

View file

@ -48,10 +48,9 @@ export const useUpdateSessionName: useMutationFunctionType<
const mutation: UseMutationResult<Message[], any, UpdateSessionParams> =
mutate(["useUpdateSessionName"], updateSessionApi, {
...options,
onSettled: (data, variables, context) => {
// Invalidate and refetch relevant queries
queryClient.refetchQueries({
queryKey: ["useGetMessagesQuery"],
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ["useGetSessionsFromFlowQuery"],
});
},
});

View file

@ -1,4 +1,4 @@
import IOModal from "@/modals/IOModal/new-modal";
import IOModal from "@/modals/IOModal/playground-modal";
import { IOModalPropsType } from "@/types/components";
export function CustomIOModal({

View file

@ -26,6 +26,7 @@ export default function SessionSelector({
selectedView,
setSelectedView,
playgroundPage,
setActiveSession,
}: {
deleteSession: (session: string) => void;
session: string;
@ -36,6 +37,7 @@ export default function SessionSelector({
selectedView?: { type: string; id: string };
setSelectedView: (view: { type: string; id: string } | undefined) => void;
playgroundPage: boolean;
setActiveSession: (session: string) => void;
}) {
const clientId = useUtilityStore((state) => state.clientId);
let realFlowId = useFlowsManagerStore((state) => state.currentFlowId);
@ -46,6 +48,9 @@ export default function SessionSelector({
const [editedSession, setEditedSession] = useState(session);
const { mutate: updateSessionName } = useUpdateSessionName();
const inputRef = useRef<HTMLInputElement>(null);
const setNewChatOnPlayground = useFlowStore(
(state) => state.setNewChatOnPlayground,
);
useEffect(() => {
setEditedSession(session);
@ -68,13 +73,13 @@ export default function SessionSelector({
{
onSuccess: () => {
if (isVisible) {
updateVisibleSession(editedSession);
updateVisibleSession(editedSession.trim());
}
if (
selectedView?.type === "Session" &&
selectedView?.id === session
) {
setSelectedView({ type: "Session", id: editedSession });
setSelectedView({ type: "Session", id: editedSession.trim() });
}
},
},

View file

@ -0,0 +1,11 @@
export const createNewSessionName = () => {
return `Session ${new Date().toLocaleString("en-US", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
second: "2-digit",
timeZone: "UTC",
})}`;
};

View file

@ -1,5 +1,6 @@
import ShadTooltip from "@/components/common/shadTooltipComponent";
import { Button } from "@/components/ui/button";
import useFlowStore from "@/stores/flowStore";
import { useVoiceStore } from "@/stores/voiceStore";
import IconComponent from "../../../components/common/genericIconComponent";
import { SidebarOpenViewProps } from "../types/sidebar-open-view";
@ -13,11 +14,16 @@ export const SidebarOpenView = ({
visibleSession,
selectedViewField,
playgroundPage,
setActiveSession,
}: SidebarOpenViewProps) => {
const setNewSessionCloseVoiceAssistant = useVoiceStore(
(state) => state.setNewSessionCloseVoiceAssistant,
);
const setNewChatOnPlayground = useFlowStore(
(state) => state.setNewChatOnPlayground,
);
return (
<>
<div className="flex flex-col pl-3">
@ -40,6 +46,7 @@ export const SidebarOpenView = ({
setvisibleSession(undefined);
setSelectedViewField(undefined);
setNewSessionCloseVoiceAssistant(true);
setNewChatOnPlayground(true);
}}
>
<IconComponent
@ -78,6 +85,9 @@ export const SidebarOpenView = ({
type: "Session",
});
}}
setActiveSession={(session) => {
setActiveSession(session);
}}
/>
))}
</div>

View file

@ -1,18 +1,18 @@
//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 { useDeleteSession } from "@/controllers/API/queries/messages/use-delete-sessions";
import { useGetSessionsFromFlowQuery } from "@/controllers/API/queries/messages/use-get-sessions-from-flow";
import { ENABLE_PUBLISH } from "@/customization/feature-flags";
import { track } from "@/customization/utils/analytics";
import { customOpenNewTab } from "@/customization/utils/custom-open-new-tab";
import { LangflowButtonRedirectTarget } from "@/customization/utils/urls";
import { useUtilityStore } from "@/stores/utilityStore";
import { swatchColors } from "@/utils/styleUtils";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { v5 as uuidv5 } from "uuid";
import { useShallow } from "zustand/react/shallow";
import LangflowLogoColor from "../../assets/LangflowLogoColor.svg?react";
@ -27,6 +27,7 @@ import { IOModalPropsType } from "../../types/components";
import { cn, getNumberFromString } from "../../utils/utils";
import BaseModal from "../baseModal";
import { ChatViewWrapper } from "./components/chat-view-wrapper";
import { createNewSessionName } from "./components/chatView/chatInput/components/voice-assistant/helpers/create-new-session-name";
import { SelectedViewField } from "./components/selected-view-field";
import { SidebarOpenView } from "./components/sidebar-open-view";
@ -46,6 +47,13 @@ export default function IOModal({
const buildFlow = useFlowStore((state) => state.buildFlow);
const setIsBuilding = useFlowStore((state) => state.setIsBuilding);
const isBuilding = useFlowStore((state) => state.isBuilding);
const newChatOnPlayground = useFlowStore(
(state) => state.newChatOnPlayground,
);
const setNewChatOnPlayground = useFlowStore(
(state) => state.setNewChatOnPlayground,
);
const { flowIcon, flowId, flowGradient, flowName } = useFlowStore(
useShallow((state) => ({
flowIcon: state.currentFlow?.icon,
@ -76,12 +84,36 @@ export default function IOModal({
: realFlowId;
const [sidebarOpen, setSidebarOpen] = useState(true);
const { mutate: deleteSessionFunction } = useDeleteMessages();
const { mutate: deleteMessagesFunction } = useDeleteMessages();
const { mutate: deleteSessionFunction } = useDeleteSession();
const [visibleSession, setvisibleSession] = useState<string | undefined>(
currentFlowId,
);
const PlaygroundTitle = playgroundPage && flowName ? flowName : "Playground";
const {
data: sessionsFromDb,
isLoading: sessionsLoading,
refetch: refetchSessions,
} = useGetSessionsFromFlowQuery(
{
id: currentFlowId,
},
{ enabled: open },
);
useEffect(() => {
if (sessionsFromDb && !sessionsLoading) {
const sessions = [...sessionsFromDb.sessions];
// Always include the currentFlowId as the default session if it's not already present
if (!sessions.includes(currentFlowId)) {
sessions.unshift(currentFlowId);
}
setSessions(sessions);
}
}, [sessionsFromDb, sessionsLoading, currentFlowId]);
useEffect(() => {
setIOModalOpen(open);
return () => {
@ -90,25 +122,45 @@ export default function IOModal({
}, [open]);
function handleDeleteSession(session_id: string) {
// Update UI optimistically
if (visibleSession === session_id) {
const remainingSessions = sessions.filter((s) => s !== session_id);
if (remainingSessions.length > 0) {
setvisibleSession(remainingSessions[0]);
} else {
setvisibleSession(currentFlowId);
}
}
// Delete the session (which will delete all associated messages on the backend)
deleteSessionFunction(
{
ids: messages
.filter((msg) => msg.session_id === session_id)
.map((msg) => msg.id),
},
{ sessionId: session_id },
{
onSuccess: () => {
// Remove the session from local state
deleteSession(session_id);
// Remove all messages for this session from local state
const messageIdsToRemove = messages
.filter((msg) => msg.session_id === session_id)
.map((msg) => msg.id);
if (messageIdsToRemove.length > 0) {
removeMessages(messageIdsToRemove);
}
setSuccessData({
title: "Session deleted successfully.",
});
deleteSession(session_id);
if (visibleSession === session_id) {
setvisibleSession(undefined);
}
},
onError: () => {
// Revert optimistic UI update on error
if (visibleSession !== session_id) {
setvisibleSession(session_id);
}
setErrorData({
title: "Error deleting Session.",
title: "Error deleting session.",
});
},
},
@ -132,27 +184,24 @@ export default function IOModal({
>(startView());
const messages = useMessagesStore((state) => state.messages);
const [sessions, setSessions] = useState<string[]>(
Array.from(
new Set(
messages
.filter((message) => message.flow_id === currentFlowId)
.map((message) => message.session_id),
),
),
);
const removeMessages = useMessagesStore((state) => state.removeMessages);
const [sessions, setSessions] = useState<string[]>([]);
const [sessionId, setSessionId] = useState<string>(currentFlowId);
const setCurrentSessionId = useUtilityStore(
(state) => state.setCurrentSessionId,
);
const { isFetched: messagesFetched } = useGetMessagesQuery(
{
mode: "union",
id: currentFlowId,
},
{ enabled: open },
);
const { isFetched: messagesFetched, refetch: refetchMessages } =
useGetMessagesQuery(
{
mode: "union",
id: currentFlowId,
params: {
session_id: visibleSession,
},
},
{ enabled: open },
);
const chatValue = useUtilityStore((state) => state.chatValueStore);
const setChatValue = useUtilityStore((state) => state.setChatValueStore);
@ -185,28 +234,28 @@ export default function IOModal({
);
useEffect(() => {
const sessions = new Set<string>();
messages
.filter((message) => message.flow_id === currentFlowId)
.forEach((row) => {
sessions.add(row.session_id);
});
setSessions((prev) => {
if (prev.length < Array.from(sessions).length) {
// set the new session as visible
setvisibleSession(
Array.from(sessions)[Array.from(sessions).length - 1],
);
}
return Array.from(sessions);
});
if (newChatOnPlayground && !sessionsLoading) {
const handleRefetchAndSetSession = async () => {
try {
const result = await refetchSessions();
if (result.data?.sessions && result.data.sessions.length > 0) {
setvisibleSession(
result.data.sessions[result.data.sessions.length - 1],
);
}
} catch (error) {
console.error("Error refetching sessions:", error);
}
};
handleRefetchAndSetSession();
setNewChatOnPlayground(false);
}
}, [messages]);
useEffect(() => {
if (!visibleSession) {
setSessionId(
`Session ${new Date().toLocaleString("en-US", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit", hour12: false, second: "2-digit", timeZone: "UTC" })}`,
);
setSessionId(createNewSessionName());
setCurrentSessionId(currentFlowId);
} else if (visibleSession) {
setSessionId(visibleSession);
@ -271,6 +320,35 @@ export default function IOModal({
: getNumberFromString(flowGradient ?? flowId ?? "")) %
swatchColors.length;
const setActiveSession = (session: string) => {
setvisibleSession((prev) => {
if (prev === session) {
return undefined;
}
return session;
});
};
const [hasInitialized, setHasInitialized] = useState(false);
const prevVisibleSessionRef = useRef<string | undefined>(visibleSession);
useEffect(() => {
if (!hasInitialized) {
setHasInitialized(true);
prevVisibleSessionRef.current = visibleSession;
return;
}
if (
open &&
visibleSession &&
prevVisibleSessionRef.current !== visibleSession
) {
refetchMessages();
}
prevVisibleSessionRef.current = visibleSession;
}, [visibleSession]);
return (
<BaseModal
open={open}
@ -336,7 +414,7 @@ export default function IOModal({
</Button>
</ShadTooltip>
</div>
{sidebarOpen && (
{sidebarOpen && !sessionsLoading && (
<SidebarOpenView
sessions={sessions}
setSelectedViewField={setSelectedViewField}
@ -345,6 +423,7 @@ export default function IOModal({
visibleSession={visibleSession}
selectedViewField={selectedViewField}
playgroundPage={!!playgroundPage}
setActiveSession={setActiveSession}
/>
)}
{sidebarOpen && showPublishOptions && (
@ -383,7 +462,7 @@ export default function IOModal({
</div>
)}
<div className="flex h-full min-w-96 flex-grow bg-background">
{selectedViewField && (
{selectedViewField && !sessionsLoading && (
<SelectedViewField
selectedViewField={selectedViewField}
setSelectedViewField={setSelectedViewField}

View file

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

View file

@ -1061,6 +1061,10 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
);
set({ dismissedNodes: newDismissedNodes });
},
setNewChatOnPlayground: (newChat: boolean) => {
set({ newChatOnPlayground: newChat });
},
newChatOnPlayground: false,
}));
export default useFlowStore;

View file

@ -285,4 +285,6 @@ export type FlowStoreType = {
setCurrentBuildingNodeId: (nodeIds: string[] | undefined) => void;
clearEdgesRunningByNodes: () => Promise<void>;
updateToolMode: (nodeId: string, toolMode: boolean) => void;
newChatOnPlayground: boolean;
setNewChatOnPlayground: (newChat: boolean) => void;
};

View file

@ -902,3 +902,93 @@ export function getOS() {
return os;
}
/**
* Encodes a session ID for safe URL transmission
* Handles both UUID format and date-time format session IDs
* @param {string} session_id - The session ID to encode
* @returns {string} The URL-encoded session ID
*/
export function encodeSessionId(session_id: string): string {
if (!session_id) return "";
// Use encodeURIComponent to properly encode spaces, commas, colons, etc.
return encodeURIComponent(session_id);
}
/**
* Decodes a session ID from URL encoding
* @param {string} encoded_session_id - The URL-encoded session ID
* @returns {string} The decoded session ID
*/
export function decodeSessionId(encoded_session_id: string): string {
if (!encoded_session_id) return "";
try {
return decodeURIComponent(encoded_session_id);
} catch (error) {
console.warn("Failed to decode session ID:", encoded_session_id, error);
return encoded_session_id; // Return as-is if decoding fails
}
}
/**
* Validates if a string is a valid UUID format
* @param {string} str - The string to validate
* @returns {boolean} True if the string is a valid UUID format
*/
export function isUUID(str: string): boolean {
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidRegex.test(str);
}
/**
* Validates if a string is a date-time session format
* @param {string} str - The string to validate
* @returns {boolean} True if the string appears to be a date-time session format
*/
export function isDateTimeSession(str: string): boolean {
// Check for patterns like "Session Jun 16, 15:44:08" or similar
const dateTimeSessionRegex =
/^Session\s+\w{3}\s+\d{1,2},\s+\d{2}:\d{2}:\d{2}$/;
return dateTimeSessionRegex.test(str);
}
/**
* Formats and normalizes session IDs for consistent handling
* Handles both UUID format and date-time format session IDs
* @param {string} session_id - The session ID to format
* @returns {string} The formatted session ID
*/
export function sessionIdFormatted(session_id: string): string {
if (!session_id) return "";
// Decode if it appears to be URL encoded
let decodedId = session_id;
if (session_id.includes("%") || session_id.includes("+")) {
decodedId = decodeSessionId(session_id);
}
// If it's a UUID, return as-is (already in good format)
if (isUUID(decodedId)) {
return decodedId;
}
// If it's a date-time session, return as-is
if (isDateTimeSession(decodedId)) {
return decodedId;
}
// For any other format, return as-is but ensure it's properly trimmed
return decodedId.trim();
}
/**
* Safely prepares a session ID for API requests
* This function should be used when adding session_id to API parameters
* @param {string} session_id - The session ID to prepare
* @returns {string} The properly encoded session ID for API use
*/
export function prepareSessionIdForAPI(session_id: string): string {
const formatted = sessionIdFormatted(session_id);
return encodeSessionId(formatted);
}