1235 lines
42 KiB
TypeScript
1235 lines
42 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();
|
|
}
|
|
|
|
// Save filter association for this response
|
|
if (conversationFilter && typeof window !== "undefined") {
|
|
const newKey = `conversation_filter_${responseId}`;
|
|
localStorage.setItem(newKey, conversationFilter.id);
|
|
console.log("[CHAT] Saved filter association:", newKey, "=", conversationFilter.id);
|
|
}
|
|
}
|
|
},
|
|
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];
|
|
|
|
console.log("[CHAT] Sending streaming message:", {
|
|
conversationFilter: conversationFilter?.id,
|
|
currentConversationId,
|
|
responseIdToUse,
|
|
});
|
|
|
|
// Use the hook to send the message
|
|
await sendStreamingMessage({
|
|
prompt: userMessage.content,
|
|
previousResponseId: responseIdToUse || undefined,
|
|
filters: processedFilters,
|
|
filter_id: conversationFilter?.id, // ✅ Add filter_id for this conversation
|
|
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;
|
|
}
|
|
|
|
// Add filter_id if a filter is selected for this conversation
|
|
if (conversationFilter) {
|
|
requestBody.filter_id = conversationFilter.id;
|
|
}
|
|
|
|
// Debug logging
|
|
console.log("[DEBUG] Sending message with:", {
|
|
previous_response_id: requestBody.previous_response_id,
|
|
filter_id: requestBody.filter_id,
|
|
currentConversationId,
|
|
previousResponseIds,
|
|
});
|
|
|
|
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) {
|
|
console.log("[DEBUG] Received response_id:", result.response_id, "currentConversationId:", currentConversationId);
|
|
|
|
setPreviousResponseIds((prev) => ({
|
|
...prev,
|
|
[endpoint]: result.response_id,
|
|
}));
|
|
|
|
// If this is a new conversation (no currentConversationId), set it now
|
|
if (!currentConversationId) {
|
|
console.log("[DEBUG] Setting currentConversationId to:", result.response_id);
|
|
setCurrentConversationId(result.response_id);
|
|
refreshConversations(true);
|
|
} else {
|
|
console.log("[DEBUG] Existing conversation, doing silent refresh");
|
|
// For existing conversations, do a silent refresh to keep backend in sync
|
|
refreshConversationsSilent();
|
|
}
|
|
|
|
// Carry forward the filter association to the new response_id
|
|
if (conversationFilter && typeof window !== "undefined") {
|
|
const newKey = `conversation_filter_${result.response_id}`;
|
|
localStorage.setItem(newKey, conversationFilter.id);
|
|
console.log("[DEBUG] Saved filter association:", newKey, "=", conversationFilter.id);
|
|
}
|
|
}
|
|
} 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>
|
|
);
|
|
}
|