gaojintao01
Add files using Git LFS
f8b5d42
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 };
}