mirror of
https://github.com/Mintplex-Labs/anything-llm
synced 2026-04-25 17:15:37 +02:00
* Add keybindings to scroll to top and bottom of chat history * fix isUserScrolling flag and set scrollToBottom to be instant instead of smoothe * fix stream scroll * fix default export by removing unneeded constant * Replace file-defined `isMac` variable with global util * extract funcitonality to hooks for clarity * patch import --------- Co-authored-by: shatfield4 <seanhatfield5@gmail.com> Co-authored-by: Timothy Carambat <rambat1010@gmail.com>
334 lines
11 KiB
JavaScript
334 lines
11 KiB
JavaScript
import { useState, useEffect, useContext } from "react";
|
|
import ChatHistory from "./ChatHistory";
|
|
import { CLEAR_ATTACHMENTS_EVENT, DndUploaderContext } from "./DnDWrapper";
|
|
import PromptInput, {
|
|
PROMPT_INPUT_EVENT,
|
|
PROMPT_INPUT_ID,
|
|
} from "./PromptInput";
|
|
import Workspace from "@/models/workspace";
|
|
import handleChat, { ABORT_STREAM_EVENT } from "@/utils/chat";
|
|
import { isMobile } from "react-device-detect";
|
|
import { SidebarMobileHeader } from "../../Sidebar";
|
|
import { useParams } from "react-router-dom";
|
|
import { v4 } from "uuid";
|
|
import handleSocketResponse, {
|
|
websocketURI,
|
|
AGENT_SESSION_END,
|
|
AGENT_SESSION_START,
|
|
} from "@/utils/chat/agent";
|
|
import DnDFileUploaderWrapper from "./DnDWrapper";
|
|
import SpeechRecognition, {
|
|
useSpeechRecognition,
|
|
} from "react-speech-recognition";
|
|
import { ChatTooltips } from "./ChatTooltips";
|
|
import { MetricsProvider } from "./ChatHistory/HistoricalMessage/Actions/RenderMetrics";
|
|
import useChatContainerQuickScroll from "@/hooks/useChatContainerQuickScroll";
|
|
|
|
export default function ChatContainer({ workspace, knownHistory = [] }) {
|
|
const { threadSlug = null } = useParams();
|
|
const [message, setMessage] = useState("");
|
|
const [loadingResponse, setLoadingResponse] = useState(false);
|
|
const [chatHistory, setChatHistory] = useState(knownHistory);
|
|
const [socketId, setSocketId] = useState(null);
|
|
const [websocket, setWebsocket] = useState(null);
|
|
const { files, parseAttachments } = useContext(DndUploaderContext);
|
|
const { chatHistoryRef } = useChatContainerQuickScroll();
|
|
|
|
// Maintain state of message from whatever is in PromptInput
|
|
const handleMessageChange = (event) => {
|
|
setMessage(event.target.value);
|
|
};
|
|
|
|
const { listening, resetTranscript } = useSpeechRecognition({
|
|
clearTranscriptOnListen: true,
|
|
});
|
|
|
|
/**
|
|
* Emit an update to the state of the prompt input without directly
|
|
* passing a prop in so that it does not re-render constantly.
|
|
* @param {string} messageContent - The message content to set
|
|
* @param {'replace' | 'append'} writeMode - Replace current text or append to existing text (default: replace)
|
|
*/
|
|
function setMessageEmit(messageContent = "", writeMode = "replace") {
|
|
if (writeMode === "append") setMessage((prev) => prev + messageContent);
|
|
else setMessage(messageContent ?? "");
|
|
|
|
// Push the update to the PromptInput component (same logic as above to keep in sync)
|
|
window.dispatchEvent(
|
|
new CustomEvent(PROMPT_INPUT_EVENT, {
|
|
detail: { messageContent, writeMode },
|
|
})
|
|
);
|
|
}
|
|
|
|
const handleSubmit = async (event) => {
|
|
event.preventDefault();
|
|
if (!message || message === "") return false;
|
|
const prevChatHistory = [
|
|
...chatHistory,
|
|
{
|
|
content: message,
|
|
role: "user",
|
|
attachments: parseAttachments(),
|
|
},
|
|
{
|
|
content: "",
|
|
role: "assistant",
|
|
pending: true,
|
|
userMessage: message,
|
|
animate: true,
|
|
},
|
|
];
|
|
|
|
if (listening) {
|
|
// Stop the mic if the send button is clicked
|
|
endSTTSession();
|
|
}
|
|
setChatHistory(prevChatHistory);
|
|
setMessageEmit("");
|
|
setLoadingResponse(true);
|
|
};
|
|
|
|
function endSTTSession() {
|
|
SpeechRecognition.stopListening();
|
|
resetTranscript();
|
|
}
|
|
|
|
const regenerateAssistantMessage = (chatId) => {
|
|
const updatedHistory = chatHistory.slice(0, -1);
|
|
const lastUserMessage = updatedHistory.slice(-1)[0];
|
|
Workspace.deleteChats(workspace.slug, [chatId])
|
|
.then(() =>
|
|
sendCommand({
|
|
text: lastUserMessage.content,
|
|
autoSubmit: true,
|
|
history: updatedHistory,
|
|
attachments: lastUserMessage?.attachments,
|
|
})
|
|
)
|
|
.catch((e) => console.error(e));
|
|
};
|
|
|
|
/**
|
|
* Send a command to the LLM prompt input.
|
|
* @param {Object} options - Arguments to send to the LLM
|
|
* @param {string} options.text - The text to send to the LLM
|
|
* @param {boolean} options.autoSubmit - Determines if the text should be sent immediately or if it should be added to the message state (default: false)
|
|
* @param {Object[]} options.history - The history of the chat prior to this message for overriding the current chat history
|
|
* @param {Object[import("./DnDWrapper").Attachment]} options.attachments - The attachments to send to the LLM for this message
|
|
* @param {'replace' | 'append'} options.writeMode - Replace current text or append to existing text (default: replace)
|
|
* @returns {void}
|
|
*/
|
|
const sendCommand = async ({
|
|
text = "",
|
|
autoSubmit = false,
|
|
history = [],
|
|
attachments = [],
|
|
writeMode = "replace",
|
|
} = {}) => {
|
|
// If we are not auto-submitting, we can just emit the text to the prompt input.
|
|
if (!autoSubmit) {
|
|
setMessageEmit(text, writeMode);
|
|
return;
|
|
}
|
|
|
|
// If we are auto-submitting in append mode
|
|
// than we need to update text with whatever is in the prompt input + the text we are sending.
|
|
// @note: `message` will not work here since it is not updated yet.
|
|
// If text is still empty, after this, then we should just return.
|
|
if (writeMode === "append") {
|
|
const currentText = document.getElementById(PROMPT_INPUT_ID)?.value;
|
|
text = currentText + text;
|
|
}
|
|
|
|
if (!text || text === "") return false;
|
|
// If we are auto-submitting
|
|
// Then we can replace the current text since this is not accumulating.
|
|
let prevChatHistory;
|
|
if (history.length > 0) {
|
|
// use pre-determined history chain.
|
|
prevChatHistory = [
|
|
...history,
|
|
{
|
|
content: "",
|
|
role: "assistant",
|
|
pending: true,
|
|
userMessage: text,
|
|
attachments,
|
|
animate: true,
|
|
},
|
|
];
|
|
} else {
|
|
prevChatHistory = [
|
|
...chatHistory,
|
|
{
|
|
content: text,
|
|
role: "user",
|
|
attachments,
|
|
},
|
|
{
|
|
content: "",
|
|
role: "assistant",
|
|
pending: true,
|
|
userMessage: text,
|
|
animate: true,
|
|
},
|
|
];
|
|
}
|
|
|
|
setChatHistory(prevChatHistory);
|
|
setMessageEmit("");
|
|
setLoadingResponse(true);
|
|
};
|
|
|
|
useEffect(() => {
|
|
async function fetchReply() {
|
|
const promptMessage =
|
|
chatHistory.length > 0 ? chatHistory[chatHistory.length - 1] : null;
|
|
const remHistory = chatHistory.length > 0 ? chatHistory.slice(0, -1) : [];
|
|
var _chatHistory = [...remHistory];
|
|
|
|
// Override hook for new messages to now go to agents until the connection closes
|
|
if (!!websocket) {
|
|
if (!promptMessage || !promptMessage?.userMessage) return false;
|
|
window.dispatchEvent(new CustomEvent(CLEAR_ATTACHMENTS_EVENT));
|
|
websocket.send(
|
|
JSON.stringify({
|
|
type: "awaitingFeedback",
|
|
feedback: promptMessage?.userMessage,
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!promptMessage || !promptMessage?.userMessage) return false;
|
|
|
|
// If running and edit or regeneration, this history will already have attachments
|
|
// so no need to parse the current state.
|
|
const attachments = promptMessage?.attachments ?? parseAttachments();
|
|
window.dispatchEvent(new CustomEvent(CLEAR_ATTACHMENTS_EVENT));
|
|
|
|
await Workspace.multiplexStream({
|
|
workspaceSlug: workspace.slug,
|
|
threadSlug,
|
|
prompt: promptMessage.userMessage,
|
|
chatHandler: (chatResult) =>
|
|
handleChat(
|
|
chatResult,
|
|
setLoadingResponse,
|
|
setChatHistory,
|
|
remHistory,
|
|
_chatHistory,
|
|
setSocketId
|
|
),
|
|
attachments,
|
|
});
|
|
return;
|
|
}
|
|
loadingResponse === true && fetchReply();
|
|
}, [loadingResponse, chatHistory, workspace]);
|
|
|
|
// TODO: Simplify this WSS stuff
|
|
useEffect(() => {
|
|
function handleWSS() {
|
|
try {
|
|
if (!socketId || !!websocket) return;
|
|
const socket = new WebSocket(
|
|
`${websocketURI()}/api/agent-invocation/${socketId}`
|
|
);
|
|
socket.supportsAgentStreaming = false;
|
|
|
|
window.addEventListener(ABORT_STREAM_EVENT, () => {
|
|
window.dispatchEvent(new CustomEvent(AGENT_SESSION_END));
|
|
websocket.close();
|
|
});
|
|
|
|
socket.addEventListener("message", (event) => {
|
|
setLoadingResponse(true);
|
|
try {
|
|
handleSocketResponse(socket, event, setChatHistory);
|
|
} catch (e) {
|
|
console.error("Failed to parse data");
|
|
window.dispatchEvent(new CustomEvent(AGENT_SESSION_END));
|
|
socket.close();
|
|
}
|
|
setLoadingResponse(false);
|
|
});
|
|
|
|
socket.addEventListener("close", (_event) => {
|
|
window.dispatchEvent(new CustomEvent(AGENT_SESSION_END));
|
|
setChatHistory((prev) => [
|
|
...prev.filter((msg) => !!msg.content),
|
|
{
|
|
uuid: v4(),
|
|
type: "statusResponse",
|
|
content: "Agent session complete.",
|
|
role: "assistant",
|
|
sources: [],
|
|
closed: true,
|
|
error: null,
|
|
animate: false,
|
|
pending: false,
|
|
},
|
|
]);
|
|
setLoadingResponse(false);
|
|
setWebsocket(null);
|
|
setSocketId(null);
|
|
});
|
|
setWebsocket(socket);
|
|
window.dispatchEvent(new CustomEvent(AGENT_SESSION_START));
|
|
window.dispatchEvent(new CustomEvent(CLEAR_ATTACHMENTS_EVENT));
|
|
} catch (e) {
|
|
setChatHistory((prev) => [
|
|
...prev.filter((msg) => !!msg.content),
|
|
{
|
|
uuid: v4(),
|
|
type: "abort",
|
|
content: e.message,
|
|
role: "assistant",
|
|
sources: [],
|
|
closed: true,
|
|
error: e.message,
|
|
animate: false,
|
|
pending: false,
|
|
},
|
|
]);
|
|
setLoadingResponse(false);
|
|
setWebsocket(null);
|
|
setSocketId(null);
|
|
}
|
|
}
|
|
handleWSS();
|
|
}, [socketId]);
|
|
|
|
return (
|
|
<div
|
|
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
|
|
className="transition-all duration-500 relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-theme-bg-secondary w-full h-full overflow-y-scroll no-scroll z-[2]"
|
|
>
|
|
{isMobile && <SidebarMobileHeader />}
|
|
<DnDFileUploaderWrapper>
|
|
<MetricsProvider>
|
|
<ChatHistory
|
|
ref={chatHistoryRef}
|
|
history={chatHistory}
|
|
workspace={workspace}
|
|
sendCommand={sendCommand}
|
|
updateHistory={setChatHistory}
|
|
regenerateAssistantMessage={regenerateAssistantMessage}
|
|
hasAttachments={files.length > 0}
|
|
/>
|
|
</MetricsProvider>
|
|
<PromptInput
|
|
submit={handleSubmit}
|
|
onChange={handleMessageChange}
|
|
isStreaming={loadingResponse}
|
|
sendCommand={sendCommand}
|
|
attachments={files}
|
|
/>
|
|
</DnDFileUploaderWrapper>
|
|
<ChatTooltips />
|
|
</div>
|
|
);
|
|
}
|