diff --git a/flows/openrag_agent.json b/flows/openrag_agent.json index e878eb59..ddaee850 100644 --- a/flows/openrag_agent.json +++ b/flows/openrag_agent.json @@ -1262,7 +1262,7 @@ "display_name": "as_dataframe", "name": "as_dataframe", "readonly": false, - "status": true, + "status": false, "tags": [ "as_dataframe" ] @@ -1281,7 +1281,7 @@ "display_name": "as_vector_store", "name": "as_vector_store", "readonly": false, - "status": true, + "status": false, "tags": [ "as_vector_store" ] @@ -2087,7 +2087,7 @@ "trace_as_input": true, "trace_as_metadata": true, "type": "str", - "value": "You are a helpful assistant that can use tools to answer questions and perform tasks." + "value": "You are a helpful assistant that can use tools to answer questions and perform tasks. You are part of OpenRAG, an assistant that analyzes documents and provides informations about them. When asked about what is OpenRAG, answer the following:\n\n\"OpenRAG is an open-source package for building agentic RAG systems. It supports integration with a wide range of orchestration tools, vector databases, and LLM providers. OpenRAG connects and amplifies three popular, proven open-source projects into one powerful platform:\n\n**Langflow** – Langflow is a powerful tool to build and deploy AI agents and MCP servers [Read more](https://www.langflow.org/)\n\n**OpenSearch** – Langflow is a powerful tool to build and deploy AI agents and MCP servers [Read more](https://opensearch.org/)\n\n**Docling** – Langflow is a powerful tool to build and deploy AI agents and MCP servers [Read more](https://www.docling.ai/)\"" }, "tools": { "_input_type": "HandleInput", diff --git a/frontend/components/docling-health-banner.tsx b/frontend/components/docling-health-banner.tsx index 673d952d..b15a009c 100644 --- a/frontend/components/docling-health-banner.tsx +++ b/frontend/components/docling-health-banner.tsx @@ -1,147 +1,148 @@ "use client"; -import { AlertTriangle, ExternalLink, Copy } from "lucide-react"; -import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery"; -import { Banner, BannerIcon, BannerTitle, BannerAction } from "@/components/ui/banner"; +import { AlertTriangle, Copy, ExternalLink } from "lucide-react"; +import { useState } from "react"; +import { + Banner, + BannerAction, + BannerIcon, + BannerTitle, +} from "@/components/ui/banner"; import { Button } from "@/components/ui/button"; import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, } from "@/components/ui/dialog"; +import { HEADER_HEIGHT } from "@/lib/constants"; import { cn } from "@/lib/utils"; -import { useState } from "react"; +import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery"; interface DoclingHealthBannerProps { - className?: string; + className?: string; } // DoclingSetupDialog component interface DoclingSetupDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - className?: string; + open: boolean; + onOpenChange: (open: boolean) => void; + className?: string; } function DoclingSetupDialog({ - open, - onOpenChange, - className + open, + onOpenChange, + className, }: DoclingSetupDialogProps) { - const [copied, setCopied] = useState(false); + const [copied, setCopied] = useState(false); - const handleCopy = async () => { - await navigator.clipboard.writeText("uv run openrag"); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; + const handleCopy = async () => { + await navigator.clipboard.writeText("uv run openrag"); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; - return ( - - - - - - docling-serve is stopped. Knowledge ingest is unavailable. - - - Start docling-serve by running: - - + return ( + + + + + + docling-serve is stopped. Knowledge ingest is unavailable. + + Start docling-serve by running: + -
-
- - uv run openrag - - -
+
+
+ + uv run openrag + + +
- - Then, select Start All Services in the TUI. Once docling-serve is running, refresh OpenRAG. - -
+ + Then, select{" "} + + Start All Services + {" "} + in the TUI. Once docling-serve is running, refresh OpenRAG. + +
- - - -
-
- ); + + + +
+
+ ); } // Custom hook to check docling health status export function useDoclingHealth() { - const { data: health, isLoading, isError } = useDoclingHealthQuery(); + const { data: health, isLoading, isError } = useDoclingHealthQuery(); - const isHealthy = health?.status === "healthy" && !isError; - const isUnhealthy = health?.status === "unhealthy" || isError; + const isHealthy = health?.status === "healthy" && !isError; + const isUnhealthy = health?.status === "unhealthy" || isError; - return { - health, - isLoading, - isError, - isHealthy, - isUnhealthy, - }; + return { + health, + isLoading, + isError, + isHealthy, + isUnhealthy, + }; } export function DoclingHealthBanner({ className }: DoclingHealthBannerProps) { - const { isLoading, isHealthy, isUnhealthy } = useDoclingHealth(); - const [showDialog, setShowDialog] = useState(false); + const { isLoading, isHealthy, isUnhealthy } = useDoclingHealth(); + const [showDialog, setShowDialog] = useState(false); - // Only show banner when service is unhealthy - if (isLoading || isHealthy) { - return null; - } + // Only show banner when service is unhealthy + if (isLoading || isHealthy) { + return null; + } - if (isUnhealthy) { - return ( - <> - - - - docling-serve native service is stopped. Knowledge ingest is unavailable. - - setShowDialog(true)} - className="bg-foreground text-background hover:bg-primary/90" - > - Setup Docling Serve - - - + if (isUnhealthy) { + return ( + <> + + + + docling-serve native service is stopped. Knowledge ingest is + unavailable. + + setShowDialog(true)} + className="bg-foreground text-background hover:bg-primary/90" + > + Setup Docling Serve + + + - - - ); - } + + + ); + } - return null; -} \ No newline at end of file + return null; +} diff --git a/frontend/components/knowledge-dropdown.tsx b/frontend/components/knowledge-dropdown.tsx index 1e42803f..951b53dd 100644 --- a/frontend/components/knowledge-dropdown.tsx +++ b/frontend/components/knowledge-dropdown.tsx @@ -32,12 +32,16 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useTask } from "@/contexts/task-context"; +import { cn } from "@/lib/utils"; +import { + duplicateCheck, + uploadFile as uploadFileUtil, +} from "@/lib/upload-utils"; import type { File as SearchFile } from "@/src/app/api/queries/useGetSearchQuery"; import GoogleDriveIcon from "@/app/settings/icons/google-drive-icon"; import OneDriveIcon from "@/app/settings/icons/one-drive-icon"; import SharePointIcon from "@/app/settings/icons/share-point-icon"; import AwsIcon from "@/app/settings/icons/aws-icon"; -import { cn } from "@/lib/utils"; export function KnowledgeDropdown() { const { addTask } = useTask(); @@ -155,45 +159,33 @@ export function KnowledgeDropdown() { fileInputRef.current?.click(); }; - const handleFileChange = async (e: React.ChangeEvent) => { - const files = e.target.files; + const resetFileInput = () => { + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const handleFileChange = async ( + event: React.ChangeEvent + ) => { + const files = event.target.files; + if (files && files.length > 0) { const file = files[0]; // File selection will close dropdown automatically try { - // Check if filename already exists (using ORIGINAL filename) console.log("[Duplicate Check] Checking file:", file.name); - const checkResponse = await fetch( - `/api/documents/check-filename?filename=${encodeURIComponent( - file.name - )}` - ); - - console.log("[Duplicate Check] Response status:", checkResponse.status); - - if (!checkResponse.ok) { - const errorText = await checkResponse.text(); - console.error("[Duplicate Check] Error response:", errorText); - throw new Error( - `Failed to check duplicates: ${checkResponse.statusText}` - ); - } - - const checkData = await checkResponse.json(); + const checkData = await duplicateCheck(file); console.log("[Duplicate Check] Result:", checkData); if (checkData.exists) { - // Show duplicate handling dialog console.log("[Duplicate Check] Duplicate detected, showing dialog"); setPendingFile(file); setDuplicateFilename(file.name); setShowDuplicateDialog(true); - // Reset file input - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } + resetFileInput(); return; } @@ -208,105 +200,20 @@ export function KnowledgeDropdown() { } } - // Reset file input - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } + resetFileInput(); }; const uploadFile = async (file: File, replace: boolean) => { setFileUploading(true); - // Trigger the same file upload event as the chat page - window.dispatchEvent( - new CustomEvent("fileUploadStart", { - detail: { filename: file.name }, - }) - ); - try { - const formData = new FormData(); - formData.append("file", file); - formData.append("replace_duplicates", replace.toString()); - - // Use router upload and ingest endpoint (automatically routes based on configuration) - const uploadIngestRes = await fetch("/api/router/upload_ingest", { - method: "POST", - body: formData, - }); - - const uploadIngestJson = await uploadIngestRes.json(); - - if (!uploadIngestRes.ok) { - throw new Error(uploadIngestJson?.error || "Upload and ingest failed"); - } - - // Extract results from the response - handle both unified and simple formats - const fileId = - uploadIngestJson?.upload?.id || - uploadIngestJson?.id || - uploadIngestJson?.task_id; - const filePath = - uploadIngestJson?.upload?.path || uploadIngestJson?.path || "uploaded"; - const runJson = uploadIngestJson?.ingestion; - const deleteResult = uploadIngestJson?.deletion; - console.log("c", uploadIngestJson); - if (!fileId) { - throw new Error("Upload successful but no file id returned"); - } - // Check if ingestion actually succeeded - if ( - runJson && - runJson.status !== "COMPLETED" && - runJson.status !== "SUCCESS" - ) { - const errorMsg = runJson.error || "Ingestion pipeline failed"; - throw new Error( - `Ingestion failed: ${errorMsg}. Try setting DISABLE_INGEST_WITH_LANGFLOW=true if you're experiencing Langflow component issues.` - ); - } - // Log deletion status if provided - if (deleteResult) { - if (deleteResult.status === "deleted") { - console.log( - "File successfully cleaned up from Langflow:", - deleteResult.file_id - ); - } else if (deleteResult.status === "delete_failed") { - console.warn( - "Failed to cleanup file from Langflow:", - deleteResult.error - ); - } - } - // Notify UI - window.dispatchEvent( - new CustomEvent("fileUploaded", { - detail: { - file: file, - result: { - file_id: fileId, - file_path: filePath, - run: runJson, - deletion: deleteResult, - unified: true, - }, - }, - }) - ); - + await uploadFileUtil(file, replace); refetchTasks(); } catch (error) { - window.dispatchEvent( - new CustomEvent("fileUploadError", { - detail: { - filename: file.name, - error: error instanceof Error ? error.message : "Upload failed", - }, - }) - ); + toast.error("Upload failed", { + description: error instanceof Error ? error.message : "Unknown error", + }); } finally { - window.dispatchEvent(new CustomEvent("fileUploadComplete")); setFileUploading(false); } }; @@ -323,6 +230,7 @@ export function KnowledgeDropdown() { }); await uploadFile(pendingFile, true); + setPendingFile(null); setDuplicateFilename(""); } diff --git a/frontend/components/knowledge-search-input.tsx b/frontend/components/knowledge-search-input.tsx index 57899a16..0f31214d 100644 --- a/frontend/components/knowledge-search-input.tsx +++ b/frontend/components/knowledge-search-input.tsx @@ -1,100 +1,100 @@ -import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; -import { - ChangeEvent, - FormEvent, - useCallback, - useEffect, - useState, -} from "react"; -import { filterAccentClasses } from "./knowledge-filter-panel"; import { ArrowRight, Search, X } from "lucide-react"; +import { + type ChangeEvent, + type FormEvent, + useCallback, + useEffect, + useState, +} from "react"; import { Button } from "@/components/ui/button"; +import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; import { cn } from "@/lib/utils"; +import { filterAccentClasses } from "./knowledge-filter-panel"; export const KnowledgeSearchInput = () => { - const { - selectedFilter, - setSelectedFilter, - parsedFilterData, - queryOverride, - setQueryOverride, - } = useKnowledgeFilter(); + const { + selectedFilter, + setSelectedFilter, + parsedFilterData, + queryOverride, + setQueryOverride, + } = useKnowledgeFilter(); - const [searchQueryInput, setSearchQueryInput] = useState(queryOverride || ""); + const [searchQueryInput, setSearchQueryInput] = useState(queryOverride || ""); - const handleSearch = useCallback( - (e?: FormEvent) => { - if (e) e.preventDefault(); - setQueryOverride(searchQueryInput.trim()); - }, - [searchQueryInput, setQueryOverride] - ); + const handleSearch = useCallback( + (e?: FormEvent) => { + if (e) e.preventDefault(); + setQueryOverride(searchQueryInput.trim()); + }, + [searchQueryInput, setQueryOverride], + ); - // Reset the query text when the selected filter changes - useEffect(() => { - setSearchQueryInput(queryOverride); - }, [queryOverride]); + // Reset the query text when the selected filter changes + useEffect(() => { + setSearchQueryInput(queryOverride); + }, [queryOverride]); - return ( -
-
- {selectedFilter?.name && ( -
- {selectedFilter?.name} - setSelectedFilter(null)} - /> -
- )} - - ) => - setSearchQueryInput(e.target.value) - } - /> - {queryOverride && ( - - )} - -
-
- ); + return ( +
+
+ {selectedFilter?.name && ( +
+ {selectedFilter?.name} + setSelectedFilter(null)} + /> +
+ )} + + ) => + setSearchQueryInput(e.target.value) + } + /> + {queryOverride && ( + + )} + +
+
+ ); }; diff --git a/frontend/components/logo/dog-icon.tsx b/frontend/components/logo/dog-icon.tsx index ce0b4104..03158f17 100644 --- a/frontend/components/logo/dog-icon.tsx +++ b/frontend/components/logo/dog-icon.tsx @@ -3,25 +3,22 @@ interface DogIconProps extends React.SVGProps { } const DogIcon = ({ disabled = false, stroke, ...props }: DogIconProps) => { - const strokeColor = disabled ? "#71717A" : (stroke || "#0F62FE"); + const fillColor = disabled ? "#71717A" : (stroke || "#22A7AF"); return ( - - - + disabled ? ( + + + + + ) : ( + + + + + + + ) ) } diff --git a/frontend/components/logo/logo.tsx b/frontend/components/logo/logo.tsx index 018665de..490b5029 100644 --- a/frontend/components/logo/logo.tsx +++ b/frontend/components/logo/logo.tsx @@ -1,19 +1,9 @@ export default function Logo(props: React.SVGProps) { return ( - + OpenRAG Logo - - - + + ); } diff --git a/frontend/components/markdown-renderer.tsx b/frontend/components/markdown-renderer.tsx index 1b2276db..5b011615 100644 --- a/frontend/components/markdown-renderer.tsx +++ b/frontend/components/markdown-renderer.tsx @@ -7,6 +7,7 @@ import CodeComponent from "./code-component"; type MarkdownRendererProps = { chatMessage: string; + className?: string; }; const preprocessChatMessage = (text: string): string => { @@ -48,7 +49,7 @@ export const cleanupTableEmptyCells = (text: string): string => { }) .join("\n"); }; -export const MarkdownRenderer = ({ chatMessage }: MarkdownRendererProps) => { +export const MarkdownRenderer = ({ chatMessage, className }: MarkdownRendererProps) => { // Process the chat message to handle tags and clean up tables const processedChatMessage = preprocessChatMessage(chatMessage); @@ -57,6 +58,7 @@ export const MarkdownRenderer = ({ chatMessage }: MarkdownRendererProps) => { className={cn( "markdown prose flex w-full max-w-full flex-col items-baseline text-base font-normal word-break-break-word dark:prose-invert", !chatMessage ? "text-muted-foreground" : "text-primary", + className, )} > { urlTransform={(url) => url} components={{ p({ node, ...props }) { - return

{props.children}

; + return

{props.children}

; }, ol({ node, ...props }) { return
    {props.children}
; }, + strong({ node, ...props }) { + return {props.children}; + }, h1({ node, ...props }) { return

{props.children}

; }, diff --git a/frontend/components/navigation.tsx b/frontend/components/navigation.tsx index 423172f5..8fe635ab 100644 --- a/frontend/components/navigation.tsx +++ b/frontend/components/navigation.tsx @@ -1,13 +1,13 @@ "use client"; import { - EllipsisVertical, - FileText, - Library, - MessageSquare, - Plus, - Settings2, - Trash2, + EllipsisVertical, + FileText, + Library, + MessageSquare, + Plus, + Settings2, + Trash2, } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -15,13 +15,14 @@ import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useDeleteSessionMutation } from "@/app/api/queries/useDeleteSessionMutation"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { type EndpointType, useChat } from "@/contexts/chat-context"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; +import { FILES_REGEX } from "@/lib/constants"; import { cn } from "@/lib/utils"; import { useLoadingStore } from "@/stores/loadingStore"; import { DeleteSessionModal } from "./delete-session-modal"; @@ -29,486 +30,494 @@ import { KnowledgeFilterList } from "./knowledge-filter-list"; // Re-export the types for backward compatibility export interface RawConversation { - response_id: string; - title: string; - endpoint: string; - messages: Array<{ - role: string; - content: string; - timestamp?: string; - response_id?: string; - }>; - created_at?: string; - last_activity?: string; - previous_response_id?: string; - total_messages: number; - [key: string]: unknown; + response_id: string; + title: string; + endpoint: string; + messages: Array<{ + role: string; + content: string; + timestamp?: string; + response_id?: string; + }>; + created_at?: string; + last_activity?: string; + previous_response_id?: string; + total_messages: number; + [key: string]: unknown; } export interface ChatConversation { - response_id: string; - title: string; - endpoint: EndpointType; - messages: Array<{ - role: string; - content: string; - timestamp?: string; - response_id?: string; - }>; - created_at?: string; - last_activity?: string; - previous_response_id?: string; - total_messages: number; - [key: string]: unknown; + response_id: string; + title: string; + endpoint: EndpointType; + messages: Array<{ + role: string; + content: string; + timestamp?: string; + response_id?: string; + }>; + created_at?: string; + last_activity?: string; + previous_response_id?: string; + total_messages: number; + [key: string]: unknown; } interface NavigationProps { - conversations?: ChatConversation[]; - isConversationsLoading?: boolean; - onNewConversation?: () => void; + conversations?: ChatConversation[]; + isConversationsLoading?: boolean; + onNewConversation?: () => void; } export function Navigation({ - conversations = [], - isConversationsLoading = false, - onNewConversation, + conversations = [], + isConversationsLoading = false, + onNewConversation, }: NavigationProps = {}) { - const pathname = usePathname(); - const { - endpoint, - loadConversation, - currentConversationId, - setCurrentConversationId, - startNewConversation, - conversationDocs, - addConversationDoc, - refreshConversations, - placeholderConversation, - setPlaceholderConversation, - } = useChat(); + const pathname = usePathname(); + const { + endpoint, + loadConversation, + currentConversationId, + setCurrentConversationId, + startNewConversation, + conversationDocs, + conversationData, + addConversationDoc, + refreshConversations, + placeholderConversation, + setPlaceholderConversation, + } = useChat(); - const { loading } = useLoadingStore(); + const { loading } = useLoadingStore(); - const [loadingNewConversation, setLoadingNewConversation] = useState(false); - const [previousConversationCount, setPreviousConversationCount] = useState(0); - const [deleteModalOpen, setDeleteModalOpen] = useState(false); - const [conversationToDelete, setConversationToDelete] = - useState(null); - const fileInputRef = useRef(null); + const [loadingNewConversation, setLoadingNewConversation] = useState(false); + const [previousConversationCount, setPreviousConversationCount] = useState(0); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [conversationToDelete, setConversationToDelete] = + useState(null); + const fileInputRef = useRef(null); - const { selectedFilter, setSelectedFilter } = useKnowledgeFilter(); + const { selectedFilter, setSelectedFilter } = useKnowledgeFilter(); - // Delete session mutation - const deleteSessionMutation = useDeleteSessionMutation({ - onSuccess: () => { - toast.success("Conversation deleted successfully"); + // Delete session mutation + const deleteSessionMutation = useDeleteSessionMutation({ + onSuccess: () => { + toast.success("Conversation deleted successfully"); - // If we deleted the current conversation, select another one - if ( - conversationToDelete && - currentConversationId === conversationToDelete.response_id - ) { - // Filter out the deleted conversation and find the next one - const remainingConversations = conversations.filter( - conv => conv.response_id !== conversationToDelete.response_id - ); + // If we deleted the current conversation, select another one + if ( + conversationToDelete && + currentConversationId === conversationToDelete.response_id + ) { + // Filter out the deleted conversation and find the next one + const remainingConversations = conversations.filter( + (conv) => conv.response_id !== conversationToDelete.response_id, + ); - if (remainingConversations.length > 0) { - // Load the first available conversation (most recent) - loadConversation(remainingConversations[0]); - } else { - // No conversations left, start a new one - setCurrentConversationId(null); - if (onNewConversation) { - onNewConversation(); - } else { - refreshConversations(); - startNewConversation(); - } - } - } + if (remainingConversations.length > 0) { + // Load the first available conversation (most recent) + loadConversation(remainingConversations[0]); + } else { + // No conversations left, start a new one + setCurrentConversationId(null); + if (onNewConversation) { + onNewConversation(); + } else { + refreshConversations(); + startNewConversation(); + } + } + } - setDeleteModalOpen(false); - setConversationToDelete(null); - }, - onError: error => { - toast.error(`Failed to delete conversation: ${error.message}`); - }, - }); + setDeleteModalOpen(false); + setConversationToDelete(null); + }, + onError: (error) => { + toast.error(`Failed to delete conversation: ${error.message}`); + }, + }); - const handleNewConversation = () => { - setLoadingNewConversation(true); + const handleNewConversation = () => { + setLoadingNewConversation(true); - // Use the prop callback if provided, otherwise use the context method - if (onNewConversation) { - onNewConversation(); - } else { - refreshConversations(); - startNewConversation(); - } + // Use the prop callback if provided, otherwise use the context method + if (onNewConversation) { + onNewConversation(); + } else { + refreshConversations(); + startNewConversation(); + } - if (typeof window !== "undefined") { - window.dispatchEvent(new CustomEvent("newConversation")); - } - // Clear loading state after a short delay to show the new conversation is created - setTimeout(() => { - setLoadingNewConversation(false); - }, 300); - }; + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("newConversation")); + } + // Clear loading state after a short delay to show the new conversation is created + setTimeout(() => { + setLoadingNewConversation(false); + }, 300); + }; - const handleFileUpload = async (file: File) => { - console.log("Navigation file upload:", file.name); + const handleFileUpload = async (file: File) => { + console.log("Navigation file upload:", file.name); - // Trigger loading start event for chat page - window.dispatchEvent( - new CustomEvent("fileUploadStart", { - detail: { filename: file.name }, - }) - ); + // Trigger loading start event for chat page + window.dispatchEvent( + new CustomEvent("fileUploadStart", { + detail: { filename: file.name }, + }), + ); - try { - const formData = new FormData(); - formData.append("file", file); - formData.append("endpoint", endpoint); + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("endpoint", endpoint); - const response = await fetch("/api/upload_context", { - method: "POST", - body: formData, - }); + const response = await fetch("/api/upload_context", { + method: "POST", + body: formData, + }); - if (!response.ok) { - const errorText = await response.text(); - console.error("Upload failed:", errorText); + if (!response.ok) { + const errorText = await response.text(); + console.error("Upload failed:", errorText); - // Trigger error event for chat page to handle - window.dispatchEvent( - new CustomEvent("fileUploadError", { - detail: { - filename: file.name, - error: "Failed to process document", - }, - }) - ); + // Trigger error event for chat page to handle + window.dispatchEvent( + new CustomEvent("fileUploadError", { + detail: { + filename: file.name, + error: "Failed to process document", + }, + }), + ); - // Trigger loading end event - window.dispatchEvent(new CustomEvent("fileUploadComplete")); - return; - } + // Trigger loading end event + window.dispatchEvent(new CustomEvent("fileUploadComplete")); + return; + } - const result = await response.json(); - console.log("Upload result:", result); + const result = await response.json(); + console.log("Upload result:", result); - // Add the file to conversation docs - if (result.filename) { - addConversationDoc(result.filename); - } + // Add the file to conversation docs + if (result.filename) { + addConversationDoc(result.filename); + } - // Trigger file upload event for chat page to handle - window.dispatchEvent( - new CustomEvent("fileUploaded", { - detail: { file, result }, - }) - ); + // Trigger file upload event for chat page to handle + window.dispatchEvent( + new CustomEvent("fileUploaded", { + detail: { file, result }, + }), + ); - // Trigger loading end event - window.dispatchEvent(new CustomEvent("fileUploadComplete")); - } catch (error) { - console.error("Upload failed:", error); - // Trigger loading end event even on error - window.dispatchEvent(new CustomEvent("fileUploadComplete")); + // Trigger loading end event + window.dispatchEvent(new CustomEvent("fileUploadComplete")); + } catch (error) { + console.error("Upload failed:", error); + // Trigger loading end event even on error + window.dispatchEvent(new CustomEvent("fileUploadComplete")); - // Trigger error event for chat page to handle - window.dispatchEvent( - new CustomEvent("fileUploadError", { - detail: { filename: file.name, error: "Failed to process document" }, - }) - ); - } - }; + // Trigger error event for chat page to handle + window.dispatchEvent( + new CustomEvent("fileUploadError", { + detail: { filename: file.name, error: "Failed to process document" }, + }), + ); + } + }; - const handleFilePickerClick = () => { - fileInputRef.current?.click(); - }; + const handleFilePickerClick = () => { + fileInputRef.current?.click(); + }; - const handleFilePickerChange = (e: React.ChangeEvent) => { - const files = e.target.files; - if (files && files.length > 0) { - handleFileUpload(files[0]); - } - // Reset the input so the same file can be selected again - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - }; + const handleFilePickerChange = (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + handleFileUpload(files[0]); + } + // Reset the input so the same file can be selected again + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; - const handleDeleteConversation = ( - conversation: ChatConversation, - event?: React.MouseEvent - ) => { - if (event) { - event.preventDefault(); - event.stopPropagation(); - } - setConversationToDelete(conversation); - setDeleteModalOpen(true); - }; + const handleDeleteConversation = ( + conversation: ChatConversation, + event?: React.MouseEvent, + ) => { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + setConversationToDelete(conversation); + setDeleteModalOpen(true); + }; - const handleContextMenuAction = ( - action: string, - conversation: ChatConversation - ) => { - switch (action) { - case "delete": - handleDeleteConversation(conversation); - break; - // Add more actions here in the future (rename, duplicate, etc.) - default: - break; - } - }; + const handleContextMenuAction = ( + action: string, + conversation: ChatConversation, + ) => { + switch (action) { + case "delete": + handleDeleteConversation(conversation); + break; + // Add more actions here in the future (rename, duplicate, etc.) + default: + break; + } + }; - const confirmDeleteConversation = () => { - if (conversationToDelete) { - deleteSessionMutation.mutate({ - sessionId: conversationToDelete.response_id, - endpoint: endpoint, - }); - } - }; + const confirmDeleteConversation = () => { + if (conversationToDelete) { + deleteSessionMutation.mutate({ + sessionId: conversationToDelete.response_id, + endpoint: endpoint, + }); + } + }; - const routes = [ - { - label: "Chat", - icon: MessageSquare, - href: "/chat", - active: pathname === "/" || pathname === "/chat", - }, - { - label: "Knowledge", - icon: Library, - href: "/knowledge", - active: pathname === "/knowledge", - }, - { - label: "Settings", - icon: Settings2, - href: "/settings", - active: pathname === "/settings", - }, - ]; + const routes = [ + { + label: "Chat", + icon: MessageSquare, + href: "/chat", + active: pathname === "/" || pathname === "/chat", + }, + { + label: "Knowledge", + icon: Library, + href: "/knowledge", + active: pathname === "/knowledge", + }, + { + label: "Settings", + icon: Settings2, + href: "/settings", + active: pathname === "/settings", + }, + ]; - const isOnChatPage = pathname === "/" || pathname === "/chat"; - const isOnKnowledgePage = pathname.startsWith("/knowledge"); + const isOnChatPage = pathname === "/" || pathname === "/chat"; + const isOnKnowledgePage = pathname.startsWith("/knowledge"); - // Clear placeholder when conversation count increases (new conversation was created) - useEffect(() => { - const currentCount = conversations.length; + // Clear placeholder when conversation count increases (new conversation was created) + useEffect(() => { + const currentCount = conversations.length; - // If we had a placeholder and the conversation count increased, clear the placeholder and highlight the new conversation - if ( - placeholderConversation && - currentCount > previousConversationCount && - conversations.length > 0 - ) { - setPlaceholderConversation(null); - // Highlight the most recent conversation (first in sorted array) without loading its messages - const newestConversation = conversations[0]; - if (newestConversation) { - setCurrentConversationId(newestConversation.response_id); - } - } + // If we had a placeholder and the conversation count increased, clear the placeholder and highlight the new conversation + if ( + placeholderConversation && + currentCount > previousConversationCount && + conversations.length > 0 + ) { + setPlaceholderConversation(null); + // Highlight the most recent conversation (first in sorted array) without loading its messages + const newestConversation = conversations[0]; + if (newestConversation) { + setCurrentConversationId(newestConversation.response_id); + } + } - // Update the previous count - setPreviousConversationCount(currentCount); - }, [ - conversations.length, - placeholderConversation, - setPlaceholderConversation, - previousConversationCount, - conversations, - setCurrentConversationId, - ]); + // Update the previous count + setPreviousConversationCount(currentCount); + }, [ + conversations.length, + placeholderConversation, + setPlaceholderConversation, + previousConversationCount, + conversations, + setCurrentConversationId, + ]); - return ( -
-
-
- {routes.map(route => ( -
- -
- - {route.label} -
- - {route.label === "Settings" && ( -
- )} -
- ))} -
-
+ const newConversationFiles = conversationData?.messages + .filter( + (message) => + message.role === "user" && + (message.content.match(FILES_REGEX)?.[0] ?? null) !== null, + ) + .map((message) => message.content.match(FILES_REGEX)?.[0] ?? null); - {isOnKnowledgePage && ( - - )} + return ( +
+
+
+ {routes.map((route) => ( +
+ +
+ + {route.label} +
+ + {route.label === "Settings" && ( +
+ )} +
+ ))} +
+
- {/* Chat Page Specific Sections */} - {isOnChatPage && ( -
- {/* Conversations Section */} -
-
-

- Conversations -

- -
-
+ {isOnKnowledgePage && ( + + )} -
- {/* Conversations List - grows naturally, doesn't fill all space */} -
- {loadingNewConversation || isConversationsLoading ? ( -
- Loading... -
- ) : ( - <> - {/* Show placeholder conversation if it exists */} - {placeholderConversation && ( - - )} + {/* Chat Page Specific Sections */} + {isOnChatPage && ( +
+ {/* Conversations Section */} +
+
+

+ Conversations +

+ +
+
- {/* Show regular conversations */} - {conversations.length === 0 && !placeholderConversation ? ( -
- No conversations yet -
- ) : ( - conversations.map(conversation => ( - - )) - )} - - )} -
+
+
+ {loadingNewConversation || isConversationsLoading ? ( +
+ Loading... +
+ ) : ( + <> + {/* Show placeholder conversation if it exists */} + {placeholderConversation && ( + + )} - {/* Conversation Knowledge Section - appears right after last conversation */} + {/* Show regular conversations */} + {conversations.length === 0 && !placeholderConversation ? ( +
+ No conversations yet +
+ ) : ( + conversations.map((conversation) => ( + + )) + )} + + )} +
+ + {/* Conversation Knowledge Section - appears right after last conversation

@@ -551,22 +560,44 @@ export function Navigation({ )) )}

-
-
-
- )} +
*/} +
+
+

+ Files +

+
+
+ {newConversationFiles?.length === 0 ? ( +
+ No documents yet +
+ ) : ( + newConversationFiles?.map((file) => ( +
+
+ {file} +
+
+ )) + )} +
+
+
+
+ )} - {/* Delete Session Modal */} - { - setDeleteModalOpen(false); - setConversationToDelete(null); - }} - onConfirm={confirmDeleteConversation} - sessionTitle={conversationToDelete?.title || ""} - isDeleting={deleteSessionMutation.isPending} - /> -
- ); + {/* Delete Session Modal */} + { + setDeleteModalOpen(false); + setConversationToDelete(null); + }} + onConfirm={confirmDeleteConversation} + sessionTitle={conversationToDelete?.title || ""} + isDeleting={deleteSessionMutation.isPending} + /> +
+ ); } diff --git a/frontend/components/ui/accordion.tsx b/frontend/components/ui/accordion.tsx index 2be19b59..37ec5c3c 100644 --- a/frontend/components/ui/accordion.tsx +++ b/frontend/components/ui/accordion.tsx @@ -14,7 +14,7 @@ const AccordionItem = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/frontend/components/ui/tabs.tsx b/frontend/components/ui/tabs.tsx index 5d75c455..874f054f 100644 --- a/frontend/components/ui/tabs.tsx +++ b/frontend/components/ui/tabs.tsx @@ -13,7 +13,7 @@ const TabsList = React.forwardRef< { + const response = await fetch( + `/api/documents/check-filename?filename=${encodeURIComponent(file.name)}` + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + errorText || `Failed to check duplicates: ${response.statusText}` + ); + } + + return response.json(); +} + +export async function uploadFileForContext( + file: File +): Promise { + window.dispatchEvent( + new CustomEvent("fileUploadStart", { + detail: { filename: file.name }, + }) + ); + + try { + const formData = new FormData(); + formData.append("file", file); + + const uploadResponse = await fetch("/api/upload_context", { + method: "POST", + body: formData, + }); + + let payload: unknown; + try { + payload = await uploadResponse.json(); + } catch (error) { + throw new Error("Upload failed: unable to parse server response"); + } + + const uploadJson = + typeof payload === "object" && payload !== null ? payload : {}; + + if (!uploadResponse.ok) { + const errorMessage = + (uploadJson as { error?: string }).error || + "Upload failed"; + throw new Error(errorMessage); + } + + const fileId = + (uploadJson as { response_id?: string }).response_id || "uploaded"; + const filePath = + (uploadJson as { filename?: string }).filename || file.name; + const pages = (uploadJson as { pages?: number }).pages; + const contentLength = (uploadJson as { content_length?: number }).content_length; + const confirmation = (uploadJson as { confirmation?: string }).confirmation; + + const result: UploadFileResult = { + fileId, + filePath, + run: null, + deletion: null, + unified: false, + raw: uploadJson, + }; + + window.dispatchEvent( + new CustomEvent("fileUploaded", { + detail: { + file, + result: { + file_id: fileId, + file_path: filePath, + filename: filePath, + pages: pages, + content_length: contentLength, + confirmation: confirmation, + response_id: fileId, + run: null, + deletion: null, + unified: false, + }, + }, + }) + ); + + return result; + } catch (error) { + window.dispatchEvent( + new CustomEvent("fileUploadError", { + detail: { + filename: file.name, + error: + error instanceof Error ? error.message : "Upload failed", + }, + }) + ); + throw error; + } finally { + window.dispatchEvent(new CustomEvent("fileUploadComplete")); + } +} + +export async function uploadFile( + file: File, + replace = false +): Promise { + window.dispatchEvent( + new CustomEvent("fileUploadStart", { + detail: { filename: file.name }, + }) + ); + + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("replace_duplicates", replace.toString()); + + const uploadResponse = await fetch("/api/router/upload_ingest", { + method: "POST", + body: formData, + }); + + let payload: unknown; + try { + payload = await uploadResponse.json(); + } catch (error) { + throw new Error("Upload failed: unable to parse server response"); + } + + const uploadIngestJson = + typeof payload === "object" && payload !== null ? payload : {}; + + if (!uploadResponse.ok) { + const errorMessage = + (uploadIngestJson as { error?: string }).error || + "Upload and ingest failed"; + throw new Error(errorMessage); + } + + const fileId = + (uploadIngestJson as { upload?: { id?: string } }).upload?.id || + (uploadIngestJson as { id?: string }).id || + (uploadIngestJson as { task_id?: string }).task_id; + const filePath = + (uploadIngestJson as { upload?: { path?: string } }).upload?.path || + (uploadIngestJson as { path?: string }).path || + "uploaded"; + const runJson = (uploadIngestJson as { ingestion?: unknown }).ingestion; + const deletionJson = (uploadIngestJson as { deletion?: unknown }).deletion; + + if (!fileId) { + throw new Error("Upload successful but no file id returned"); + } + + if ( + runJson && + typeof runJson === "object" && + "status" in (runJson as Record) && + (runJson as { status?: string }).status !== "COMPLETED" && + (runJson as { status?: string }).status !== "SUCCESS" + ) { + const errorMsg = + (runJson as { error?: string }).error || + "Ingestion pipeline failed"; + throw new Error( + `Ingestion failed: ${errorMsg}. Try setting DISABLE_INGEST_WITH_LANGFLOW=true if you're experiencing Langflow component issues.` + ); + } + + const result: UploadFileResult = { + fileId, + filePath, + run: runJson, + deletion: deletionJson, + unified: true, + raw: uploadIngestJson, + }; + + window.dispatchEvent( + new CustomEvent("fileUploaded", { + detail: { + file, + result: { + file_id: fileId, + file_path: filePath, + run: runJson, + deletion: deletionJson, + unified: true, + }, + }, + }) + ); + + return result; + } catch (error) { + window.dispatchEvent( + new CustomEvent("fileUploadError", { + detail: { + filename: file.name, + error: + error instanceof Error ? error.message : "Upload failed", + }, + }) + ); + throw error; + } finally { + window.dispatchEvent(new CustomEvent("fileUploadComplete")); + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0c1c2c73..c724fde9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -52,6 +52,7 @@ "sonner": "^2.0.6", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", + "use-stick-to-bottom": "^1.1.1", "zustand": "^5.0.8" }, "devDependencies": { @@ -10224,6 +10225,15 @@ } } }, + "node_modules/use-stick-to-bottom": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/use-stick-to-bottom/-/use-stick-to-bottom-1.1.1.tgz", + "integrity": "sha512-JkDp0b0tSmv7HQOOpL1hT7t7QaoUBXkq045WWWOFDTlLGRzgIIyW7vyzOIJzY7L2XVIG7j1yUxeDj2LHm9Vwng==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5347aa8c..fd6fc0cb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -53,6 +53,7 @@ "sonner": "^2.0.6", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", + "use-stick-to-bottom": "^1.1.1", "zustand": "^5.0.8" }, "devDependencies": { diff --git a/frontend/src/app/api/[...path]/route.ts b/frontend/src/app/api/[...path]/route.ts index 7e7a8dbb..5718dc66 100644 --- a/frontend/src/app/api/[...path]/route.ts +++ b/frontend/src/app/api/[...path]/route.ts @@ -106,9 +106,8 @@ async function proxyRequest( } const response = await fetch(backendUrl, init); - const responseBody = await response.text(); const responseHeaders = new Headers(); - + // Copy response headers for (const [key, value] of response.headers.entries()) { if (!key.toLowerCase().startsWith('transfer-encoding') && @@ -117,11 +116,22 @@ async function proxyRequest( } } - return new NextResponse(responseBody, { - status: response.status, - statusText: response.statusText, - headers: responseHeaders, - }); + // For streaming responses, pass the body directly without buffering + if (response.body) { + return new NextResponse(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } else { + // Fallback for non-streaming responses + const responseBody = await response.text(); + return new NextResponse(responseBody, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } } catch (error) { console.error('Proxy error:', error); return NextResponse.json( diff --git a/frontend/src/app/auth/callback/page.tsx b/frontend/src/app/auth/callback/page.tsx index 780b45b6..8c1683cf 100644 --- a/frontend/src/app/auth/callback/page.tsx +++ b/frontend/src/app/auth/callback/page.tsx @@ -158,8 +158,8 @@ function AuthCallbackContent() { } return ( -
- +
+ {status === "processing" && ( diff --git a/frontend/src/app/chat/components/assistant-message.tsx b/frontend/src/app/chat/components/assistant-message.tsx index 5f50606d..4c600517 100644 --- a/frontend/src/app/chat/components/assistant-message.tsx +++ b/frontend/src/app/chat/components/assistant-message.tsx @@ -1,63 +1,87 @@ -import { Bot, GitBranch } from "lucide-react"; +import { GitBranch } from "lucide-react"; +import { motion } from "motion/react"; +import DogIcon from "@/components/logo/dog-icon"; import { MarkdownRenderer } from "@/components/markdown-renderer"; +import { cn } from "@/lib/utils"; +import type { FunctionCall } from "../types"; import { FunctionCalls } from "./function-calls"; import { Message } from "./message"; -import type { FunctionCall } from "../types"; -import DogIcon from "@/components/logo/dog-icon"; interface AssistantMessageProps { - content: string; - functionCalls?: FunctionCall[]; - messageIndex?: number; - expandedFunctionCalls: Set; - onToggle: (functionCallId: string) => void; - isStreaming?: boolean; - showForkButton?: boolean; - onFork?: (e: React.MouseEvent) => void; + content: string; + functionCalls?: FunctionCall[]; + messageIndex?: number; + expandedFunctionCalls: Set; + onToggle: (functionCallId: string) => void; + isStreaming?: boolean; + showForkButton?: boolean; + onFork?: (e: React.MouseEvent) => void; + isCompleted?: boolean; + animate?: boolean; + delay?: number; } export function AssistantMessage({ - content, - functionCalls = [], - messageIndex, - expandedFunctionCalls, - onToggle, - isStreaming = false, - showForkButton = false, - onFork, + content, + functionCalls = [], + messageIndex, + expandedFunctionCalls, + onToggle, + isStreaming = false, + showForkButton = false, + onFork, + isCompleted = false, + animate = true, + delay = 0.2, }: AssistantMessageProps) { - const updatedOnboarding = process.env.UPDATED_ONBOARDING === "true"; - const IconComponent = updatedOnboarding ? DogIcon : Bot; - - return ( - - -
- } - actions={ - showForkButton && onFork ? ( - - ) : undefined - } - > - - - {isStreaming && ( - - )} - - ); + + return ( + + + +
+ } + actions={ + showForkButton && onFork ? ( + + ) : undefined + } + > + +
+ ' + : content + } + /> +
+ + + ); } diff --git a/frontend/src/app/chat/components/chat-input.tsx b/frontend/src/app/chat/components/chat-input.tsx index e63a5236..7a4bee59 100644 --- a/frontend/src/app/chat/components/chat-input.tsx +++ b/frontend/src/app/chat/components/chat-input.tsx @@ -1,282 +1,281 @@ -import { Check, Funnel, Loader2, Plus, X } from "lucide-react"; -import TextareaAutosize from "react-textarea-autosize"; +import { ArrowRight, Check, Funnel, Loader2, Plus, X } from "lucide-react"; import { forwardRef, useImperativeHandle, useRef } from "react"; +import TextareaAutosize from "react-textarea-autosize"; +import type { FilterColor } from "@/components/filter-icon-popover"; import { filterAccentClasses } from "@/components/knowledge-filter-panel"; import { Button } from "@/components/ui/button"; import { - Popover, - PopoverAnchor, - PopoverContent, + Popover, + PopoverAnchor, + PopoverContent, } from "@/components/ui/popover"; import type { KnowledgeFilterData } from "../types"; -import { FilterColor } from "@/components/filter-icon-popover"; export interface ChatInputHandle { - focusInput: () => void; - clickFileInput: () => void; + focusInput: () => void; + clickFileInput: () => void; } interface ChatInputProps { - input: string; - loading: boolean; - isUploading: boolean; - selectedFilter: KnowledgeFilterData | null; - isFilterDropdownOpen: boolean; - availableFilters: KnowledgeFilterData[]; - filterSearchTerm: string; - selectedFilterIndex: number; - anchorPosition: { x: number; y: number } | null; - textareaHeight: number; - parsedFilterData: { color?: FilterColor } | null; - onSubmit: (e: React.FormEvent) => void; - onChange: (e: React.ChangeEvent) => void; - onKeyDown: (e: React.KeyboardEvent) => void; - onHeightChange: (height: number) => void; - onFilterSelect: (filter: KnowledgeFilterData | null) => void; - onAtClick: () => void; - onFilePickerChange: (e: React.ChangeEvent) => void; - onFilePickerClick: () => void; - setSelectedFilter: (filter: KnowledgeFilterData | null) => void; - setIsFilterHighlighted: (highlighted: boolean) => void; - setIsFilterDropdownOpen: (open: boolean) => void; + input: string; + loading: boolean; + isUploading: boolean; + selectedFilter: KnowledgeFilterData | null; + isFilterDropdownOpen: boolean; + availableFilters: KnowledgeFilterData[]; + filterSearchTerm: string; + selectedFilterIndex: number; + anchorPosition: { x: number; y: number } | null; + textareaHeight: number; + parsedFilterData: { color?: FilterColor } | null; + onSubmit: (e: React.FormEvent) => void; + onChange: (e: React.ChangeEvent) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onHeightChange: (height: number) => void; + onFilterSelect: (filter: KnowledgeFilterData | null) => void; + onAtClick: () => void; + onFilePickerChange: (e: React.ChangeEvent) => void; + onFilePickerClick: () => void; + setSelectedFilter: (filter: KnowledgeFilterData | null) => void; + setIsFilterHighlighted: (highlighted: boolean) => void; + setIsFilterDropdownOpen: (open: boolean) => void; } -export const ChatInput = forwardRef(( - { - input, - loading, - isUploading, - selectedFilter, - isFilterDropdownOpen, - availableFilters, - filterSearchTerm, - selectedFilterIndex, - anchorPosition, - textareaHeight, - parsedFilterData, - onSubmit, - onChange, - onKeyDown, - onHeightChange, - onFilterSelect, - onAtClick, - onFilePickerChange, - onFilePickerClick, - setSelectedFilter, - setIsFilterHighlighted, - setIsFilterDropdownOpen, - }, - ref -) => { - const inputRef = useRef(null); - const fileInputRef = useRef(null); +export const ChatInput = forwardRef( + ( + { + input, + loading, + isUploading, + selectedFilter, + isFilterDropdownOpen, + availableFilters, + filterSearchTerm, + selectedFilterIndex, + anchorPosition, + textareaHeight, + parsedFilterData, + onSubmit, + onChange, + onKeyDown, + onHeightChange, + onFilterSelect, + onAtClick, + onFilePickerChange, + onFilePickerClick, + setSelectedFilter, + setIsFilterHighlighted, + setIsFilterDropdownOpen, + }, + ref, + ) => { + const inputRef = useRef(null); + const fileInputRef = useRef(null); - useImperativeHandle(ref, () => ({ - focusInput: () => { - inputRef.current?.focus(); - }, - clickFileInput: () => { - fileInputRef.current?.click(); - }, - })); + useImperativeHandle(ref, () => ({ + focusInput: () => { + inputRef.current?.focus(); + }, + clickFileInput: () => { + fileInputRef.current?.click(); + }, + })); - return ( -
-
-
-
- {selectedFilter && ( -
- - @filter:{selectedFilter.name} - - -
- )} -
- - {/* Safe area at bottom for buttons */} -
-
-
- - - { - setIsFilterDropdownOpen(open); - }} - > - {anchorPosition && ( - -
- - )} - { - // Prevent auto focus on the popover content - e.preventDefault(); - // Keep focus on the input - }} - > -
- {filterSearchTerm && ( -
- Searching: @{filterSearchTerm} -
- )} - {availableFilters.length === 0 ? ( -
- No knowledge filters available -
- ) : ( - <> - {!filterSearchTerm && ( - - )} - {availableFilters - .filter(filter => - filter.name - .toLowerCase() - .includes(filterSearchTerm.toLowerCase()) - ) - .map((filter, index) => ( - - ))} - {availableFilters.filter(filter => - filter.name - .toLowerCase() - .includes(filterSearchTerm.toLowerCase()) - ).length === 0 && - filterSearchTerm && ( -
- No filters match "{filterSearchTerm}" -
- )} - - )} -
-
- - - - -
-
- ); -}); + return ( +
+
+
+ {selectedFilter ? ( + + {selectedFilter.name} + + + ) : ( + + )} +
+ +
+ + +
+ + + { + setIsFilterDropdownOpen(open); + }} + > + {anchorPosition && ( + +
+ + )} + { + // Prevent auto focus on the popover content + e.preventDefault(); + // Keep focus on the input + }} + > +
+ {filterSearchTerm && ( +
+ Searching: @{filterSearchTerm} +
+ )} + {availableFilters.length === 0 ? ( +
+ No knowledge filters available +
+ ) : ( + <> + {!filterSearchTerm && ( + + )} + {availableFilters + .filter((filter) => + filter.name + .toLowerCase() + .includes(filterSearchTerm.toLowerCase()), + ) + .map((filter, index) => ( + + ))} + {availableFilters.filter((filter) => + filter.name + .toLowerCase() + .includes(filterSearchTerm.toLowerCase()), + ).length === 0 && + filterSearchTerm && ( +
+ No filters match "{filterSearchTerm}" +
+ )} + + )} +
+
+ + +
+ ); + }, +); ChatInput.displayName = "ChatInput"; diff --git a/frontend/src/app/chat/components/user-message.tsx b/frontend/src/app/chat/components/user-message.tsx index 882b3416..21c97f4f 100644 --- a/frontend/src/app/chat/components/user-message.tsx +++ b/frontend/src/app/chat/components/user-message.tsx @@ -1,33 +1,58 @@ -import { User } from "lucide-react"; +import { FileText, User } from "lucide-react"; +import { motion } from "motion/react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { useAuth } from "@/contexts/auth-context"; +import { cn } from "@/lib/utils"; import { Message } from "./message"; interface UserMessageProps { - content: string; + content: string; + isCompleted?: boolean; + animate?: boolean; + files?: string; } -export function UserMessage({ content }: UserMessageProps) { - const { user } = useAuth(); +export function UserMessage({ content, isCompleted, animate = true, files }: UserMessageProps) { + const { user } = useAuth(); - return ( - - - - {user?.name ? ( - user.name.charAt(0).toUpperCase() - ) : ( - - )} - - - } - > -

- {content} -

-
- ); + + return ( + + + + + {user?.name ? user.name.charAt(0).toUpperCase() : } + + + } + > + {files && ( +

+ + {files} +

+ )} +

