From fcf7a302d03818999cd215680b38f4230cb77444 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira <62335616+lucaseduoli@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:03:23 -0300 Subject: [PATCH] feat: adds what is openrag prompt, refactors chat design, adds scroll to bottom on chat, adds streaming support (#283) * Changed prompts to include info about OpenRAG, change status of As Dataframe and As Vector Store to false on OpenSearch component * added markdown to onboarding step * added className to markdown renderer * changed onboarding step to not render span * Added nudges to onboarding content * Added onboarding style for nudges * updated user message and assistant message designs * updated route.ts to handle streaming messages * created new useChatStreaming to handle streaming * changed useChatStreaming to work with the chat page * changed onboarding content to use default messages instead of onboarding steps, and to use the new hook to send messages * added span to the markdown renderer on stream * updated page to use new chat streaming hook * disable animation on completed steps * changed markdown renderer margins * changed css to not display markdown links and texts on white always * added isCompleted to assistant and user messages * removed space between elements on onboarding step to ensure smoother animation * removed opacity 50 on onboarding messages * changed default api to be langflow on chat streaming * added fade in and color transition * added color transition * Rendered onboarding with use-stick-to-bottom * Added use stick to bottom on page * fixed nudges design * changed chat input design * fixed nudges design * made overflow be hidden on main * Added overflow y auto on other pages * Put animate on messages * Add source to types * Adds animate and delay props to messages --- flows/openrag_agent.json | 6 +- frontend/components/markdown-renderer.tsx | 9 +- frontend/package-lock.json | 10 + frontend/package.json | 1 + frontend/src/app/api/[...path]/route.ts | 24 +- .../app/chat/components/assistant-message.tsx | 130 +- .../src/app/chat/components/chat-input.tsx | 530 +-- .../src/app/chat/components/user-message.tsx | 65 +- frontend/src/app/chat/nudges.tsx | 91 +- frontend/src/app/chat/page.tsx | 3154 +++++++---------- frontend/src/app/chat/types.ts | 1 + frontend/src/app/globals.css | 9 + .../components/onboarding-content.tsx | 222 +- .../components/onboarding-step.tsx | 177 +- frontend/src/components/chat-renderer.tsx | 336 +- frontend/src/hooks/useChatStreaming.ts | 492 +++ frontend/src/lib/constants.ts | 2 +- src/agent.py | 2 +- 18 files changed, 2660 insertions(+), 2601 deletions(-) create mode 100644 frontend/src/hooks/useChatStreaming.ts diff --git a/flows/openrag_agent.json b/flows/openrag_agent.json index 13259ae3..aad7be61 100644 --- a/flows/openrag_agent.json +++ b/flows/openrag_agent.json @@ -1261,7 +1261,7 @@ "display_name": "as_dataframe", "name": "as_dataframe", "readonly": false, - "status": true, + "status": false, "tags": [ "as_dataframe" ] @@ -1280,7 +1280,7 @@ "display_name": "as_vector_store", "name": "as_vector_store", "readonly": false, - "status": true, + "status": false, "tags": [ "as_vector_store" ] @@ -2086,7 +2086,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/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/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/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..85563886 100644 --- a/frontend/src/app/chat/components/chat-input.tsx +++ b/frontend/src/app/chat/components/chat-input.tsx @@ -1,282 +1,284 @@ import { Check, Funnel, Loader2, Plus, X } from "lucide-react"; -import TextareaAutosize from "react-textarea-autosize"; 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 && ( +
+ + @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}" +
+ )} + + )} +
+
+ + + + +
+
+ ); + }, +); 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..0f9deed9 100644 --- a/frontend/src/app/chat/components/user-message.tsx +++ b/frontend/src/app/chat/components/user-message.tsx @@ -1,33 +1,52 @@ import { 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; } -export function UserMessage({ content }: UserMessageProps) { - const { user } = useAuth(); +export function UserMessage({ content, isCompleted, animate = true }: UserMessageProps) { + const { user } = useAuth(); - return ( - - - - {user?.name ? ( - user.name.charAt(0).toUpperCase() - ) : ( - - )} - - - } - > -

- {content} -

-
- ); + console.log("animate", animate); + + return ( + + + + + {user?.name ? user.name.charAt(0).toUpperCase() : } + + + } + > +

+ {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..dc85f43e 100644 --- a/frontend/src/app/chat/page.tsx +++ b/frontend/src/app/chat/page.tsx @@ -1,1918 +1,1274 @@ "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 { 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.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 [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]); + + const { data: nudges = [], cancel: cancelNudges } = useGetNudgesQuery( + previousResponseIds[endpoint], + ); + + 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" && ( + + )} + + {message.role === "assistant" && ( + 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 317879a3..7f07f074 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -345,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/new-onboarding/components/onboarding-content.tsx b/frontend/src/app/new-onboarding/components/onboarding-content.tsx index 1e5e7c03..46886f47 100644 --- a/frontend/src/app/new-onboarding/components/onboarding-content.tsx +++ b/frontend/src/app/new-onboarding/components/onboarding-content.tsx @@ -1,78 +1,168 @@ "use client"; +import { useState } from "react"; +import { StickToBottom } from "use-stick-to-bottom"; +import { AssistantMessage } from "@/app/chat/components/assistant-message"; +import { UserMessage } from "@/app/chat/components/user-message"; +import Nudges from "@/app/chat/nudges"; +import type { Message } from "@/app/chat/types"; import OnboardingCard from "@/app/onboarding/components/onboarding-card"; +import { useChatStreaming } from "@/hooks/useChatStreaming"; import { OnboardingStep } from "./onboarding-step"; export function OnboardingContent({ - handleStepComplete, - currentStep, + handleStepComplete, + currentStep, }: { - handleStepComplete: () => void; - currentStep: number; + handleStepComplete: () => void; + currentStep: number; }) { - return ( -
- = 0} - isCompleted={currentStep > 0} - text="Let's get started by setting up your model provider." - > - - + const [responseId, setResponseId] = useState(null); + const [selectedNudge, setSelectedNudge] = useState(""); + const [assistantMessage, setAssistantMessage] = useState( + null, + ); - = 1} - isCompleted={currentStep > 1} - text="Step 1: Configure your settings" - > -
-

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

- -
-
+ const { streamingMessage, isLoading, sendMessage } = useChatStreaming({ + onComplete: (message, newResponseId) => { + setAssistantMessage(message); + if (newResponseId) { + setResponseId(newResponseId); + } + }, + onError: (error) => { + console.error("Chat error:", error); + setAssistantMessage({ + role: "assistant", + content: + "Sorry, I couldn't connect to the chat service. Please try again.", + timestamp: new Date(), + }); + }, + }); - = 2} - isCompleted={currentStep > 2} - text="Step 2: Connect your model" - > -
-

- Choose and connect your preferred AI model provider. -

- -
-
+ const NUDGES = ["What is OpenRAG?"]; - = 3} - isCompleted={currentStep > 3} - text="Step 3: You're all set!" - > -
-

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

- -
-
-
- ); + const handleNudgeClick = async (nudge: string) => { + setSelectedNudge(nudge); + setAssistantMessage(null); + setTimeout(async () => { + await sendMessage({ + prompt: nudge, + previousResponseId: responseId || undefined, + }); + }, 1500); + }; + + // Determine which message to show (streaming takes precedence) + const displayMessage = streamingMessage || assistantMessage; + + return ( + + +
+ = 0} + isCompleted={currentStep > 0} + text="Let's get started by setting up your model provider." + > + + + + = 1} + isCompleted={currentStep > 1 || !!selectedNudge} + text="Excellent, let's move on to learning the basics." + > +
+ +
+
+ + {/* User message - show when nudge is selected */} + {currentStep >= 1 && !!selectedNudge && ( + 1} + /> + )} + + {/* Assistant message - show streaming or final message */} + {currentStep >= 1 && + !!selectedNudge && + (displayMessage || isLoading) && ( + <> + {}} + isStreaming={!!streamingMessage} + isCompleted={currentStep > 1} + /> + {!isLoading && displayMessage && currentStep === 1 && ( +
+ +
+ )} + + )} + + = 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! +

