From be1caf2b6159df5c3fc902cc5a1d145214ef175b Mon Sep 17 00:00:00 2001 From: Lucas Oliveira <62335616+lucaseduoli@users.noreply.github.com> Date: Thu, 8 May 2025 16:41:54 -0300 Subject: [PATCH] fix: reorder playground messages, fix scroll behavior (#7928) * updated vite-env to stop svg loading lint errors * added scroll direction package * added new Chat Scroll Anchor * removed scroll handling from chat message * added scroll handling on chat view * removed console.log * Removed validator from table and added on messagebase * removed validator from model * changed to scroll down after error * [autofix.ci] apply automated fixes * fixed not scrolling to bottom * fix constant * refactor: update MessageTable model configuration for validation and type allowance * refactor: update properties type in MessageTable model and adjust validation logic * refactor: update content_blocks type in MessageTable model to allow dict or ContentBlock * Fix playground failing when it's Run Flow --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Carlos Coelho <80289056+carlosrcoelho@users.noreply.github.com> Co-authored-by: Gabriel Luiz Freitas Almeida Co-authored-by: Edwin Jose --- .../custom/custom_component/component.py | 2 +- .../services/database/models/message/model.py | 49 ++++++--------- src/frontend/package-lock.json | 13 ++++ src/frontend/package.json | 1 + .../chatView/chatMessage/chat-message.tsx | 27 +------- .../components/chat-scroll-anchor.tsx | 50 +++++++++++++++ .../chatView/components/chat-view.tsx | 61 ++++++++++++++++--- src/frontend/src/vite-env.d.ts | 1 + 8 files changed, 139 insertions(+), 65 deletions(-) create mode 100644 src/frontend/src/modals/IOModal/components/chatView/components/chat-scroll-anchor.tsx diff --git a/src/backend/base/langflow/custom/custom_component/component.py b/src/backend/base/langflow/custom/custom_component/component.py index 8b6471497..e49f1b646 100644 --- a/src/backend/base/langflow/custom/custom_component/component.py +++ b/src/backend/base/langflow/custom/custom_component/component.py @@ -1311,7 +1311,7 @@ class Component(CustomComponent): async def _send_message_event(self, message: Message, id_: str | None = None, category: str | None = None) -> None: if hasattr(self, "_event_manager") and self._event_manager: - data_dict = message.data.copy() if hasattr(message, "data") else message.model_dump() + data_dict = message.model_dump()["data"] if hasattr(message, "data") else message.model_dump() if id_ and not data_dict.get("id"): data_dict["id"] = id_ category = category or data_dict.get("category", None) diff --git a/src/backend/base/langflow/services/database/models/message/model.py b/src/backend/base/langflow/services/database/models/message/model.py index f0b3fdb77..5ba681a0e 100644 --- a/src/backend/base/langflow/services/database/models/message/model.py +++ b/src/backend/base/langflow/services/database/models/message/model.py @@ -3,6 +3,7 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING, Annotated from uuid import UUID, uuid4 +from litellm import ConfigDict from pydantic import field_serializer, field_validator from sqlalchemy import Text from sqlmodel import JSON, Column, Field, SQLModel @@ -31,11 +32,16 @@ class MessageBase(SQLModel): category: str = Field(default="message") content_blocks: list[ContentBlock] = Field(default_factory=list) - @field_validator("timestamp", mode="before") - @classmethod - def validate_timestamp(cls, value): + @field_serializer("timestamp") + def serialize_timestamp(self, value): + if isinstance(value, datetime): + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.strftime("%Y-%m-%d %H:%M:%S %Z") if isinstance(value, str): - return datetime.fromisoformat(value) + # Make sure the timestamp is in UTC + value = datetime.fromisoformat(value).replace(tzinfo=timezone.utc) + return value.strftime("%Y-%m-%d %H:%M:%S %Z") return value @field_validator("files", mode="before") @@ -110,36 +116,20 @@ class MessageBase(SQLModel): class MessageTable(MessageBase, table=True): # type: ignore[call-arg] + model_config = ConfigDict(validate_assignment=True, arbitrary_types_allowed=True) __tablename__ = "message" id: UUID = Field(default_factory=uuid4, primary_key=True) + flow_id: UUID | None = Field(default=None) files: list[str] = Field(sa_column=Column(JSON)) - properties: Properties = Field(default_factory=lambda: Properties().model_dump(), sa_column=Column(JSON)) # type: ignore[assignment] + properties: dict | Properties = Field(default_factory=lambda: Properties().model_dump(), sa_column=Column(JSON)) # type: ignore[assignment] category: str = Field(sa_column=Column(Text)) - content_blocks: list[ContentBlock] = Field(default_factory=list, sa_column=Column(JSON)) # type: ignore[assignment] + content_blocks: list[dict | ContentBlock] = Field(default_factory=list, sa_column=Column(JSON)) # type: ignore[assignment] # We need to make sure the datetimes have timezone after running session.refresh # because we are losing the timezone information when we save the message to the database # and when we read it back. We use field_validator to make sure the datetimes have timezone # after running session.refresh - @field_validator("timestamp", mode="after") - @classmethod - def validate_timestamp(cls, value): - if isinstance(value, datetime): - return value.replace(tzinfo=timezone.utc) - return value - - @field_serializer("timestamp") - def serialize_timestamp(self, value, _info): - if isinstance(value, datetime): - if value.tzinfo is None: - value = value.replace(tzinfo=timezone.utc) - return value.strftime("%Y-%m-%d %H:%M:%S %Z") - if isinstance(value, str): - # Make sure the timestamp is in UTC - value = datetime.fromisoformat(value).replace(tzinfo=timezone.utc) - return value.strftime("%Y-%m-%d %H:%M:%S %Z") - return value @field_validator("flow_id", mode="before") @classmethod @@ -150,7 +140,7 @@ class MessageTable(MessageBase, table=True): # type: ignore[call-arg] value = UUID(value) return value - @field_validator("properties", "content_blocks") + @field_validator("properties", "content_blocks", mode="before") @classmethod def validate_properties_or_content_blocks(cls, value): if isinstance(value, list): @@ -162,19 +152,16 @@ class MessageTable(MessageBase, table=True): # type: ignore[call-arg] return value @field_serializer("properties", "content_blocks") - def serialize_properties_or_content_blocks(self, value) -> dict | list[dict]: + @classmethod + def serialize_properties_or_content_blocks(cls, value) -> dict | list[dict]: if isinstance(value, list): - return [self.serialize_properties_or_content_blocks(item) for item in value] + return [cls.serialize_properties_or_content_blocks(item) for item in value] if hasattr(value, "model_dump"): return value.model_dump() if isinstance(value, str): return json.loads(value) return value - # Needed for Column(JSON) - class Config: - arbitrary_types_allowed = True - class MessageRead(MessageBase): id: UUID diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index e7fe682a0..ee356ed62 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -27,6 +27,7 @@ "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", + "@smakss/react-scroll-direction": "^4.2.0", "@tabler/icons-react": "^3.6.0", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/line-clamp": "^0.4.4", @@ -4307,6 +4308,18 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@smakss/react-scroll-direction": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smakss/react-scroll-direction/-/react-scroll-direction-4.2.0.tgz", + "integrity": "sha512-Nim3WSq53mvTfXOpYAJMaXTbXD2vL9Zjip87FHRXtb84uLJn83dBkF8/nKENCCvWqL4p4xcdRPVXsX3DhHJbVA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", diff --git a/src/frontend/package.json b/src/frontend/package.json index d22fca642..1bfef6633 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -22,6 +22,7 @@ "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", + "@smakss/react-scroll-direction": "^4.2.0", "@tabler/icons-react": "^3.6.0", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/line-clamp": "^0.4.4", diff --git a/src/frontend/src/modals/IOModal/components/chatView/chatMessage/chat-message.tsx b/src/frontend/src/modals/IOModal/components/chatView/chatMessage/chat-message.tsx index 6f4538545..8da96095b 100644 --- a/src/frontend/src/modals/IOModal/components/chatView/chatMessage/chat-message.tsx +++ b/src/frontend/src/modals/IOModal/components/chatView/chatMessage/chat-message.tsx @@ -5,7 +5,6 @@ import { CustomProfileIcon } from "@/customization/components/custom-profile-ico import { ENABLE_DATASTAX_LANGFLOW } from "@/customization/feature-flags"; import useFlowsManagerStore from "@/stores/flowsManagerStore"; import useFlowStore from "@/stores/flowStore"; -import { useUtilityStore } from "@/stores/utilityStore"; import Convert from "ansi-to-html"; import { useEffect, useRef, useState } from "react"; import Robot from "../../../../../assets/robot.png"; @@ -14,7 +13,6 @@ import IconComponent, { } from "../../../../../components/common/genericIconComponent"; import SanitizedHTMLWrapper from "../../../../../components/common/sanitizedHTMLWrapper"; import { EMPTY_INPUT_SEND_MESSAGE } from "../../../../../constants/constants"; -import useTabVisibility from "../../../../../shared/hooks/use-tab-visibility"; import useAlertStore from "../../../../../stores/alertStore"; import { chatMessagePropsType } from "../../../../../types/components"; import { cn } from "../../../../../utils/utils"; @@ -58,13 +56,6 @@ export default function ChatMessage({ chatMessageRef.current = chatMessage; }, [chat, isBuilding]); - const playgroundScrollBehaves = useUtilityStore( - (state) => state.playgroundScrollBehaves, - ); - const setPlaygroundScrollBehaves = useUtilityStore( - (state) => state.setPlaygroundScrollBehaves, - ); - // The idea now is that chat.stream_url MAY be a URL if we should stream the output of the chat // probably the message is empty when we have a stream_url // what we need is to update the chat_message with the SSE data @@ -119,22 +110,6 @@ export default function ChatMessage({ }; }, []); - const isTabHidden = useTabVisibility(); - - useEffect(() => { - const element = document.getElementById("last-chat-message"); - if (element && isTabHidden) { - if (playgroundScrollBehaves === "instant") { - element.scrollIntoView({ behavior: playgroundScrollBehaves }); - setPlaygroundScrollBehaves("smooth"); - } else { - setTimeout(() => { - element.scrollIntoView({ behavior: playgroundScrollBehaves }); - }, 200); - } - } - }, [lastMessage, chat]); - useEffect(() => { if (chat.category === "error") { // Short delay before showing error to allow for loading animation @@ -374,7 +349,7 @@ export default function ChatMessage({ className="h-8 w-8 animate-pulse" /> ) : ( -
+
{editMessage ? ( (null); + + const playgroundScrollBehaves = useUtilityStore( + (state) => state.playgroundScrollBehaves, + ); + const setPlaygroundScrollBehaves = useUtilityStore( + (state) => state.setPlaygroundScrollBehaves, + ); + + useEffect(() => { + if (canScroll) { + if (!scrollRef.current) return; + + if ( + playgroundScrollBehaves === "instant" || + trackVisibility.category === "error" + ) { + scrollRef.current.scrollIntoView({ + behavior: playgroundScrollBehaves, + }); + setTimeout(() => { + if (!scrollRef.current) return; + scrollRef.current.scrollIntoView({ + behavior: "smooth", + }); + }, 400); + setPlaygroundScrollBehaves("smooth"); + } else { + scrollRef.current.scrollIntoView({ + behavior: playgroundScrollBehaves, + }); + } + } + }, [canScroll, trackVisibility]); + + return
; +} diff --git a/src/frontend/src/modals/IOModal/components/chatView/components/chat-view.tsx b/src/frontend/src/modals/IOModal/components/chatView/components/chat-view.tsx index f2c1620e0..7ab4faeb3 100644 --- a/src/frontend/src/modals/IOModal/components/chatView/components/chat-view.tsx +++ b/src/frontend/src/modals/IOModal/components/chatView/components/chat-view.tsx @@ -6,6 +6,10 @@ import { useMessagesStore } from "@/stores/messagesStore"; import { useUtilityStore } from "@/stores/utilityStore"; import { useVoiceStore } from "@/stores/voiceStore"; import { cn } from "@/utils/utils"; +import useDetectScroll, { + Axis, + Direction, +} from "@smakss/react-scroll-direction"; import { memo, useEffect, useMemo, useRef, useState } from "react"; import { v5 as uuidv5 } from "uuid"; import useTabVisibility from "../../../../../shared/hooks/use-tab-visibility"; @@ -18,6 +22,7 @@ import ChatInput from "../chatInput/chat-input"; import useDragAndDrop from "../chatInput/hooks/use-drag-and-drop"; import { useFileHandler } from "../chatInput/hooks/use-file-handler"; import ChatMessage from "../chatMessage/chat-message"; +import { ChatScrollAnchor } from "./chat-scroll-anchor"; const MemoizedChatMessage = memo(ChatMessage, (prevProps, nextProps) => { return ( @@ -115,12 +120,6 @@ export default function ChatView({ setChatHistory(finalChatHistory); }, [messages, visibleSession]); - useEffect(() => { - if (messagesRef.current) { - messagesRef.current.scrollTop = messagesRef.current.scrollHeight; - } - }, []); - const ref = useRef(null); useEffect(() => { @@ -171,6 +170,44 @@ export default function ChatView({ (state) => state.isVoiceAssistantActive, ); + const [customElement, setCustomElement] = useState(); + + useEffect(() => { + if (messagesRef.current) { + setCustomElement(messagesRef.current); + } + }, [messagesRef]); + + const { scrollDir } = useDetectScroll({ + target: customElement, + axis: Axis.Y, + thr: 0, + }); + + const [canScroll, setCanScroll] = useState(false); + const [scrolledUp, setScrolledUp] = useState(false); + + const handleScroll = () => { + if (!messagesRef.current) return; + + const { scrollTop, scrollHeight, clientHeight } = messagesRef.current; + const atBottom = scrollHeight - clientHeight <= scrollTop + 3; + + if (scrollDir === Direction.Up) { + setCanScroll(false); + setScrolledUp(true); + } else { + if (atBottom || !scrolledUp) { + setCanScroll(true); + } + setScrolledUp(false); + } + }; + + useEffect(() => { + setCanScroll(true); + }, [chatHistory?.length]); + return (
-
+
{chatHistory && (isBuilding || chatHistory?.length > 0 ? ( <> @@ -199,6 +240,12 @@ export default function ChatView({ playgroundPage={playgroundPage} /> ))} + {chatHistory?.length > 0 && ( + + )} ) : ( <> diff --git a/src/frontend/src/vite-env.d.ts b/src/frontend/src/vite-env.d.ts index 18ba6c575..5c28fd4ab 100644 --- a/src/frontend/src/vite-env.d.ts +++ b/src/frontend/src/vite-env.d.ts @@ -1,4 +1,5 @@ /// +/// declare module "*.svg" { const content: string;