diff --git a/pyproject.toml b/pyproject.toml index 29c67dc0ac64..33df6d8b82e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -232,7 +232,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 diff --git a/src/backend/base/langflow/api/v1/monitor.py b/src/backend/base/langflow/api/v1/monitor.py index 6658c1cb5a8c..a98040070ea9 100644 --- a/src/backend/base/langflow/api/v1/monitor.py +++ b/src/backend/base/langflow/api/v1/monitor.py @@ -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: diff --git a/src/backend/tests/unit/test_messages_endpoints.py b/src/backend/tests/unit/test_messages_endpoints.py index 61cd6f133743..9b72c90ceed2 100644 --- a/src/backend/tests/unit/test_messages_endpoints.py +++ b/src/backend/tests/unit/test_messages_endpoints.py @@ -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 diff --git a/src/backend/tests/unit/test_session_endpoint.py b/src/backend/tests/unit/test_session_endpoint.py new file mode 100644 index 000000000000..ca20a2b09466 --- /dev/null +++ b/src/backend/tests/unit/test_session_endpoint.py @@ -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() diff --git a/src/frontend/src/controllers/API/queries/messages/use-delete-messages.ts b/src/frontend/src/controllers/API/queries/messages/use-delete-messages.ts index 1f577f072eb8..ad5bcbffd39d 100644 --- a/src/frontend/src/controllers/API/queries/messages/use-delete-messages.ts +++ b/src/frontend/src/controllers/API/queries/messages/use-delete-messages.ts @@ -12,7 +12,7 @@ export const useDeleteMessages: useMutationFunctionType< undefined, DeleteMessagesParams > = (options?) => { - const { mutate } = UseRequestProcessor(); + const { mutate, queryClient } = UseRequestProcessor(); const deleteMessage = async ({ ids }: DeleteMessagesParams): Promise => { 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; }; diff --git a/src/frontend/src/controllers/API/queries/messages/use-delete-sessions.ts b/src/frontend/src/controllers/API/queries/messages/use-delete-sessions.ts new file mode 100644 index 000000000000..8f778a662e82 --- /dev/null +++ b/src/frontend/src/controllers/API/queries/messages/use-delete-sessions.ts @@ -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 => { + 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; +}; diff --git a/src/frontend/src/controllers/API/queries/messages/use-get-messages-polling.ts b/src/frontend/src/controllers/API/queries/messages/use-get-messages-polling.ts index 6fca4ef414d1..6e78fd333279 100644 --- a/src/frontend/src/controllers/API/queries/messages/use-get-messages-polling.ts +++ b/src/frontend/src/controllers/API/queries/messages/use-get-messages-polling.ts @@ -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(`${getURL("MESSAGES")}`, config); diff --git a/src/frontend/src/controllers/API/queries/messages/use-get-messages.ts b/src/frontend/src/controllers/API/queries/messages/use-get-messages.ts index 52d5408fde75..07a0497584f9 100644 --- a/src/frontend/src/controllers/API/queries/messages/use-get-messages.ts +++ b/src/frontend/src/controllers/API/queries/messages/use-get-messages.ts @@ -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(`${getURL("MESSAGES")}`, config); diff --git a/src/frontend/src/controllers/API/queries/messages/use-get-sessions-from-flow.ts b/src/frontend/src/controllers/API/queries/messages/use-get-sessions-from-flow.ts new file mode 100644 index 000000000000..f203f2f1c9af --- /dev/null +++ b/src/frontend/src/controllers/API/queries/messages/use-get-sessions-from-flow.ts @@ -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(`${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; +}; diff --git a/src/frontend/src/controllers/API/queries/messages/use-rename-session.ts b/src/frontend/src/controllers/API/queries/messages/use-rename-session.ts index c4cc5e1427cf..f9df9fa0cca9 100644 --- a/src/frontend/src/controllers/API/queries/messages/use-rename-session.ts +++ b/src/frontend/src/controllers/API/queries/messages/use-rename-session.ts @@ -48,10 +48,9 @@ export const useUpdateSessionName: useMutationFunctionType< const mutation: UseMutationResult = mutate(["useUpdateSessionName"], updateSessionApi, { ...options, - onSettled: (data, variables, context) => { - // Invalidate and refetch relevant queries - queryClient.refetchQueries({ - queryKey: ["useGetMessagesQuery"], + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: ["useGetSessionsFromFlowQuery"], }); }, }); diff --git a/src/frontend/src/customization/components/custom-new-modal.tsx b/src/frontend/src/customization/components/custom-new-modal.tsx index 9c95cd2703b2..781baf143ab4 100644 --- a/src/frontend/src/customization/components/custom-new-modal.tsx +++ b/src/frontend/src/customization/components/custom-new-modal.tsx @@ -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({ diff --git a/src/frontend/src/modals/IOModal/components/IOFieldView/components/session-selector.tsx b/src/frontend/src/modals/IOModal/components/IOFieldView/components/session-selector.tsx index 0d379f010030..c11369a12ed2 100644 --- a/src/frontend/src/modals/IOModal/components/IOFieldView/components/session-selector.tsx +++ b/src/frontend/src/modals/IOModal/components/IOFieldView/components/session-selector.tsx @@ -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(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() }); } }, }, diff --git a/src/frontend/src/modals/IOModal/components/chatView/chatInput/components/voice-assistant/helpers/create-new-session-name.ts b/src/frontend/src/modals/IOModal/components/chatView/chatInput/components/voice-assistant/helpers/create-new-session-name.ts new file mode 100644 index 000000000000..933c5e5ecf2b --- /dev/null +++ b/src/frontend/src/modals/IOModal/components/chatView/chatInput/components/voice-assistant/helpers/create-new-session-name.ts @@ -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", + })}`; +}; diff --git a/src/frontend/src/modals/IOModal/components/sidebar-open-view.tsx b/src/frontend/src/modals/IOModal/components/sidebar-open-view.tsx index ef036a23608a..ecc7743f688d 100644 --- a/src/frontend/src/modals/IOModal/components/sidebar-open-view.tsx +++ b/src/frontend/src/modals/IOModal/components/sidebar-open-view.tsx @@ -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 ( <>
@@ -40,6 +46,7 @@ export const SidebarOpenView = ({ setvisibleSession(undefined); setSelectedViewField(undefined); setNewSessionCloseVoiceAssistant(true); + setNewChatOnPlayground(true); }} > { + setActiveSession(session); + }} /> ))}
diff --git a/src/frontend/src/modals/IOModal/new-modal.tsx b/src/frontend/src/modals/IOModal/playground-modal.tsx similarity index 76% rename from src/frontend/src/modals/IOModal/new-modal.tsx rename to src/frontend/src/modals/IOModal/playground-modal.tsx index 4e57b2e62b37..6d2b941f58a6 100644 --- a/src/frontend/src/modals/IOModal/new-modal.tsx +++ b/src/frontend/src/modals/IOModal/playground-modal.tsx @@ -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( 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( - 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([]); const [sessionId, setSessionId] = useState(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(); - 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(visibleSession); + + useEffect(() => { + if (!hasInitialized) { + setHasInitialized(true); + prevVisibleSessionRef.current = visibleSession; + return; + } + if ( + open && + visibleSession && + prevVisibleSessionRef.current !== visibleSession + ) { + refetchMessages(); + } + + prevVisibleSessionRef.current = visibleSession; + }, [visibleSession]); + return ( - {sidebarOpen && ( + {sidebarOpen && !sessionsLoading && ( )} {sidebarOpen && showPublishOptions && ( @@ -383,7 +462,7 @@ export default function IOModal({ )}
- {selectedViewField && ( + {selectedViewField && !sessionsLoading && ( void; }; diff --git a/src/frontend/src/stores/flowStore.ts b/src/frontend/src/stores/flowStore.ts index 1dacfa3fcfdc..f52a437a6a9e 100644 --- a/src/frontend/src/stores/flowStore.ts +++ b/src/frontend/src/stores/flowStore.ts @@ -1061,6 +1061,10 @@ const useFlowStore = create((set, get) => ({ ); set({ dismissedNodes: newDismissedNodes }); }, + setNewChatOnPlayground: (newChat: boolean) => { + set({ newChatOnPlayground: newChat }); + }, + newChatOnPlayground: false, })); export default useFlowStore; diff --git a/src/frontend/src/types/zustand/flow/index.ts b/src/frontend/src/types/zustand/flow/index.ts index 933a5e79dffb..1ce0907f4e10 100644 --- a/src/frontend/src/types/zustand/flow/index.ts +++ b/src/frontend/src/types/zustand/flow/index.ts @@ -285,4 +285,6 @@ export type FlowStoreType = { setCurrentBuildingNodeId: (nodeIds: string[] | undefined) => void; clearEdgesRunningByNodes: () => Promise; updateToolMode: (nodeId: string, toolMode: boolean) => void; + newChatOnPlayground: boolean; + setNewChatOnPlayground: (newChat: boolean) => void; }; diff --git a/src/frontend/src/utils/utils.ts b/src/frontend/src/utils/utils.ts index c002a09d565a..f21d38630ad1 100644 --- a/src/frontend/src/utils/utils.ts +++ b/src/frontend/src/utils/utils.ts @@ -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); +}