mirror of
https://github.com/Mintplex-Labs/anything-llm
synced 2026-04-25 17:15:37 +02:00
* feat: add web app manifest and mobile PWA meta tags * feat: serve dynamic manifest.json with custom branding for pwa * feat: add ios status bar theming for pwa * fix: prevent overscroll behavior for mobile * fix: prevent ios safari auto-zoom on chat input * fix: remove theme-color meta tags conflicting with ios status bar * fix: add missing apple-mobile-web-app-capable meta tag for ios pwa * fix: move catch-all route after manifest endpoint to prevent interception * feat: add pwa detection helper and conditional styling for standalone mode * PWA refactor * undo changes to native CSS * class fix * proper response obj * fix patch for import * fix manifest errors --------- Co-authored-by: Christian De Santis <christian.constantino98@gmail.com>
378 lines
12 KiB
JavaScript
378 lines
12 KiB
JavaScript
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 pwa:pb-5">
|
|
<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 pwa:rounded-3xl 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 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 pwa:!text-[16px] ${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 };
|
|
}
|