|
|
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(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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]); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function captureEnterOrUndo(event) { |
|
|
|
|
|
if (event.keyCode === 13 && !event.shiftKey) { |
|
|
event.preventDefault(); |
|
|
if (isStreaming || isDisabled) return; |
|
|
return submit(event); |
|
|
} |
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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 } }); |
|
|
|
|
|
|
|
|
|
|
|
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> |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function useIsDisabled() { |
|
|
const [isDisabled, setIsDisabled] = useState(false); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 }; |
|
|
} |
|
|
|