+ {content} +

+
+
+ ); } diff --git a/frontend/src/app/chat/nudges.tsx b/frontend/src/app/chat/nudges.tsx index c5929924..c98ead63 100644 --- a/frontend/src/app/chat/nudges.tsx +++ b/frontend/src/app/chat/nudges.tsx @@ -1,46 +1,55 @@ -import { motion, AnimatePresence } from "motion/react"; +import { AnimatePresence, motion } from "motion/react"; +import { cn } from "@/lib/utils"; export default function Nudges({ - nudges, - handleSuggestionClick, + nudges, + onboarding, + handleSuggestionClick, }: { - nudges: string[]; - - handleSuggestionClick: (suggestion: string) => void; + nudges: string[]; + onboarding?: boolean; + handleSuggestionClick: (suggestion: string) => void; }) { - return ( -
- - {nudges.length > 0 && ( - -
-
-
- {nudges.map((suggestion: string, index: number) => ( - - ))} -
- {/* Fade out gradient on the right */} -
-
-
-
- )} -
-
- ); + return ( +
+ + {nudges.length > 0 && ( + +
+
+
+ {nudges.map((suggestion: string, index: number) => ( + + ))} +
+ {/* Fade out gradient on the right */} +
+
+
+
+ )} +
+
+ ); } diff --git a/frontend/src/app/chat/page.tsx b/frontend/src/app/chat/page.tsx index d3fe85fe..5c7ac6d5 100644 --- a/frontend/src/app/chat/page.tsx +++ b/frontend/src/app/chat/page.tsx @@ -1,1918 +1,1321 @@ "use client"; -import { Bot, Loader2, Zap } from "lucide-react"; +import { Loader2, Zap } from "lucide-react"; import { useEffect, 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 { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; import { useTask } from "@/contexts/task-context"; +import { useChatStreaming } from "@/hooks/useChatStreaming"; +import { FILES_REGEX } from "@/lib/constants"; import { useLoadingStore } from "@/stores/loadingStore"; import { useGetNudgesQuery } from "../api/queries/useGetNudgesQuery"; -import Nudges from "./nudges"; -import { UserMessage } from "./components/user-message"; import { AssistantMessage } from "./components/assistant-message"; import { ChatInput, type ChatInputHandle } from "./components/chat-input"; -import { Button } from "@/components/ui/button"; +import { UserMessage } from "./components/user-message"; +import Nudges from "./nudges"; import type { - Message, - FunctionCall, - ToolCallResult, - SelectedFilters, - KnowledgeFilterData, - RequestBody, + FunctionCall, + KnowledgeFilterData, + Message, + RequestBody, + SelectedFilters, + ToolCallResult, } from "./types"; function ChatPage() { - const isDebugMode = - process.env.NODE_ENV === "development" || - process.env.NEXT_PUBLIC_OPENRAG_DEBUG === "true"; - const { - endpoint, - setEndpoint, - currentConversationId, - conversationData, - setCurrentConversationId, - addConversationDoc, - forkFromResponse, - refreshConversations, - refreshConversationsSilent, - previousResponseIds, - setPreviousResponseIds, - placeholderConversation, - } = useChat(); - const [messages, setMessages] = useState([ - { - role: "assistant", - content: "How can I assist?", - timestamp: new Date(), - }, - ]); - const [input, setInput] = useState(""); - const { loading, setLoading } = useLoadingStore(); - const [asyncMode, setAsyncMode] = useState(true); - const [streamingMessage, setStreamingMessage] = useState<{ - content: string; - functionCalls: FunctionCall[]; - timestamp: Date; - } | null>(null); - const [expandedFunctionCalls, setExpandedFunctionCalls] = useState< - Set - >(new Set()); - // previousResponseIds now comes from useChat context - const [isUploading, setIsUploading] = useState(false); - const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); - const [availableFilters, setAvailableFilters] = useState< - KnowledgeFilterData[] - >([]); - const [textareaHeight, setTextareaHeight] = useState(40); - const [filterSearchTerm, setFilterSearchTerm] = useState(""); - const [selectedFilterIndex, setSelectedFilterIndex] = useState(0); - const [isFilterHighlighted, setIsFilterHighlighted] = useState(false); - const [dropdownDismissed, setDropdownDismissed] = useState(false); - const [isUserInteracting, setIsUserInteracting] = useState(false); - const [isForkingInProgress, setIsForkingInProgress] = useState(false); - const [anchorPosition, setAnchorPosition] = useState<{ - x: number; - y: number; - } | null>(null); - const messagesEndRef = useRef(null); - const chatInputRef = useRef(null); - const streamAbortRef = useRef(null); - const streamIdRef = useRef(0); - const lastLoadedConversationRef = useRef(null); - const { addTask } = useTask(); - const { selectedFilter, parsedFilterData, setSelectedFilter } = - useKnowledgeFilter(); - - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }; - - const getCursorPosition = (textarea: HTMLTextAreaElement) => { - // Create a hidden div with the same styles as the textarea - const div = document.createElement("div"); - const computedStyle = getComputedStyle(textarea); - - // Copy all computed styles to the hidden div - for (const style of computedStyle) { - (div.style as any)[style] = computedStyle.getPropertyValue(style); - } - - // Set the div to be hidden but not un-rendered - div.style.position = "absolute"; - div.style.visibility = "hidden"; - div.style.whiteSpace = "pre-wrap"; - div.style.wordWrap = "break-word"; - div.style.overflow = "hidden"; - div.style.height = "auto"; - div.style.width = `${textarea.getBoundingClientRect().width}px`; - - // Get the text up to the cursor position - const cursorPos = textarea.selectionStart || 0; - const textBeforeCursor = textarea.value.substring(0, cursorPos); - - // Add the text before cursor - div.textContent = textBeforeCursor; - - // Create a span to mark the end position - const span = document.createElement("span"); - span.textContent = "|"; // Cursor marker - div.appendChild(span); - - // Add the text after cursor to handle word wrapping - const textAfterCursor = textarea.value.substring(cursorPos); - div.appendChild(document.createTextNode(textAfterCursor)); - - // Add the div to the document temporarily - document.body.appendChild(div); - - // Get positions - const inputRect = textarea.getBoundingClientRect(); - const divRect = div.getBoundingClientRect(); - const spanRect = span.getBoundingClientRect(); - - // Calculate the cursor position relative to the input - const x = inputRect.left + (spanRect.left - divRect.left); - const y = inputRect.top + (spanRect.top - divRect.top); - - // Clean up - document.body.removeChild(div); - - return { x, y }; - }; - - 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); - - // Add initial upload message - const uploadStartMessage: Message = { - role: "assistant", - content: `🔄 Starting upload of **${file.name}**...`, - timestamp: new Date(), - }; - setMessages(prev => [...prev, uploadStartMessage]); - - 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); - - // Update message to show task is being tracked - const pollingMessage: Message = { - role: "assistant", - content: `⏳ Upload initiated for **${file.name}**. Processing in background... (Task ID: ${taskId})`, - timestamp: new Date(), - }; - setMessages(prev => [...prev.slice(0, -1), pollingMessage]); - } else if (response.ok) { - // Original flow: Direct response - - const uploadMessage: Message = { - role: "assistant", - content: `📄 Document uploaded: **${result.filename}** (${ - result.pages - } pages, ${result.content_length.toLocaleString()} characters)\n\n${ - result.confirmation - }`, - timestamp: new Date(), - }; - - setMessages(prev => [...prev.slice(0, -1), uploadMessage]); - - // 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(); - } - } - } 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 handleFilePickerChange = (e: React.ChangeEvent) => { - const files = e.target.files; - if (files && files.length > 0) { - handleFileUpload(files[0]); - } - }; - - const loadAvailableFilters = async () => { - try { - const response = await fetch("/api/knowledge-filter/search", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - query: "", - limit: 20, - }), - }); - - const result = await response.json(); - if (response.ok && result.success) { - setAvailableFilters(result.filters); - } else { - console.error("Failed to load knowledge filters:", result.error); - setAvailableFilters([]); - } - } catch (error) { - console.error("Failed to load knowledge filters:", error); - setAvailableFilters([]); - } - }; - - const handleFilterSelect = (filter: KnowledgeFilterData | null) => { - setSelectedFilter(filter); - setIsFilterDropdownOpen(false); - setFilterSearchTerm(""); - setIsFilterHighlighted(false); - - // Remove the @searchTerm from the input and replace with filter pill - const words = input.split(" "); - const lastWord = words[words.length - 1]; - - if (lastWord.startsWith("@")) { - // Remove the @search term - words.pop(); - setInput(words.join(" ") + (words.length > 0 ? " " : "")); - } - }; - - useEffect(() => { - // Only auto-scroll if not in the middle of user interaction - if (!isUserInteracting) { - const timer = setTimeout(() => { - scrollToBottom(); - }, 50); // Small delay to avoid conflicts with click events - - return () => clearTimeout(timer); - } - }, [messages, streamingMessage, isUserInteracting]); - - // Reset selected index when search term changes - useEffect(() => { - setSelectedFilterIndex(0); - }, [filterSearchTerm]); - - // 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 - if (streamAbortRef.current) { - streamAbortRef.current.abort(); - } - // Reset chat UI even if context state was already 'new' - setMessages([ - { - role: "assistant", - content: "How can I assist?", - timestamp: new Date(), - }, - ]); - setInput(""); - setStreamingMessage(null); - setExpandedFunctionCalls(new Set()); - setIsFilterHighlighted(false); - setLoading(false); - lastLoadedConversationRef.current = null; - }; - - const handleFocusInput = () => { - chatInputRef.current?.focusInput(); - }; - - window.addEventListener("newConversation", handleNewConversation); - window.addEventListener("focusInput", handleFocusInput); - return () => { - window.removeEventListener("newConversation", handleNewConversation); - window.removeEventListener("focusInput", handleFocusInput); - }; - }, []); - - // 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 && - 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) || {}, - argumentsString: JSON.stringify(toolCall.inputs || {}), - result: toolCall.results as - | Record - | 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 - | ToolCallResult[]) || - (chunk as Record); - 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, - })); - } - }, [ - 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; - } - }, [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); - - // Add initial upload message - const uploadStartMessage: Message = { - role: "assistant", - content: `🔄 Starting upload of **${filename}**...`, - timestamp: new Date(), - }; - setMessages(prev => [...prev, uploadStartMessage]); - }; - - const handleFileUploaded = (event: CustomEvent) => { - const { result } = event.detail; - console.log("Chat page received file upload event:", result); - - // Replace the last message with upload complete message - const uploadMessage: Message = { - role: "assistant", - content: `📄 Document uploaded: **${result.filename}** (${ - result.pages - } pages, ${result.content_length.toLocaleString()} characters)\n\n${ - result.confirmation - }`, - timestamp: new Date(), - }; - - setMessages(prev => [...prev.slice(0, -1), uploadMessage]); - - // 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]); - }; - - 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]); - - const { data: nudges = [], cancel: cancelNudges } = useGetNudgesQuery( - previousResponseIds[endpoint] - ); - - const handleSSEStream = async (userMessage: Message) => { - const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"; - - try { - // Abort any existing stream before starting a new one - if (streamAbortRef.current) { - streamAbortRef.current.abort(); - } - const controller = new AbortController(); - streamAbortRef.current = controller; - const thisStreamId = ++streamIdRef.current; - const requestBody: RequestBody = { - prompt: userMessage.content, - stream: true, - ...(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), - signal: controller.signal, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const reader = response.body?.getReader(); - if (!reader) { - throw new Error("No reader available"); - } - - const decoder = new TextDecoder(); - let buffer = ""; - let currentContent = ""; - const currentFunctionCalls: FunctionCall[] = []; - let newResponseId: string | null = null; - - // Initialize streaming message - if (!controller.signal.aborted && thisStreamId === streamIdRef.current) { - setStreamingMessage({ - content: "", - functionCalls: [], - timestamp: new Date(), - }); - } - - try { - while (true) { - const { done, value } = await reader.read(); - if (controller.signal.aborted || thisStreamId !== streamIdRef.current) - break; - if (done) break; - buffer += decoder.decode(value, { stream: true }); - - // Process complete lines (JSON objects) - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; // Keep incomplete line in buffer - - for (const line of lines) { - if (line.trim()) { - try { - const chunk = JSON.parse(line); - console.log( - "Received chunk:", - chunk.type || chunk.object, - chunk - ); - - // Extract response ID if present - if (chunk.id) { - newResponseId = chunk.id; - } else if (chunk.response_id) { - newResponseId = chunk.response_id; - } - - // Handle OpenAI Chat Completions streaming format - if (chunk.object === "response.chunk" && chunk.delta) { - // Handle function calls in delta - if (chunk.delta.function_call) { - console.log( - "Function call in delta:", - chunk.delta.function_call - ); - - // Check if this is a new function call - if (chunk.delta.function_call.name) { - console.log( - "New function call:", - chunk.delta.function_call.name - ); - const functionCall: FunctionCall = { - name: chunk.delta.function_call.name, - arguments: undefined, - status: "pending", - argumentsString: - chunk.delta.function_call.arguments || "", - }; - currentFunctionCalls.push(functionCall); - console.log("Added function call:", functionCall); - } - // Or if this is arguments continuation - else if (chunk.delta.function_call.arguments) { - console.log( - "Function call arguments delta:", - chunk.delta.function_call.arguments - ); - const lastFunctionCall = - currentFunctionCalls[currentFunctionCalls.length - 1]; - if (lastFunctionCall) { - if (!lastFunctionCall.argumentsString) { - lastFunctionCall.argumentsString = ""; - } - lastFunctionCall.argumentsString += - chunk.delta.function_call.arguments; - console.log( - "Accumulated arguments:", - lastFunctionCall.argumentsString - ); - - // Try to parse arguments if they look complete - if (lastFunctionCall.argumentsString.includes("}")) { - try { - const parsed = JSON.parse( - lastFunctionCall.argumentsString - ); - lastFunctionCall.arguments = parsed; - lastFunctionCall.status = "completed"; - console.log("Parsed function arguments:", parsed); - } catch (e) { - console.log( - "Arguments not yet complete or invalid JSON:", - e - ); - } - } - } - } - } - - // Handle tool calls in delta - else if ( - chunk.delta.tool_calls && - Array.isArray(chunk.delta.tool_calls) - ) { - console.log("Tool calls in delta:", chunk.delta.tool_calls); - - for (const toolCall of chunk.delta.tool_calls) { - if (toolCall.function) { - // Check if this is a new tool call - if (toolCall.function.name) { - console.log("New tool call:", toolCall.function.name); - const functionCall: FunctionCall = { - name: toolCall.function.name, - arguments: undefined, - status: "pending", - argumentsString: toolCall.function.arguments || "", - }; - currentFunctionCalls.push(functionCall); - console.log("Added tool call:", functionCall); - } - // Or if this is arguments continuation - else if (toolCall.function.arguments) { - console.log( - "Tool call arguments delta:", - toolCall.function.arguments - ); - const lastFunctionCall = - currentFunctionCalls[ - currentFunctionCalls.length - 1 - ]; - if (lastFunctionCall) { - if (!lastFunctionCall.argumentsString) { - lastFunctionCall.argumentsString = ""; - } - lastFunctionCall.argumentsString += - toolCall.function.arguments; - console.log( - "Accumulated tool arguments:", - lastFunctionCall.argumentsString - ); - - // Try to parse arguments if they look complete - if ( - lastFunctionCall.argumentsString.includes("}") - ) { - try { - const parsed = JSON.parse( - lastFunctionCall.argumentsString - ); - lastFunctionCall.arguments = parsed; - lastFunctionCall.status = "completed"; - console.log("Parsed tool arguments:", parsed); - } catch (e) { - console.log( - "Tool arguments not yet complete or invalid JSON:", - e - ); - } - } - } - } - } - } - } - - // Handle content/text in delta - else if (chunk.delta.content) { - console.log("Content delta:", chunk.delta.content); - currentContent += chunk.delta.content; - } - - // Handle finish reason - if (chunk.delta.finish_reason) { - console.log("Finish reason:", chunk.delta.finish_reason); - // Mark any pending function calls as completed - currentFunctionCalls.forEach(fc => { - if (fc.status === "pending" && fc.argumentsString) { - try { - fc.arguments = JSON.parse(fc.argumentsString); - fc.status = "completed"; - console.log("Completed function call on finish:", fc); - } catch (e) { - fc.arguments = { raw: fc.argumentsString }; - fc.status = "error"; - console.log( - "Error parsing function call on finish:", - fc, - e - ); - } - } - }); - } - } - - // Handle Realtime API format (this is what you're actually getting!) - else if ( - chunk.type === "response.output_item.added" && - chunk.item?.type === "function_call" - ) { - console.log( - "🟢 CREATING function call (added):", - chunk.item.id, - chunk.item.tool_name || chunk.item.name - ); - - // Try to find an existing pending call to update (created by earlier deltas) - let existing = currentFunctionCalls.find( - fc => fc.id === chunk.item.id - ); - if (!existing) { - existing = [...currentFunctionCalls] - .reverse() - .find( - fc => - fc.status === "pending" && - !fc.id && - fc.name === (chunk.item.tool_name || chunk.item.name) - ); - } - - if (existing) { - existing.id = chunk.item.id; - existing.type = chunk.item.type; - existing.name = - chunk.item.tool_name || chunk.item.name || existing.name; - existing.arguments = - chunk.item.inputs || existing.arguments; - console.log( - "🟢 UPDATED existing pending function call with id:", - existing.id - ); - } else { - const functionCall: FunctionCall = { - name: - chunk.item.tool_name || chunk.item.name || "unknown", - arguments: chunk.item.inputs || undefined, - status: "pending", - argumentsString: "", - id: chunk.item.id, - type: chunk.item.type, - }; - currentFunctionCalls.push(functionCall); - console.log( - "🟢 Function calls now:", - currentFunctionCalls.map(fc => ({ - id: fc.id, - name: fc.name, - })) - ); - } - } - - // Handle function call arguments streaming (Realtime API) - else if ( - chunk.type === "response.function_call_arguments.delta" - ) { - console.log( - "Function args delta (Realtime API):", - chunk.delta - ); - const lastFunctionCall = - currentFunctionCalls[currentFunctionCalls.length - 1]; - if (lastFunctionCall) { - if (!lastFunctionCall.argumentsString) { - lastFunctionCall.argumentsString = ""; - } - lastFunctionCall.argumentsString += chunk.delta || ""; - console.log( - "Accumulated arguments (Realtime API):", - lastFunctionCall.argumentsString - ); - } - } - - // Handle function call arguments completion (Realtime API) - else if ( - chunk.type === "response.function_call_arguments.done" - ) { - console.log( - "Function args done (Realtime API):", - chunk.arguments - ); - const lastFunctionCall = - currentFunctionCalls[currentFunctionCalls.length - 1]; - if (lastFunctionCall) { - try { - lastFunctionCall.arguments = JSON.parse( - chunk.arguments || "{}" - ); - lastFunctionCall.status = "completed"; - console.log( - "Parsed function arguments (Realtime API):", - lastFunctionCall.arguments - ); - } catch (e) { - lastFunctionCall.arguments = { raw: chunk.arguments }; - lastFunctionCall.status = "error"; - console.log( - "Error parsing function arguments (Realtime API):", - e - ); - } - } - } - - // Handle function call completion (Realtime API) - else if ( - chunk.type === "response.output_item.done" && - chunk.item?.type === "function_call" - ) { - console.log( - "🔵 UPDATING function call (done):", - chunk.item.id, - chunk.item.tool_name || chunk.item.name - ); - console.log( - "🔵 Looking for existing function calls:", - currentFunctionCalls.map(fc => ({ - id: fc.id, - name: fc.name, - })) - ); - - // Find existing function call by ID or name - const functionCall = currentFunctionCalls.find( - fc => - fc.id === chunk.item.id || - fc.name === chunk.item.tool_name || - fc.name === chunk.item.name - ); - - if (functionCall) { - console.log( - "🔵 FOUND existing function call, updating:", - functionCall.id, - functionCall.name - ); - // Update existing function call with completion data - functionCall.status = - chunk.item.status === "completed" ? "completed" : "error"; - functionCall.id = chunk.item.id; - functionCall.type = chunk.item.type; - functionCall.name = - chunk.item.tool_name || - chunk.item.name || - functionCall.name; - functionCall.arguments = - chunk.item.inputs || functionCall.arguments; - - // Set results if present - if (chunk.item.results) { - functionCall.result = chunk.item.results; - } - } else { - console.log( - "🔴 WARNING: Could not find existing function call to update:", - chunk.item.id, - chunk.item.tool_name, - chunk.item.name - ); - } - } - - // Handle tool call completion with results - else if ( - chunk.type === "response.output_item.done" && - chunk.item?.type?.includes("_call") && - chunk.item?.type !== "function_call" - ) { - console.log("Tool call done with results:", chunk.item); - - // Find existing function call by ID, or by name/type if ID not available - const functionCall = currentFunctionCalls.find( - fc => - fc.id === chunk.item.id || - fc.name === chunk.item.tool_name || - fc.name === chunk.item.name || - fc.name === chunk.item.type || - fc.name.includes(chunk.item.type.replace("_call", "")) || - chunk.item.type.includes(fc.name) - ); - - if (functionCall) { - // Update existing function call - functionCall.arguments = - chunk.item.inputs || functionCall.arguments; - functionCall.status = - chunk.item.status === "completed" ? "completed" : "error"; - functionCall.id = chunk.item.id; - functionCall.type = chunk.item.type; - - // Set the results - if (chunk.item.results) { - functionCall.result = chunk.item.results; - } - } else { - // Create new function call if not found - const newFunctionCall = { - name: - chunk.item.tool_name || - chunk.item.name || - chunk.item.type || - "unknown", - arguments: chunk.item.inputs || {}, - status: "completed" as const, - id: chunk.item.id, - type: chunk.item.type, - result: chunk.item.results, - }; - currentFunctionCalls.push(newFunctionCall); - } - } - - // Handle function call output item added (new format) - else if ( - chunk.type === "response.output_item.added" && - chunk.item?.type?.includes("_call") && - chunk.item?.type !== "function_call" - ) { - console.log( - "🟡 CREATING tool call (added):", - chunk.item.id, - chunk.item.tool_name || chunk.item.name, - chunk.item.type - ); - - // Dedupe by id or pending with same name - let existing = currentFunctionCalls.find( - fc => fc.id === chunk.item.id - ); - if (!existing) { - existing = [...currentFunctionCalls] - .reverse() - .find( - fc => - fc.status === "pending" && - !fc.id && - fc.name === - (chunk.item.tool_name || - chunk.item.name || - chunk.item.type) - ); - } - - if (existing) { - existing.id = chunk.item.id; - existing.type = chunk.item.type; - existing.name = - chunk.item.tool_name || - chunk.item.name || - chunk.item.type || - existing.name; - existing.arguments = - chunk.item.inputs || existing.arguments; - console.log( - "🟡 UPDATED existing pending tool call with id:", - existing.id - ); - } else { - const functionCall = { - name: - chunk.item.tool_name || - chunk.item.name || - chunk.item.type || - "unknown", - arguments: chunk.item.inputs || {}, - status: "pending" as const, - id: chunk.item.id, - type: chunk.item.type, - }; - currentFunctionCalls.push(functionCall); - console.log( - "🟡 Function calls now:", - currentFunctionCalls.map(fc => ({ - id: fc.id, - name: fc.name, - type: fc.type, - })) - ); - } - } - - // Handle function call results - else if ( - chunk.type === "response.function_call.result" || - chunk.type === "function_call_result" - ) { - console.log("Function call result:", chunk.result || chunk); - const lastFunctionCall = - currentFunctionCalls[currentFunctionCalls.length - 1]; - if (lastFunctionCall) { - lastFunctionCall.result = - chunk.result || chunk.output || chunk.response; - lastFunctionCall.status = "completed"; - } - } - - // Handle tool call results - else if ( - chunk.type === "response.tool_call.result" || - chunk.type === "tool_call_result" - ) { - console.log("Tool call result:", chunk.result || chunk); - const lastFunctionCall = - currentFunctionCalls[currentFunctionCalls.length - 1]; - if (lastFunctionCall) { - lastFunctionCall.result = - chunk.result || chunk.output || chunk.response; - lastFunctionCall.status = "completed"; - } - } - - // Handle generic results that might be in different formats - else if ( - (chunk.type && chunk.type.includes("result")) || - chunk.result - ) { - console.log("Generic result:", chunk); - const lastFunctionCall = - currentFunctionCalls[currentFunctionCalls.length - 1]; - if (lastFunctionCall && !lastFunctionCall.result) { - lastFunctionCall.result = - chunk.result || chunk.output || chunk.response || chunk; - lastFunctionCall.status = "completed"; - } - } - - // Handle text output streaming (Realtime API) - else if (chunk.type === "response.output_text.delta") { - console.log("Text delta (Realtime API):", chunk.delta); - currentContent += chunk.delta || ""; - } - - // Log unhandled chunks - else if ( - chunk.type !== null && - chunk.object !== "response.chunk" - ) { - console.log("Unhandled chunk format:", chunk); - } - - // Update streaming message - if ( - !controller.signal.aborted && - thisStreamId === streamIdRef.current - ) { - setStreamingMessage({ - content: currentContent, - functionCalls: [...currentFunctionCalls], - timestamp: new Date(), - }); - } - } catch (parseError) { - console.warn("Failed to parse chunk:", line, parseError); - } - } - } - } - } finally { - reader.releaseLock(); - } - - // Finalize the message - const finalMessage: Message = { - role: "assistant", - content: currentContent, - functionCalls: currentFunctionCalls, - timestamp: new Date(), - }; - - if (!controller.signal.aborted && thisStreamId === streamIdRef.current) { - setMessages(prev => [...prev, finalMessage]); - setStreamingMessage(null); - if (previousResponseIds[endpoint]) { - cancelNudges(); - } - } - - // Store the response ID for the next request for this endpoint - if ( - newResponseId && - !controller.signal.aborted && - thisStreamId === streamIdRef.current - ) { - setPreviousResponseIds(prev => ({ - ...prev, - [endpoint]: newResponseId, - })); - - // If this is a new conversation (no currentConversationId), set it now - if (!currentConversationId) { - setCurrentConversationId(newResponseId); - refreshConversations(true); - } else { - // For existing conversations, do a silent refresh to keep backend in sync - refreshConversationsSilent(); - } - } - } catch (error) { - // If stream was aborted (e.g., starting new conversation), do not append errors or final messages - if (streamAbortRef.current?.signal.aborted) { - return; - } - console.error("SSE Stream error:", error); - setStreamingMessage(null); - - 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]); - } - }; - - const handleSendMessage = async (inputMessage: string) => { - if (!inputMessage.trim() || loading) return; - - const userMessage: Message = { - role: "user", - content: inputMessage.trim(), - timestamp: new Date(), - }; - - setMessages(prev => [...prev, userMessage]); - setInput(""); - setLoading(true); - setIsFilterHighlighted(false); - - if (asyncMode) { - await handleSSEStream(userMessage); - } 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(); - handleSendMessage(input); - }; - - 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); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - // Handle backspace for filter clearing - if (e.key === "Backspace" && selectedFilter && input.trim() === "") { - e.preventDefault(); - - if (isFilterHighlighted) { - // Second backspace - remove the filter - setSelectedFilter(null); - setIsFilterHighlighted(false); - } else { - // First backspace - highlight the filter - setIsFilterHighlighted(true); - } - return; - } - - if (isFilterDropdownOpen) { - const filteredFilters = availableFilters.filter(filter => - filter.name.toLowerCase().includes(filterSearchTerm.toLowerCase()) - ); - - if (e.key === "Escape") { - e.preventDefault(); - setIsFilterDropdownOpen(false); - setFilterSearchTerm(""); - setSelectedFilterIndex(0); - setDropdownDismissed(true); - - // Keep focus on the textarea so user can continue typing normally - chatInputRef.current?.focusInput(); - return; - } - - if (e.key === "ArrowDown") { - e.preventDefault(); - setSelectedFilterIndex(prev => - prev < filteredFilters.length - 1 ? prev + 1 : 0 - ); - return; - } - - if (e.key === "ArrowUp") { - e.preventDefault(); - setSelectedFilterIndex(prev => - prev > 0 ? prev - 1 : filteredFilters.length - 1 - ); - return; - } - - if (e.key === "Enter") { - // Check if we're at the end of an @ mention (space before cursor or end of input) - const cursorPos = e.currentTarget.selectionStart || 0; - const textBeforeCursor = input.slice(0, cursorPos); - const words = textBeforeCursor.split(" "); - const lastWord = words[words.length - 1]; - - if (lastWord.startsWith("@") && filteredFilters[selectedFilterIndex]) { - e.preventDefault(); - handleFilterSelect(filteredFilters[selectedFilterIndex]); - return; - } - } - - if (e.key === " ") { - // Select filter on space if we're typing an @ mention - const cursorPos = e.currentTarget.selectionStart || 0; - const textBeforeCursor = input.slice(0, cursorPos); - const words = textBeforeCursor.split(" "); - const lastWord = words[words.length - 1]; - - if (lastWord.startsWith("@") && filteredFilters[selectedFilterIndex]) { - e.preventDefault(); - handleFilterSelect(filteredFilters[selectedFilterIndex]); - return; - } - } - } - - if (e.key === "Enter" && !e.shiftKey && !isFilterDropdownOpen) { - 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(); - } - } - } - }; - - const onChange = (e: React.ChangeEvent) => { - const newValue = e.target.value; - setInput(newValue); - - // Clear filter highlight when user starts typing - if (isFilterHighlighted) { - setIsFilterHighlighted(false); - } - - // Find if there's an @ at the start of the last word - const words = newValue.split(" "); - const lastWord = words[words.length - 1]; - - if (lastWord.startsWith("@") && !dropdownDismissed) { - const searchTerm = lastWord.slice(1); // Remove the @ - console.log("Setting search term:", searchTerm); - setFilterSearchTerm(searchTerm); - setSelectedFilterIndex(0); - - // Only set anchor position when @ is first detected (search term is empty) - if (searchTerm === "") { - const pos = getCursorPosition(e.target); - setAnchorPosition(pos); - } - - if (!isFilterDropdownOpen) { - loadAvailableFilters(); - setIsFilterDropdownOpen(true); - } - } else if (isFilterDropdownOpen) { - // Close dropdown if @ is no longer present - console.log("Closing dropdown - no @ found"); - setIsFilterDropdownOpen(false); - setFilterSearchTerm(""); - } - - // Reset dismissed flag when user moves to a different word - if (dropdownDismissed && !lastWord.startsWith("@")) { - setDropdownDismissed(false); - } - }; - - const onAtClick = () => { - if (!isFilterDropdownOpen) { - loadAvailableFilters(); - setIsFilterDropdownOpen(true); - setFilterSearchTerm(""); - setSelectedFilterIndex(0); - - // Get button position for popover anchoring - const button = document.querySelector( - "[data-filter-button]" - ) as HTMLElement; - if (button) { - const rect = button.getBoundingClientRect(); - setAnchorPosition({ - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2 - 12, - }); - } - } else { - setIsFilterDropdownOpen(false); - setAnchorPosition(null); - } - }; - - return ( -
- {/* Debug header - only show in debug mode */} - {isDebugMode && ( -
-
-
- {/* Async Mode Toggle */} -
- - -
- {/* Endpoint Toggle */} -
- - -
-
-
- )} - -
-
- {/* Messages Area */} -
- {messages.length === 0 && !streamingMessage ? ( -
-
- {isUploading ? ( - <> - -

Processing your document...

-

- This may take a few moments -

- - ) : null} -
-
- ) : ( - <> - {messages.map((message, index) => ( -
- {message.role === "user" && ( - - )} - - {message.role === "assistant" && ( - handleForkConversation(index, e)} - /> - )} -
- ))} - - {/* Streaming Message Display */} - {streamingMessage && ( - - )} - - {/* Loading animation - shows immediately after user submits */} - {loading && ( -
-
- -
-
-
- - - Thinking... - -
-
-
- )} -
- - )} -
-
-
- - {/* Suggestion chips - always show unless streaming */} - {!streamingMessage && ( - - )} - - {/* Input Area - Fixed at bottom */} - setTextareaHeight(height)} - onFilterSelect={handleFilterSelect} - onAtClick={onAtClick} - onFilePickerChange={handleFilePickerChange} - onFilePickerClick={handleFilePickerClick} - setSelectedFilter={setSelectedFilter} - setIsFilterHighlighted={setIsFilterHighlighted} - setIsFilterDropdownOpen={setIsFilterDropdownOpen} - /> -
- ); + const isDebugMode = process.env.NEXT_PUBLIC_OPENRAG_DEBUG === "true"; + const { + endpoint, + setEndpoint, + currentConversationId, + conversationData, + setCurrentConversationId, + addConversationDoc, + forkFromResponse, + refreshConversations, + refreshConversationsSilent, + previousResponseIds, + setPreviousResponseIds, + placeholderConversation, + } = useChat(); + const [messages, setMessages] = useState([ + { + 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 + >(new Set()); + // previousResponseIds now comes from useChat context + const [isUploading, setIsUploading] = useState(false); + const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); + const [availableFilters, setAvailableFilters] = useState< + KnowledgeFilterData[] + >([]); + const [textareaHeight, setTextareaHeight] = useState(40); + const [filterSearchTerm, setFilterSearchTerm] = useState(""); + const [selectedFilterIndex, setSelectedFilterIndex] = useState(0); + const [isFilterHighlighted, setIsFilterHighlighted] = useState(false); + const [dropdownDismissed, setDropdownDismissed] = useState(false); + const [isUserInteracting, setIsUserInteracting] = useState(false); + const [isForkingInProgress, setIsForkingInProgress] = useState(false); + const [anchorPosition, setAnchorPosition] = useState<{ + x: number; + y: number; + } | null>(null); + const chatInputRef = useRef(null); + + const { scrollToBottom } = useStickToBottomContext(); + + const lastLoadedConversationRef = useRef(null); + const { addTask } = useTask(); + const { selectedFilter, parsedFilterData, setSelectedFilter } = + useKnowledgeFilter(); + + // Use the chat streaming hook + const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"; + const { + streamingMessage, + sendMessage: sendStreamingMessage, + abortStream, + } = useChatStreaming({ + endpoint: apiEndpoint, + onComplete: (message, responseId) => { + setMessages((prev) => [...prev, message]); + setLoading(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); + 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]); + }, + }); + + const getCursorPosition = (textarea: HTMLTextAreaElement) => { + // Create a hidden div with the same styles as the textarea + const div = document.createElement("div"); + const computedStyle = getComputedStyle(textarea); + + // Copy all computed styles to the hidden div + for (const style of computedStyle) { + (div.style as any)[style] = computedStyle.getPropertyValue(style); + } + + // Set the div to be hidden but not un-rendered + div.style.position = "absolute"; + div.style.visibility = "hidden"; + div.style.whiteSpace = "pre-wrap"; + div.style.wordWrap = "break-word"; + div.style.overflow = "hidden"; + div.style.height = "auto"; + div.style.width = `${textarea.getBoundingClientRect().width}px`; + + // Get the text up to the cursor position + const cursorPos = textarea.selectionStart || 0; + const textBeforeCursor = textarea.value.substring(0, cursorPos); + + // Add the text before cursor + div.textContent = textBeforeCursor; + + // Create a span to mark the end position + const span = document.createElement("span"); + span.textContent = "|"; // Cursor marker + div.appendChild(span); + + // Add the text after cursor to handle word wrapping + const textAfterCursor = textarea.value.substring(cursorPos); + div.appendChild(document.createTextNode(textAfterCursor)); + + // Add the div to the document temporarily + document.body.appendChild(div); + + // Get positions + const inputRect = textarea.getBoundingClientRect(); + const divRect = div.getBoundingClientRect(); + const spanRect = span.getBoundingClientRect(); + + // Calculate the cursor position relative to the input + const x = inputRect.left + (spanRect.left - divRect.left); + const y = inputRect.top + (spanRect.top - divRect.top); + + // Clean up + document.body.removeChild(div); + + return { x, y }; + }; + + 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); + + // Add initial upload message + const uploadStartMessage: Message = { + role: "assistant", + content: `🔄 Starting upload of **${file.name}**...`, + timestamp: new Date(), + }; + setMessages((prev) => [...prev, uploadStartMessage]); + + 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); + + // Update message to show task is being tracked + const pollingMessage: Message = { + role: "assistant", + content: `⏳ Upload initiated for **${file.name}**. Processing in background... (Task ID: ${taskId})`, + timestamp: new Date(), + }; + setMessages((prev) => [...prev.slice(0, -1), pollingMessage]); + } else if (response.ok) { + // Original flow: Direct response + + const uploadMessage: Message = { + role: "assistant", + content: `📄 Document uploaded: **${result.filename}** (${ + result.pages + } pages, ${result.content_length.toLocaleString()} characters)\n\n${ + result.confirmation + }`, + timestamp: new Date(), + }; + + setMessages((prev) => [...prev.slice(0, -1), uploadMessage]); + + // 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(); + } + } + } 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 handleFilePickerChange = (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + handleFileUpload(files[0]); + } + }; + + const loadAvailableFilters = async () => { + try { + const response = await fetch("/api/knowledge-filter/search", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: "", + limit: 20, + }), + }); + + const result = await response.json(); + if (response.ok && result.success) { + setAvailableFilters(result.filters); + } else { + console.error("Failed to load knowledge filters:", result.error); + setAvailableFilters([]); + } + } catch (error) { + console.error("Failed to load knowledge filters:", error); + setAvailableFilters([]); + } + }; + + const handleFilterSelect = (filter: KnowledgeFilterData | null) => { + setSelectedFilter(filter); + setIsFilterDropdownOpen(false); + setFilterSearchTerm(""); + setIsFilterHighlighted(false); + + // Remove the @searchTerm from the input and replace with filter pill + const words = input.split(" "); + const lastWord = words[words.length - 1]; + + if (lastWord.startsWith("@")) { + // Remove the @search term + words.pop(); + setInput(words.join(" ") + (words.length > 0 ? " " : "")); + } + }; + + // Reset selected index when search term changes + useEffect(() => { + setSelectedFilterIndex(0); + }, []); + + // 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; + }; + + 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 && + 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) || {}, + argumentsString: JSON.stringify(toolCall.inputs || {}), + result: toolCall.results as + | Record + | 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 + | ToolCallResult[]) || + (chunk as Record); + 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, + })); + } + }, [ + 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; + } + }, [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); + + // Add initial upload message + const uploadStartMessage: Message = { + role: "assistant", + content: `🔄 Starting upload of **${filename}**...`, + timestamp: new Date(), + }; + setMessages((prev) => [...prev, uploadStartMessage]); + }; + + const handleFileUploaded = (event: CustomEvent) => { + const { result } = event.detail; + console.log("Chat page received file upload event:", result); + + // Replace the last message with upload complete message + const uploadMessage: Message = { + role: "assistant", + content: `📄 Document uploaded: **${result.filename}** (${ + result.pages + } pages, ${result.content_length.toLocaleString()} characters)\n\n${ + result.confirmation + }`, + timestamp: new Date(), + }; + + setMessages((prev) => [...prev.slice(0, -1), uploadMessage]); + + // 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]); + }; + + 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); + }, []); + + const { data: nudges = [], cancel: cancelNudges } = useGetNudgesQuery( + previousResponseIds[endpoint], + { + enabled: isOnboardingComplete, // Only fetch nudges after onboarding is complete + }, + ); + + const handleSSEStream = async (userMessage: Message) => { + // 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 the hook to send the message + await sendStreamingMessage({ + prompt: userMessage.content, + previousResponseId: previousResponseIds[endpoint] || undefined, + filters: processedFilters, + limit: parsedFilterData?.limit ?? 10, + scoreThreshold: parsedFilterData?.scoreThreshold ?? 0, + }); + scrollToBottom({ + animation: "smooth", + duration: 1000, + }); + }; + + const handleSendMessage = async (inputMessage: string) => { + if (!inputMessage.trim() || loading) return; + + const userMessage: Message = { + role: "user", + content: inputMessage.trim(), + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, userMessage]); + setInput(""); + setLoading(true); + setIsFilterHighlighted(false); + + scrollToBottom({ + animation: "smooth", + duration: 1000, + }); + + if (asyncMode) { + await handleSSEStream(userMessage); + } 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(); + handleSendMessage(input); + }; + + 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); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Handle backspace for filter clearing + if (e.key === "Backspace" && selectedFilter && input.trim() === "") { + e.preventDefault(); + + if (isFilterHighlighted) { + // Second backspace - remove the filter + setSelectedFilter(null); + setIsFilterHighlighted(false); + } else { + // First backspace - highlight the filter + setIsFilterHighlighted(true); + } + return; + } + + if (isFilterDropdownOpen) { + const filteredFilters = availableFilters.filter((filter) => + filter.name.toLowerCase().includes(filterSearchTerm.toLowerCase()), + ); + + if (e.key === "Escape") { + e.preventDefault(); + setIsFilterDropdownOpen(false); + setFilterSearchTerm(""); + setSelectedFilterIndex(0); + setDropdownDismissed(true); + + // Keep focus on the textarea so user can continue typing normally + chatInputRef.current?.focusInput(); + return; + } + + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedFilterIndex((prev) => + prev < filteredFilters.length - 1 ? prev + 1 : 0, + ); + return; + } + + if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedFilterIndex((prev) => + prev > 0 ? prev - 1 : filteredFilters.length - 1, + ); + return; + } + + if (e.key === "Enter") { + // Check if we're at the end of an @ mention (space before cursor or end of input) + const cursorPos = e.currentTarget.selectionStart || 0; + const textBeforeCursor = input.slice(0, cursorPos); + const words = textBeforeCursor.split(" "); + const lastWord = words[words.length - 1]; + + if (lastWord.startsWith("@") && filteredFilters[selectedFilterIndex]) { + e.preventDefault(); + handleFilterSelect(filteredFilters[selectedFilterIndex]); + return; + } + } + + if (e.key === " ") { + // Select filter on space if we're typing an @ mention + const cursorPos = e.currentTarget.selectionStart || 0; + const textBeforeCursor = input.slice(0, cursorPos); + const words = textBeforeCursor.split(" "); + const lastWord = words[words.length - 1]; + + if (lastWord.startsWith("@") && filteredFilters[selectedFilterIndex]) { + e.preventDefault(); + handleFilterSelect(filteredFilters[selectedFilterIndex]); + return; + } + } + } + + if (e.key === "Enter" && !e.shiftKey && !isFilterDropdownOpen) { + 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(); + } + } + } + }; + + const onChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInput(newValue); + + // Clear filter highlight when user starts typing + if (isFilterHighlighted) { + setIsFilterHighlighted(false); + } + + // Find if there's an @ at the start of the last word + const words = newValue.split(" "); + const lastWord = words[words.length - 1]; + + if (lastWord.startsWith("@") && !dropdownDismissed) { + const searchTerm = lastWord.slice(1); // Remove the @ + console.log("Setting search term:", searchTerm); + setFilterSearchTerm(searchTerm); + setSelectedFilterIndex(0); + + // Only set anchor position when @ is first detected (search term is empty) + if (searchTerm === "") { + const pos = getCursorPosition(e.target); + setAnchorPosition(pos); + } + + if (!isFilterDropdownOpen) { + loadAvailableFilters(); + setIsFilterDropdownOpen(true); + } + } else if (isFilterDropdownOpen) { + // Close dropdown if @ is no longer present + console.log("Closing dropdown - no @ found"); + setIsFilterDropdownOpen(false); + setFilterSearchTerm(""); + } + + // Reset dismissed flag when user moves to a different word + if (dropdownDismissed && !lastWord.startsWith("@")) { + setDropdownDismissed(false); + } + }; + + const onAtClick = () => { + if (!isFilterDropdownOpen) { + loadAvailableFilters(); + setIsFilterDropdownOpen(true); + setFilterSearchTerm(""); + setSelectedFilterIndex(0); + + // Get button position for popover anchoring + const button = document.querySelector( + "[data-filter-button]", + ) as HTMLElement; + if (button) { + const rect = button.getBoundingClientRect(); + setAnchorPosition({ + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2 - 12, + }); + } + } else { + setIsFilterDropdownOpen(false); + setAnchorPosition(null); + } + }; + + return ( + <> + {/* Debug header - only show in debug mode */} + {isDebugMode && ( +
+
+
+ {/* Async Mode Toggle */} +
+ + +
+ {/* Endpoint Toggle */} +
+ + +
+
+
+ )} + + +
+ {messages.length === 0 && !streamingMessage ? ( +
+
+ {isUploading ? ( + <> + +

Processing your document...

+

This may take a few moments

+ + ) : null} +
+
+ ) : ( + <> + {messages.map((message, index) => ( +
+ {message.role === "user" && + (messages[index]?.content.match(FILES_REGEX)?.[0] ?? + null) === null && ( +
+ = 2 + ? (messages[index - 2]?.content.match( + FILES_REGEX, + )?.[0] ?? undefined) + : undefined + } + /> +
+ )} + + {message.role === "assistant" && + (index < 1 || + (messages[index - 1]?.content.match(FILES_REGEX)?.[0] ?? + null) === null) && ( +
+ handleForkConversation(index, e)} + animate={false} + /> +
+ )} +
+ ))} + + {/* Streaming Message Display */} + {streamingMessage && ( + + )} + + )} + {!streamingMessage && ( +
+ +
+ )} +
+
+
+ {/* Input Area - Fixed at bottom */} + setTextareaHeight(height)} + onFilterSelect={handleFilterSelect} + onAtClick={onAtClick} + onFilePickerChange={handleFilePickerChange} + onFilePickerClick={handleFilePickerClick} + setSelectedFilter={setSelectedFilter} + setIsFilterHighlighted={setIsFilterHighlighted} + setIsFilterDropdownOpen={setIsFilterDropdownOpen} + /> +
+ + ); } export default function ProtectedChatPage() { - return ( - - - - ); + return ( + +
+ + + +
+
+ ); } diff --git a/frontend/src/app/chat/types.ts b/frontend/src/app/chat/types.ts index 507dfe09..5f9c54d6 100644 --- a/frontend/src/app/chat/types.ts +++ b/frontend/src/app/chat/types.ts @@ -4,6 +4,7 @@ export interface Message { timestamp: Date; functionCalls?: FunctionCall[]; isStreaming?: boolean; + source?: "langflow" | "chat"; } export interface FunctionCall { diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index d8460fd6..7f07f074 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -109,15 +109,12 @@ @layer components { .app-grid-arrangement { - --sidebar-width: 0px; --notifications-width: 0px; --filters-width: 0px; - --app-header-height: 53px; --top-banner-height: 0px; + --header-height: 54px; + --sidebar-width: 280px; - @media (width >= 48rem) { - --sidebar-width: 288px; - } &.notifications-open { --notifications-width: 320px; } @@ -132,7 +129,7 @@ width: 100%; grid-template-rows: var(--top-banner-height) - var(--app-header-height) + var(--header-height) 1fr; grid-template-columns: var(--sidebar-width) @@ -147,10 +144,6 @@ grid-template-rows 0.25s ease-in-out; } - .header-arrangement { - @apply flex w-full items-center justify-between border-b border-border; - } - .header-start-display { @apply flex items-center gap-2; } @@ -352,6 +345,15 @@ @apply text-xs opacity-70; } + .prose :where(strong):not(:where([class~="not-prose"],[class~="not-prose"] *)) { + @apply text-current; + } + + .prose :where(a):not(:where([class~="not-prose"],[class~="not-prose"] *)) + { + @apply text-current; + } + .box-shadow-inner::after { content: " "; position: absolute; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 10ed826b..16db9579 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -38,7 +38,7 @@ export default function RootLayout({ return ( { - if (!isLoading && !isSettingsLoading && (isAuthenticated || isNoAuthMode)) { + if (!isLoading && (isAuthenticated || isNoAuthMode)) { router.push(redirect); } }, [ isLoading, - isSettingsLoading, isAuthenticated, isNoAuthMode, router, redirect, ]); - if (isLoading || isSettingsLoading) { + if (isLoading) { return (
@@ -55,21 +44,10 @@ function LoginPageContent() { } return ( -
- -
- -
+
+
+ +

Welcome to OpenRAG

+ hideIcon ? ( +
+ ) : ( + icon || ( +
+ +
+ ) + ) } > -
-

- {displayedText} - {!showChildren && !isCompleted && } -

- - {showChildren && !isCompleted && ( - + {isLoadingModels && loadingStatus.length > 0 ? ( +
+
+
+ +
+ + Thinking + +
+
+
+
+
+ + + {loadingStatus[currentStatusIndex]} + + +
+
+
+
+ ) : isMarkdown ? ( + + ) : ( + <> +

- {children} - - )} - + {displayedText} + {!showChildren && !isCompleted && ( + + )} +

+ {reserveSpaceForThinking && ( +
+ )} + + )} + {children && ( + + {((showChildren && !isCompleted) || isMarkdown) && ( + +
+ {children}
+
+ )} +
+ )}
diff --git a/frontend/src/app/new-onboarding/components/onboarding-upload.tsx b/frontend/src/app/new-onboarding/components/onboarding-upload.tsx new file mode 100644 index 00000000..dab12e53 --- /dev/null +++ b/frontend/src/app/new-onboarding/components/onboarding-upload.tsx @@ -0,0 +1,108 @@ +import { ChangeEvent, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { uploadFileForContext } from "@/lib/upload-utils"; +import { AnimatePresence, motion } from "motion/react"; +import { AnimatedProviderSteps } from "@/app/onboarding/components/animated-provider-steps"; + +interface OnboardingUploadProps { + onComplete: () => void; +} + +const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => { + const fileInputRef = useRef(null); + const [isUploading, setIsUploading] = useState(false); + const [currentStep, setCurrentStep] = useState(null); + + const STEP_LIST = [ + "Uploading your document", + "Processing your document", + ]; + + const resetFileInput = () => { + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; + + const performUpload = async (file: File) => { + setIsUploading(true); + try { + setCurrentStep(0); + await uploadFileForContext(file); + console.log("Document uploaded successfully"); + } catch (error) { + console.error("Upload failed", (error as Error).message); + } finally { + setIsUploading(false); + await new Promise(resolve => setTimeout(resolve, 1000)); + setCurrentStep(STEP_LIST.length); + await new Promise(resolve => setTimeout(resolve, 500)); + onComplete(); + } + }; + + const handleFileChange = async (event: ChangeEvent) => { + const selectedFile = event.target.files?.[0]; + if (!selectedFile) { + resetFileInput(); + return; + } + + try { + await performUpload(selectedFile); + } catch (error) { + console.error("Unable to prepare file for upload", (error as Error).message); + } finally { + resetFileInput(); + } + }; + + + return ( + + {currentStep === null ? ( + + + + + ) : ( + + + + )} + + ) +} + +export default OnboardingUpload; diff --git a/frontend/src/app/new-onboarding/components/progress-bar.tsx b/frontend/src/app/new-onboarding/components/progress-bar.tsx index e0e12e45..e54224c5 100644 --- a/frontend/src/app/new-onboarding/components/progress-bar.tsx +++ b/frontend/src/app/new-onboarding/components/progress-bar.tsx @@ -8,17 +8,17 @@ export function ProgressBar({ currentStep, totalSteps }: ProgressBarProps) { return (
-
-
+
+
- + {currentStep + 1}/{totalSteps}
diff --git a/frontend/src/app/new-onboarding/page.tsx b/frontend/src/app/new-onboarding/page.tsx index efc50bf0..9c1aeec7 100644 --- a/frontend/src/app/new-onboarding/page.tsx +++ b/frontend/src/app/new-onboarding/page.tsx @@ -1,109 +1,44 @@ "use client"; import { Suspense, useState } from "react"; -import { ProtectedRoute } from "@/components/protected-route"; import { DoclingHealthBanner } from "@/components/docling-health-banner"; -import { DotPattern } from "@/components/ui/dot-pattern"; -import { cn } from "@/lib/utils"; -import { OnboardingStep } from "./components/onboarding-step"; +import { ProtectedRoute } from "@/components/protected-route"; +import { OnboardingContent } from "./components/onboarding-content"; import { ProgressBar } from "./components/progress-bar"; -import OnboardingCard from "../onboarding/components/onboarding-card"; const TOTAL_STEPS = 4; function NewOnboardingPage() { - const [currentStep, setCurrentStep] = useState(0); + const [currentStep, setCurrentStep] = useState(0); - const handleStepComplete = () => { - if (currentStep < TOTAL_STEPS - 1) { - setCurrentStep(currentStep + 1); - } - }; + const handleStepComplete = () => { + if (currentStep < TOTAL_STEPS - 1) { + setCurrentStep(currentStep + 1); + } + }; - return ( -
- + return ( +
+ - {/* Chat-like content area */} -
-
-
- = 0} - isCompleted={currentStep > 0} - text="Let's get started by setting up your model provider." - > - - + {/* Chat-like content area */} +
+
+ +
- = 1} - isCompleted={currentStep > 1} - text="Step 1: Configure your settings" - > -
-

- Let's configure some basic settings for your account. -

- -
-
- - = 2} - isCompleted={currentStep > 2} - text="Step 2: Connect your model" - > -
-

- Choose and connect your preferred AI model provider. -

- -
-
- - = 3} - isCompleted={currentStep > 3} - text="Step 3: You're all set!" - > -
-

- Your account is ready to use. Let's start chatting! -

- -
-
-
-
- - -
-
- ); + +
+
+ ); } export default function ProtectedNewOnboardingPage() { - return ( - - Loading...
}> - - - - ); + return ( + + Loading...
}> + + + + ); } diff --git a/frontend/src/app/onboarding/components/advanced.tsx b/frontend/src/app/onboarding/components/advanced.tsx index 565b5af9..94145b35 100644 --- a/frontend/src/app/onboarding/components/advanced.tsx +++ b/frontend/src/app/onboarding/components/advanced.tsx @@ -75,20 +75,6 @@ export function AdvancedOnboarding({ /> )} - {(hasLanguageModels || hasEmbeddingModels) && !updatedOnboarding && } - {!updatedOnboarding && ( - - - - )} diff --git a/frontend/src/app/onboarding/components/animated-provider-steps.tsx b/frontend/src/app/onboarding/components/animated-provider-steps.tsx new file mode 100644 index 00000000..2d943789 --- /dev/null +++ b/frontend/src/app/onboarding/components/animated-provider-steps.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { CheckIcon } from "lucide-react"; +import { useEffect } from "react"; +import { AnimatedProcessingIcon } from "@/components/ui/animated-processing-icon"; +import { cn } from "@/lib/utils"; + +export function AnimatedProviderSteps({ + currentStep, + setCurrentStep, + steps, +}: { + currentStep: number; + setCurrentStep: (step: number) => void; + steps: string[]; +}) { + + useEffect(() => { + if (currentStep < steps.length - 1) { + const interval = setInterval(() => { + setCurrentStep(currentStep + 1); + }, 1500); + return () => clearInterval(interval); + } + }, [currentStep, setCurrentStep, steps]); + + const isDone = currentStep >= steps.length; + + return ( +
+
+
+ + +
+ + + {isDone ? "Done" : "Thinking"} + +
+
+ + {!isDone && ( + +
+
+ + + {steps[currentStep]} + + +
+ + )} + +
+
+ ); +} diff --git a/frontend/src/app/onboarding/components/ibm-onboarding.tsx b/frontend/src/app/onboarding/components/ibm-onboarding.tsx index b696e220..cd638025 100644 --- a/frontend/src/app/onboarding/components/ibm-onboarding.tsx +++ b/frontend/src/app/onboarding/components/ibm-onboarding.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { LabelInput } from "@/components/label-input"; import { LabelWrapper } from "@/components/label-wrapper"; import IBMLogo from "@/components/logo/ibm-logo"; @@ -14,10 +14,14 @@ export function IBMOnboarding({ setSettings, sampleDataset, setSampleDataset, + setIsLoadingModels, + setLoadingStatus, }: { setSettings: (settings: OnboardingVariables) => void; sampleDataset: boolean; setSampleDataset: (dataset: boolean) => void; + setIsLoadingModels?: (isLoading: boolean) => void; + setLoadingStatus?: (status: string[]) => void; }) { const [endpoint, setEndpoint] = useState("https://us-south.ml.cloud.ibm.com"); const [apiKey, setApiKey] = useState(""); @@ -99,6 +103,19 @@ export function IBMOnboarding({ }, setSettings, ); + + // Notify parent about loading state + useEffect(() => { + setIsLoadingModels?.(isLoadingModels); + + // Set detailed loading status + if (isLoadingModels) { + const status = ["Connecting to IBM watsonx.ai", "Fetching language models", "Fetching embedding models"]; + setLoadingStatus?.(status); + } else { + setLoadingStatus?.([]); + } + }, [isLoadingModels, setIsLoadingModels, setLoadingStatus]); return ( <>
diff --git a/frontend/src/app/onboarding/components/ollama-onboarding.tsx b/frontend/src/app/onboarding/components/ollama-onboarding.tsx index b40e6714..82d86d83 100644 --- a/frontend/src/app/onboarding/components/ollama-onboarding.tsx +++ b/frontend/src/app/onboarding/components/ollama-onboarding.tsx @@ -7,154 +7,160 @@ import type { OnboardingVariables } from "../../api/mutations/useOnboardingMutat import { useGetOllamaModelsQuery } from "../../api/queries/useGetModelsQuery"; import { useModelSelection } from "../hooks/useModelSelection"; import { useUpdateSettings } from "../hooks/useUpdateSettings"; -import { AdvancedOnboarding } from "./advanced"; import { ModelSelector } from "./model-selector"; export function OllamaOnboarding({ - setSettings, - sampleDataset, - setSampleDataset, + setSettings, + sampleDataset, + setSampleDataset, + setIsLoadingModels, + setLoadingStatus, }: { - setSettings: (settings: OnboardingVariables) => void; - sampleDataset: boolean; - setSampleDataset: (dataset: boolean) => void; + setSettings: (settings: OnboardingVariables) => void; + sampleDataset: boolean; + setSampleDataset: (dataset: boolean) => void; + setIsLoadingModels?: (isLoading: boolean) => void; + setLoadingStatus?: (status: string[]) => void; }) { - const [endpoint, setEndpoint] = useState(`http://localhost:11434`); - const [showConnecting, setShowConnecting] = useState(false); - const debouncedEndpoint = useDebouncedValue(endpoint, 500); + const [endpoint, setEndpoint] = useState(`http://localhost:11434`); + const [showConnecting, setShowConnecting] = useState(false); + const debouncedEndpoint = useDebouncedValue(endpoint, 500); - // Fetch models from API when endpoint is provided (debounced) - const { - data: modelsData, - isLoading: isLoadingModels, - error: modelsError, - } = useGetOllamaModelsQuery( - debouncedEndpoint ? { endpoint: debouncedEndpoint } : undefined, - ); + // Fetch models from API when endpoint is provided (debounced) + const { + data: modelsData, + isLoading: isLoadingModels, + error: modelsError, + } = useGetOllamaModelsQuery( + debouncedEndpoint ? { endpoint: debouncedEndpoint } : undefined, + ); - // Use custom hook for model selection logic - const { - languageModel, - embeddingModel, - setLanguageModel, - setEmbeddingModel, - languageModels, - embeddingModels, - } = useModelSelection(modelsData); + // Use custom hook for model selection logic + const { + languageModel, + embeddingModel, + setLanguageModel, + setEmbeddingModel, + languageModels, + embeddingModels, + } = useModelSelection(modelsData); - // Handle delayed display of connecting state - useEffect(() => { - let timeoutId: NodeJS.Timeout; + // Handle delayed display of connecting state + useEffect(() => { + let timeoutId: NodeJS.Timeout; - if (debouncedEndpoint && isLoadingModels) { - timeoutId = setTimeout(() => { - setShowConnecting(true); - }, 500); - } else { - setShowConnecting(false); - } + if (debouncedEndpoint && isLoadingModels) { + timeoutId = setTimeout(() => { + setShowConnecting(true); + }, 500); + } else { + setShowConnecting(false); + } - return () => { - if (timeoutId) { - clearTimeout(timeoutId); - } - }; - }, [debouncedEndpoint, isLoadingModels]); + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [debouncedEndpoint, isLoadingModels]); - const handleSampleDatasetChange = (dataset: boolean) => { - setSampleDataset(dataset); - }; + // Update settings when values change + useUpdateSettings( + "ollama", + { + endpoint, + languageModel, + embeddingModel, + }, + setSettings, + ); - // Update settings when values change - useUpdateSettings( - "ollama", - { - endpoint, - languageModel, - embeddingModel, - }, - setSettings, - ); + // Notify parent about loading state + useEffect(() => { + setIsLoadingModels?.(isLoadingModels); - // Check validation state based on models query - const hasConnectionError = debouncedEndpoint && modelsError; - const hasNoModels = - modelsData && - !modelsData.language_models?.length && - !modelsData.embedding_models?.length; + // Set detailed loading status + if (isLoadingModels) { + const status = ["Connecting to Ollama", "Fetching language models", "Fetching embedding models"]; + setLoadingStatus?.(status); + } else { + setLoadingStatus?.([]); + } + }, [isLoadingModels, setIsLoadingModels, setLoadingStatus]); - return ( - <> -
-
- setEndpoint(e.target.value)} - /> - {showConnecting && ( -

- Connecting to Ollama server... -

- )} - {hasConnectionError && ( -

- Can’t reach Ollama at {debouncedEndpoint}. Update the base URL or - start the server. -

- )} - {hasNoModels && ( -

- No models found. Install embedding and agent models on your Ollama - server. -

- )} -
- - } - noOptionsPlaceholder={ - isLoadingModels - ? "Loading models..." - : "No embedding models detected. Install an embedding model to continue." - } - value={embeddingModel} - onValueChange={setEmbeddingModel} - /> - - - } - noOptionsPlaceholder={ - isLoadingModels - ? "Loading models..." - : "No language models detected. Install a language model to continue." - } - value={languageModel} - onValueChange={setLanguageModel} - /> - -
- - - ); + // Check validation state based on models query + const hasConnectionError = debouncedEndpoint && modelsError; + const hasNoModels = + modelsData && + !modelsData.language_models?.length && + !modelsData.embedding_models?.length; + + return ( +
+
+ setEndpoint(e.target.value)} + /> + {showConnecting && ( +

+ Connecting to Ollama server... +

+ )} + {hasConnectionError && ( +

+ Can’t reach Ollama at {debouncedEndpoint}. Update the base URL or + start the server. +

+ )} + {hasNoModels && ( +

+ No models found. Install embedding and agent models on your Ollama + server. +

+ )} +
+ + } + noOptionsPlaceholder={ + isLoadingModels + ? "Loading models..." + : "No embedding models detected. Install an embedding model to continue." + } + value={embeddingModel} + onValueChange={setEmbeddingModel} + /> + + + } + noOptionsPlaceholder={ + isLoadingModels + ? "Loading models..." + : "No language models detected. Install a language model to continue." + } + value={languageModel} + onValueChange={setLanguageModel} + /> + +
+ ); } diff --git a/frontend/src/app/onboarding/components/onboarding-card.tsx b/frontend/src/app/onboarding/components/onboarding-card.tsx index 58f629f7..64413fe3 100644 --- a/frontend/src/app/onboarding/components/onboarding-card.tsx +++ b/frontend/src/app/onboarding/components/onboarding-card.tsx @@ -1,182 +1,322 @@ "use client"; -import { useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; import { - type OnboardingVariables, - useOnboardingMutation, + type OnboardingVariables, + useOnboardingMutation, } from "@/app/api/mutations/useOnboardingMutation"; +import { useGetTasksQuery } from "@/app/api/queries/useGetTasksQuery"; import { useDoclingHealth } from "@/components/docling-health-banner"; import IBMLogo from "@/components/logo/ibm-logo"; import OllamaLogo from "@/components/logo/ollama-logo"; import OpenAILogo from "@/components/logo/openai-logo"; +import { AnimatedProcessingIcon } from "@/components/ui/animated-processing-icon"; import { Button } from "@/components/ui/button"; import { - Card, - CardContent, - CardFooter, - CardHeader, + Card, + CardContent, + CardFooter, + CardHeader, } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { - Tooltip, - TooltipContent, - TooltipTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { AnimatedProviderSteps } from "./animated-provider-steps"; import { IBMOnboarding } from "./ibm-onboarding"; import { OllamaOnboarding } from "./ollama-onboarding"; import { OpenAIOnboarding } from "./openai-onboarding"; interface OnboardingCardProps { - onComplete: () => void; + onComplete: () => void; + setIsLoadingModels?: (isLoading: boolean) => void; + setLoadingStatus?: (status: string[]) => void; } -const OnboardingCard = ({ onComplete }: OnboardingCardProps) => { - const updatedOnboarding = process.env.UPDATED_ONBOARDING === "true"; - const { isHealthy: isDoclingHealthy } = useDoclingHealth(); +const STEP_LIST = [ + "Setting up your model provider", + "Defining schema", + "Configuring Langflow", + "Ingesting sample data", +]; - const [modelProvider, setModelProvider] = useState("openai"); +const TOTAL_PROVIDER_STEPS = STEP_LIST.length; - const [sampleDataset, setSampleDataset] = useState(true); +const OnboardingCard = ({ + onComplete, + setIsLoadingModels: setIsLoadingModelsParent, + setLoadingStatus: setLoadingStatusParent, +}: OnboardingCardProps) => { + const { isHealthy: isDoclingHealthy } = useDoclingHealth(); - const handleSetModelProvider = (provider: string) => { - setModelProvider(provider); - setSettings({ - model_provider: provider, - embedding_model: "", - llm_model: "", - }); - }; + const [modelProvider, setModelProvider] = useState("openai"); - const [settings, setSettings] = useState({ - model_provider: modelProvider, - embedding_model: "", - llm_model: "", - }); + const [sampleDataset, setSampleDataset] = useState(true); - // Mutations - const onboardingMutation = useOnboardingMutation({ - onSuccess: (data) => { - console.log("Onboarding completed successfully", data); - onComplete(); - }, - onError: (error) => { - toast.error("Failed to complete onboarding", { - description: error.message, - }); - }, - }); + const [isLoadingModels, setIsLoadingModels] = useState(false); - const handleComplete = () => { - if ( - !settings.model_provider || - !settings.llm_model || - !settings.embedding_model - ) { - toast.error("Please complete all required fields"); - return; - } + const [loadingStatus, setLoadingStatus] = useState([]); - // Prepare onboarding data - const onboardingData: OnboardingVariables = { - model_provider: settings.model_provider, - llm_model: settings.llm_model, - embedding_model: settings.embedding_model, - sample_data: sampleDataset, - }; + const [currentStatusIndex, setCurrentStatusIndex] = useState(0); - // Add API key if available - if (settings.api_key) { - onboardingData.api_key = settings.api_key; - } + // Pass loading state to parent + useEffect(() => { + setIsLoadingModelsParent?.(isLoadingModels); + }, [isLoadingModels, setIsLoadingModelsParent]); - // Add endpoint if available - if (settings.endpoint) { - onboardingData.endpoint = settings.endpoint; - } + useEffect(() => { + setLoadingStatusParent?.(loadingStatus); + }, [loadingStatus, setLoadingStatusParent]); - // Add project_id if available - if (settings.project_id) { - onboardingData.project_id = settings.project_id; - } + // Cycle through loading status messages once + useEffect(() => { + if (!isLoadingModels || loadingStatus.length === 0) { + setCurrentStatusIndex(0); + return; + } - onboardingMutation.mutate(onboardingData); - }; + const interval = setInterval(() => { + setCurrentStatusIndex((prev) => { + const nextIndex = prev + 1; + // Stop at the last message + if (nextIndex >= loadingStatus.length - 1) { + clearInterval(interval); + return loadingStatus.length - 1; + } + return nextIndex; + }); + }, 1500); // Change status every 1.5 seconds - const isComplete = !!settings.llm_model && !!settings.embedding_model && isDoclingHealthy; + return () => clearInterval(interval); + }, [isLoadingModels, loadingStatus]); - return ( - - - - - - - OpenAI - - - - IBM watsonx.ai - - - - Ollama - - - - - - - - - - - - - - - - - - -
- -
-
- {!isComplete && ( - - {!!settings.llm_model && !!settings.embedding_model && !isDoclingHealthy - ? "docling-serve must be running to continue" - : "Please fill in all required fields"} - - )} -
-
-
- ) -} + const handleSetModelProvider = (provider: string) => { + setModelProvider(provider); + setSettings({ + model_provider: provider, + embedding_model: "", + llm_model: "", + }); + }; + + const [settings, setSettings] = useState({ + model_provider: modelProvider, + embedding_model: "", + llm_model: "", + }); + + const [currentStep, setCurrentStep] = useState(null); + + // Query tasks to track completion + const { data: tasks } = useGetTasksQuery({ + enabled: currentStep !== null, // Only poll when onboarding has started + refetchInterval: currentStep !== null ? 1000 : false, // Poll every 1 second during onboarding + }); + + // Monitor tasks and call onComplete when all tasks are done + useEffect(() => { + if (currentStep === null || !tasks) { + return; + } + + // Check if there are any active tasks (pending, running, or processing) + const activeTasks = tasks.find( + (task) => + task.status === "pending" || + task.status === "running" || + task.status === "processing", + ); + + // If no active tasks and we've started onboarding, complete it + if ( + (!activeTasks || (activeTasks.processed_files ?? 0) > 0) && + tasks.length > 0 + ) { + // Set to final step to show "Done" + setCurrentStep(TOTAL_PROVIDER_STEPS); + // Wait a bit before completing + setTimeout(() => { + onComplete(); + }, 1000); + } + }, [tasks, currentStep, onComplete]); + + // Mutations + const onboardingMutation = useOnboardingMutation({ + onSuccess: (data) => { + console.log("Onboarding completed successfully", data); + setCurrentStep(0); + }, + onError: (error) => { + toast.error("Failed to complete onboarding", { + description: error.message, + }); + }, + }); + + const handleComplete = () => { + if ( + !settings.model_provider || + !settings.llm_model || + !settings.embedding_model + ) { + toast.error("Please complete all required fields"); + return; + } + + // Prepare onboarding data + const onboardingData: OnboardingVariables = { + model_provider: settings.model_provider, + llm_model: settings.llm_model, + embedding_model: settings.embedding_model, + sample_data: sampleDataset, + }; + + // Add API key if available + if (settings.api_key) { + onboardingData.api_key = settings.api_key; + } + + // Add endpoint if available + if (settings.endpoint) { + onboardingData.endpoint = settings.endpoint; + } + + // Add project_id if available + if (settings.project_id) { + onboardingData.project_id = settings.project_id; + } + + onboardingMutation.mutate(onboardingData); + setCurrentStep(0); + }; + + const isComplete = + !!settings.llm_model && !!settings.embedding_model && isDoclingHealthy; + + return ( + + {currentStep === null ? ( + +
+ + + +
+ +
+ OpenAI +
+ +
+ +
+ IBM watsonx.ai +
+ +
+ +
+ Ollama +
+
+ + + + + + + + + +
+ + {!isLoadingModels && ( + + +
+ +
+
+ {!isComplete && ( + + {!!settings.llm_model && + !!settings.embedding_model && + !isDoclingHealthy + ? "docling-serve must be running to continue" + : "Please fill in all required fields"} + + )} +
+ )} +
+
+ ) : ( + + + + )} +
+ ); +}; export default OnboardingCard; diff --git a/frontend/src/app/onboarding/components/openai-onboarding.tsx b/frontend/src/app/onboarding/components/openai-onboarding.tsx index b057efc0..01646ad9 100644 --- a/frontend/src/app/onboarding/components/openai-onboarding.tsx +++ b/frontend/src/app/onboarding/components/openai-onboarding.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { LabelInput } from "@/components/label-input"; import { LabelWrapper } from "@/components/label-wrapper"; import OpenAILogo from "@/components/logo/openai-logo"; @@ -14,10 +14,14 @@ export function OpenAIOnboarding({ setSettings, sampleDataset, setSampleDataset, + setIsLoadingModels, + setLoadingStatus, }: { setSettings: (settings: OnboardingVariables) => void; sampleDataset: boolean; setSampleDataset: (dataset: boolean) => void; + setIsLoadingModels?: (isLoading: boolean) => void; + setLoadingStatus?: (status: string[]) => void; }) { const [apiKey, setApiKey] = useState(""); const [getFromEnv, setGetFromEnv] = useState(true); @@ -68,6 +72,19 @@ export function OpenAIOnboarding({ }, setSettings, ); + + // Notify parent about loading state + useEffect(() => { + setIsLoadingModels?.(isLoadingModels); + + // Set detailed loading status + if (isLoadingModels) { + const status = ["Connecting to OpenAI", "Fetching language models", "Fetching embedding models"]; + setLoadingStatus?.(status); + } else { + setLoadingStatus?.([]); + } + }, [isLoadingModels, setIsLoadingModels, setLoadingStatus]); return ( <>
diff --git a/frontend/src/components/animated-conditional.tsx b/frontend/src/components/animated-conditional.tsx new file mode 100644 index 00000000..e2499890 --- /dev/null +++ b/frontend/src/components/animated-conditional.tsx @@ -0,0 +1,53 @@ +import { motion } from "framer-motion"; +import { ANIMATION_DURATION } from "@/lib/constants"; + +export const AnimatedConditional = ({ + children, + isOpen, + className, + slide = false, + delay, + vertical = false, +}: { + children: React.ReactNode; + isOpen: boolean; + className?: string; + delay?: number; + vertical?: boolean; + slide?: boolean; +}) => { + const animationProperty = slide + ? vertical + ? "translateY" + : "translateX" + : vertical + ? "height" + : "width"; + const animationValue = isOpen + ? slide + ? "0px" + : "auto" + : slide + ? "-100%" + : "0px"; + + return ( + + {children} + + ); +}; diff --git a/frontend/src/components/chat-renderer.tsx b/frontend/src/components/chat-renderer.tsx new file mode 100644 index 00000000..925ddb76 --- /dev/null +++ b/frontend/src/components/chat-renderer.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { motion } from "framer-motion"; +import { usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; +import { + type ChatConversation, + useGetConversationsQuery, +} from "@/app/api/queries/useGetConversationsQuery"; +import type { Settings } from "@/app/api/queries/useGetSettingsQuery"; +import { OnboardingContent } from "@/app/new-onboarding/components/onboarding-content"; +import { ProgressBar } from "@/app/new-onboarding/components/progress-bar"; +import { AnimatedConditional } from "@/components/animated-conditional"; +import { Header } from "@/components/header"; +import { Navigation } from "@/components/navigation"; +import { useAuth } from "@/contexts/auth-context"; +import { useChat } from "@/contexts/chat-context"; +import { + ANIMATION_DURATION, + HEADER_HEIGHT, + ONBOARDING_STEP_KEY, + SIDEBAR_WIDTH, + TOTAL_ONBOARDING_STEPS, +} from "@/lib/constants"; +import { cn } from "@/lib/utils"; + +export function ChatRenderer({ + settings, + children, +}: { + settings: Settings | undefined; + children: React.ReactNode; +}) { + const pathname = usePathname(); + const { isAuthenticated, isNoAuthMode } = useAuth(); + const { + endpoint, + refreshTrigger, + refreshConversations, + startNewConversation, + } = useChat(); + + // Initialize onboarding state based on local storage and settings + const [currentStep, setCurrentStep] = useState(() => { + if (typeof window === "undefined") return 0; + const savedStep = localStorage.getItem(ONBOARDING_STEP_KEY); + return savedStep !== null ? parseInt(savedStep, 10) : 0; + }); + + const [showLayout, setShowLayout] = useState(() => { + if (typeof window === "undefined") return false; + const savedStep = localStorage.getItem(ONBOARDING_STEP_KEY); + // Show layout if settings.edited is true and if no onboarding step is saved + const isEdited = settings?.edited ?? true; + return isEdited ? savedStep === null : false; + }); + + // Only fetch conversations on chat page + const isOnChatPage = pathname === "/" || pathname === "/chat"; + const { data: conversations = [], isLoading: isConversationsLoading } = + useGetConversationsQuery(endpoint, refreshTrigger, { + enabled: isOnChatPage && (isAuthenticated || isNoAuthMode), + }) as { data: ChatConversation[]; isLoading: boolean }; + + const handleNewConversation = () => { + refreshConversations(); + startNewConversation(); + }; + + // Save current step to local storage whenever it changes + useEffect(() => { + if (typeof window !== "undefined" && !showLayout) { + localStorage.setItem(ONBOARDING_STEP_KEY, currentStep.toString()); + } + }, [currentStep, showLayout]); + + const handleStepComplete = () => { + if (currentStep < TOTAL_ONBOARDING_STEPS - 1) { + setCurrentStep(currentStep + 1); + } else { + // Onboarding is complete - remove from local storage and show layout + if (typeof window !== "undefined") { + localStorage.removeItem(ONBOARDING_STEP_KEY); + } + setShowLayout(true); + } + }; + + // List of paths with smaller max-width + const smallWidthPaths = ["/settings/connector/new"]; + const isSmallWidthPath = smallWidthPaths.includes(pathname); + + const x = showLayout ? "0px" : `calc(-${SIDEBAR_WIDTH / 2}px + 50vw)`; + const y = showLayout ? "0px" : `calc(-${HEADER_HEIGHT / 2}px + 50vh)`; + const translateY = showLayout ? "0px" : `-50vh`; + const translateX = showLayout ? "0px" : `-50vw`; + + // For all other pages, render with Langflow-styled navigation and task menu + return ( + <> + +
+ + + {/* Sidebar Navigation */} + + + + + {/* Main Content */} +
+ +
+ +
+ {children} +
+ {!showLayout && ( + + )} +
+
+
+ + + +
+ + ); +} diff --git a/frontend/src/components/header.tsx b/frontend/src/components/header.tsx new file mode 100644 index 00000000..e542b53e --- /dev/null +++ b/frontend/src/components/header.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Bell } from "lucide-react"; +import Logo from "@/components/logo/logo"; +import { UserNav } from "@/components/user-nav"; +import { useTask } from "@/contexts/task-context"; +import { cn } from "@/lib/utils"; + +export function Header() { + const { tasks, toggleMenu } = useTask(); + + // Calculate active tasks for the bell icon + const activeTasks = tasks.filter( + (task) => + task.status === "pending" || + task.status === "running" || + task.status === "processing", + ); + + return ( +
+
+ {/* Logo/Title */} +
+ + OpenRAG +
+
+
+
+ {/* Knowledge Filter Dropdown */} + {/* */} + + {/* GitHub Star Button */} + {/* */} + + {/* Discord Link */} + {/* */} + + {/* Task Notification Bell */} + + + {/* Separator */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/layout-wrapper.tsx b/frontend/src/components/layout-wrapper.tsx index 130ad3f0..36713e74 100644 --- a/frontend/src/components/layout-wrapper.tsx +++ b/frontend/src/components/layout-wrapper.tsx @@ -1,39 +1,25 @@ "use client"; -import { Bell, Loader2 } from "lucide-react"; +import { Loader2 } from "lucide-react"; import { usePathname } from "next/navigation"; -import { - useGetConversationsQuery, - type ChatConversation, -} from "@/app/api/queries/useGetConversationsQuery"; import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; import { DoclingHealthBanner } from "@/components/docling-health-banner"; import { KnowledgeFilterPanel } from "@/components/knowledge-filter-panel"; -import Logo from "@/components/logo/logo"; -import { Navigation } from "@/components/navigation"; import { TaskNotificationMenu } from "@/components/task-notification-menu"; -import { UserNav } from "@/components/user-nav"; import { useAuth } from "@/contexts/auth-context"; -import { useChat } from "@/contexts/chat-context"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; -// import { GitHubStarButton } from "@/components/github-star-button" -// import { DiscordLink } from "@/components/discord-link" import { useTask } from "@/contexts/task-context"; -import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery"; import { cn } from "@/lib/utils"; +import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery"; +import { ChatRenderer } from "./chat-renderer"; export function LayoutWrapper({ children }: { children: React.ReactNode }) { const pathname = usePathname(); - const { tasks, isMenuOpen, toggleMenu } = useTask(); + const { isMenuOpen } = useTask(); const { isPanelOpen } = useKnowledgeFilter(); const { isLoading, isAuthenticated, isNoAuthMode } = useAuth(); - const { - endpoint, - refreshTrigger, - refreshConversations, - startNewConversation, - } = useChat(); - const { isLoading: isSettingsLoading } = useGetSettingsQuery({ + + const { data: settings, isLoading: isSettingsLoading } = useGetSettingsQuery({ enabled: isAuthenticated || isNoAuthMode, }); const { @@ -42,40 +28,17 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) { isError, } = useDoclingHealthQuery(); - // Only fetch conversations on chat page - const isOnChatPage = pathname === "/" || pathname === "/chat"; - const { data: conversations = [], isLoading: isConversationsLoading } = - useGetConversationsQuery(endpoint, refreshTrigger, { - enabled: isOnChatPage && (isAuthenticated || isNoAuthMode), - }) as { data: ChatConversation[]; isLoading: boolean }; - - const handleNewConversation = () => { - refreshConversations(); - startNewConversation(); - }; - // List of paths that should not show navigation - const authPaths = ["/login", "/auth/callback", "/onboarding", "/new-onboarding"]; + const authPaths = ["/login", "/auth/callback"]; const isAuthPage = authPaths.includes(pathname); const isOnKnowledgePage = pathname.startsWith("/knowledge"); - // List of paths with smaller max-width - const smallWidthPaths = ["/settings/connector/new"]; - const isSmallWidthPath = smallWidthPaths.includes(pathname); - - // Calculate active tasks for the bell icon - const activeTasks = tasks.filter( - task => - task.status === "pending" || - task.status === "running" || - task.status === "processing" - ); - const isUnhealthy = health?.status === "unhealthy" || isError; const isBannerVisible = !isHealthLoading && isUnhealthy; + const isSettingsLoadingOrError = isSettingsLoading || !settings; // Show loading state when backend isn't ready - if (isLoading || isSettingsLoading) { + if (isLoading || (isSettingsLoadingOrError && (isNoAuthMode || isAuthenticated))) { return (
@@ -93,88 +56,31 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) { // For all other pages, render with Langflow-styled navigation and task menu return ( -
-
- +
+
+
+ +
+ + {children} + + {/* Task Notifications Panel */} + + + {/* Knowledge Filter Panel */} +
-
-
- {/* Logo/Title */} -
- - OpenRAG -
-
-
-
- {/* Knowledge Filter Dropdown */} - {/* */} - - {/* GitHub Star Button */} - {/* */} - - {/* Discord Link */} - {/* */} - - {/* Task Notification Bell */} - - - {/* Separator */} -
- - -
-
-
- - {/* Sidebar Navigation */} - - - {/* Main Content */} -
-
- {children} -
-
- - {/* Task Notifications Panel */} - - - {/* Knowledge Filter Panel */} -
); } diff --git a/frontend/src/components/protected-route.tsx b/frontend/src/components/protected-route.tsx index a5403e1a..a6f8bf62 100644 --- a/frontend/src/components/protected-route.tsx +++ b/frontend/src/components/protected-route.tsx @@ -3,7 +3,6 @@ import { Loader2 } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; import { useEffect } from "react"; -import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; import { useAuth } from "@/contexts/auth-context"; interface ProtectedRouteProps { @@ -12,10 +11,6 @@ interface ProtectedRouteProps { export function ProtectedRoute({ children }: ProtectedRouteProps) { const { isLoading, isAuthenticated, isNoAuthMode } = useAuth(); - const { data: settings = {}, isLoading: isSettingsLoading } = - useGetSettingsQuery({ - enabled: isAuthenticated || isNoAuthMode, - }); const router = useRouter(); const pathname = usePathname(); @@ -31,30 +26,22 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) { ); useEffect(() => { - if (!isLoading && !isSettingsLoading && !isAuthenticated && !isNoAuthMode) { + if (!isLoading && !isAuthenticated && !isNoAuthMode) { // Redirect to login with current path as redirect parameter const redirectUrl = `/login?redirect=${encodeURIComponent(pathname)}`; router.push(redirectUrl); return; } - - if (!isLoading && !isSettingsLoading && !settings.edited) { - const updatedOnboarding = process.env.UPDATED_ONBOARDING === "true"; - router.push(updatedOnboarding ? "/new-onboarding" : "/onboarding"); - } }, [ isLoading, - isSettingsLoading, isAuthenticated, isNoAuthMode, router, pathname, - isSettingsLoading, - settings.edited, ]); // Show loading state while checking authentication - if (isLoading || isSettingsLoading) { + if (isLoading) { return (
diff --git a/frontend/src/hooks/useChatStreaming.ts b/frontend/src/hooks/useChatStreaming.ts new file mode 100644 index 00000000..6a7202e8 --- /dev/null +++ b/frontend/src/hooks/useChatStreaming.ts @@ -0,0 +1,492 @@ +import { useRef, useState } from "react"; +import type { FunctionCall, Message, SelectedFilters } from "@/app/chat/types"; + +interface UseChatStreamingOptions { + endpoint?: string; + onComplete?: (message: Message, responseId: string | null) => void; + onError?: (error: Error) => void; +} + +interface SendMessageOptions { + prompt: string; + previousResponseId?: string; + filters?: SelectedFilters; + limit?: number; + scoreThreshold?: number; +} + +export function useChatStreaming({ + endpoint = "/api/langflow", + onComplete, + onError, +}: UseChatStreamingOptions = {}) { + const [streamingMessage, setStreamingMessage] = useState( + null, + ); + const [isLoading, setIsLoading] = useState(false); + const streamAbortRef = useRef(null); + const streamIdRef = useRef(0); + + const sendMessage = async ({ + prompt, + previousResponseId, + filters, + limit = 10, + scoreThreshold = 0, + }: SendMessageOptions) => { + try { + setIsLoading(true); + + // Abort any existing stream before starting a new one + if (streamAbortRef.current) { + streamAbortRef.current.abort(); + } + + const controller = new AbortController(); + streamAbortRef.current = controller; + const thisStreamId = ++streamIdRef.current; + + const requestBody: { + prompt: string; + stream: boolean; + previous_response_id?: string; + filters?: SelectedFilters; + limit?: number; + scoreThreshold?: number; + } = { + prompt, + stream: true, + limit, + scoreThreshold, + }; + + if (previousResponseId) { + requestBody.previous_response_id = previousResponseId; + } + + if (filters) { + requestBody.filters = filters; + } + + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error("No reader available"); + } + + const decoder = new TextDecoder(); + let buffer = ""; + let currentContent = ""; + const currentFunctionCalls: FunctionCall[] = []; + let newResponseId: string | null = null; + + // Initialize streaming message + if (!controller.signal.aborted && thisStreamId === streamIdRef.current) { + setStreamingMessage({ + role: "assistant", + content: "", + timestamp: new Date(), + isStreaming: true, + }); + } + + try { + while (true) { + const { done, value } = await reader.read(); + if (controller.signal.aborted || thisStreamId !== streamIdRef.current) + break; + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Process complete lines (JSON objects) + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; // Keep incomplete line in buffer + + for (const line of lines) { + if (line.trim()) { + try { + const chunk = JSON.parse(line); + + // Extract response ID if present + if (chunk.id) { + newResponseId = chunk.id; + } else if (chunk.response_id) { + newResponseId = chunk.response_id; + } + + // Handle OpenAI Chat Completions streaming format + if (chunk.object === "response.chunk" && chunk.delta) { + // Handle function calls in delta + if (chunk.delta.function_call) { + if (chunk.delta.function_call.name) { + const functionCall: FunctionCall = { + name: chunk.delta.function_call.name, + arguments: undefined, + status: "pending", + argumentsString: + chunk.delta.function_call.arguments || "", + }; + currentFunctionCalls.push(functionCall); + } else if (chunk.delta.function_call.arguments) { + const lastFunctionCall = + currentFunctionCalls[currentFunctionCalls.length - 1]; + if (lastFunctionCall) { + if (!lastFunctionCall.argumentsString) { + lastFunctionCall.argumentsString = ""; + } + lastFunctionCall.argumentsString += + chunk.delta.function_call.arguments; + + if (lastFunctionCall.argumentsString.includes("}")) { + try { + const parsed = JSON.parse( + lastFunctionCall.argumentsString + ); + lastFunctionCall.arguments = parsed; + lastFunctionCall.status = "completed"; + } catch (e) { + // Arguments not yet complete + } + } + } + } + } + // Handle tool calls in delta + else if ( + chunk.delta.tool_calls && + Array.isArray(chunk.delta.tool_calls) + ) { + for (const toolCall of chunk.delta.tool_calls) { + if (toolCall.function) { + if (toolCall.function.name) { + const functionCall: FunctionCall = { + name: toolCall.function.name, + arguments: undefined, + status: "pending", + argumentsString: toolCall.function.arguments || "", + }; + currentFunctionCalls.push(functionCall); + } else if (toolCall.function.arguments) { + const lastFunctionCall = + currentFunctionCalls[ + currentFunctionCalls.length - 1 + ]; + if (lastFunctionCall) { + if (!lastFunctionCall.argumentsString) { + lastFunctionCall.argumentsString = ""; + } + lastFunctionCall.argumentsString += + toolCall.function.arguments; + + if ( + lastFunctionCall.argumentsString.includes("}") + ) { + try { + const parsed = JSON.parse( + lastFunctionCall.argumentsString + ); + lastFunctionCall.arguments = parsed; + lastFunctionCall.status = "completed"; + } catch (e) { + // Arguments not yet complete + } + } + } + } + } + } + } + // Handle content/text in delta + else if (chunk.delta.content) { + currentContent += chunk.delta.content; + } + + // Handle finish reason + if (chunk.delta.finish_reason) { + currentFunctionCalls.forEach((fc) => { + if (fc.status === "pending" && fc.argumentsString) { + try { + fc.arguments = JSON.parse(fc.argumentsString); + fc.status = "completed"; + } catch (e) { + fc.arguments = { raw: fc.argumentsString }; + fc.status = "error"; + } + } + }); + } + } + // Handle Realtime API format - function call added + else if ( + chunk.type === "response.output_item.added" && + chunk.item?.type === "function_call" + ) { + let existing = currentFunctionCalls.find( + (fc) => fc.id === chunk.item.id + ); + if (!existing) { + existing = [...currentFunctionCalls] + .reverse() + .find( + (fc) => + fc.status === "pending" && + !fc.id && + fc.name === (chunk.item.tool_name || chunk.item.name) + ); + } + + if (existing) { + existing.id = chunk.item.id; + existing.type = chunk.item.type; + existing.name = + chunk.item.tool_name || chunk.item.name || existing.name; + existing.arguments = + chunk.item.inputs || existing.arguments; + } else { + const functionCall: FunctionCall = { + name: + chunk.item.tool_name || chunk.item.name || "unknown", + arguments: chunk.item.inputs || undefined, + status: "pending", + argumentsString: "", + id: chunk.item.id, + type: chunk.item.type, + }; + currentFunctionCalls.push(functionCall); + } + } + // Handle Realtime API format - tool call added + else if ( + chunk.type === "response.output_item.added" && + chunk.item?.type?.includes("_call") && + chunk.item?.type !== "function_call" + ) { + let existing = currentFunctionCalls.find( + (fc) => fc.id === chunk.item.id + ); + if (!existing) { + existing = [...currentFunctionCalls] + .reverse() + .find( + (fc) => + fc.status === "pending" && + !fc.id && + fc.name === + (chunk.item.tool_name || + chunk.item.name || + chunk.item.type) + ); + } + + if (existing) { + existing.id = chunk.item.id; + existing.type = chunk.item.type; + existing.name = + chunk.item.tool_name || + chunk.item.name || + chunk.item.type || + existing.name; + existing.arguments = + chunk.item.inputs || existing.arguments; + } else { + const functionCall = { + name: + chunk.item.tool_name || + chunk.item.name || + chunk.item.type || + "unknown", + arguments: chunk.item.inputs || {}, + status: "pending" as const, + id: chunk.item.id, + type: chunk.item.type, + }; + currentFunctionCalls.push(functionCall); + } + } + // Handle function call done + else if ( + chunk.type === "response.output_item.done" && + chunk.item?.type === "function_call" + ) { + const functionCall = currentFunctionCalls.find( + (fc) => + fc.id === chunk.item.id || + fc.name === chunk.item.tool_name || + fc.name === chunk.item.name + ); + + if (functionCall) { + functionCall.status = + chunk.item.status === "completed" ? "completed" : "error"; + functionCall.id = chunk.item.id; + functionCall.type = chunk.item.type; + functionCall.name = + chunk.item.tool_name || + chunk.item.name || + functionCall.name; + functionCall.arguments = + chunk.item.inputs || functionCall.arguments; + + if (chunk.item.results) { + functionCall.result = chunk.item.results; + } + } + } + // Handle tool call done with results + else if ( + chunk.type === "response.output_item.done" && + chunk.item?.type?.includes("_call") && + chunk.item?.type !== "function_call" + ) { + const functionCall = currentFunctionCalls.find( + (fc) => + fc.id === chunk.item.id || + fc.name === chunk.item.tool_name || + fc.name === chunk.item.name || + fc.name === chunk.item.type || + fc.name.includes(chunk.item.type.replace("_call", "")) || + chunk.item.type.includes(fc.name) + ); + + if (functionCall) { + functionCall.arguments = + chunk.item.inputs || functionCall.arguments; + functionCall.status = + chunk.item.status === "completed" ? "completed" : "error"; + functionCall.id = chunk.item.id; + functionCall.type = chunk.item.type; + + if (chunk.item.results) { + functionCall.result = chunk.item.results; + } + } else { + const newFunctionCall = { + name: + chunk.item.tool_name || + chunk.item.name || + chunk.item.type || + "unknown", + arguments: chunk.item.inputs || {}, + status: "completed" as const, + id: chunk.item.id, + type: chunk.item.type, + result: chunk.item.results, + }; + currentFunctionCalls.push(newFunctionCall); + } + } + // Handle text output streaming (Realtime API) + else if (chunk.type === "response.output_text.delta") { + currentContent += chunk.delta || ""; + } + // Handle OpenRAG backend format + else if (chunk.output_text) { + currentContent += chunk.output_text; + } else if (chunk.delta) { + if (typeof chunk.delta === "string") { + currentContent += chunk.delta; + } else if (typeof chunk.delta === "object") { + if (chunk.delta.content) { + currentContent += chunk.delta.content; + } else if (chunk.delta.text) { + currentContent += chunk.delta.text; + } + } + } + + // Update streaming message in real-time + if ( + !controller.signal.aborted && + thisStreamId === streamIdRef.current + ) { + setStreamingMessage({ + role: "assistant", + content: currentContent, + functionCalls: + currentFunctionCalls.length > 0 + ? [...currentFunctionCalls] + : undefined, + timestamp: new Date(), + isStreaming: true, + }); + } + } catch (parseError) { + console.warn("Failed to parse chunk:", line, parseError); + } + } + } + } + } finally { + reader.releaseLock(); + } + + // Finalize the message + const finalMessage: Message = { + role: "assistant", + content: currentContent, + functionCalls: + currentFunctionCalls.length > 0 ? currentFunctionCalls : undefined, + timestamp: new Date(), + isStreaming: false, + }; + + if (!controller.signal.aborted && thisStreamId === streamIdRef.current) { + // Clear streaming message and call onComplete with final message + setStreamingMessage(null); + onComplete?.(finalMessage, newResponseId); + return finalMessage; + } + + return null; + } catch (error) { + // If stream was aborted, don't handle as error + if (streamAbortRef.current?.signal.aborted) { + return null; + } + + console.error("SSE Stream error:", error); + setStreamingMessage(null); + onError?.(error as Error); + + const errorMessage: Message = { + role: "assistant", + content: + "Sorry, I couldn't connect to the chat service. Please try again.", + timestamp: new Date(), + isStreaming: false, + }; + + return errorMessage; + } finally { + setIsLoading(false); + } + }; + + const abortStream = () => { + if (streamAbortRef.current) { + streamAbortRef.current.abort(); + } + setStreamingMessage(null); + setIsLoading(false); + }; + + return { + streamingMessage, + isLoading, + sendMessage, + abortStream, + }; +} diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts index 9ce34634..dfd7358a 100644 --- a/frontend/src/lib/constants.ts +++ b/frontend/src/lib/constants.ts @@ -3,7 +3,7 @@ */ export const DEFAULT_AGENT_SETTINGS = { llm_model: "gpt-4o-mini", - system_prompt: "You are a helpful assistant that can use tools to answer questions and perform tasks." + system_prompt: "You are a helpful assistant that can use tools to answer questions and perform tasks. You are part of OpenRAG, an assistant that analyzes documents and provides informations about them. When asked about what is OpenRAG, answer the following:\n\n\"OpenRAG is an open-source package for building agentic RAG systems. It supports integration with a wide range of orchestration tools, vector databases, and LLM providers. OpenRAG connects and amplifies three popular, proven open-source projects into one powerful platform:\n\n**Langflow** – Langflow is a powerful tool to build and deploy AI agents and MCP servers [Read more](https://www.langflow.org/)\n\n**OpenSearch** – Langflow is a powerful tool to build and deploy AI agents and MCP servers [Read more](https://opensearch.org/)\n\n**Docling** – Langflow is a powerful tool to build and deploy AI agents and MCP servers [Read more](https://www.docling.ai/)\"" } as const; /** @@ -22,4 +22,17 @@ export const DEFAULT_KNOWLEDGE_SETTINGS = { */ export const UI_CONSTANTS = { MAX_SYSTEM_PROMPT_CHARS: 2000, -} as const; \ No newline at end of file +} as const; + +export const ANIMATION_DURATION = 0.4; +export const SIDEBAR_WIDTH = 280; +export const HEADER_HEIGHT = 54; +export const TOTAL_ONBOARDING_STEPS = 3; + +/** + * Local Storage Keys + */ +export const ONBOARDING_STEP_KEY = "onboarding_current_step"; + +export const FILES_REGEX = + /(?<=I'm uploading a document called ['"])[^'"]+\.[^.]+(?=['"]\. Here is its content:)/; \ No newline at end of file diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index a65cef05..6567616b 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -72,6 +72,14 @@ const config = { height: "0", }, }, + shimmer: { + "0%": { + backgroundPosition: "200% 0", + }, + "100%": { + backgroundPosition: "-200% 0", + }, + }, }, animation: { overlayShow: "overlayShow 400ms cubic-bezier(0.16, 1, 0.3, 1)", @@ -79,6 +87,7 @@ const config = { wiggle: "wiggle 150ms ease-in-out 1", "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", + shimmer: "shimmer 3s ease-in-out infinite", }, colors: { border: "hsl(var(--border))", diff --git a/src/agent.py b/src/agent.py index eceb2ac4..84394ebc 100644 --- a/src/agent.py +++ b/src/agent.py @@ -34,7 +34,7 @@ def get_conversation_thread(user_id: str, previous_response_id: str = None): "messages": [ { "role": "system", - "content": "You are a helpful assistant. Always use the search_tools to answer questions.", + "content": "You are a helpful assistant that can use tools to answer questions and perform tasks. You are part of OpenRAG, an assistant that analyzes documents and provides informations about them. When asked about what is OpenRAG, answer the following:\n\n\"OpenRAG is an open-source package for building agentic RAG systems. It supports integration with a wide range of orchestration tools, vector databases, and LLM providers. OpenRAG connects and amplifies three popular, proven open-source projects into one powerful platform:\n\n**Langflow** – Langflow is a powerful tool to build and deploy AI agents and MCP servers [Read more](https://www.langflow.org/)\n\n**OpenSearch** – Langflow is a powerful tool to build and deploy AI agents and MCP servers [Read more](https://opensearch.org/)\n\n**Docling** – Langflow is a powerful tool to build and deploy AI agents and MCP servers [Read more](https://www.docling.ai/)\"", } ], "previous_response_id": previous_response_id, # Parent response_id for branching diff --git a/src/api/settings.py b/src/api/settings.py index b4a15745..5fc30cf5 100644 --- a/src/api/settings.py +++ b/src/api/settings.py @@ -424,13 +424,10 @@ async def onboarding(request, flows_service): # Get current configuration current_config = get_openrag_config() - # Check if config is NOT marked as edited (only allow onboarding if not yet configured) + # Warn if config was already edited (onboarding being re-run) if current_config.edited: - return JSONResponse( - { - "error": "Configuration has already been edited. Use /settings endpoint for updates." - }, - status_code=403, + logger.warning( + "Onboarding is being run although configuration was already edited before" ) # Parse request body diff --git a/src/api/upload.py b/src/api/upload.py index 2bc15dea..39270cbc 100644 --- a/src/api/upload.py +++ b/src/api/upload.py @@ -99,13 +99,12 @@ async def upload_context( # Get optional parameters previous_response_id = form.get("previous_response_id") endpoint = form.get("endpoint", "langflow") - - jwt_token = session_manager.get_effective_jwt_token(user_id, request.state.jwt_token) - + # Get user info from request state (set by auth middleware) user = request.state.user user_id = user.user_id if user else None + jwt_token = session_manager.get_effective_jwt_token(user_id, request.state.jwt_token) # Process document and extract content doc_result = await document_service.process_upload_context(upload_file, filename)