openrag/frontend/app/chat/page.tsx
2025-11-20 12:31:04 -06:00

1197 lines
40 KiB
TypeScript

"use client";
import { Loader2, Zap } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
import { ProtectedRoute } from "@/components/protected-route";
import { Button } from "@/components/ui/button";
import { type EndpointType, useChat } from "@/contexts/chat-context";
import { useTask } from "@/contexts/task-context";
import { useChatStreaming } from "@/hooks/useChatStreaming";
import { FILE_CONFIRMATION, FILES_REGEX } from "@/lib/constants";
import { useLoadingStore } from "@/stores/loadingStore";
import { useGetNudgesQuery } from "../api/queries/useGetNudgesQuery";
import { AssistantMessage } from "./_components/assistant-message";
import { ChatInput, type ChatInputHandle } from "./_components/chat-input";
import Nudges from "./_components/nudges";
import { UserMessage } from "./_components/user-message";
import type {
FunctionCall,
KnowledgeFilterData,
Message,
RequestBody,
SelectedFilters,
ToolCallResult,
} from "./_types/types";
function ChatPage() {
const isDebugMode = process.env.NEXT_PUBLIC_OPENRAG_DEBUG === "true";
const {
endpoint,
setEndpoint,
currentConversationId,
conversationData,
setCurrentConversationId,
addConversationDoc,
forkFromResponse,
refreshConversations,
refreshConversationsSilent,
previousResponseIds,
setPreviousResponseIds,
placeholderConversation,
conversationFilter,
setConversationFilter,
} = useChat();
const [messages, setMessages] = useState<Message[]>([
{
role: "assistant",
content: "How can I assist?",
timestamp: new Date(),
},
]);
const [input, setInput] = useState("");
const { loading, setLoading } = useLoadingStore();
const [asyncMode, setAsyncMode] = useState(true);
const [expandedFunctionCalls, setExpandedFunctionCalls] = useState<
Set<string>
>(new Set());
// previousResponseIds now comes from useChat context
const [isUploading, setIsUploading] = useState(false);
const [isFilterHighlighted, setIsFilterHighlighted] = useState(false);
const [isUserInteracting, setIsUserInteracting] = useState(false);
const [isForkingInProgress, setIsForkingInProgress] = useState(false);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [waitingTooLong, setWaitingTooLong] = useState(false);
const chatInputRef = useRef<ChatInputHandle>(null);
const { scrollToBottom } = useStickToBottomContext();
const lastLoadedConversationRef = useRef<string | null>(null);
const { addTask } = useTask();
// Use conversation-specific filter instead of global filter
const selectedFilter = conversationFilter;
// Parse the conversation filter data
const parsedFilterData = useMemo(() => {
if (!selectedFilter?.query_data) return null;
try {
return JSON.parse(selectedFilter.query_data);
} catch (error) {
console.error("Error parsing filter data:", error);
return null;
}
}, [selectedFilter]);
// Use the chat streaming hook
const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow";
const {
streamingMessage,
sendMessage: sendStreamingMessage,
abortStream,
isLoading: isStreamLoading,
} = useChatStreaming({
endpoint: apiEndpoint,
onComplete: (message, responseId) => {
setMessages((prev) => [...prev, message]);
setLoading(false);
setWaitingTooLong(false);
if (responseId) {
cancelNudges();
setPreviousResponseIds((prev) => ({
...prev,
[endpoint]: responseId,
}));
if (!currentConversationId) {
setCurrentConversationId(responseId);
refreshConversations(true);
} else {
refreshConversationsSilent();
}
}
},
onError: (error) => {
console.error("Streaming error:", error);
setLoading(false);
setWaitingTooLong(false);
const errorMessage: Message = {
role: "assistant",
content:
"Sorry, I couldn't connect to the chat service. Please try again.",
timestamp: new Date(),
};
setMessages((prev) => [...prev, errorMessage]);
},
});
// Show warning if waiting too long (20 seconds)
useEffect(() => {
let timeoutId: NodeJS.Timeout | null = null;
if (isStreamLoading && !streamingMessage) {
timeoutId = setTimeout(() => {
setWaitingTooLong(true);
}, 20000); // 20 seconds
} else {
setWaitingTooLong(false);
}
return () => {
if (timeoutId) clearTimeout(timeoutId);
};
}, [isStreamLoading, streamingMessage]);
const handleEndpointChange = (newEndpoint: EndpointType) => {
setEndpoint(newEndpoint);
// Clear the conversation when switching endpoints to avoid response ID conflicts
setMessages([]);
setPreviousResponseIds({ chat: null, langflow: null });
};
const handleFileUpload = async (file: File) => {
console.log("handleFileUpload called with file:", file.name);
if (isUploading) return;
setIsUploading(true);
setLoading(true);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("endpoint", endpoint);
// Add previous_response_id if we have one for this endpoint
const currentResponseId = previousResponseIds[endpoint];
if (currentResponseId) {
formData.append("previous_response_id", currentResponseId);
}
const response = await fetch("/api/upload_context", {
method: "POST",
body: formData,
});
console.log("Upload response status:", response.status);
if (!response.ok) {
const errorText = await response.text();
console.error(
"Upload failed with status:",
response.status,
"Response:",
errorText,
);
throw new Error("Failed to process document");
}
const result = await response.json();
console.log("Upload result:", result);
if (response.status === 201) {
// New flow: Got task ID, start tracking with centralized system
const taskId = result.task_id || result.id;
if (!taskId) {
console.error("No task ID in 201 response:", result);
throw new Error("No task ID received from server");
}
// Add task to centralized tracking
addTask(taskId);
return null;
} else if (response.ok) {
// Original flow: Direct response
const uploadMessage: Message = {
role: "user",
content: `I'm uploading a document called "${result.filename}". Here is its content:`,
timestamp: new Date(),
};
const confirmationMessage: Message = {
role: "assistant",
content: `Confirmed`,
timestamp: new Date(),
};
setMessages((prev) => [...prev, uploadMessage, confirmationMessage]);
// Add file to conversation docs
if (result.filename) {
addConversationDoc(result.filename);
}
// Update the response ID for this endpoint
if (result.response_id) {
setPreviousResponseIds((prev) => ({
...prev,
[endpoint]: result.response_id,
}));
// If this is a new conversation (no currentConversationId), set it now
if (!currentConversationId) {
setCurrentConversationId(result.response_id);
refreshConversations(true);
} else {
// For existing conversations, do a silent refresh to keep backend in sync
refreshConversationsSilent();
}
return result.response_id;
}
} else {
throw new Error(`Upload failed: ${response.status}`);
}
} catch (error) {
console.error("Upload failed:", error);
const errorMessage: Message = {
role: "assistant",
content: `❌ Failed to process document. Please try again.`,
timestamp: new Date(),
};
setMessages((prev) => [...prev.slice(0, -1), errorMessage]);
} finally {
setIsUploading(false);
setLoading(false);
}
};
const handleFilePickerClick = () => {
chatInputRef.current?.clickFileInput();
};
const handleFilterSelect = (filter: KnowledgeFilterData | null) => {
// Update conversation-specific filter
setConversationFilter(filter);
setIsFilterHighlighted(false);
};
// Auto-focus the input on component mount
useEffect(() => {
chatInputRef.current?.focusInput();
}, []);
// Explicitly handle external new conversation trigger
useEffect(() => {
const handleNewConversation = () => {
// Abort any in-flight streaming so it doesn't bleed into new chat
abortStream();
// Reset chat UI even if context state was already 'new'
setMessages([
{
role: "assistant",
content: "How can I assist?",
timestamp: new Date(),
},
]);
setInput("");
setExpandedFunctionCalls(new Set());
setIsFilterHighlighted(false);
setLoading(false);
lastLoadedConversationRef.current = null;
// Focus input after a short delay to ensure rendering is complete
setTimeout(() => {
chatInputRef.current?.focusInput();
}, 100);
};
const handleFocusInput = () => {
chatInputRef.current?.focusInput();
};
window.addEventListener("newConversation", handleNewConversation);
window.addEventListener("focusInput", handleFocusInput);
return () => {
window.removeEventListener("newConversation", handleNewConversation);
window.removeEventListener("focusInput", handleFocusInput);
};
}, [abortStream, setLoading]);
// Load conversation only when user explicitly selects a conversation
useEffect(() => {
// Only load conversation data when:
// 1. conversationData exists AND
// 2. It's different from the last loaded conversation AND
// 3. User is not in the middle of an interaction
if (
conversationData?.messages &&
lastLoadedConversationRef.current !== conversationData.response_id &&
!isUserInteracting &&
!isForkingInProgress
) {
console.log(
"Loading conversation with",
conversationData.messages.length,
"messages",
);
// Convert backend message format to frontend Message interface
const convertedMessages: Message[] = conversationData.messages.map(
(msg: {
role: string;
content: string;
timestamp?: string;
response_id?: string;
chunks?: Array<{
item?: {
type?: string;
tool_name?: string;
id?: string;
inputs?: unknown;
results?: unknown;
status?: string;
};
delta?: {
tool_calls?: Array<{
id?: string;
function?: { name?: string; arguments?: string };
type?: string;
}>;
};
type?: string;
result?: unknown;
output?: unknown;
response?: unknown;
}>;
response_data?: unknown;
}) => {
const message: Message = {
role: msg.role as "user" | "assistant",
content: msg.content,
timestamp: new Date(msg.timestamp || new Date()),
};
// Extract function calls from chunks or response_data
if (msg.role === "assistant" && (msg.chunks || msg.response_data)) {
const functionCalls: FunctionCall[] = [];
console.log("Processing assistant message for function calls:", {
hasChunks: !!msg.chunks,
chunksLength: msg.chunks?.length,
hasResponseData: !!msg.response_data,
});
// Process chunks (streaming data)
if (msg.chunks && Array.isArray(msg.chunks)) {
for (const chunk of msg.chunks) {
// Handle Langflow format: chunks[].item.tool_call
if (chunk.item && chunk.item.type === "tool_call") {
const toolCall = chunk.item;
console.log("Found Langflow tool call:", toolCall);
functionCalls.push({
id: toolCall.id || "",
name: toolCall.tool_name || "unknown",
arguments:
(toolCall.inputs as Record<string, unknown>) || {},
argumentsString: JSON.stringify(toolCall.inputs || {}),
result: toolCall.results as
| Record<string, unknown>
| ToolCallResult[],
status:
(toolCall.status as "pending" | "completed" | "error") ||
"completed",
type: "tool_call",
});
}
// Handle OpenAI format: chunks[].delta.tool_calls
else if (chunk.delta?.tool_calls) {
for (const toolCall of chunk.delta.tool_calls) {
if (toolCall.function) {
functionCalls.push({
id: toolCall.id || "",
name: toolCall.function.name || "unknown",
arguments: toolCall.function.arguments
? JSON.parse(toolCall.function.arguments)
: {},
argumentsString: toolCall.function.arguments || "",
status: "completed",
type: toolCall.type || "function",
});
}
}
}
// Process tool call results from chunks
if (
chunk.type === "response.tool_call.result" ||
chunk.type === "tool_call_result"
) {
const lastCall = functionCalls[functionCalls.length - 1];
if (lastCall) {
lastCall.result =
(chunk.result as
| Record<string, unknown>
| ToolCallResult[]) ||
(chunk as Record<string, unknown>);
lastCall.status = "completed";
}
}
}
}
// Process response_data (non-streaming data)
if (msg.response_data && typeof msg.response_data === "object") {
// Look for tool_calls in various places in the response data
const responseData =
typeof msg.response_data === "string"
? JSON.parse(msg.response_data)
: msg.response_data;
if (
responseData.tool_calls &&
Array.isArray(responseData.tool_calls)
) {
for (const toolCall of responseData.tool_calls) {
functionCalls.push({
id: toolCall.id,
name: toolCall.function?.name || toolCall.name,
arguments:
toolCall.function?.arguments || toolCall.arguments,
argumentsString:
typeof (
toolCall.function?.arguments || toolCall.arguments
) === "string"
? toolCall.function?.arguments || toolCall.arguments
: JSON.stringify(
toolCall.function?.arguments || toolCall.arguments,
),
result: toolCall.result,
status: "completed",
type: toolCall.type || "function",
});
}
}
}
if (functionCalls.length > 0) {
console.log("Setting functionCalls on message:", functionCalls);
message.functionCalls = functionCalls;
} else {
console.log("No function calls found in message");
}
}
return message;
},
);
setMessages(convertedMessages);
lastLoadedConversationRef.current = conversationData.response_id;
// Set the previous response ID for this conversation
setPreviousResponseIds((prev) => ({
...prev,
[conversationData.endpoint]: conversationData.response_id,
}));
// Focus input when loading a conversation
setTimeout(() => {
chatInputRef.current?.focusInput();
}, 100);
}
}, [
conversationData,
isUserInteracting,
isForkingInProgress,
setPreviousResponseIds,
]);
// Handle new conversation creation - only reset messages when placeholderConversation is set
useEffect(() => {
if (placeholderConversation && currentConversationId === null) {
console.log("Starting new conversation");
setMessages([
{
role: "assistant",
content: "How can I assist?",
timestamp: new Date(),
},
]);
lastLoadedConversationRef.current = null;
// Focus input when starting a new conversation
setTimeout(() => {
chatInputRef.current?.focusInput();
}, 100);
}
}, [placeholderConversation, currentConversationId]);
// Listen for file upload events from navigation
useEffect(() => {
const handleFileUploadStart = (event: CustomEvent) => {
const { filename } = event.detail;
console.log("Chat page received file upload start event:", filename);
setLoading(true);
setIsUploading(true);
setUploadedFile(null); // Clear previous file
};
const handleFileUploaded = (event: CustomEvent) => {
const { result } = event.detail;
console.log("Chat page received file upload event:", result);
setUploadedFile(null); // Clear file after upload
// Update the response ID for this endpoint
if (result.response_id) {
setPreviousResponseIds((prev) => ({
...prev,
[endpoint]: result.response_id,
}));
}
};
const handleFileUploadComplete = () => {
console.log("Chat page received file upload complete event");
setLoading(false);
setIsUploading(false);
};
const handleFileUploadError = (event: CustomEvent) => {
const { filename, error } = event.detail;
console.log(
"Chat page received file upload error event:",
filename,
error,
);
// Replace the last message with error message
const errorMessage: Message = {
role: "assistant",
content: `❌ Upload failed for **${filename}**: ${error}`,
timestamp: new Date(),
};
setMessages((prev) => [...prev.slice(0, -1), errorMessage]);
setUploadedFile(null); // Clear file on error
};
window.addEventListener(
"fileUploadStart",
handleFileUploadStart as EventListener,
);
window.addEventListener(
"fileUploaded",
handleFileUploaded as EventListener,
);
window.addEventListener(
"fileUploadComplete",
handleFileUploadComplete as EventListener,
);
window.addEventListener(
"fileUploadError",
handleFileUploadError as EventListener,
);
return () => {
window.removeEventListener(
"fileUploadStart",
handleFileUploadStart as EventListener,
);
window.removeEventListener(
"fileUploaded",
handleFileUploaded as EventListener,
);
window.removeEventListener(
"fileUploadComplete",
handleFileUploadComplete as EventListener,
);
window.removeEventListener(
"fileUploadError",
handleFileUploadError as EventListener,
);
};
}, [endpoint, setPreviousResponseIds, setLoading]);
// Check if onboarding is complete by looking at local storage
const [isOnboardingComplete, setIsOnboardingComplete] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem("onboarding-step") === null;
});
// Listen for storage changes to detect when onboarding completes
useEffect(() => {
const checkOnboarding = () => {
if (typeof window !== "undefined") {
setIsOnboardingComplete(
localStorage.getItem("onboarding-step") === null,
);
}
};
// Check periodically since storage events don't fire in the same tab
const interval = setInterval(checkOnboarding, 500);
return () => clearInterval(interval);
}, []);
// Prepare filters for nudges (same as chat)
const processedFiltersForNudges = parsedFilterData?.filters
? (() => {
const filters = parsedFilterData.filters;
const processed: SelectedFilters = {
data_sources: [],
document_types: [],
owners: [],
};
processed.data_sources = filters.data_sources.includes("*")
? []
: filters.data_sources;
processed.document_types = filters.document_types.includes("*")
? []
: filters.document_types;
processed.owners = filters.owners.includes("*") ? [] : filters.owners;
const hasFilters =
processed.data_sources.length > 0 ||
processed.document_types.length > 0 ||
processed.owners.length > 0;
return hasFilters ? processed : undefined;
})()
: undefined;
const { data: nudges = [], cancel: cancelNudges } = useGetNudgesQuery(
{
chatId: previousResponseIds[endpoint],
filters: processedFiltersForNudges,
limit: parsedFilterData?.limit ?? 3,
scoreThreshold: parsedFilterData?.scoreThreshold ?? 0,
},
{
enabled: isOnboardingComplete, // Only fetch nudges after onboarding is complete
},
);
const handleSSEStream = async (
userMessage: Message,
previousResponseId?: string,
) => {
// Prepare filters
const processedFilters = parsedFilterData?.filters
? (() => {
const filters = parsedFilterData.filters;
const processed: SelectedFilters = {
data_sources: [],
document_types: [],
owners: [],
};
processed.data_sources = filters.data_sources.includes("*")
? []
: filters.data_sources;
processed.document_types = filters.document_types.includes("*")
? []
: filters.document_types;
processed.owners = filters.owners.includes("*") ? [] : filters.owners;
const hasFilters =
processed.data_sources.length > 0 ||
processed.document_types.length > 0 ||
processed.owners.length > 0;
return hasFilters ? processed : undefined;
})()
: undefined;
// Use passed previousResponseId if available, otherwise fall back to state
const responseIdToUse = previousResponseId || previousResponseIds[endpoint];
// Use the hook to send the message
await sendStreamingMessage({
prompt: userMessage.content,
previousResponseId: responseIdToUse || undefined,
filters: processedFilters,
limit: parsedFilterData?.limit ?? 10,
scoreThreshold: parsedFilterData?.scoreThreshold ?? 0,
});
scrollToBottom({
animation: "smooth",
duration: 1000,
});
};
const handleSendMessage = async (
inputMessage: string,
previousResponseId?: string,
) => {
if (!inputMessage.trim() || loading) return;
const userMessage: Message = {
role: "user",
content: inputMessage.trim(),
timestamp: new Date(),
};
if (messages.length === 1) {
setMessages([userMessage]);
} else {
setMessages((prev) => [...prev, userMessage]);
}
setInput("");
setLoading(true);
setIsFilterHighlighted(false);
scrollToBottom({
animation: "smooth",
duration: 1000,
});
if (asyncMode) {
await handleSSEStream(userMessage, previousResponseId);
} else {
// Original non-streaming logic
try {
const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow";
const requestBody: RequestBody = {
prompt: userMessage.content,
...(parsedFilterData?.filters &&
(() => {
const filters = parsedFilterData.filters;
const processed: SelectedFilters = {
data_sources: [],
document_types: [],
owners: [],
};
// Only copy non-wildcard arrays
processed.data_sources = filters.data_sources.includes("*")
? []
: filters.data_sources;
processed.document_types = filters.document_types.includes("*")
? []
: filters.document_types;
processed.owners = filters.owners.includes("*")
? []
: filters.owners;
// Only include filters if any array has values
const hasFilters =
processed.data_sources.length > 0 ||
processed.document_types.length > 0 ||
processed.owners.length > 0;
return hasFilters ? { filters: processed } : {};
})()),
limit: parsedFilterData?.limit ?? 10,
scoreThreshold: parsedFilterData?.scoreThreshold ?? 0,
};
// Add previous_response_id if we have one for this endpoint
const currentResponseId = previousResponseIds[endpoint];
if (currentResponseId) {
requestBody.previous_response_id = currentResponseId;
}
const response = await fetch(apiEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
const result = await response.json();
if (response.ok) {
const assistantMessage: Message = {
role: "assistant",
content: result.response,
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMessage]);
if (result.response_id) {
cancelNudges();
}
// Store the response ID if present for this endpoint
if (result.response_id) {
setPreviousResponseIds((prev) => ({
...prev,
[endpoint]: result.response_id,
}));
// If this is a new conversation (no currentConversationId), set it now
if (!currentConversationId) {
setCurrentConversationId(result.response_id);
refreshConversations(true);
} else {
// For existing conversations, do a silent refresh to keep backend in sync
refreshConversationsSilent();
}
}
} else {
console.error("Chat failed:", result.error);
const errorMessage: Message = {
role: "assistant",
content: "Sorry, I encountered an error. Please try again.",
timestamp: new Date(),
};
setMessages((prev) => [...prev, errorMessage]);
}
} catch (error) {
console.error("Chat error:", error);
const errorMessage: Message = {
role: "assistant",
content:
"Sorry, I couldn't connect to the chat service. Please try again.",
timestamp: new Date(),
};
setMessages((prev) => [...prev, errorMessage]);
}
}
setLoading(false);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Check if there's an uploaded file and upload it first
let uploadedResponseId: string | null = null;
if (uploadedFile) {
// Upload the file first
const responseId = await handleFileUpload(uploadedFile);
// Clear the file after upload
setUploadedFile(null);
// If the upload resulted in a new conversation, store the response ID
if (responseId) {
uploadedResponseId = responseId;
setPreviousResponseIds((prev) => ({
...prev,
[endpoint]: responseId,
}));
}
}
// Only send message if there's input text
if (input.trim() || uploadedFile) {
// Pass the responseId from upload (if any) to handleSendMessage
handleSendMessage(
!input.trim() ? FILE_CONFIRMATION : input,
uploadedResponseId || undefined,
);
}
};
const toggleFunctionCall = (functionCallId: string) => {
setExpandedFunctionCalls((prev) => {
const newSet = new Set(prev);
if (newSet.has(functionCallId)) {
newSet.delete(functionCallId);
} else {
newSet.add(functionCallId);
}
return newSet;
});
};
const handleForkConversation = (
messageIndex: number,
event?: React.MouseEvent,
) => {
// Prevent any default behavior and stop event propagation
if (event) {
event.preventDefault();
event.stopPropagation();
}
// Set interaction state to prevent auto-scroll interference
setIsUserInteracting(true);
setIsForkingInProgress(true);
console.log("Fork conversation called for message index:", messageIndex);
// Get messages up to and including the selected assistant message
const messagesToKeep = messages.slice(0, messageIndex + 1);
// The selected message should be an assistant message (since fork button is only on assistant messages)
const forkedMessage = messages[messageIndex];
if (forkedMessage.role !== "assistant") {
console.error("Fork button should only be on assistant messages");
setIsUserInteracting(false);
setIsForkingInProgress(false);
return;
}
// For forking, we want to continue from the response_id of the assistant message we're forking from
// Since we don't store individual response_ids per message yet, we'll use the current conversation's response_id
// This means we're continuing the conversation thread from that point
const responseIdToForkFrom =
currentConversationId || previousResponseIds[endpoint];
// Create a new conversation by properly forking
setMessages(messagesToKeep);
// Use the chat context's fork method which handles creating a new conversation properly
if (forkFromResponse) {
forkFromResponse(responseIdToForkFrom || "");
} else {
// Fallback to manual approach
setCurrentConversationId(null); // This creates a new conversation thread
// Set the response_id we want to continue from as the previous response ID
// This tells the backend to continue the conversation from this point
setPreviousResponseIds((prev) => ({
...prev,
[endpoint]: responseIdToForkFrom,
}));
}
console.log("Forked conversation with", messagesToKeep.length, "messages");
// Reset interaction state after a longer delay to ensure all effects complete
setTimeout(() => {
setIsUserInteracting(false);
setIsForkingInProgress(false);
console.log("Fork interaction complete, re-enabling auto effects");
}, 500);
// The original conversation remains unchanged in the sidebar
// This new forked conversation will get its own response_id when the user sends the next message
};
const handleSuggestionClick = (suggestion: string) => {
handleSendMessage(suggestion);
};
return (
<>
{/* Debug header - only show in debug mode */}
{isDebugMode && (
<div className="flex items-center justify-between p-6">
<div className="flex items-center gap-2"></div>
<div className="flex items-center gap-4">
{/* Async Mode Toggle */}
<div className="flex items-center gap-2 bg-muted/50 rounded-lg p-1">
<Button
variant={!asyncMode ? "default" : "ghost"}
size="sm"
onClick={() => setAsyncMode(false)}
className="h-7 text-xs"
>
Streaming Off
</Button>
<Button
variant={asyncMode ? "default" : "ghost"}
size="sm"
onClick={() => setAsyncMode(true)}
className="h-7 text-xs"
>
<Zap className="h-3 w-3 mr-1" />
Streaming On
</Button>
</div>
{/* Endpoint Toggle */}
<div className="flex items-center gap-2 bg-muted/50 rounded-lg p-1">
<Button
variant={endpoint === "chat" ? "default" : "ghost"}
size="sm"
onClick={() => handleEndpointChange("chat")}
className="h-7 text-xs"
>
Chat
</Button>
<Button
variant={endpoint === "langflow" ? "default" : "ghost"}
size="sm"
onClick={() => handleEndpointChange("langflow")}
className="h-7 text-xs"
>
Langflow
</Button>
</div>
</div>
</div>
)}
<StickToBottom.Content className="flex flex-col min-h-full overflow-x-hidden p-6">
<div className="flex flex-col place-self-center space-y-6 max-w-[960px] w-full mx-auto">
{messages.length === 0 && !streamingMessage ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">
{isUploading ? (
<>
<Loader2 className="h-12 w-12 mx-auto mb-4 animate-spin" />
<p>Processing your document...</p>
<p className="text-sm mt-2">This may take a few moments</p>
</>
) : null}
</div>
</div>
) : (
<>
{messages.map((message, index) =>
message.role === "user"
? (messages[index]?.content.match(FILES_REGEX)?.[0] ??
null) === null && (
<div
key={`${
message.role
}-${index}-${message.timestamp?.getTime()}`}
className="space-y-6 group"
>
<UserMessage
animate={
message.source
? message.source !== "langflow"
: false
}
content={
index >= 2 &&
(messages[index - 2]?.content.match(
FILES_REGEX,
)?.[0] ??
undefined) &&
message.content === FILE_CONFIRMATION
? undefined
: message.content
}
files={
index >= 2
? (messages[index - 2]?.content.match(
FILES_REGEX,
)?.[0] ?? undefined)
: undefined
}
/>
</div>
)
: message.role === "assistant" &&
(index < 1 ||
(messages[index - 1]?.content.match(FILES_REGEX)?.[0] ??
null) === null) && (
<div
key={`${
message.role
}-${index}-${message.timestamp?.getTime()}`}
className="space-y-6 group"
>
<AssistantMessage
content={message.content}
functionCalls={message.functionCalls}
messageIndex={index}
expandedFunctionCalls={expandedFunctionCalls}
onToggle={toggleFunctionCall}
showForkButton={endpoint === "chat"}
onFork={(e) => handleForkConversation(index, e)}
animate={false}
isInactive={index < messages.length - 1}
isInitialGreeting={
index === 0 &&
messages.length === 1 &&
message.content === "How can I assist?"
}
/>
</div>
),
)}
{/* Streaming Message Display */}
{streamingMessage && (
<AssistantMessage
content={streamingMessage.content}
functionCalls={streamingMessage.functionCalls}
messageIndex={messages.length}
expandedFunctionCalls={expandedFunctionCalls}
onToggle={toggleFunctionCall}
delay={0.4}
isStreaming
isCompleted={false}
/>
)}
{/* Waiting too long indicator */}
{waitingTooLong && !streamingMessage && loading && (
<div className="pl-10 space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>The server is taking longer than expected...</span>
</div>
<p className="text-xs text-muted-foreground">
This may be due to high server load. The request will
timeout after 60 seconds.
</p>
</div>
)}
</>
)}
{!streamingMessage && (
<div className="pl-10">
<Nudges
nudges={loading ? [] : (nudges as string[])}
handleSuggestionClick={handleSuggestionClick}
/>
</div>
)}
</div>
</StickToBottom.Content>
<div className="p-6 pt-0 max-w-[960px] mx-auto w-full">
{/* Input Area - Fixed at bottom */}
<ChatInput
ref={chatInputRef}
input={input}
loading={loading}
isUploading={isUploading}
selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData}
uploadedFile={uploadedFile}
onSubmit={handleSubmit}
onChange={setInput}
onKeyDown={(e) => {
// Handle backspace for filter clearing
if (
e.key === "Backspace" &&
selectedFilter &&
input.trim() === ""
) {
e.preventDefault();
if (isFilterHighlighted) {
// Second backspace - remove the filter
setConversationFilter(null);
setIsFilterHighlighted(false);
} else {
// First backspace - highlight the filter
setIsFilterHighlighted(true);
}
return;
}
// Handle Enter key for form submission
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (input.trim() && !loading) {
// Trigger form submission by finding the form and calling submit
const form = e.currentTarget.closest("form");
if (form) {
form.requestSubmit();
}
}
}
}}
onFilterSelect={handleFilterSelect}
onFilePickerClick={handleFilePickerClick}
onFileSelected={setUploadedFile}
setSelectedFilter={setConversationFilter}
setIsFilterHighlighted={setIsFilterHighlighted}
/>
</div>
</>
);
}
export default function ProtectedChatPage() {
return (
<ProtectedRoute>
<div className="flex w-full h-full overflow-hidden">
<StickToBottom
className="flex h-full flex-1 flex-col"
resize="smooth"
initial="instant"
mass={1}
>
<ChatPage />
</StickToBottom>
</div>
</ProtectedRoute>
);
}