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 <gabriel@langflow.org> Co-authored-by: Edwin Jose <edwin.jose@datastax.com>
This commit is contained in:
parent
5f900c9296
commit
be1caf2b61
8 changed files with 139 additions and 65 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
13
src/frontend/package-lock.json
generated
13
src/frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
<div className="min-h-8 w-full">
|
||||
{editMessage ? (
|
||||
<EditMessageField
|
||||
key={`edit-message-${chat.id}`}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { useUtilityStore } from "@/stores/utilityStore";
|
||||
import { ChatMessageType, ChatType } from "@/types/chat";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface ChatScrollAnchorProps {
|
||||
trackVisibility: ChatMessageType;
|
||||
canScroll: boolean;
|
||||
}
|
||||
|
||||
export function ChatScrollAnchor({
|
||||
trackVisibility,
|
||||
canScroll,
|
||||
}: ChatScrollAnchorProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(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 <div ref={scrollRef} className="h-px w-full" />;
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -171,6 +170,44 @@ export default function ChatView({
|
|||
(state) => state.isVoiceAssistantActive,
|
||||
);
|
||||
|
||||
const [customElement, setCustomElement] = useState<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesRef.current) {
|
||||
setCustomElement(messagesRef.current);
|
||||
}
|
||||
}, [messagesRef]);
|
||||
|
||||
const { scrollDir } = useDetectScroll({
|
||||
target: customElement,
|
||||
axis: Axis.Y,
|
||||
thr: 0,
|
||||
});
|
||||
|
||||
const [canScroll, setCanScroll] = useState<boolean>(false);
|
||||
const [scrolledUp, setScrolledUp] = useState<boolean>(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 (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -185,7 +222,11 @@ export default function ChatView({
|
|||
onDragLeave={dragLeave}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<div ref={messagesRef} className="chat-message-div">
|
||||
<div
|
||||
ref={messagesRef}
|
||||
onScroll={handleScroll}
|
||||
className="chat-message-div"
|
||||
>
|
||||
{chatHistory &&
|
||||
(isBuilding || chatHistory?.length > 0 ? (
|
||||
<>
|
||||
|
|
@ -199,6 +240,12 @@ export default function ChatView({
|
|||
playgroundPage={playgroundPage}
|
||||
/>
|
||||
))}
|
||||
{chatHistory?.length > 0 && (
|
||||
<ChatScrollAnchor
|
||||
trackVisibility={chatHistory?.[chatHistory.length - 1]}
|
||||
canScroll={canScroll}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
1
src/frontend/src/vite-env.d.ts
vendored
1
src/frontend/src/vite-env.d.ts
vendored
|
|
@ -1,4 +1,5 @@
|
|||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-svgr/client" />
|
||||
|
||||
declare module "*.svg" {
|
||||
const content: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue