Download frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx from leowuming/rag: direct link, hf CLI and curl.
- Browser
- Download file 12.8 kB
-
https://huggingface.co/spaces/leowuming/rag/resolve/main/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx
- Command line
-
hf download hf://spaces/leowuming/rag/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx
-
curl -L -o index.jsx https://huggingface.co/spaces/leowuming/rag/resolve/main/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx
12.8 kB
| 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 ( | |
| <div className="w-full fixed md:absolute bottom-0 left-0 z-10 md:z-0 flex justify-center items-center"> | |
| <SlashCommands | |
| showing={showSlashCommand} | |
| setShowing={setShowSlashCommand} | |
| sendCommand={sendCommand} | |
| promptRef={textareaRef} | |
| /> | |
| <AvailableAgents | |
| showing={showAgents} | |
| setShowing={setShowAgents} | |
| sendCommand={sendCommand} | |
| promptRef={textareaRef} | |
| /> | |
| <form | |
| onSubmit={handleSubmit} | |
| className="flex flex-col gap-y-1 rounded-t-lg md:w-3/4 w-full mx-auto max-w-xl items-center" | |
| > | |
| <div className="flex items-center rounded-lg md:mb-4 md:w-full"> | |
| <div className="w-[95vw] md:w-[635px] bg-theme-bg-chat-input light:bg-white light:border-solid light:border-[1px] light:border-theme-chat-input-border shadow-sm rounded-2xl flex flex-col px-2 overflow-hidden"> | |
| <AttachmentManager attachments={attachments} /> | |
| <div className="flex items-center border-b border-theme-chat-input-border mx-3"> | |
| <textarea | |
| id={PROMPT_INPUT_ID} | |
| ref={textareaRef} | |
| onChange={handleChange} | |
| onKeyDown={captureEnterOrUndo} | |
| onPaste={(e) => { | |
| saveCurrentState(); | |
| handlePasteEvent(e); | |
| }} | |
| required={true} | |
| onFocus={() => setFocused(true)} | |
| onBlur={(e) => { | |
| setFocused(false); | |
| adjustTextArea(e); | |
| }} | |
| value={promptInput} | |
| spellCheck={Appearance.get("enableSpellCheck")} | |
| className={`border-none cursor-text max-h-[50vh] md:max-h-[350px] md:min-h-[40px] mx-2 md:mx-0 pt-[12px] w-full leading-5 md:text-md text-white bg-transparent placeholder:text-white/60 light:placeholder:text-theme-text-primary resize-none active:outline-none focus:outline-none flex-grow mb-1 ${textSizeClass}`} | |
| placeholder={t("chat_window.send_message")} | |
| /> | |
| {isStreaming ? ( | |
| <StopGenerationButton /> | |
| ) : ( | |
| <> | |
| <button | |
| ref={formRef} | |
| type="submit" | |
| disabled={isDisabled} | |
| className="border-none inline-flex justify-center rounded-2xl cursor-pointer opacity-60 hover:opacity-100 light:opacity-100 light:hover:opacity-60 ml-4 disabled:cursor-not-allowed group" | |
| data-tooltip-id="send-prompt" | |
| data-tooltip-content={ | |
| isDisabled | |
| ? t("chat_window.attachments_processing") | |
| : t("chat_window.send") | |
| } | |
| aria-label={t("chat_window.send")} | |
| > | |
| <PaperPlaneRight | |
| color="var(--theme-sidebar-footer-icon-fill)" | |
| className="w-[22px] h-[22px] pointer-events-none text-theme-text-primary group-disabled:opacity-[25%]" | |
| weight="fill" | |
| /> | |
| <span className="sr-only">Send message</span> | |
| </button> | |
| <Tooltip | |
| id="send-prompt" | |
| place="bottom" | |
| delayShow={300} | |
| className="tooltip !text-xs z-99" | |
| /> | |
| </> | |
| )} | |
| </div> | |
| <div className="flex justify-between py-3.5 mx-3 mb-1"> | |
| <div className="flex gap-x-2"> | |
| <AttachItem /> | |
| <SlashCommandsButton | |
| showing={showSlashCommand} | |
| setShowSlashCommand={setShowSlashCommand} | |
| /> | |
| <AvailableAgentsButton | |
| showing={showAgents} | |
| setShowAgents={setShowAgents} | |
| /> | |
| <TextSizeButton /> | |
| <LLMSelectorAction /> | |
| </div> | |
| <div className="flex gap-x-2"> | |
| <SpeechToText sendCommand={sendCommand} /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </form> | |
| </div> | |
| ); | |
| } | |
| /** | |
| * Handle event listeners to prevent the send button from being used | |
| * for whatever reason that may we may want to prevent the user from sending a message. | |
| */ | |
| function useIsDisabled() { | |
| const [isDisabled, setIsDisabled] = useState(false); | |
| /** | |
| * Handle attachments processing and processed events | |
| * to prevent the send button from being clicked when attachments are processing | |
| * or else the query may not have relevant context since RAG is not yet ready. | |
| */ | |
| useEffect(() => { | |
| if (!window) return; | |
| window.addEventListener(ATTACHMENTS_PROCESSING_EVENT, () => | |
| setIsDisabled(true) | |
| ); | |
| window.addEventListener(ATTACHMENTS_PROCESSED_EVENT, () => | |
| setIsDisabled(false) | |
| ); | |
| return () => { | |
| window?.removeEventListener(ATTACHMENTS_PROCESSING_EVENT, () => | |
| setIsDisabled(true) | |
| ); | |
| window?.removeEventListener(ATTACHMENTS_PROCESSED_EVENT, () => | |
| setIsDisabled(false) | |
| ); | |
| }; | |
| }, []); | |
| return { isDisabled }; | |
| } | |