import React, { useState, useRef, useEffect } from "react"; import SlashCommandsButton, { SlashCommands, useSlashCommands, } from "./SlashCommands"; import debounce from "lodash.debounce"; import { PaperPlaneRight } from "@phosphor-icons/react"; import StopGenerationButton from "./StopGenerationButton"; import AvailableAgentsButton, { AvailableAgents, useAvailableAgents, } from "./AgentMenu"; import TextSizeButton from "./TextSizeMenu"; import LLMSelectorAction from "./LLMSelector/action"; import SpeechToText from "./SpeechToText"; import { Tooltip } from "react-tooltip"; import AttachmentManager from "./Attachments"; import AttachItem from "./AttachItem"; import { ATTACHMENTS_PROCESSED_EVENT, ATTACHMENTS_PROCESSING_EVENT, PASTE_ATTACHMENT_EVENT, } from "../DnDWrapper"; import useTextSize from "@/hooks/useTextSize"; import { useTranslation } from "react-i18next"; import Appearance from "@/models/appearance"; export const PROMPT_INPUT_ID = "primary-prompt-input"; export const PROMPT_INPUT_EVENT = "set_prompt_input"; const MAX_EDIT_STACK_SIZE = 100; export default function PromptInput({ submit, onChange, isStreaming, sendCommand, attachments = [], }) { const { t } = useTranslation(); const { isDisabled } = useIsDisabled(); const [promptInput, setPromptInput] = useState(""); const { showAgents, setShowAgents } = useAvailableAgents(); const { showSlashCommand, setShowSlashCommand } = useSlashCommands(); const formRef = useRef(null); const textareaRef = useRef(null); const [_, setFocused] = useState(false); const undoStack = useRef([]); const redoStack = useRef([]); const { textSizeClass } = useTextSize(); /** * To prevent too many re-renders we remotely listen for updates from the parent * via an event cycle. Otherwise, using message as a prop leads to a re-render every * change on the input. * @param {{detail: {messageContent: string, writeMode: 'replace' | 'append'}}} e */ function handlePromptUpdate(e) { const { messageContent, writeMode = "replace" } = e?.detail ?? {}; if (writeMode === "append") setPromptInput((prev) => prev + messageContent); else setPromptInput(messageContent ?? ""); } useEffect(() => { if (!!window) window.addEventListener(PROMPT_INPUT_EVENT, handlePromptUpdate); return () => window?.removeEventListener(PROMPT_INPUT_EVENT, handlePromptUpdate); }, []); useEffect(() => { if (!isStreaming && textareaRef.current) textareaRef.current.focus(); resetTextAreaHeight(); }, [isStreaming]); /** * Save the current state before changes * @param {number} adjustment */ function saveCurrentState(adjustment = 0) { if (undoStack.current.length >= MAX_EDIT_STACK_SIZE) undoStack.current.shift(); undoStack.current.push({ value: promptInput, cursorPositionStart: textareaRef.current.selectionStart + adjustment, cursorPositionEnd: textareaRef.current.selectionEnd + adjustment, }); } const debouncedSaveState = debounce(saveCurrentState, 250); function handleSubmit(e) { setFocused(false); submit(e); } function resetTextAreaHeight() { if (!textareaRef.current) return; textareaRef.current.style.height = "auto"; } function checkForSlash(e) { const input = e.target.value; if (input === "/") setShowSlashCommand(true); if (showSlashCommand) setShowSlashCommand(false); return; } const watchForSlash = debounce(checkForSlash, 300); function checkForAt(e) { const input = e.target.value; if (input === "@") return setShowAgents(true); if (showAgents) return setShowAgents(false); } const watchForAt = debounce(checkForAt, 300); /** * Capture enter key press to handle submission, redo, or undo * via keyboard shortcuts * @param {KeyboardEvent} event */ function captureEnterOrUndo(event) { // Is simple enter key press w/o shift key if (event.keyCode === 13 && !event.shiftKey) { event.preventDefault(); if (isStreaming || isDisabled) return; // Prevent submission if streaming or disabled return submit(event); } // Is undo with Ctrl+Z or Cmd+Z + Shift key = Redo if ( (event.ctrlKey || event.metaKey) && event.key === "z" && event.shiftKey ) { event.preventDefault(); if (redoStack.current.length === 0) return; const nextState = redoStack.current.pop(); if (!nextState) return; undoStack.current.push({ value: promptInput, cursorPositionStart: textareaRef.current.selectionStart, cursorPositionEnd: textareaRef.current.selectionEnd, }); setPromptInput(nextState.value); setTimeout(() => { textareaRef.current.setSelectionRange( nextState.cursorPositionStart, nextState.cursorPositionEnd ); }, 0); } // Undo with Ctrl+Z or Cmd+Z if ( (event.ctrlKey || event.metaKey) && event.key === "z" && !event.shiftKey ) { if (undoStack.current.length === 0) return; const lastState = undoStack.current.pop(); if (!lastState) return; redoStack.current.push({ value: promptInput, cursorPositionStart: textareaRef.current.selectionStart, cursorPositionEnd: textareaRef.current.selectionEnd, }); setPromptInput(lastState.value); setTimeout(() => { textareaRef.current.setSelectionRange( lastState.cursorPositionStart, lastState.cursorPositionEnd ); }, 0); } } function adjustTextArea(event) { const element = event.target; element.style.height = "auto"; element.style.height = `${element.scrollHeight}px`; } function handlePasteEvent(e) { e.preventDefault(); if (e.clipboardData.items.length === 0) return false; // paste any clipboard items that are images. for (const item of e.clipboardData.items) { if (item.type.startsWith("image/")) { const file = item.getAsFile(); window.dispatchEvent( new CustomEvent(PASTE_ATTACHMENT_EVENT, { detail: { files: [file] }, }) ); continue; } // handle files specifically that are not images as uploads if (item.kind === "file") { const file = item.getAsFile(); window.dispatchEvent( new CustomEvent(PASTE_ATTACHMENT_EVENT, { detail: { files: [file] }, }) ); continue; } } const pasteText = e.clipboardData.getData("text/plain"); if (pasteText) { const textarea = textareaRef.current; const start = textarea.selectionStart; const end = textarea.selectionEnd; const newPromptInput = promptInput.substring(0, start) + pasteText + promptInput.substring(end); setPromptInput(newPromptInput); onChange({ target: { value: newPromptInput } }); // Set the cursor position after the pasted text // we need to use setTimeout to prevent the cursor from being set to the end of the text setTimeout(() => { textarea.selectionStart = textarea.selectionEnd = start + pasteText.length; adjustTextArea({ target: textarea }); }, 0); } return; } function handleChange(e) { debouncedSaveState(-1); onChange(e); watchForSlash(e); watchForAt(e); adjustTextArea(e); setPromptInput(e.target.value); } return (