import type { CSSProperties } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { v4 as uuidv4 } from "uuid"; import IconPaperclip from "virtual:icons/mdi/paperclip"; import IconSend from "virtual:icons/mdi/send"; import ChatFile from "./ChatFile"; import { useChat, useI18n, useOptions } from "../composables"; import { chatEventBus } from "../event-buses"; import type { ChatMessage } from "../types"; import { constructChatWebsocketUrl } from "../utils"; import "./Input.scss"; export interface ChatInputProps { placeholder?: string; } const defaultPlaceholderKey = "inputPlaceholder"; type CSSVariableProperties = CSSProperties & Record; export default function Input({ placeholder = defaultPlaceholderKey, }: ChatInputProps) { const { t } = useI18n(); const { options } = useOptions(); const chatStore = useChat(); const [inputValue, setInputValue] = useState(""); const [files, setFiles] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [waitingForChatResponse, setWaitingForChatResponse] = useState(false); const textareaRef = useRef(null); const fileInputRef = useRef(null); const resizeObserverRef = useRef(null); const allowFileUploads = Boolean(options.allowFileUploads); const allowedFileTypes = options.allowedFilesMimeTypes ?? ""; const complianceNotice = options.complianceNotice?.trim() ?? ""; const isTopicSelectionPending = chatStore.isTopicSelectionPending; const isDisabled = Boolean(options.disabled || isTopicSelectionPending); const placeholderKey = isTopicSelectionPending ? "topicSelectionInputPlaceholder" : placeholder; const isSubmitDisabled = useMemo(() => { if (waitingForChatResponse) return false; return ( inputValue.trim() === "" || chatStore.waitingForResponse || isDisabled ); }, [ waitingForChatResponse, inputValue, chatStore.waitingForResponse, isDisabled, ]); const isFileUploadDisabled = chatStore.waitingForResponse || isDisabled; const styleVars = useMemo( () => ({ "--controls-count": (allowFileUploads ? 2 : 1).toString(), }), [allowFileUploads], ); const adjustTextAreaHeight = useCallback(() => { const textarea = textareaRef.current; if (!textarea) return; textarea.style.height = "var(--chat--textarea--height)"; const newHeight = Math.min(textarea.scrollHeight, 480); textarea.style.height = `${newHeight}px`; }, []); useEffect(() => { const focusHandler = () => textareaRef.current?.focus(); const blurHandler = () => textareaRef.current?.blur(); const valueHandler = (value?: string) => { setInputValue(value ?? ""); focusHandler(); }; const offFocus = chatEventBus.on("focusInput", focusHandler); const offBlur = chatEventBus.on("blurInput", blurHandler); const offSetValue = chatEventBus.on("setInputValue", valueHandler); return () => { offFocus(); offBlur(); offSetValue(); }; }, []); useEffect(() => { if (!textareaRef.current) return; resizeObserverRef.current = new ResizeObserver(() => adjustTextAreaHeight(), ); resizeObserverRef.current.observe(textareaRef.current); return () => { resizeObserverRef.current?.disconnect(); resizeObserverRef.current = null; }; }, [adjustTextAreaHeight]); const appendMessage = chatStore.appendMessage; const attachFiles = useCallback(() => { if (!files.length) return []; const currentFiles = [...files]; setFiles([]); return currentFiles; }, [files]); const processFiles = useCallback(async (data: File[] | undefined) => { if (!data || data.length === 0) return []; return await Promise.all( data.map( (file) => new Promise<{ name: string; type: string; data: string }>( (resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve({ name: file.name, type: file.type, data: reader.result as string, }); reader.onerror = () => reject( new Error( `Error reading file: ${reader.error?.message ?? "Unknown error"}`, ), ); reader.readAsDataURL(file); }, ), ), ); }, []); const respondToChatNode = useCallback( async (ws: WebSocket, messageText: string, attachedFiles: File[]) => { const sentMessage: ChatMessage = { id: uuidv4(), text: messageText, sender: "user", files: attachedFiles, }; appendMessage(sentMessage); ws.send( JSON.stringify({ sessionId: chatStore.currentSessionId, action: "sendMessage", chatInput: messageText, files: await processFiles(attachedFiles), }), ); chatStore.setWaitingForResponse(true); setWaitingForChatResponse(false); }, [appendMessage, chatStore, processFiles], ); const setupWebsocketConnection = useCallback( (executionId: string) => { if (!options.webhookUrl || !chatStore.currentSessionId) return; try { const wsUrl = constructChatWebsocketUrl( options.webhookUrl, executionId, chatStore.currentSessionId, true, ); const ws = new WebSocket(wsUrl); chatStore.websocketRef.current = ws; ws.onmessage = (event) => { if (event.data === "chat-widget|heartbeat") { ws.send("chat-widget|heartbeat-ack"); return; } if (event.data === "chat-widget|continue") { setWaitingForChatResponse(false); chatStore.setWaitingForResponse(true); return; } const newMessage: ChatMessage = { id: uuidv4(), text: event.data, sender: "bot", }; appendMessage(newMessage); setWaitingForChatResponse(true); chatStore.setWaitingForResponse(false); }; ws.onclose = () => { chatStore.websocketRef.current = null; setWaitingForChatResponse(false); chatStore.setWaitingForResponse(false); }; } catch (error) { console.error("Error setting up websocket connection", error); } }, [appendMessage, chatStore, options.webhookUrl], ); const handleSubmit = useCallback( async (event: React.MouseEvent | React.KeyboardEvent) => { event.preventDefault(); if (isSubmitDisabled) return; const messageText = inputValue; const attachedFiles = attachFiles(); setInputValue(""); setIsSubmitting(true); if (chatStore.websocketRef.current && waitingForChatResponse) { await respondToChatNode( chatStore.websocketRef.current, messageText, attachedFiles, ); setIsSubmitting(false); return; } const response = await chatStore.sendMessage( messageText, attachedFiles, ); if (response?.executionId) { setupWebsocketConnection(response.executionId); } setIsSubmitting(false); }, [ isSubmitDisabled, inputValue, attachFiles, chatStore, waitingForChatResponse, respondToChatNode, setupWebsocketConnection, ], ); const handleEnterKey = async ( event: React.KeyboardEvent, ) => { const isComposing = Boolean( (event.nativeEvent && "isComposing" in event.nativeEvent && event.nativeEvent.isComposing) || ("isComposing" in event && (event as unknown as { isComposing?: boolean }) .isComposing), ); if (event.shiftKey || isComposing) { return; } await handleSubmit(event); adjustTextAreaHeight(); }; const handleFileRemove = (file: File) => { setFiles((prev) => prev.filter((current) => current.name !== file.name), ); }; const handleFileButtonClick = () => { if (isFileUploadDisabled) return; fileInputRef.current?.click(); }; const handleFileInputChange = ( event: React.ChangeEvent, ) => { const newFiles = Array.from(event.target.files ?? []); if (!newFiles.length) return; setFiles((prev) => [...prev, ...newFiles]); event.target.value = ""; }; const handleTextareaKeyDown = ( event: React.KeyboardEvent, ) => { if (event.key === "ArrowUp" || event.key === "ArrowDown") { event.preventDefault(); } else if (event.key === "Escape") { event.preventDefault(); } }; return (
event.stopPropagation()} >