merlyn/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx
Timothy Carambat b3944eb50e Revert "Add automatic chat mode with native tool calling support (#5140)"
- Need to support documents in agents
- Need to support images in agent mode

This reverts commit 4c69960dca.
2026-03-04 15:29:41 -08:00

404 lines
13 KiB
JavaScript

import { useState, useEffect, useContext, useRef } 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 { useNavigate, 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";
import { PENDING_HOME_MESSAGE } from "@/utils/constants";
import { safeJsonParse } from "@/utils/request";
import { useTranslation } from "react-i18next";
import paths from "@/utils/paths";
import QuickActions from "@/components/lib/QuickActions";
import SuggestedMessages from "@/components/lib/SuggestedMessages";
export default function ChatContainer({ workspace, knownHistory = [] }) {
const navigate = useNavigate();
const { t } = useTranslation();
const { threadSlug = null } = useParams();
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();
const pendingMessageChecked = useRef(false);
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") {
window.dispatchEvent(
new CustomEvent(PROMPT_INPUT_EVENT, {
detail: { messageContent, writeMode },
})
);
}
const handleSubmit = async (event) => {
event.preventDefault();
const currentMessage =
document.getElementById(PROMPT_INPUT_ID)?.value || "";
if (!currentMessage) return false;
const prevChatHistory = [
...chatHistory,
{
content: currentMessage,
role: "user",
attachments: parseAttachments(),
},
{
content: "",
role: "assistant",
pending: true,
userMessage: currentMessage,
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,
attachments,
animate: true,
},
];
}
setChatHistory(prevChatHistory);
setMessageEmit("");
setLoadingResponse(true);
};
useEffect(() => {
if (pendingMessageChecked.current || !workspace?.slug) return;
pendingMessageChecked.current = true;
const pending = safeJsonParse(sessionStorage.getItem(PENDING_HOME_MESSAGE));
if (pending?.message) {
setTimeout(() => {
sessionStorage.removeItem(PENDING_HOME_MESSAGE);
sendCommand({
text: pending.message,
attachments: pending.attachments || [],
autoSubmit: true,
});
}, 100);
}
}, [workspace?.slug]);
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 {
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]);
const isEmpty =
chatHistory.length === 0 && !sessionStorage.getItem(PENDING_HOME_MESSAGE);
if (isEmpty) {
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-hidden"
>
{isMobile && <SidebarMobileHeader />}
<DnDFileUploaderWrapper>
<div className="flex flex-col h-full w-full items-center justify-center">
<div className="flex flex-col items-center w-full max-w-[750px]">
<h1 className="text-white text-xl md:text-2xl mb-11 text-center">
{t("main-page.greeting")}
</h1>
<PromptInput
submit={handleSubmit}
isStreaming={loadingResponse}
sendCommand={sendCommand}
attachments={files}
centered={true}
/>
<QuickActions
hasAvailableWorkspace={!!workspace}
onCreateAgent={() => navigate(paths.settings.agentSkills())}
onEditWorkspace={() =>
navigate(
paths.workspace.settings.generalAppearance(workspace.slug)
)
}
onUploadDocument={() =>
document.getElementById("dnd-chat-file-uploader")?.click()
}
/>
</div>
<SuggestedMessages
suggestedMessages={workspace?.suggestedMessages}
sendCommand={sendCommand}
/>
</div>
</DnDFileUploaderWrapper>
<ChatTooltips />
</div>
);
}
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>
<div className="flex flex-col h-full w-full">
<div className="contents">
<MetricsProvider>
<ChatHistory
ref={chatHistoryRef}
history={chatHistory}
workspace={workspace}
sendCommand={sendCommand}
updateHistory={setChatHistory}
regenerateAssistantMessage={regenerateAssistantMessage}
/>
</MetricsProvider>
<PromptInput
submit={handleSubmit}
isStreaming={loadingResponse}
sendCommand={sendCommand}
attachments={files}
centered={false}
/>
</div>
</div>
</DnDFileUploaderWrapper>
<ChatTooltips />
</div>
);
}