+ +
+
+
+
+
+ ); } diff --git a/frontend/src/app/new-onboarding/components/onboarding-step.tsx b/frontend/src/app/new-onboarding/components/onboarding-step.tsx index 16b62010..613f08a9 100644 --- a/frontend/src/app/new-onboarding/components/onboarding-step.tsx +++ b/frontend/src/app/new-onboarding/components/onboarding-step.tsx @@ -2,89 +2,120 @@ import { AnimatePresence, motion } from "motion/react"; import { type ReactNode, useEffect, useState } from "react"; import { Message } from "@/app/chat/components/message"; import DogIcon from "@/components/logo/dog-icon"; +import { MarkdownRenderer } from "@/components/markdown-renderer"; +import { cn } from "@/lib/utils"; interface OnboardingStepProps { - text: string; - children: ReactNode; - isVisible: boolean; - isCompleted?: boolean; + text: string; + children?: ReactNode; + isVisible: boolean; + isCompleted?: boolean; + icon?: ReactNode; + isMarkdown?: boolean; } export function OnboardingStep({ - text, - children, - isVisible, - isCompleted = false, + text, + children, + isVisible, + isCompleted = false, + icon, + isMarkdown = false, }: OnboardingStepProps) { - const [displayedText, setDisplayedText] = useState(""); - const [showChildren, setShowChildren] = useState(false); + const [displayedText, setDisplayedText] = useState(""); + const [showChildren, setShowChildren] = useState(false); - useEffect(() => { - if (!isVisible) { - setDisplayedText(""); - setShowChildren(false); - return; - } + useEffect(() => { + if (!isVisible) { + setDisplayedText(""); + setShowChildren(false); + return; + } - let currentIndex = 0; - setDisplayedText(""); - setShowChildren(false); + if (isCompleted) { + setDisplayedText(text); + setShowChildren(true); + return; + } - const interval = setInterval(() => { - if (currentIndex < text.length) { - setDisplayedText(text.slice(0, currentIndex + 1)); - currentIndex++; - } else { - clearInterval(interval); - setShowChildren(true); - } - }, 20); // 20ms per character + let currentIndex = 0; + setDisplayedText(""); + setShowChildren(false); - return () => clearInterval(interval); - }, [text, isVisible]); + const interval = setInterval(() => { + if (currentIndex < text.length) { + setDisplayedText(text.slice(0, currentIndex + 1)); + currentIndex++; + } else { + clearInterval(interval); + setShowChildren(true); + } + }, 20); // 20ms per character - if (!isVisible) return null; + return () => clearInterval(interval); + }, [text, isVisible, isCompleted]); - return ( - - - -
- } - > -
-

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

- - {showChildren && !isCompleted && ( - - {children} - - )} - -
- - - ); + if (!isVisible) return null; + + return ( + + + +
+ ) + } + > +
+ {isMarkdown ? ( + + ) : ( +

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

+ )} + {children && ( + + {((showChildren && !isCompleted) || isMarkdown) && ( + +
+ {children}
+
+ )} +
+ )} +
+ + + ); } diff --git a/frontend/src/components/chat-renderer.tsx b/frontend/src/components/chat-renderer.tsx index 8721aaa1..3087b586 100644 --- a/frontend/src/components/chat-renderer.tsx +++ b/frontend/src/components/chat-renderer.tsx @@ -4,8 +4,8 @@ import { motion } from "framer-motion"; import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; import { - type ChatConversation, - useGetConversationsQuery, + 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"; @@ -16,187 +16,187 @@ 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, + 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, + children, }: { - settings: Settings; - children: React.ReactNode; + settings: Settings; + children: React.ReactNode; }) { - const pathname = usePathname(); - const { isAuthenticated, isNoAuthMode } = useAuth(); - const { - endpoint, - refreshTrigger, - refreshConversations, - startNewConversation, - } = useChat(); + 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; - }); + // 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 - return !!settings?.edited && savedStep === null; - }); + 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 + return !!settings?.edited && savedStep === null; + }); - // 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 }; + // 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(); - }; + 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]); + // 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); - } - }; + 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); + // 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`; + 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 ( - <> - -
- + // For all other pages, render with Langflow-styled navigation and task menu + return ( + <> + +
+ - {/* Sidebar Navigation */} - - - + {/* Sidebar Navigation */} + + + - {/* Main Content */} -
- -
- -
- {children} -
- {!showLayout && ( - - )} -
-
-
- - - -
- - ); + {/* Main Content */} +
+ +
+ +
+ {children} +
+ {!showLayout && ( + + )} +
+
+
+ + + +
+ + ); } 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 43956ae8..b6169617 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; /** 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