feature: add new playground ui (#4193)

* create newmodal.tsx

* Add new icons to styleUtils.ts

* Refactor import path for IOModal in chatComponent/index.tsx

* Refactor import path for IOModal in chatComponent/index.tsx

* Refactor session selector UI in IOFieldView component

* Refactor NodeToolbarComponent to add a minimum width to SelectContent

* Refactor IOModal to use newChatView component for ChatView

* improve html structure to ensure aligment of texts

* Refactor IOModal to improve sidebar behavior and alignment

* add new input

* Add Image icon to nodeIconsLucide in styleUtils.ts

* Refactor uploadFileButton to use Image icon instead of PaperclipIcon

* Refactor chat input layout for improved alignment and behavior

* Refactor buttonSendWrapper component to show a "Stop" button with loading indicator

* add a new TextAreaWrapper component

* add icons and update colors

* Add CornerDownLeft icon to nodeIconsLucide in styleUtils.ts

* Refactor buttonSendWrapper component to use default button state

* create newFilePreview

* Refactor file preview component and update button styling

* Refactor file preview component and update button styling

* fix overflow bug

* Refactor ChatView component to include a lockChat feature and display a flow running message

* [autofix.ci] apply automated fixes

* Refactor ChatView component to fix padding issue

* [autofix.ci] apply automated fixes

* Refactor dependencies in uv.lock to use a more specific version specifier for astra-assistants package

* Refactor ChatView component to import newChatMessage instead of chatMessage

* Refactor IOModal newModal component to conditionally display the session name

* Refactor TextAreaWrapper component to update placeholder text and fix padding issue

* Refactor TextAreaWrapper component to update placeholder text and fix padding issue

* Refactor ChatView component to update lockChat UI and display flow running message

* Refactor ChatView component to update lockChat UI and display flow running message

* Refactor ChatMessage component to update sender name UI and fix message width issue

* Refactor EditMessageButton component to update button UI and add tooltips

* Refactor EditMessageButton component to update button UI and add tooltips

* Refactor EditMessageField component to update UI and add functionality

* update edit message field

* fix group reference

* Refactor CodeTabsComponent to update UI, add functionality, and improve code structure

* Refactor CodeTabsComponent to update UI and improve code structure

* Refactor CodeTabsComponent to simplify and improve UI

* [autofix.ci] apply automated fixes

* Refactor ChatView component to fix UI layout issue

* fix overflow code boundaries problems

* Refactor CSS to update background color variables

* Refactor ChatCodeTabComponent to update UI and improve code structure

* fix broken rebase changes

* [autofix.ci] apply automated fixes

* Refactor EditMessageButton component to remove delete functionality

* Refactor SessionSelector component to update UI and improve code structure

* Refactor SessionSelector component to update UI and improve code structure

* [autofix.ci] apply automated fixes

* refactor session selector to use old code and keep updates on newSessionSelector

* create new button send wrapper

* restore old button send wrapper and update imports

* restore upload file button and create new UploadFileButton

* [autofix.ci] apply automated fixes

* Add feature flag for new IO modal

* Refactor IOModal imports to support feature flag

* update package-lock.json

* [autofix.ci] apply automated fixes

* remove console.log

* Refactor session selector event handlers

* Refactor file deletion in ChatInput component

* [autofix.ci] apply automated fixes

* Refactor file handling in ChatInput component

* [autofix.ci] apply automated fixes

* add user icon to messages

* feat: Add "Run Flow" button to ChatInput component

This commit adds a new button labeled "Run Flow" to the ChatInput component. When clicked, it triggers the sendMessage function with a repeat value of 1. Additionally, if there is no input in the chat, a message is displayed prompting the user to add a Chat Input component to their flow.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
anovazzi1 2024-10-22 15:07:27 -03:00 committed by GitHub
commit 4bb4f02e5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2226 additions and 12 deletions

View file

@ -923,6 +923,7 @@
},
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
"version": "1.3.0",
"extraneous": true,
"inBundle": true,
"license": "MIT",
"engines": {

View file

@ -1,7 +1,9 @@
import { ENABLE_NEW_IO_MODAL } from "@/customization/feature-flags";
import { track } from "@/customization/utils/analytics";
import { useState } from "react";
import { Control } from "react-hook-form";
import IOModal from "../../modals/IOModal";
import IOModalOld from "../../modals/IOModal";
import IOModalNew from "../../modals/IOModal/newModal";
import useAlertStore from "../../stores/alertStore";
import useFlowsManagerStore from "../../stores/flowsManagerStore";
import { FlowType } from "../../types/flow";
@ -22,6 +24,7 @@ import { FormControl, FormField } from "../ui/form";
import Loading from "../ui/loading";
import useDragStart from "./hooks/use-on-drag-start";
import { convertTestName } from "./utils/convert-test-name";
const IOModal = ENABLE_NEW_IO_MODAL ? IOModalNew : IOModalOld;
export default function CollectionCardComponent({
data,

View file

@ -1,10 +1,11 @@
import { ENABLE_API } from "@/customization/feature-flags";
import { ENABLE_API, ENABLE_NEW_IO_MODAL } from "@/customization/feature-flags";
import { track } from "@/customization/utils/analytics";
import { Transition } from "@headlessui/react";
import { useEffect, useMemo, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import IOModal from "../../modals/IOModal";
import ApiModal from "../../modals/apiModal";
import IOModalOld from "../../modals/IOModal";
import IOModalNew from "../../modals/IOModal/newModal";
import ShareModal from "../../modals/shareModal";
import useFlowStore from "../../stores/flowStore";
import { useShortcutsStore } from "../../stores/shortcuts";
@ -12,6 +13,7 @@ import { useStoreStore } from "../../stores/storeStore";
import { classNames, isThereModal } from "../../utils/utils";
import ForwardedIconComponent from "../genericIconComponent";
import { Separator } from "../ui/separator";
const IOModal = ENABLE_NEW_IO_MODAL ? IOModalNew : IOModalOld;
export default function FlowToolbar(): JSX.Element {
const preventDefault = true;

View file

@ -0,0 +1,59 @@
import { useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { useDarkStore } from "../../stores/darkStore";
import IconComponent from "../genericIconComponent";
import { Button } from "../ui/button";
type SimplifiedCodeTabProps = {
code: string;
language: string;
};
export default function SimplifiedCodeTabComponent({
code,
language,
}: SimplifiedCodeTabProps) {
const [isCopied, setIsCopied] = useState<boolean>(false);
const copyToClipboard = () => {
if (!navigator.clipboard || !navigator.clipboard.writeText) {
return;
}
navigator.clipboard.writeText(code).then(() => {
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, 2000);
});
};
return (
<div className="flex w-full flex-col overflow-hidden rounded-md text-left">
<div className="flex w-full items-center justify-between bg-zinc-700 px-4 py-2">
<span className="text-sm font-semibold">{language}</span>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:bg-[#3a3a3a]"
onClick={copyToClipboard}
>
{isCopied ? (
<IconComponent name="Check" className="h-4 w-4" />
) : (
<IconComponent name="Copy" className="h-4 w-4" />
)}
</Button>
</div>
<SyntaxHighlighter
language={language.toLowerCase()}
style={oneDark}
className="!mt-0 h-full w-full overflow-scroll !rounded-b-md !rounded-t-none text-left !custom-scroll"
>
{code}
</SyntaxHighlighter>
</div>
);
}

View file

@ -144,7 +144,7 @@ export default function CodeTabsComponent({
</div>
) : tab.name.toLowerCase() === "tweaks" ? (
<>
<TweaksComponent open={open} />
<TweaksComponent open={open ?? false} />
</>
) : null}
</TabsContent>

View file

@ -1,7 +1,9 @@
import { usePostLikeComponent } from "@/controllers/API/queries/store";
import { ENABLE_NEW_IO_MODAL } from "@/customization/feature-flags";
import { useState } from "react";
import { getComponent } from "../../controllers/API";
import IOModal from "../../modals/IOModal";
import IOModalOld from "../../modals/IOModal";
import IOModalNew from "../../modals/IOModal/newModal";
import useAlertStore from "../../stores/alertStore";
import useFlowsManagerStore from "../../stores/flowsManagerStore";
import { useStoreStore } from "../../stores/storeStore";
@ -25,6 +27,7 @@ import Loading from "../ui/loading";
import useDataEffect from "./hooks/use-data-effect";
import useInstallComponent from "./hooks/use-handle-install";
import { convertTestName } from "./utils/convert-test-name";
const IOModal = ENABLE_NEW_IO_MODAL ? IOModalNew : IOModalOld;
export default function StoreCardComponent({
data,

View file

@ -33,7 +33,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-[14rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"relative z-50 min-w-[11.5rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,

View file

@ -7,5 +7,6 @@ export const ENABLE_BRANDING = true;
export const ENABLE_MVPS = false;
export const ENABLE_CUSTOM_PARAM = false;
export const ENABLE_INTEGRATIONS = false;
export const ENABLE_NEW_IO_MODAL = false;
export const ENABLE_NEW_LOGO = false;
export const ENABLE_DATASTAX_LANGFLOW = false;

View file

@ -0,0 +1,214 @@
import IconComponent from "@/components/genericIconComponent";
import ShadTooltip from "@/components/shadTooltipComponent";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select-custom";
import { useUpdateSessionName } from "@/controllers/API/queries/messages/use-rename-session";
import useFlowStore from "@/stores/flowStore";
import { cn } from "@/utils/utils";
import React, { useEffect, useRef, useState } from "react";
export default function SessionSelector({
deleteSession,
session,
toggleVisibility,
isVisible,
inspectSession,
updateVisibleSession,
selectedView,
setSelectedView,
}: {
deleteSession: (session: string) => void;
session: string;
toggleVisibility: () => void;
isVisible: boolean;
inspectSession: (session: string) => void;
updateVisibleSession: (session: string) => void;
selectedView?: { type: string; id: string };
setSelectedView: (view: { type: string; id: string } | undefined) => void;
}) {
const currentFlowId = useFlowStore((state) => state.currentFlow?.id);
const [isEditing, setIsEditing] = useState(false);
const [editedSession, setEditedSession] = useState(session);
const { mutate: updateSessionName } = useUpdateSessionName();
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setEditedSession(session);
}, [session]);
const handleEditClick = (e?: React.MouseEvent<HTMLDivElement>) => {
e?.stopPropagation();
setIsEditing(true);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEditedSession(e.target.value);
};
const handleConfirm = () => {
setIsEditing(false);
if (editedSession.trim() !== session) {
updateSessionName(
{ old_session_id: session, new_session_id: editedSession.trim() },
{
onSuccess: () => {
if (isVisible) {
updateVisibleSession(editedSession);
}
if (
selectedView?.type === "Session" &&
selectedView?.id === session
) {
setSelectedView({ type: "Session", id: editedSession });
}
},
},
);
}
};
const handleCancel = () => {
setIsEditing(false);
setEditedSession(session);
};
const handleSelectChange = (value: string) => {
switch (value) {
case "rename":
handleEditClick();
break;
case "messageLogs":
inspectSession(session);
break;
case "delete":
deleteSession(session);
break;
}
};
const handleOnBlur = (e: React.FocusEvent<HTMLInputElement>) => {
if (
!e.relatedTarget ||
e.relatedTarget.getAttribute("data-confirm") !== "true"
) {
handleCancel();
}
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
handleConfirm();
}
};
return (
<div
data-testid="session-selector"
onClick={(e) => {
if (isEditing) e.stopPropagation();
else toggleVisibility();
}}
className={cn(
"file-component-accordion-div group cursor-pointer rounded-md hover:bg-muted-foreground/30",
isVisible ? "bg-muted-foreground/15" : "",
)}
>
<div className="flex w-full items-center justify-between gap-2 overflow-hidden px-2 py-1 align-middle">
<div className="flex min-w-0 items-center gap-2">
{isEditing ? (
<div className="flex items-center">
<Input
ref={inputRef}
value={editedSession}
onKeyDown={onKeyDown}
onChange={handleInputChange}
onBlur={handleOnBlur}
autoFocus
className="h-6 flex-grow px-1 py-0"
/>
<button
onClick={handleCancel}
className="hover:text-status-red-hover ml-2 text-status-red"
>
<IconComponent name="X" className="h-4 w-4" />
</button>
<button
onClick={handleConfirm}
data-confirm="true"
className="ml-2 text-green-500 hover:text-green-600"
>
<IconComponent name="Check" className="h-4 w-4" />
</button>
</div>
) : (
<ShadTooltip styleClasses="z-50" content={session}>
<div>
{session === currentFlowId ? "Default Session" : session}
</div>
</ShadTooltip>
)}
</div>
<Select value={""} onValueChange={handleSelectChange}>
<SelectTrigger
onClick={(e) => {
e.stopPropagation();
}}
onFocusCapture={() => {
inputRef.current?.focus();
}}
data-confirm="true"
className={cn(
"h-8 w-fit border-none bg-transparent p-2 focus:ring-0",
isVisible ? "visible" : "invisible group-hover:visible",
)}
>
<IconComponent name="MoreHorizontal" className="h-4 w-4" />
</SelectTrigger>
<SelectContent side="right" align="start" className="p-0">
<SelectItem
value="rename"
className="cursor-pointer px-3 py-2 focus:bg-muted"
>
<div className="flex items-center">
<IconComponent name="SquarePen" className="mr-2 h-4 w-4" />
Rename
</div>
</SelectItem>
<SelectItem
value="messageLogs"
className="cursor-pointer px-3 py-2 focus:bg-muted"
>
<div className="flex w-full items-center justify-between">
<div className="flex items-center">
<IconComponent name="Scroll" className="mr-2 h-4 w-4" />
Message logs
</div>
<IconComponent
name="ArrowUpRight"
className="absolute right-2 h-4 w-4"
/>
</div>
</SelectItem>
<SelectItem
value="delete"
className="cursor-pointer px-3 py-2 focus:bg-muted"
>
<div className="flex items-center text-status-red hover:text-status-red">
<IconComponent name="Trash2" className="mr-2 h-4 w-4" />
Delete
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}

View file

@ -0,0 +1,86 @@
import Loading from "@/components/ui/loading";
import useFlowStore from "@/stores/flowStore";
import IconComponent from "../../../../../../../components/genericIconComponent";
import { Button } from "../../../../../../../components/ui/button";
import { Case } from "../../../../../../../shared/components/caseComponent";
import { FilePreviewType } from "../../../../../../../types/components";
import { classNames } from "../../../../../../../utils/utils";
const BUTTON_STATES = {
NO_INPUT: "bg-high-indigo text-background",
HAS_CHAT_VALUE: "text-primary",
SHOW_STOP: "bg-zinc-400 text-white cursor-pointer",
DEFAULT: "bg-chat-send text-background",
};
type ButtonSendWrapperProps = {
send: () => void;
lockChat: boolean;
noInput: boolean;
chatValue: string;
files: FilePreviewType[];
};
const ButtonSendWrapper = ({
send,
lockChat,
noInput,
chatValue,
files,
}: ButtonSendWrapperProps) => {
const stopBuilding = useFlowStore((state) => state.stopBuilding);
const isBuilding = useFlowStore((state) => state.isBuilding);
const showStopButton = lockChat || files.some((file) => file.loading);
const showPlayButton = !lockChat && noInput;
const showSendButton =
!(lockChat || files.some((file) => file.loading)) && !noInput;
const getButtonState = () => {
if (showStopButton) return BUTTON_STATES.SHOW_STOP;
if (noInput) return BUTTON_STATES.NO_INPUT;
if (chatValue) return BUTTON_STATES.DEFAULT;
return BUTTON_STATES.DEFAULT;
};
const buttonClasses = classNames("form-modal-send-button", getButtonState());
const handleClick = () => {
if (showStopButton && isBuilding) {
stopBuilding();
} else if (!showStopButton) {
send();
}
};
return (
<Button
className={buttonClasses}
disabled={lockChat && !isBuilding}
onClick={handleClick}
unstyled
>
<Case condition={showStopButton}>
<div className="flex items-center gap-2">
Stop
<Loading className="text-black" />
</div>
</Case>
<Case condition={showPlayButton}>
<IconComponent
name="Zap"
className="form-modal-play-icon"
aria-hidden="true"
/>
</Case>
<Case condition={showSendButton}>
<div className="flex items-center gap-2">Send</div>
</Case>
</Button>
);
};
export default ButtonSendWrapper;

View file

@ -0,0 +1,82 @@
import { useEffect } from "react";
import { Textarea } from "../../../../../../../components/ui/textarea";
import { classNames } from "../../../../../../../utils/utils";
const TextAreaWrapper = ({
checkSendingOk,
send,
lockChat,
noInput,
chatValue,
setChatValue,
CHAT_INPUT_PLACEHOLDER,
CHAT_INPUT_PLACEHOLDER_SEND,
inputRef,
setInputFocus,
files,
isDragging,
}) => {
const getPlaceholderText = (
isDragging: boolean,
noInput: boolean,
): string => {
if (isDragging) {
return "Drop here";
} else if (noInput) {
return CHAT_INPUT_PLACEHOLDER;
} else {
return "Send a message...";
}
};
const lockClass = noInput
? "form-modal-no-input bg-input"
: "form-modal-lock-false bg-background";
const fileClass = files.length > 0 ? "!rounded-t-none border-t-0" : "";
const additionalClassNames =
"form-input block w-full border-0 custom-scroll focus:border-ring focus:ring-0 p-0 sm:text-sm";
useEffect(() => {
if (!lockChat && !noInput) {
inputRef.current?.focus();
}
}, [lockChat, noInput]);
return (
<Textarea
data-testid="input-chat-playground"
onFocus={(e) => {
setInputFocus(true);
}}
onBlur={() => setInputFocus(false)}
onKeyDown={(event) => {
if (checkSendingOk(event)) {
send();
}
}}
rows={1}
ref={inputRef}
disabled={lockChat || noInput}
style={{
resize: "none",
bottom: `${inputRef?.current?.scrollHeight}px`,
maxHeight: "150px",
overflow: `${
inputRef.current && inputRef.current.scrollHeight > 150
? "auto"
: "hidden"
}`,
}}
value={chatValue}
onChange={(event): void => {
setChatValue(event.target.value);
}}
className={classNames(lockClass, fileClass, additionalClassNames)}
placeholder={getPlaceholderText(isDragging, noInput)}
/>
);
};
export default TextAreaWrapper;

View file

@ -0,0 +1,33 @@
import ForwardedIconComponent from "../../../../../../../components/genericIconComponent";
import { Button } from "../../../../../../../components/ui/button";
const UploadFileButton = ({
fileInputRef,
handleFileChange,
handleButtonClick,
lockChat,
}) => {
return (
<div>
<input
disabled={lockChat}
type="file"
ref={fileInputRef}
style={{ display: "none" }}
onChange={handleFileChange}
/>
<Button
disabled={lockChat}
className={`rounded-md bg-zinc-500 p-1 font-bold transition-all ${
lockChat ? "cursor-not-allowed" : "hover:text-muted-foreground"
}`}
onClick={handleButtonClick}
unstyled
>
<ForwardedIconComponent name="Image" />
</Button>
</div>
);
};
export default UploadFileButton;

View file

@ -0,0 +1,88 @@
import { INVALID_FILE_SIZE_ALERT } from "@/constants/alerts_constants";
import {
ALLOWED_IMAGE_INPUT_EXTENSIONS,
FS_ERROR_TEXT,
SN_ERROR_TEXT,
} from "@/constants/constants";
import { usePostUploadFile } from "@/controllers/API/queries/files/use-post-upload-file";
import useAlertStore from "@/stores/alertStore";
import { useUtilityStore } from "@/stores/utilityStore";
import { FilePreviewType } from "@/types/components";
import { useState } from "react";
import ShortUniqueId from "short-unique-id";
export const useFileHandler = (currentFlowId: string) => {
const [files, setFiles] = useState<FilePreviewType[]>([]);
const { mutate } = usePostUploadFile();
const { setErrorData } = useAlertStore();
const maxFileSizeUpload = useUtilityStore((state) => state.maxFileSizeUpload);
const handleFiles = (uploadedFiles: FileList) => {
if (uploadedFiles) {
const file = uploadedFiles[0];
const fileExtension = file.name.split(".").pop()?.toLowerCase();
if (file.size > maxFileSizeUpload) {
setErrorData({
title: INVALID_FILE_SIZE_ALERT(maxFileSizeUpload / 1024 / 1024),
});
return;
}
if (
!fileExtension ||
!ALLOWED_IMAGE_INPUT_EXTENSIONS.includes(fileExtension)
) {
console.log("Error uploading file");
setErrorData({
title: "Error uploading file",
list: [FS_ERROR_TEXT, SN_ERROR_TEXT],
});
return;
}
const uid = new ShortUniqueId();
const newId = uid.randomUUID(3);
const type = file.type.split("/")[0];
const blob = file;
setFiles((prevFiles) => [
...prevFiles,
{ file: blob, loading: true, error: false, id: newId, type },
]);
mutate(
{ file: blob, id: currentFlowId },
{
onSuccess: (data) => {
setFiles((prev) => {
const newFiles = [...prev];
const updatedIndex = newFiles.findIndex(
(file) => file.id === newId,
);
newFiles[updatedIndex].loading = false;
newFiles[updatedIndex].path = data.file_path;
return newFiles;
});
},
onError: (error) => {
setFiles((prev) => {
const newFiles = [...prev];
const updatedIndex = newFiles.findIndex(
(file) => file.id === newId,
);
newFiles[updatedIndex].loading = false;
newFiles[updatedIndex].error = true;
return newFiles;
});
setErrorData({
title: "Error uploading file",
list: [error.response?.data?.detail],
});
},
},
);
}
};
return { files, setFiles, handleFiles };
};

View file

@ -158,7 +158,7 @@ export default function ChatInput({
return (
<div className="flex w-full flex-col-reverse">
<div className="relative w-full">
<div className="w-full">
<TextAreaWrapper
checkSendingOk={checkSendingOk}
send={send}

View file

@ -0,0 +1,246 @@
import { Button } from "@/components/ui/button";
import { usePostUploadFile } from "@/controllers/API/queries/files/use-post-upload-file";
import useFileSizeValidator from "@/shared/hooks/use-file-size-validator";
import useAlertStore from "@/stores/alertStore";
import { useEffect, useRef, useState } from "react";
import ShortUniqueId from "short-unique-id";
import {
ALLOWED_IMAGE_INPUT_EXTENSIONS,
CHAT_INPUT_PLACEHOLDER,
CHAT_INPUT_PLACEHOLDER_SEND,
FS_ERROR_TEXT,
SN_ERROR_TEXT,
} from "../../../../../constants/constants";
import useFlowsManagerStore from "../../../../../stores/flowsManagerStore";
import {
ChatInputType,
FilePreviewType,
} from "../../../../../types/components";
import FilePreview from "../filePreviewChat/newFilePreview";
import ButtonSendWrapper from "./components/buttonSendWrapper/newButtonSendWrapper";
import TextAreaWrapper from "./components/textAreaWrapper/newTextAreaWrapper";
import UploadFileButton from "./components/uploadFileButton/newUploadFileButton";
import useAutoResizeTextArea from "./hooks/use-auto-resize-text-area";
import useFocusOnUnlock from "./hooks/use-focus-unlock";
export default function ChatInput({
lockChat,
chatValue,
sendMessage,
setChatValue,
inputRef,
noInput,
files,
setFiles,
isDragging,
}: ChatInputType): JSX.Element {
const currentFlowId = useFlowsManagerStore((state) => state.currentFlowId);
const [inputFocus, setInputFocus] = useState<boolean>(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const setErrorData = useAlertStore((state) => state.setErrorData);
const { validateFileSize } = useFileSizeValidator(setErrorData);
useFocusOnUnlock(lockChat, inputRef);
useAutoResizeTextArea(chatValue, inputRef);
const { mutate } = usePostUploadFile();
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement> | ClipboardEvent,
) => {
let file: File | null = null;
if ("clipboardData" in event) {
const items = event.clipboardData?.items;
if (items) {
for (let i = 0; i < items.length; i++) {
const blob = items[i].getAsFile();
if (blob) {
file = blob;
break;
}
}
}
} else {
const fileInput = event.target as HTMLInputElement;
file = fileInput.files?.[0] ?? null;
}
if (file) {
const fileExtension = file.name.split(".").pop()?.toLowerCase();
if (!validateFileSize(file)) {
return;
}
if (
!fileExtension ||
!ALLOWED_IMAGE_INPUT_EXTENSIONS.includes(fileExtension)
) {
setErrorData({
title: "Error uploading file",
list: [FS_ERROR_TEXT, SN_ERROR_TEXT],
});
return;
}
const uid = new ShortUniqueId();
const id = uid.randomUUID(10);
const type = file.type.split("/")[0];
setFiles((prevFiles) => [
...prevFiles,
{ file, loading: true, error: false, id, type },
]);
mutate(
{ file, id: currentFlowId },
{
onSuccess: (data) => {
setFiles((prev) => {
const newFiles = [...prev];
const updatedIndex = newFiles.findIndex((file) => file.id === id);
newFiles[updatedIndex].loading = false;
newFiles[updatedIndex].path = data.file_path;
return newFiles;
});
},
onError: (error) => {
setFiles((prev) => {
const newFiles = [...prev];
const updatedIndex = newFiles.findIndex((file) => file.id === id);
newFiles[updatedIndex].loading = false;
newFiles[updatedIndex].error = true;
return newFiles;
});
setErrorData({
title: "Error uploading file",
list: [error.response?.data?.detail],
});
},
},
);
}
if ("target" in event && event.target instanceof HTMLInputElement) {
event.target.value = "";
}
};
useEffect(() => {
document.addEventListener("paste", handleFileChange);
return () => {
document.removeEventListener("paste", handleFileChange);
};
}, [handleFileChange, currentFlowId, lockChat]);
const send = () => {
sendMessage({
repeat: 1,
files: files.map((file) => file.path ?? "").filter((file) => file !== ""),
});
setFiles([]);
};
const checkSendingOk = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
return (
event.key === "Enter" &&
!lockChat &&
!event.shiftKey &&
!event.nativeEvent.isComposing
);
};
const classNameFilePreview = `flex w-full items-center gap-2 bg-background py-2 overflow-auto custom-scroll`;
const handleButtonClick = () => {
fileInputRef.current!.click();
};
const handleDeleteFile = (file: FilePreviewType) => {
setFiles((prev: FilePreviewType[]) => prev.filter((f) => f.id !== file.id));
// TODO: delete file on backend
};
if (noInput) {
return (
<div className="flex h-full w-full flex-col items-center justify-center">
<div className="flex flex-col items-center justify-center gap-3 bg-background p-2">
<Button
className="font-semibold"
onClick={() => {
sendMessage({
repeat: 1,
});
}}
>
Run Flow
</Button>
<p className="text-muted-foreground">
Add a{" "}
<a
className="underline underline-offset-4"
target="_blank"
href="https://docs.langflow.org/components-io#chat-input"
>
Chat Input
</a>{" "}
component to your flow to send messages.
</p>
</div>
</div>
);
}
return (
<div className="flex w-full flex-col-reverse">
<div className="flex w-full flex-col rounded-md border border-border p-4">
<TextAreaWrapper
checkSendingOk={checkSendingOk}
send={send}
lockChat={lockChat}
noInput={noInput}
chatValue={chatValue}
setChatValue={setChatValue}
CHAT_INPUT_PLACEHOLDER={CHAT_INPUT_PLACEHOLDER}
CHAT_INPUT_PLACEHOLDER_SEND={CHAT_INPUT_PLACEHOLDER_SEND}
inputRef={inputRef}
setInputFocus={setInputFocus}
files={files}
isDragging={isDragging}
/>
<div className={classNameFilePreview}>
{files.map((file) => (
<FilePreview
error={file.error}
file={file.file}
loading={file.loading}
key={file.id}
onDelete={() => {
handleDeleteFile(file);
}}
/>
))}
</div>
<div className="flex w-full items-end justify-between">
<div className={lockChat ? "cursor-not-allowed" : ""}>
<UploadFileButton
lockChat={lockChat}
fileInputRef={fileInputRef}
handleFileChange={handleFileChange}
handleButtonClick={handleButtonClick}
/>
</div>
<div className="">
<ButtonSendWrapper
send={send}
lockChat={lockChat}
noInput={noInput}
chatValue={chatValue}
files={files}
/>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,67 @@
import IconComponent from "@/components/genericIconComponent";
import ShadTooltip from "@/components/shadTooltipComponent";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select-custom";
import { ButtonHTMLAttributes, useState } from "react";
export function EditMessageButton({
onEdit,
onCopy,
onDelete,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
onEdit: () => void;
onCopy: () => void;
onDelete: () => void;
}) {
const [isCopied, setIsCopied] = useState(false);
const handleCopy = () => {
onCopy();
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000); // Reset after 2 seconds
};
return (
<div className="flex items-center rounded-md border border-border bg-background">
<ShadTooltip styleClasses="z-50" content="Edit message" side="top">
<div>
<Button
variant="ghost"
size="icon"
onClick={onEdit}
className="h-8 w-8 rounded-none p-0"
>
<IconComponent name="Pencil" className="h-4 w-4" />
</Button>
</div>
</ShadTooltip>
<ShadTooltip
styleClasses="z-50"
content={isCopied ? "Copied!" : "Copy message"}
side="top"
>
<div>
<Button
variant="ghost"
size="icon"
onClick={handleCopy}
className="h-8 w-8 rounded-none p-0"
>
<IconComponent
name={isCopied ? "Check" : "Copy"}
className="h-4 w-4"
/>
</Button>
</div>
</ShadTooltip>
</div>
);
}

View file

@ -0,0 +1,77 @@
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useEffect, useRef, useState } from "react";
export default function EditMessageField({
message: initialMessage,
onEdit,
onCancel,
}: {
message: string;
onEdit: (message: string) => void;
onCancel: () => void;
}) {
const [message, setMessage] = useState(initialMessage);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isButtonClicked, setIsButtonClicked] = useState(false);
const adjustTextareaHeight = () => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 3}px`;
}
};
useEffect(() => {
adjustTextareaHeight();
}, []);
return (
<div className="flex h-fit w-full flex-col bg-zinc-800">
<Textarea
ref={textareaRef}
className="h-mx-full w-full resize-none border-0 bg-zinc-800 focus:ring-0"
onBlur={() => {
if (!isButtonClicked) {
onCancel();
}
}}
value={message}
autoFocus={true}
onChange={(e) => setMessage(e.target.value)}
/>
<div className="flex w-full flex-row-reverse justify-between">
<div className="flex flex-row-reverse gap-2">
<Button
data-testid="save-button"
variant={"primary"}
onMouseDown={() => setIsButtonClicked(true)}
onClick={() => {
onEdit(message);
setIsButtonClicked(false);
}}
className="mt-2 hover:!bg-zinc-950"
>
Save
</Button>
<Button
variant={"secondary"}
data-testid="cancel-button"
onMouseDown={() => setIsButtonClicked(true)}
onClick={() => {
onCancel();
setIsButtonClicked(false);
}}
className="mt-2 bg-white !text-black hover:bg-white"
>
Cancel
</Button>
</div>
<div>
<span className="mr-4 text-sm text-muted-foreground">
Editing messages will update the memory but won't restart the
conversation.
</span>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,500 @@
import { ProfileIcon } from "@/components/appHeaderComponent/components/ProfileIcon";
import ShadTooltip from "@/components/shadTooltipComponent";
import { useUpdateMessage } from "@/controllers/API/queries/messages";
import useFlowsManagerStore from "@/stores/flowsManagerStore";
import { useUtilityStore } from "@/stores/utilityStore";
import Convert from "ansi-to-html";
import { useEffect, useRef, useState } from "react";
import Markdown from "react-markdown";
import rehypeMathjax from "rehype-mathjax";
import remarkGfm from "remark-gfm";
import MaleTechnology from "../../../../../assets/male-technologist.png";
import Robot from "../../../../../assets/robot.png";
import CodeTabsComponent from "../../../../../components/codeTabsComponent/ChatCodeTabComponent";
import IconComponent from "../../../../../components/genericIconComponent";
import SanitizedHTMLWrapper from "../../../../../components/sanitizedHTMLWrapper";
import {
EMPTY_INPUT_SEND_MESSAGE,
EMPTY_OUTPUT_SEND_MESSAGE,
} from "../../../../../constants/constants";
import useAlertStore from "../../../../../stores/alertStore";
import { chatMessagePropsType } from "../../../../../types/components";
import { cn } from "../../../../../utils/utils";
import { EditMessageButton } from "./components/editMessageButton/newMessageOptions";
import EditMessageField from "./components/editMessageField/newEditMessageField";
import FileCardWrapper from "./components/fileCardWrapper";
export default function ChatMessage({
chat,
lockChat,
lastMessage,
updateChat,
setLockChat,
}: chatMessagePropsType): JSX.Element {
const convert = new Convert({ newline: true });
const [hidden, setHidden] = useState(true);
const template = chat.template;
const [promptOpen, setPromptOpen] = useState(false);
const [streamUrl, setStreamUrl] = useState(chat.stream_url);
const flow_id = useFlowsManagerStore((state) => state.currentFlowId);
// We need to check if message is not undefined because
// we need to run .toString() on it
const [chatMessage, setChatMessage] = useState(
chat.message ? chat.message.toString() : "",
);
const [isStreaming, setIsStreaming] = useState(false);
const eventSource = useRef<EventSource | undefined>(undefined);
const setErrorData = useAlertStore((state) => state.setErrorData);
const chatMessageRef = useRef(chatMessage);
const [editMessage, setEditMessage] = useState(false);
useEffect(() => {
const chatMessageString = chat.message ? chat.message.toString() : "";
setChatMessage(chatMessageString);
}, [chat]);
const playgroundScrollBehaves = useUtilityStore(
(state) => state.playgroundScrollBehaves,
);
const setPlaygroundScrollBehaves = useUtilityStore(
(state) => state.setPlaygroundScrollBehaves,
);
// Sync ref with state
useEffect(() => {
chatMessageRef.current = chatMessage;
}, [chatMessage]);
// 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
const streamChunks = (url: string) => {
setIsStreaming(true); // Streaming starts
return new Promise<boolean>((resolve, reject) => {
eventSource.current = new EventSource(url);
eventSource.current.onmessage = (event) => {
let parsedData = JSON.parse(event.data);
if (parsedData.chunk) {
setChatMessage((prev) => prev + parsedData.chunk);
}
};
eventSource.current.onerror = (event: any) => {
setIsStreaming(false);
eventSource.current?.close();
setStreamUrl(undefined);
if (JSON.parse(event.data)?.error) {
setErrorData({
title: "Error on Streaming",
list: [JSON.parse(event.data)?.error],
});
}
updateChat(chat, chatMessageRef.current);
reject(new Error("Streaming failed"));
};
eventSource.current.addEventListener("close", (event) => {
setStreamUrl(undefined); // Update state to reflect the stream is closed
eventSource.current?.close();
setIsStreaming(false);
resolve(true);
});
});
};
useEffect(() => {
if (streamUrl && !isStreaming) {
setLockChat(true);
streamChunks(streamUrl)
.then(() => {
setLockChat(false);
if (updateChat) {
updateChat(chat, chatMessageRef.current);
}
})
.catch((error) => {
console.error(error);
setLockChat(false);
});
}
}, [streamUrl, chatMessage]);
useEffect(() => {
return () => {
eventSource.current?.close();
};
}, []);
useEffect(() => {
const element = document.getElementById("last-chat-message");
if (element) {
if (playgroundScrollBehaves === "instant") {
element.scrollIntoView({ behavior: playgroundScrollBehaves });
setPlaygroundScrollBehaves("smooth");
} else {
setTimeout(() => {
element.scrollIntoView({ behavior: playgroundScrollBehaves });
}, 200);
}
}
}, [lastMessage, chat]);
let decodedMessage = chatMessage ?? "";
try {
decodedMessage = decodeURIComponent(chatMessage);
} catch (e) {
console.error(e);
}
const isEmpty = decodedMessage?.trim() === "";
const { mutate: updateMessageMutation } = useUpdateMessage();
const convertFiles = (
files:
| (
| string
| {
path: string;
type: string;
name: string;
}
)[]
| undefined,
) => {
if (!files) return [];
return files.map((file) => {
if (typeof file === "string") {
return file;
}
return file.path;
});
};
const handleEditMessage = (message: string) => {
updateMessageMutation(
{
message: {
...chat,
files: convertFiles(chat.files),
sender_name: chat.sender_name ?? "AI",
text: message,
sender: chat.isSend ? "User" : "Machine",
flow_id,
session_id: chat.session ?? "",
},
refetch: true,
},
{
onSuccess: () => {
updateChat(chat, message);
setEditMessage(false);
},
onError: () => {
setErrorData({
title: "Error updating messages.",
});
},
},
);
};
const editedFlag = chat.edit ? (
<span className="text-sm text-chat-trigger-disabled">(Edited)</span>
) : null;
return (
<>
<div className="flex-max-width px-2 py-6 pl-32 pr-9">
<div className={"mr-3 mt-1 flex w-11/12 pb-3"}>
<div
className={cn(
"group relative flex w-full gap-4 rounded-md p-2 hover:bg-zinc-800",
editMessage ? "bg-zinc-800" : "",
)}
>
<div
className={cn(
"relative flex h-8 w-8 items-center justify-center overflow-hidden rounded-md p-5 text-2xl",
!chat.isSend ? "bg-chat-bot-icon" : "bg-zinc-400",
)}
>
{!chat.isSend ? (
<img
src={Robot}
className="absolute scale-[60%]"
alt={"robot_image"}
/>
) : (
<div className="absolute scale-[80%]">
<ProfileIcon />
</div>
)}
</div>
<div className="flex w-[94%] flex-col">
<div>
<div
className="max-w-full truncate pb-2 font-semibold"
data-testid={
"sender_name_" + chat.sender_name?.toLocaleLowerCase()
}
>
{chat.sender_name}
</div>
{/* TODO: ADD MODEL RELATED NAME */}
</div>
{!chat.isSend ? (
<div className="form-modal-chat-text-position min-w-96 flex-grow">
<div className="form-modal-chat-text">
{hidden && chat.thought && chat.thought !== "" && (
<div
onClick={(): void => setHidden((prev) => !prev)}
className="form-modal-chat-icon-div"
>
<IconComponent
name="MessageSquare"
className="form-modal-chat-icon"
/>
</div>
)}
{chat.thought && chat.thought !== "" && !hidden && (
<SanitizedHTMLWrapper
className="form-modal-chat-thought"
content={convert.toHtml(chat.thought)}
onClick={() => setHidden((prev) => !prev)}
/>
)}
{chat.thought && chat.thought !== "" && !hidden && (
<br></br>
)}
<div className="flex w-full flex-col">
<div
className="flex w-full flex-col dark:text-white"
data-testid="div-chat-message"
>
<div
data-testid={
"chat-message-" +
chat.sender_name +
"-" +
chatMessage
}
className="flex w-full flex-col"
>
{chatMessage === "" && lockChat ? (
<IconComponent
name="MoreHorizontal"
className="h-8 w-8 animate-pulse"
/>
) : (
<div className="w-full">
{editMessage ? (
<EditMessageField
key={`edit-message-${chat.id}`}
message={decodedMessage}
onEdit={(message) => {
handleEditMessage(message);
}}
onCancel={() => setEditMessage(false)}
/>
) : (
<>
<div className="flex w-full gap-2">
<Markdown
remarkPlugins={[remarkGfm]}
linkTarget="_blank"
rehypePlugins={[rehypeMathjax]}
className={cn(
"markdown prose flex w-full max-w-full flex-col word-break-break-word dark:prose-invert",
isEmpty
? "text-chat-trigger-disabled"
: "text-primary",
)}
components={{
pre({ node, ...props }) {
return <>{props.children}</>;
},
code: ({
node,
inline,
className,
children,
...props
}) => {
let content = children as string;
if (
Array.isArray(children) &&
children.length === 1 &&
typeof children[0] === "string"
) {
content = children[0] as string;
}
if (typeof content === "string") {
if (content.length) {
if (content[0] === "▍") {
return (
<span className="form-modal-markdown-span">
</span>
);
}
}
const match = /language-(\w+)/.exec(
className || "",
);
return !inline ? (
<CodeTabsComponent
language={
(match && match[1]) || ""
}
code={String(content).replace(
/\n$/,
"",
)}
/>
) : (
<code
className={className}
{...props}
>
{content}
</code>
);
}
},
}}
>
{isEmpty && !chat.stream_url
? EMPTY_OUTPUT_SEND_MESSAGE
: chatMessage}
</Markdown>
</div>
{editedFlag}
</>
)}
</div>
)}
</div>
</div>
</div>
</div>
</div>
) : (
<div className="form-modal-chat-text-position min-w-96 flex-grow">
{template ? (
<>
<button
className="form-modal-initial-prompt-btn"
onClick={() => {
setPromptOpen((old) => !old);
}}
>
Display Prompt
<IconComponent
name="ChevronDown"
className={`h-3 w-3 transition-all ${promptOpen ? "rotate-180" : ""}`}
/>
</button>
<span
className={cn(
"prose word-break-break-word dark:prose-invert",
!isEmpty
? "text-primary"
: "text-chat-trigger-disabled",
)}
>
{promptOpen
? template?.split("\n")?.map((line, index) => {
const regex = /{([^}]+)}/g;
let match;
let parts: Array<JSX.Element | string> = [];
let lastIndex = 0;
while ((match = regex.exec(line)) !== null) {
// Push text up to the match
if (match.index !== lastIndex) {
parts.push(
line.substring(lastIndex, match.index),
);
}
// Push div with matched text
if (chat.message[match[1]]) {
parts.push(
<span className="chat-message-highlight">
{chat.message[match[1]]}
</span>,
);
}
// Update last index
lastIndex = regex.lastIndex;
}
// Push text after the last match
if (lastIndex !== line.length) {
parts.push(line.substring(lastIndex));
}
return <p>{parts}</p>;
})
: isEmpty
? EMPTY_INPUT_SEND_MESSAGE
: chatMessage}
</span>
</>
) : (
<div className="flex w-full flex-col">
{editMessage ? (
<EditMessageField
key={`edit-message-${chat.id}`}
message={decodedMessage}
onEdit={(message) => {
handleEditMessage(message);
}}
onCancel={() => setEditMessage(false)}
/>
) : (
<>
<div
className={`flex w-full gap-2 whitespace-pre-wrap break-words ${
isEmpty
? "text-chat-trigger-disabled"
: "text-primary"
}`}
data-testid={`chat-message-${chat.sender_name}-${chatMessage}`}
>
{isEmpty
? EMPTY_INPUT_SEND_MESSAGE
: decodedMessage}
</div>
{editedFlag}
</>
)}
{chat.files && (
<div className="my-2 flex flex-col gap-5">
{chat.files.map((file, index) => {
return (
<FileCardWrapper index={index} path={file} />
);
})}
</div>
)}
</div>
)}
</div>
)}
</div>
{!editMessage && (
<div className="invisible absolute -top-4 right-0 group-hover:visible">
<div>
<EditMessageButton
onCopy={() => {
navigator.clipboard.writeText(chatMessage);
}}
onDelete={() => {}}
onEdit={() => setEditMessage(true)}
className="h-fit group-hover:visible"
/>
</div>
</div>
)}
</div>
</div>
</div>
<div id={lastMessage ? "last-chat-message" : undefined} />
</>
);
}

View file

@ -0,0 +1,98 @@
import { useState } from "react";
import IconComponent, {
ForwardedIconComponent,
} from "../../../../../components/genericIconComponent";
import { Skeleton } from "../../../../../components/ui/skeleton";
import formatFileName from "./utils/format-file-name";
const supImgFiles = ["png", "jpg", "jpeg", "gif", "bmp", "webp", "image"];
export default function FilePreview({
error,
file,
loading,
onDelete,
}: {
loading: boolean;
file: File;
error: boolean;
onDelete: () => void;
}) {
const fileType = file.type.toLowerCase();
const isImage = supImgFiles.some((type) => fileType.includes(type));
return (
<div className="group relative pb-2">
{loading ? (
isImage ? (
<div className="flex h-20 w-20 items-center justify-center rounded-md border border-ring bg-background">
<svg
aria-hidden="true"
className={`h-10 w-10 animate-spin fill-black text-muted`}
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
) : (
<div
className={`relative ${
isImage ? "h-20 w-20" : "h-20 w-80"
} cursor-wait rounded-lg border border-ring bg-background transition duration-300`}
>
<div className="ml-3 flex h-full w-full items-center gap-2 text-sm">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex flex-col gap-1">
<Skeleton className="h-3 w-48" />
<Skeleton className="h-3 w-10" />
</div>
</div>
</div>
)
) : error ? (
<div>Error...</div>
) : (
<div
className={`relative mt-2 ${
isImage ? "h-20 w-32" : "h-20 w-32"
} cursor-pointer rounded-lg border border-border bg-background transition duration-300 group-hover:shadow-md`}
>
{isImage ? (
<img
src={URL.createObjectURL(file)}
alt="file"
className="block h-full w-full rounded-md border border-border"
/>
) : (
<div className="ml-3 flex h-full w-full items-center gap-2 text-sm">
<ForwardedIconComponent name="File" className="h-8 w-8" />
<div className="flex flex-col">
<span className="font-bold">{formatFileName(file.name)}</span>
<span>File</span>
</div>
</div>
)}
<div
className={`absolute -right-2 -top-2 flex h-5 w-5 items-center justify-center opacity-100 transition-opacity`}
>
<div
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-full bg-zinc-800 p-2 transition-all hover:bg-zinc-700"
onClick={onDelete}
>
<IconComponent name="X" className="stroke-zinc-100 stroke-2" />
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,188 @@
import { useDeleteBuilds } from "@/controllers/API/queries/_builds";
import { usePostUploadFile } from "@/controllers/API/queries/files/use-post-upload-file";
import { track } from "@/customization/utils/analytics";
import { useMessagesStore } from "@/stores/messagesStore";
import { useEffect, useRef, useState } from "react";
import useFlowStore from "../../../../stores/flowStore";
import useFlowsManagerStore from "../../../../stores/flowsManagerStore";
import { ChatMessageType } from "../../../../types/chat";
import { chatViewProps } from "../../../../types/components";
import useDragAndDrop from "./chatInput/hooks/use-drag-and-drop";
import { useFileHandler } from "./chatInput/hooks/use-file-handler";
import ChatInput from "./chatInput/newChatInput";
import ChatMessage from "./chatMessage/newChatMessage";
export default function ChatView({
sendMessage,
chatValue,
setChatValue,
lockChat,
setLockChat,
visibleSession,
focusChat,
}: chatViewProps): JSX.Element {
const { flowPool, inputs, CleanFlowPool } = useFlowStore();
const currentFlowId = useFlowsManagerStore((state) => state.currentFlowId);
const messagesRef = useRef<HTMLDivElement | null>(null);
const [chatHistory, setChatHistory] = useState<ChatMessageType[]>([]);
const messages = useMessagesStore((state) => state.messages);
const inputTypes = inputs.map((obj) => obj.type);
const updateFlowPool = useFlowStore((state) => state.updateFlowPool);
const { mutate: mutateDeleteFlowPool } = useDeleteBuilds();
//build chat history
useEffect(() => {
const messagesFromMessagesStore: ChatMessageType[] = messages
.filter(
(message) =>
message.flow_id === currentFlowId &&
(visibleSession === message.session_id ?? true),
)
.map((message) => {
let files = message.files;
//HANDLE THE "[]" case
if (typeof files === "string") {
files = JSON.parse(files);
}
return {
isSend: message.sender === "User",
message: message.text,
sender_name: message.sender_name,
files: files,
id: message.id,
timestamp: message.timestamp,
session: message.session_id,
edit: message.edit,
};
});
const finalChatHistory = [...messagesFromMessagesStore].sort((a, b) => {
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
});
setChatHistory(finalChatHistory);
}, [flowPool, messages, visibleSession]);
useEffect(() => {
if (messagesRef.current) {
messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
}
}, []);
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (ref.current) {
ref.current.focus();
}
// trigger focus on chat when new session is set
}, [focusChat]);
function updateChat(
chat: ChatMessageType,
message: string,
stream_url?: string,
) {
chat.message = message;
if (chat.componentId)
updateFlowPool(chat.componentId, {
message,
sender_name: chat.sender_name ?? "Bot",
sender: chat.isSend ? "User" : "Machine",
});
}
const { files, setFiles, handleFiles } = useFileHandler(currentFlowId);
const [isDragging, setIsDragging] = useState(false);
const { dragOver, dragEnter, dragLeave } = useDragAndDrop(setIsDragging);
const onDrop = (e) => {
e.preventDefault();
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleFiles(e.dataTransfer.files);
e.dataTransfer.clearData();
}
setIsDragging(false);
};
const { mutate } = usePostUploadFile();
return (
<div
className="background flex h-full w-full flex-col rounded-md"
onDragOver={dragOver}
onDragEnter={dragEnter}
onDragLeave={dragLeave}
onDrop={onDrop}
>
<div ref={messagesRef} className="chat-message-div">
{chatHistory?.length > 0 ? (
chatHistory.map((chat, index) => (
<ChatMessage
setLockChat={setLockChat}
lockChat={lockChat}
chat={chat}
lastMessage={chatHistory.length - 1 === index ? true : false}
key={`${chat.id}-${index}`}
updateChat={updateChat}
/>
))
) : (
<div className="flex h-full w-full flex-col items-center justify-center">
<div className="flex flex-col items-center justify-center bg-background p-8">
<span className="pb-5 text-4xl"></span>
<h3 className="mt-2 pb-2 text-2xl font-semibold text-primary">
New chat
</h3>
<p className="text-lg text-muted-foreground">
Test your flow with a chat prompt
</p>
</div>
</div>
)}
<div
className={lockChat ? "flex-max-width px-2 py-6 pl-32 pr-9" : ""}
ref={ref}
>
{lockChat && (
<div className={"mr-3 mt-1 flex w-full overflow-hidden pb-3"}>
<div className="flex w-full gap-4">
<div className="relative flex h-8 w-8 items-center justify-center overflow-hidden rounded-md bg-zinc-800 p-5">
<span>
<div className="text-3xl"></div>
</span>
</div>
<div className="flex items-center">
<div>
<span className="animate-pulse text-muted-foreground">
Flow running...
</span>
{/* TODO: ADD MODEL RELATED NAME */}
</div>
</div>
</div>
</div>
)}
</div>
</div>
<div className="m-auto w-5/6">
<ChatInput
chatValue={chatValue}
noInput={!inputTypes.includes("ChatInput")}
lockChat={lockChat}
sendMessage={({ repeat, files }) => {
sendMessage({ repeat, files });
track("Playground Message Sent");
}}
setChatValue={(value) => {
setChatValue(value);
}}
inputRef={ref}
files={files}
setFiles={setFiles}
isDragging={isDragging}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,430 @@
import {
useDeleteMessages,
useGetMessagesQuery,
} from "@/controllers/API/queries/messages";
import { useUtilityStore } from "@/stores/utilityStore";
import { useEffect, useState } from "react";
import AccordionComponent from "../../components/accordionComponent";
import IconComponent from "../../components/genericIconComponent";
import ShadTooltip from "../../components/shadTooltipComponent";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "../../components/ui/tabs";
import { CHAT_FORM_DIALOG_SUBTITLE } from "../../constants/constants";
import { InputOutput } from "../../constants/enums";
import useAlertStore from "../../stores/alertStore";
import useFlowStore from "../../stores/flowStore";
import useFlowsManagerStore from "../../stores/flowsManagerStore";
import { useMessagesStore } from "../../stores/messagesStore";
import { IOModalPropsType } from "../../types/components";
import { NodeType } from "../../types/flow";
import { cn } from "../../utils/utils";
import BaseModal from "../baseModal";
import IOFieldView from "./components/IOFieldView";
import SessionSelector from "./components/IOFieldView/components/sessionSelector/newSessionSelector";
import SessionView from "./components/SessionView";
import ChatView from "./components/chatView/newChatView";
export default function IOModal({
children,
open,
setOpen,
disable,
isPlayground,
}: IOModalPropsType): JSX.Element {
const allNodes = useFlowStore((state) => state.nodes);
const inputs = useFlowStore((state) => state.inputs).filter(
(input) => input.type !== "ChatInput",
);
const chatInput = useFlowStore((state) => state.inputs).find(
(input) => input.type === "ChatInput",
);
const outputs = useFlowStore((state) => state.outputs).filter(
(output) => output.type !== "ChatOutput",
);
const chatOutput = useFlowStore((state) => state.outputs).find(
(output) => output.type === "ChatOutput",
);
const nodes = useFlowStore((state) => state.nodes).filter(
(node) =>
inputs.some((input) => input.id === node.id) ||
outputs.some((output) => output.id === node.id),
);
const haveChat = chatInput || chatOutput;
const [selectedTab, setSelectedTab] = useState(
inputs.length > 0 ? 1 : outputs.length > 0 ? 2 : 0,
);
const setErrorData = useAlertStore((state) => state.setErrorData);
const setNoticeData = useAlertStore((state) => state.setNoticeData);
const setSuccessData = useAlertStore((state) => state.setSuccessData);
const deleteSession = useMessagesStore((state) => state.deleteSession);
const currentFlowId = useFlowsManagerStore((state) => state.currentFlowId);
const [sidebarOpen, setSidebarOpen] = useState(true);
const { mutate: deleteSessionFunction } = useDeleteMessages();
const [visibleSession, setvisibleSession] = useState<string | undefined>(
currentFlowId,
);
function handleDeleteSession(session_id: string) {
deleteSessionFunction(
{
ids: messages
.filter((msg) => msg.session_id === session_id)
.map((msg) => msg.id),
},
{
onSuccess: () => {
setSuccessData({
title: "Session deleted successfully.",
});
deleteSession(session_id);
if (visibleSession === session_id) {
setvisibleSession(undefined);
}
},
onError: () => {
setErrorData({
title: "Error deleting Session.",
});
},
},
);
}
function startView() {
if (!chatInput && !chatOutput) {
if (inputs.length > 0) {
return inputs[0];
} else {
return outputs[0];
}
} else {
return undefined;
}
}
const [selectedViewField, setSelectedViewField] = useState<
{ type: string; id: string } | undefined
>(startView());
const buildFlow = useFlowStore((state) => state.buildFlow);
const setIsBuilding = useFlowStore((state) => state.setIsBuilding);
const lockChat = useFlowStore((state) => state.lockChat);
const setLockChat = useFlowStore((state) => state.setLockChat);
const [chatValue, setChatValue] = useState("");
const isBuilding = useFlowStore((state) => state.isBuilding);
const setNode = useFlowStore((state) => state.setNode);
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 flowPool = useFlowStore((state) => state.flowPool);
const [sessionId, setSessionId] = useState<string>(currentFlowId);
useGetMessagesQuery(
{
mode: "union",
id: currentFlowId,
},
{ enabled: open },
);
async function sendMessage({
repeat = 1,
files,
}: {
repeat: number;
files?: string[];
}): Promise<void> {
if (isBuilding) return;
setIsBuilding(true);
setLockChat(true);
setChatValue("");
for (let i = 0; i < repeat; i++) {
await buildFlow({
input_value: chatValue,
startNodeId: chatInput?.id,
files: files,
silent: true,
session: sessionId,
setLockChat,
}).catch((err) => {
console.error(err);
setLockChat(false);
});
}
// refetch();
setLockChat(false);
if (chatInput) {
setNode(chatInput.id, (node: NodeType) => {
const newNode = { ...node };
newNode.data.node!.template["input_value"].value = chatValue;
return newNode;
});
}
}
useEffect(() => {
setSelectedTab(inputs.length > 0 ? 1 : outputs.length > 0 ? 2 : 0);
}, [allNodes.length]);
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);
});
}, [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" })}`,
);
} else if (visibleSession) {
setSessionId(visibleSession);
if (selectedViewField?.type === "Session") {
setSelectedViewField({
id: visibleSession,
type: "Session",
});
}
}
}, [visibleSession]);
const setPlaygroundScrollBehaves = useUtilityStore(
(state) => state.setPlaygroundScrollBehaves,
);
useEffect(() => {
if (open) {
setPlaygroundScrollBehaves("instant");
}
}, [open]);
return (
<BaseModal
open={open}
setOpen={setOpen}
disable={disable}
type={isPlayground ? "modal" : undefined}
onSubmit={() => sendMessage({ repeat: 1 })}
size="x-large"
className="p-0"
>
<BaseModal.Trigger>{children}</BaseModal.Trigger>
{/* TODO ADAPT TO ALL TYPES OF INPUTS AND OUTPUTS */}
<BaseModal.Content overflowHidden>
<div className="flex-max-width h-full">
<div
className={cn(
"flex h-full flex-shrink-0 flex-col justify-start transition-all duration-300",
sidebarOpen ? "w-1/5" : "w-16",
)}
>
<div className="flex h-full flex-col overflow-y-auto border-r border-border bg-zinc-950 p-6 text-center custom-scroll">
<div className="flex items-center gap-2 pb-8">
<Button
variant="ghost"
size="icon"
onClick={() => setSidebarOpen(!sidebarOpen)}
>
<IconComponent
name={sidebarOpen ? "PanelLeftClose" : "PanelLeftOpen"}
className="h-6 w-6 text-ring"
/>
</Button>
{sidebarOpen && <div className="font-semibold">Playground</div>}
</div>
{sidebarOpen && (
<div className="flex flex-col pl-3">
<div className="flex flex-col gap-2 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<IconComponent
name="MessagesSquare"
className="h-6 w-6 text-ring"
/>
<div className="font-semibold">Chat</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={(_) => {
setvisibleSession(undefined);
setSelectedViewField(undefined);
}}
>
<IconComponent
name="Plus"
className="h-6 w-6 text-ring"
/>
</Button>
</div>
</div>
<div className="flex flex-col">
{sessions.map((session, index) => (
<SessionSelector
setSelectedView={setSelectedViewField}
selectedView={selectedViewField}
key={index}
session={session}
deleteSession={(session) => {
handleDeleteSession(session);
if (selectedViewField?.id === session) {
setSelectedViewField(undefined);
}
}}
updateVisibleSession={(session) => {
setvisibleSession(session);
}}
toggleVisibility={() => {
setvisibleSession(session);
}}
isVisible={visibleSession === session}
inspectSession={(session) => {
setSelectedViewField({
id: session,
type: "Session",
});
}}
/>
))}
</div>
</div>
)}
</div>
</div>
<div className="flex h-full min-w-96 flex-grow">
{selectedViewField && (
<div
className={cn(
"flex h-full w-full flex-col items-start gap-4 pt-4",
!selectedViewField ? "hidden" : "",
)}
>
<div className="font-xl flex items-center justify-center gap-3 font-semibold">
{haveChat && (
<button onClick={() => setSelectedViewField(undefined)}>
<IconComponent
name={"ArrowLeft"}
className="h-6 w-6"
></IconComponent>
</button>
)}
{
nodes.find((node) => node.id === selectedViewField.id)?.data
.node.display_name
}
</div>
<div className="h-full w-full">
{inputs.some(
(input) => input.id === selectedViewField.id,
) && (
<IOFieldView
type={InputOutput.INPUT}
left={false}
fieldType={selectedViewField.type!}
fieldId={selectedViewField.id!}
/>
)}
{outputs.some(
(output) => output.id === selectedViewField.id,
) && (
<IOFieldView
type={InputOutput.OUTPUT}
left={false}
fieldType={selectedViewField.type!}
fieldId={selectedViewField.id!}
/>
)}
{sessions.some(
(session) => session === selectedViewField.id,
) && (
<SessionView
session={selectedViewField.id}
id={currentFlowId}
/>
)}
</div>
</div>
)}
<div
className={cn(
"flex h-full w-full flex-col p-6",
selectedViewField ? "hidden" : "",
)}
>
{visibleSession && (
<div className="mb-4 h-[5%] text-xl font-semibold">
{visibleSession === currentFlowId
? "Default Session"
: `${visibleSession}`}
</div>
)}
{haveChat ? (
<div className={visibleSession ? "h-[95%]" : "h-full"}>
<ChatView
focusChat={sessionId}
sendMessage={sendMessage}
chatValue={chatValue}
setChatValue={setChatValue}
lockChat={lockChat}
setLockChat={setLockChat}
visibleSession={visibleSession}
/>
</div>
) : (
<span className="flex h-full w-full items-center justify-center font-thin text-muted-foreground">
Select an IO component to view
</span>
)}
</div>
</div>
</div>
</BaseModal.Content>
{!haveChat ? (
<BaseModal.Footer
submit={{
label: "Run Flow",
icon: (
<IconComponent
name={isBuilding ? "Loader2" : "Zap"}
className={cn(
"h-4 w-4",
isBuilding
? "animate-spin"
: "fill-current text-medium-indigo",
)}
/>
),
}}
/>
) : (
<></>
)}
</BaseModal>
);
}

View file

@ -412,7 +412,7 @@ export default function NodeToolbarComponent({
</div>
</SelectTrigger>
</ShadTooltip>
<SelectContent>
<SelectContent className="min-w-[14rem]">
{hasCode && (
<SelectItem value={"code"}>
<ToolbarSelectItem

View file

@ -1,4 +1,5 @@
import { useGetRefreshFlows } from "@/controllers/API/queries/flows/use-get-refresh-flows";
import { ENABLE_NEW_IO_MODAL } from "@/customization/feature-flags";
import { useCustomNavigate } from "@/customization/hooks/use-custom-navigate";
import { track } from "@/customization/utils/analytics";
import { useStoreStore } from "@/stores/storeStore";
@ -6,9 +7,11 @@ import { useTypesStore } from "@/stores/typesStore";
import { useEffect } from "react";
import { useParams } from "react-router-dom";
import { getComponent } from "../../controllers/API";
import IOModal from "../../modals/IOModal";
import IOModalOld from "../../modals/IOModal";
import IOModalNew from "../../modals/IOModal/newModal";
import useFlowsManagerStore from "../../stores/flowsManagerStore";
import cloneFLowWithParent from "../../utils/storeUtils";
const IOModal = ENABLE_NEW_IO_MODAL ? IOModalNew : IOModalOld;
export default function PlaygroundPage() {
const flows = useFlowsManagerStore((state) => state.flows);

View file

@ -967,7 +967,7 @@
@apply absolute bottom-2 right-4;
}
.form-modal-send-button {
@apply rounded-md p-2 px-1 transition-all duration-300;
@apply rounded-md p-1.5 px-2.5 transition-all duration-300;
}
.form-modal-lock-icon {
@apply ml-1 mr-1 h-5 w-5 animate-pulse;

View file

@ -227,3 +227,21 @@ textarea[class^="ag-"]:focus {
0 0;
transition-duration: 1.5s;
}
span.token {
display: inline-block;
max-width: 100%;
}
code {
max-width: 100%;
display: inline-block;
width: 100%;
/* bg ignored now */
background-color: var(--canvas);
}
pre {
/* bg ignored now */
background-color: var(--canvas);
}

View file

@ -32,6 +32,7 @@
--chart-3: 197 37% 24%; /* hsl(197 37% 24%) */
--chart-4: 43 74% 66%; /* hsl(43 74% 66%) */
--chart-5: 27 87% 67%; /* hsl(27 87% 67%) */
--canvas: 240 6% 10%; /* hsl(240 6% 10%) */
--node-selected: 243 75% 59%;
--round-btn-shadow: #00000063;
@ -73,7 +74,7 @@
--chat-trigger-disabled: #b4c3da;
--status-red: #ef4444;
--status-yellow: #eab308;
--chat-send: #059669;
--chat-send: #000000;
--status-green: #4ade80;
--status-blue: #2563eb;
--status-gray: #6b7280;
@ -117,6 +118,7 @@
--chart-3: 30 80% 55%; /* hsl(30, 80%, 55%) */
--chart-4: 280 65% 60%; /* hsl(280, 65%, 60%) */
--chart-5: 340 75% 55%; /* hsl(340, 75%, 55%) */
--canvas: 240 6% 10%; /* hsl(240 6% 10%) */
--note-default: #0f172a;
--note-indigo: #312e81;
@ -167,7 +169,7 @@
--chat-trigger-disabled: #2d3b54;
--status-red: #ef4444;
--status-yellow: #eab308;
--chat-send: #059669;
--chat-send: #ffffff;
--status-green: #4ade80;
--status-blue: #2563eb;
--connection: #6d6c6c;

View file

@ -11,6 +11,7 @@ import {
ArrowBigUp,
ArrowLeft,
ArrowRight,
ArrowUpRight,
ArrowUpToLine,
Bell,
Binary,
@ -45,6 +46,7 @@ import {
Command,
Compass,
Copy,
CornerDownLeft,
Cpu,
CpuIcon,
Database,
@ -85,6 +87,7 @@ import {
Heart,
HelpCircle,
Home,
Image,
Info,
Key,
Keyboard,
@ -114,6 +117,8 @@ import {
OptionIcon,
Package2,
Palette,
PanelLeftClose,
PanelLeftOpen,
Paperclip,
PaperclipIcon,
Pencil,
@ -135,6 +140,7 @@ import {
ScanEye,
Scissors,
ScreenShare,
Scroll,
ScrollText,
Search,
Settings,
@ -636,6 +642,12 @@ export const nodeIconsLucide: iconsType = {
PaperclipIcon,
Settings,
Streamlit,
PanelLeftClose: PanelLeftClose,
PanelLeftOpen: PanelLeftOpen,
ArrowUpRight: ArrowUpRight,
Scroll,
Image,
CornerDownLeft,
MistralAI: MistralIcon,
Upstash: UpstashSvgIcon,
PGVector: CpuIcon,

View file

@ -108,6 +108,7 @@ const config = {
},
"chat-bot-icon": "var(--chat-bot-icon)",
"chat-user-icon": "var(--chat-user-icon)",
canvas: "var(--canvas)",
ice: "var(--ice)",
selected: "var(--selected)",
hover: "var(--hover)",