From b5c0788ab97fd56ae1d57a087a6b0a9c0f8ef6df Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Fri, 17 Oct 2025 16:43:38 -0300 Subject: [PATCH 01/36] Changed prompts to include info about OpenRAG, change status of As Dataframe and As Vector Store to false on OpenSearch component --- flows/openrag_agent.json | 6 +++--- frontend/src/lib/constants.ts | 2 +- src/agent.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) 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/src/lib/constants.ts b/frontend/src/lib/constants.ts index 1cd8eb90..32544c86 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 From f8e1e64b81a1ade64f7581caf6ec77e831c51853 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Fri, 17 Oct 2025 17:12:33 -0300 Subject: [PATCH 02/36] added markdown to onboarding step --- .../components/onboarding-step.tsx | 173 ++++++++++-------- 1 file changed, 100 insertions(+), 73 deletions(-) diff --git a/frontend/src/app/new-onboarding/components/onboarding-step.tsx b/frontend/src/app/new-onboarding/components/onboarding-step.tsx index 16b62010..eaeab025 100644 --- a/frontend/src/app/new-onboarding/components/onboarding-step.tsx +++ b/frontend/src/app/new-onboarding/components/onboarding-step.tsx @@ -2,89 +2,116 @@ 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); + let currentIndex = 0; + setDisplayedText(""); + setShowChildren(false); - const interval = setInterval(() => { - if (currentIndex < text.length) { - setDisplayedText(text.slice(0, currentIndex + 1)); - currentIndex++; - } else { - clearInterval(interval); - setShowChildren(true); - } - }, 20); // 20ms per character + const interval = setInterval(() => { + if (currentIndex < text.length) { + setDisplayedText(text.slice(0, currentIndex + 1)); + currentIndex++; + } else { + clearInterval(interval); + setShowChildren(true); + } + }, 20); // 20ms per character - return () => clearInterval(interval); - }, [text, isVisible]); + return () => clearInterval(interval); + }, [text, isVisible]); - if (!isVisible) return null; + if (!isVisible) return null; - return ( - - - - - } - > -
-

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

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

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

+ )} + {children && ( + + {showChildren && !isCompleted && ( + + {children} + + )} + + )} +
+
+
+ ); } From 270534043af2a4344884f5dab730c4bb73bc2ab3 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Fri, 17 Oct 2025 17:12:41 -0300 Subject: [PATCH 03/36] added className to markdown renderer --- frontend/components/markdown-renderer.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/components/markdown-renderer.tsx b/frontend/components/markdown-renderer.tsx index 1b2276db..380c05bf 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, )} > Date: Fri, 17 Oct 2025 17:19:22 -0300 Subject: [PATCH 04/36] changed onboarding step to not render span --- frontend/src/app/new-onboarding/components/onboarding-step.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/app/new-onboarding/components/onboarding-step.tsx b/frontend/src/app/new-onboarding/components/onboarding-step.tsx index eaeab025..d60c3789 100644 --- a/frontend/src/app/new-onboarding/components/onboarding-step.tsx +++ b/frontend/src/app/new-onboarding/components/onboarding-step.tsx @@ -82,7 +82,6 @@ export function OnboardingStep({ )} chatMessage={text} /> - ) : (

- {showChildren && !isCompleted && ( + {((showChildren && !isCompleted) || isMarkdown) && ( Date: Fri, 17 Oct 2025 17:19:29 -0300 Subject: [PATCH 05/36] Added nudges to onboarding content --- .../components/onboarding-content.tsx | 214 ++++++++++++------ 1 file changed, 148 insertions(+), 66 deletions(-) diff --git a/frontend/src/app/new-onboarding/components/onboarding-content.tsx b/frontend/src/app/new-onboarding/components/onboarding-content.tsx index 1e5e7c03..23508f0c 100644 --- a/frontend/src/app/new-onboarding/components/onboarding-content.tsx +++ b/frontend/src/app/new-onboarding/components/onboarding-content.tsx @@ -1,78 +1,160 @@ "use client"; +import { Loader2, User } from "lucide-react"; +import { useState } from "react"; +import Nudges from "@/app/chat/nudges"; import OnboardingCard from "@/app/onboarding/components/onboarding-card"; 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 [assistantResponse, setAssistantResponse] = useState(""); + const [isLoadingResponse, setIsLoadingResponse] = useState(false); - = 1} - isCompleted={currentStep > 1} - text="Step 1: Configure your settings" - > -
-

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

- -
-
+ const NUDGES = ["What is OpenRAG?"]; - = 2} - isCompleted={currentStep > 2} - text="Step 2: Connect your model" - > -
-

- Choose and connect your preferred AI model provider. -

- -
-
+ const handleNudgeClick = async (nudge: string) => { + setSelectedNudge(nudge); + setIsLoadingResponse(true); - = 3} - isCompleted={currentStep > 3} - text="Step 3: You're all set!" - > -
-

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

- -
-
-
- ); + try { + const requestBody: { + prompt: string; + stream?: boolean; + previous_response_id?: string; + } = { + prompt: nudge, + stream: false, + }; + + if (responseId) { + requestBody.previous_response_id = responseId; + } + + const response = await fetch("/api/chat", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + const result = await response.json(); + + if (response.ok) { + setAssistantResponse(result.response); + if (result.response_id) { + setResponseId(result.response_id); + } + } else { + setAssistantResponse( + "Sorry, I encountered an error. Please try again.", + ); + } + } catch (error) { + console.error("Chat error:", error); + setAssistantResponse( + "Sorry, I couldn't connect to the chat service. Please try again.", + ); + } finally { + setIsLoadingResponse(false); + } + }; + + 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." + > +
+ +
+
+ + = 1 && !!selectedNudge} + isCompleted={currentStep > 1} + text={selectedNudge} + icon={ +
+ +
+ } + > +
+ + = 1 && !!selectedNudge} + isCompleted={currentStep > 1} + text={isLoadingResponse ? "Thinking..." : assistantResponse} + isMarkdown={!isLoadingResponse && !!assistantResponse} + > + {isLoadingResponse ? ( +
+ +
+ ) : ( + + )} +
+ + = 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! +

+ +
+
+
+ ); } From c4e9b9017d0dd23fce821984b328962118250733 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Fri, 17 Oct 2025 17:19:39 -0300 Subject: [PATCH 06/36] Added onboarding style for nudges --- frontend/src/app/chat/nudges.tsx | 95 ++++++++++++++++++-------------- 1 file changed, 54 insertions(+), 41 deletions(-) diff --git a/frontend/src/app/chat/nudges.tsx b/frontend/src/app/chat/nudges.tsx index c5929924..7fdf0598 100644 --- a/frontend/src/app/chat/nudges.tsx +++ b/frontend/src/app/chat/nudges.tsx @@ -1,46 +1,59 @@ -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 */} +
+
+
+
+ )} +
+
+ ); } From 81e75221b4d7ec064d5daca18049b1b5100c776f Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Mon, 20 Oct 2025 18:07:37 -0300 Subject: [PATCH 07/36] updated user message and assistant message designs --- .../app/chat/components/assistant-message.tsx | 106 +++++++++--------- .../src/app/chat/components/user-message.tsx | 10 +- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/frontend/src/app/chat/components/assistant-message.tsx b/frontend/src/app/chat/components/assistant-message.tsx index 5f50606d..1fc19b41 100644 --- a/frontend/src/app/chat/components/assistant-message.tsx +++ b/frontend/src/app/chat/components/assistant-message.tsx @@ -1,63 +1,63 @@ -import { Bot, GitBranch } from "lucide-react"; +import { GitBranch } from "lucide-react"; +import DogIcon from "@/components/logo/dog-icon"; import { MarkdownRenderer } from "@/components/markdown-renderer"; +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; } export function AssistantMessage({ - content, - functionCalls = [], - messageIndex, - expandedFunctionCalls, - onToggle, - isStreaming = false, - showForkButton = false, - onFork, + content, + functionCalls = [], + messageIndex, + expandedFunctionCalls, + onToggle, + isStreaming = false, + showForkButton = false, + onFork, }: AssistantMessageProps) { - const updatedOnboarding = process.env.UPDATED_ONBOARDING === "true"; - const IconComponent = updatedOnboarding ? DogIcon : Bot; - - return ( - - - - } - actions={ - showForkButton && onFork ? ( - - ) : undefined - } - > - - - {isStreaming && ( - - )} - - ); + return ( + + + + } + actions={ + showForkButton && onFork ? ( + + ) : undefined + } + > + + + {isStreaming && ( + + )} + + ); } diff --git a/frontend/src/app/chat/components/user-message.tsx b/frontend/src/app/chat/components/user-message.tsx index 882b3416..68bf465e 100644 --- a/frontend/src/app/chat/components/user-message.tsx +++ b/frontend/src/app/chat/components/user-message.tsx @@ -13,9 +13,9 @@ export function UserMessage({ content }: UserMessageProps) { return ( + - + {user?.name ? ( user.name.charAt(0).toUpperCase() ) : ( @@ -25,9 +25,9 @@ export function UserMessage({ content }: UserMessageProps) { } > -

- {content} -

+

+ {content} +

); } From 4e20e149af21869bff51fadc7e8926bff121628a Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Mon, 20 Oct 2025 18:07:46 -0300 Subject: [PATCH 08/36] updated route.ts to handle streaming messages --- frontend/src/app/api/[...path]/route.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) 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( From 83529de32ffaf6c5437c722a2ae7e1c4808dfa92 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Mon, 20 Oct 2025 18:07:59 -0300 Subject: [PATCH 09/36] created new useChatStreaming to handle streaming --- frontend/src/hooks/useChatStreaming.ts | 207 +++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 frontend/src/hooks/useChatStreaming.ts diff --git a/frontend/src/hooks/useChatStreaming.ts b/frontend/src/hooks/useChatStreaming.ts new file mode 100644 index 00000000..1c5a0a03 --- /dev/null +++ b/frontend/src/hooks/useChatStreaming.ts @@ -0,0 +1,207 @@ +import { useRef, useState } from "react"; +import type { FunctionCall, Message } from "@/app/chat/types"; + +interface UseChatStreamingOptions { + endpoint?: string; + onComplete?: (message: Message, responseId: string | null) => void; + onError?: (error: Error) => void; +} + +export function useChatStreaming({ + endpoint = "/api/chat", + 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: string, previousResponseId?: string) => { + 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; + } = { + prompt, + stream: true, + }; + + if (previousResponseId) { + requestBody.previous_response_id = previousResponseId; + } + + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "text/event-stream", + }, + 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 OpenRAG backend format (from agent.py async_response_stream) + // The chunk is serialized via chunk.model_dump() and contains output_text or delta + if (chunk.output_text) { + // Direct output text from chunk + currentContent += chunk.output_text; + } else if (chunk.delta) { + // Handle delta - could be string, dict, or have content/text properties + 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, + }; +} From 19ad0755b6f5e31baa591520da10f91cbad11bf5 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Mon, 20 Oct 2025 18:33:15 -0300 Subject: [PATCH 10/36] changed useChatStreaming to work with the chat page --- frontend/src/hooks/useChatStreaming.ts | 301 ++++++++++++++++++++++++- 1 file changed, 293 insertions(+), 8 deletions(-) diff --git a/frontend/src/hooks/useChatStreaming.ts b/frontend/src/hooks/useChatStreaming.ts index 1c5a0a03..df8f4aa6 100644 --- a/frontend/src/hooks/useChatStreaming.ts +++ b/frontend/src/hooks/useChatStreaming.ts @@ -1,5 +1,5 @@ import { useRef, useState } from "react"; -import type { FunctionCall, Message } from "@/app/chat/types"; +import type { FunctionCall, Message, SelectedFilters } from "@/app/chat/types"; interface UseChatStreamingOptions { endpoint?: string; @@ -7,6 +7,14 @@ interface UseChatStreamingOptions { onError?: (error: Error) => void; } +interface SendMessageOptions { + prompt: string; + previousResponseId?: string; + filters?: SelectedFilters; + limit?: number; + scoreThreshold?: number; +} + export function useChatStreaming({ endpoint = "/api/chat", onComplete, @@ -19,7 +27,13 @@ export function useChatStreaming({ const streamAbortRef = useRef(null); const streamIdRef = useRef(0); - const sendMessage = async (prompt: string, previousResponseId?: string) => { + const sendMessage = async ({ + prompt, + previousResponseId, + filters, + limit = 10, + scoreThreshold = 0, + }: SendMessageOptions) => { try { setIsLoading(true); @@ -36,19 +50,28 @@ export function useChatStreaming({ 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": "text/event-stream", + "Content-Type": "application/json", }, body: JSON.stringify(requestBody), signal: controller.signal, @@ -104,13 +127,275 @@ export function useChatStreaming({ newResponseId = chunk.response_id; } - // Handle OpenRAG backend format (from agent.py async_response_stream) - // The chunk is serialized via chunk.model_dump() and contains output_text or delta - if (chunk.output_text) { - // Direct output text from chunk + // 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) { - // Handle delta - could be string, dict, or have content/text properties if (typeof chunk.delta === "string") { currentContent += chunk.delta; } else if (typeof chunk.delta === "object") { From 1b66b0cf63912dea86c2b77c77f68bbc1ea964aa Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Mon, 20 Oct 2025 18:33:44 -0300 Subject: [PATCH 11/36] changed onboarding content to use default messages instead of onboarding steps, and to use the new hook to send messages --- .../components/onboarding-content.tsx | 150 +++++++++--------- 1 file changed, 71 insertions(+), 79 deletions(-) diff --git a/frontend/src/app/new-onboarding/components/onboarding-content.tsx b/frontend/src/app/new-onboarding/components/onboarding-content.tsx index 23508f0c..9c462389 100644 --- a/frontend/src/app/new-onboarding/components/onboarding-content.tsx +++ b/frontend/src/app/new-onboarding/components/onboarding-content.tsx @@ -1,9 +1,12 @@ "use client"; -import { Loader2, User } from "lucide-react"; import { useState } from "react"; +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({ @@ -15,59 +18,42 @@ export function OnboardingContent({ }) { const [responseId, setResponseId] = useState(null); const [selectedNudge, setSelectedNudge] = useState(""); - const [assistantResponse, setAssistantResponse] = useState(""); - const [isLoadingResponse, setIsLoadingResponse] = useState(false); + const [assistantMessage, setAssistantMessage] = useState( + null, + ); + + 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(), + }); + }, + }); const NUDGES = ["What is OpenRAG?"]; const handleNudgeClick = async (nudge: string) => { setSelectedNudge(nudge); - setIsLoadingResponse(true); - - try { - const requestBody: { - prompt: string; - stream?: boolean; - previous_response_id?: string; - } = { - prompt: nudge, - stream: false, - }; - - if (responseId) { - requestBody.previous_response_id = responseId; - } - - const response = await fetch("/api/chat", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - const result = await response.json(); - - if (response.ok) { - setAssistantResponse(result.response); - if (result.response_id) { - setResponseId(result.response_id); - } - } else { - setAssistantResponse( - "Sorry, I encountered an error. Please try again.", - ); - } - } catch (error) { - console.error("Chat error:", error); - setAssistantResponse( - "Sorry, I couldn't connect to the chat service. Please try again.", - ); - } finally { - setIsLoadingResponse(false); - } + setAssistantMessage(null); + await sendMessage({ + prompt: nudge, + previousResponseId: responseId || undefined, + }); }; + // Determine which message to show (streaming takes precedence) + const displayMessage = streamingMessage || assistantMessage; + return (
= 1} isCompleted={currentStep > 1 || !!selectedNudge} - text="Excellent, let’s move on to learning the basics." + text="Excellent, let's move on to learning the basics." >
- +
- = 1 && !!selectedNudge} - isCompleted={currentStep > 1} - text={selectedNudge} - icon={ -
- -
- } - > -
+ {/* User message - show when nudge is selected */} + {currentStep >= 1 && !!selectedNudge && ( +
1 ? "opacity-50" : ""}> + +
+ )} - = 1 && !!selectedNudge} - isCompleted={currentStep > 1} - text={isLoadingResponse ? "Thinking..." : assistantResponse} - isMarkdown={!isLoadingResponse && !!assistantResponse} - > - {isLoadingResponse ? ( -
- -
- ) : ( - - )} -
+ {/* Assistant message - show streaming or final message */} + {currentStep >= 1 && !!selectedNudge && (displayMessage || isLoading) && ( +
1 ? "opacity-50" : ""}> + {}} + isStreaming={!!streamingMessage} + /> + {!isLoading && displayMessage && currentStep === 1 && ( +
+ +
+ )} +
+ )} = 2} @@ -130,6 +120,7 @@ export function OnboardingContent({ Choose and connect your preferred AI model provider.

- -
- {/* 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 messagesEndRef = useRef(null); + const chatInputRef = useRef(null); + 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, + isLoading: isStreamingLoading, + 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 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 + 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]); + + // 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) => { + // 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, + }); + }; + + 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 && ( + + )} +
+ + )} +
+
+
+ + {/* 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} + /> +
+ ); } export default function ProtectedChatPage() { - return ( - - - - ); + return ( + + + + ); } From 01b7e1ab6738baf84d3dd4e466d2b8b0dd23724c Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Tue, 21 Oct 2025 15:12:19 -0300 Subject: [PATCH 14/36] disable animation on completed steps --- .../src/app/new-onboarding/components/onboarding-step.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/new-onboarding/components/onboarding-step.tsx b/frontend/src/app/new-onboarding/components/onboarding-step.tsx index d60c3789..aefbdbf2 100644 --- a/frontend/src/app/new-onboarding/components/onboarding-step.tsx +++ b/frontend/src/app/new-onboarding/components/onboarding-step.tsx @@ -32,6 +32,12 @@ export function OnboardingStep({ return; } + if (isCompleted) { + setDisplayedText(text); + setShowChildren(true); + return; + } + let currentIndex = 0; setDisplayedText(""); setShowChildren(false); @@ -47,7 +53,7 @@ export function OnboardingStep({ }, 20); // 20ms per character return () => clearInterval(interval); - }, [text, isVisible]); + }, [text, isVisible, isCompleted]); if (!isVisible) return null; From de209a22b63f5f8eb7e6ae893f93e91fc17707fd Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Tue, 21 Oct 2025 15:46:18 -0300 Subject: [PATCH 15/36] changed markdown renderer margins --- frontend/components/markdown-renderer.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/components/markdown-renderer.tsx b/frontend/components/markdown-renderer.tsx index 380c05bf..5b011615 100644 --- a/frontend/components/markdown-renderer.tsx +++ b/frontend/components/markdown-renderer.tsx @@ -67,11 +67,14 @@ export const MarkdownRenderer = ({ chatMessage, className }: MarkdownRendererPro 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}

; }, From 0f88be14a9512b23e276e7e911cfa2e99404e132 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Tue, 21 Oct 2025 15:46:30 -0300 Subject: [PATCH 16/36] changed css to not display markdown links and texts on white always --- frontend/src/app/globals.css | 9 +++++++++ 1 file changed, 9 insertions(+) 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; From f1d36d8d71833ee5a10e7acb36945b04361cbdb7 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Tue, 21 Oct 2025 15:46:43 -0300 Subject: [PATCH 17/36] added isCompleted to assistant and user messages --- .../app/chat/components/assistant-message.tsx | 10 +++- .../src/app/chat/components/user-message.tsx | 54 +++++++++++-------- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/frontend/src/app/chat/components/assistant-message.tsx b/frontend/src/app/chat/components/assistant-message.tsx index c27b26b9..96892ffc 100644 --- a/frontend/src/app/chat/components/assistant-message.tsx +++ b/frontend/src/app/chat/components/assistant-message.tsx @@ -1,6 +1,7 @@ import { GitBranch } from "lucide-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"; @@ -14,6 +15,7 @@ interface AssistantMessageProps { isStreaming?: boolean; showForkButton?: boolean; onFork?: (e: React.MouseEvent) => void; + isCompleted?: boolean; } export function AssistantMessage({ @@ -25,12 +27,16 @@ export function AssistantMessage({ isStreaming = false, showForkButton = false, onFork, + isCompleted = false, }: AssistantMessageProps) { return ( - +
} actions={ @@ -53,7 +59,7 @@ export function AssistantMessage({ />
- - - {user?.name ? ( - user.name.charAt(0).toUpperCase() - ) : ( - - )} - - - } - > -

- {content} -

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

+ {content} +

+
+ ); } From 76e0c51a39b80f5554c33ea00b5852db7880e55d Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Tue, 21 Oct 2025 15:47:00 -0300 Subject: [PATCH 18/36] removed space between elements on onboarding step to ensure smoother animation --- .../app/new-onboarding/components/onboarding-step.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/new-onboarding/components/onboarding-step.tsx b/frontend/src/app/new-onboarding/components/onboarding-step.tsx index aefbdbf2..092084b6 100644 --- a/frontend/src/app/new-onboarding/components/onboarding-step.tsx +++ b/frontend/src/app/new-onboarding/components/onboarding-step.tsx @@ -76,9 +76,8 @@ export function OnboardingStep({ ) } > -
+
{isMarkdown ? ( -
-
) : (

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

)} @@ -110,7 +108,8 @@ export function OnboardingStep({ exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.3, delay: 0.3, ease: "easeOut" }} > - {children} +
+ {children}
)} From 469a342ab674e05483ef3313b35bb34372ea0ac9 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Tue, 21 Oct 2025 15:47:16 -0300 Subject: [PATCH 19/36] removed opacity 50 on onboarding messages --- .../new-onboarding/components/onboarding-content.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/new-onboarding/components/onboarding-content.tsx b/frontend/src/app/new-onboarding/components/onboarding-content.tsx index 9c462389..fa9025db 100644 --- a/frontend/src/app/new-onboarding/components/onboarding-content.tsx +++ b/frontend/src/app/new-onboarding/components/onboarding-content.tsx @@ -80,14 +80,12 @@ export function OnboardingContent({ {/* User message - show when nudge is selected */} {currentStep >= 1 && !!selectedNudge && ( -
1 ? "opacity-50" : ""}> - -
+ 1} /> )} {/* Assistant message - show streaming or final message */} {currentStep >= 1 && !!selectedNudge && (displayMessage || isLoading) && ( -
1 ? "opacity-50" : ""}> + <> {}} isStreaming={!!streamingMessage} + isCompleted={currentStep > 1} /> {!isLoading && displayMessage && currentStep === 1 && (
@@ -107,8 +106,7 @@ export function OnboardingContent({
)} -
- )} + )} = 2} From e236e4ac17b4b7a468340e3b691e10d4500a95e6 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Tue, 21 Oct 2025 15:47:52 -0300 Subject: [PATCH 20/36] changed default api to be langflow on chat streaming --- frontend/src/hooks/useChatStreaming.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/hooks/useChatStreaming.ts b/frontend/src/hooks/useChatStreaming.ts index df8f4aa6..6a7202e8 100644 --- a/frontend/src/hooks/useChatStreaming.ts +++ b/frontend/src/hooks/useChatStreaming.ts @@ -16,7 +16,7 @@ interface SendMessageOptions { } export function useChatStreaming({ - endpoint = "/api/chat", + endpoint = "/api/langflow", onComplete, onError, }: UseChatStreamingOptions = {}) { From 570fea9f4144d7c635f6d07c1a6d1b1f684f54a2 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Tue, 21 Oct 2025 16:19:22 -0300 Subject: [PATCH 21/36] added fade in and color transition --- .../app/chat/components/assistant-message.tsx | 83 ++++++++++--------- .../src/app/chat/components/user-message.tsx | 52 +++++++----- .../components/onboarding-content.tsx | 8 +- 3 files changed, 81 insertions(+), 62 deletions(-) diff --git a/frontend/src/app/chat/components/assistant-message.tsx b/frontend/src/app/chat/components/assistant-message.tsx index 96892ffc..93fb77d3 100644 --- a/frontend/src/app/chat/components/assistant-message.tsx +++ b/frontend/src/app/chat/components/assistant-message.tsx @@ -1,4 +1,5 @@ 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"; @@ -30,44 +31,52 @@ export function AssistantMessage({ isCompleted = false, }: AssistantMessageProps) { return ( - - + + +
+ } + actions={ + showForkButton && onFork ? ( + + ) : undefined + } + > + +
+ ' + : content + } />
- } - actions={ - showForkButton && onFork ? ( - - ) : undefined - } - > - -
- ' - : content - } - /> -
- + + ); } diff --git a/frontend/src/app/chat/components/user-message.tsx b/frontend/src/app/chat/components/user-message.tsx index 0101fc35..e5825551 100644 --- a/frontend/src/app/chat/components/user-message.tsx +++ b/frontend/src/app/chat/components/user-message.tsx @@ -1,4 +1,5 @@ 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"; @@ -13,29 +14,36 @@ export function UserMessage({ content, isCompleted }: UserMessageProps) { const { user } = useAuth(); return ( - - - - {user?.name ? user.name.charAt(0).toUpperCase() : } - - - } + -

+ + + {user?.name ? user.name.charAt(0).toUpperCase() : } + + + } > - {content} -

-
+

+ {content} +

+ + ); } diff --git a/frontend/src/app/new-onboarding/components/onboarding-content.tsx b/frontend/src/app/new-onboarding/components/onboarding-content.tsx index fa9025db..23d7e043 100644 --- a/frontend/src/app/new-onboarding/components/onboarding-content.tsx +++ b/frontend/src/app/new-onboarding/components/onboarding-content.tsx @@ -45,10 +45,12 @@ export function OnboardingContent({ const handleNudgeClick = async (nudge: string) => { setSelectedNudge(nudge); setAssistantMessage(null); + setTimeout(async () => { await sendMessage({ - prompt: nudge, - previousResponseId: responseId || undefined, - }); + prompt: nudge, + previousResponseId: responseId || undefined, + }); + }, 1500); }; // Determine which message to show (streaming takes precedence) From ec3a017f0bd9d29f084bbdd73300d3dd1b2184f3 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Tue, 21 Oct 2025 16:19:29 -0300 Subject: [PATCH 22/36] added color transition --- .../src/app/new-onboarding/components/onboarding-step.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/new-onboarding/components/onboarding-step.tsx b/frontend/src/app/new-onboarding/components/onboarding-step.tsx index 092084b6..613f08a9 100644 --- a/frontend/src/app/new-onboarding/components/onboarding-step.tsx +++ b/frontend/src/app/new-onboarding/components/onboarding-step.tsx @@ -69,7 +69,7 @@ export function OnboardingStep({ icon || (
@@ -83,13 +83,13 @@ export function OnboardingStep({ isCompleted ? "text-placeholder-foreground" : "text-foreground", - "text-sm py-1.5", + "text-sm py-1.5 transition-colors duration-300", )} chatMessage={text} /> ) : (

From a4ab95e8911a1bff5333ec0c44a9291f85561906 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Tue, 21 Oct 2025 17:09:59 -0300 Subject: [PATCH 23/36] Rendered onboarding with use-stick-to-bottom --- frontend/package-lock.json | 10 + frontend/package.json | 1 + .../components/onboarding-content.tsx | 172 ++++++++++-------- frontend/src/components/chat-renderer.tsx | 2 +- 4 files changed, 106 insertions(+), 79 deletions(-) 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/new-onboarding/components/onboarding-content.tsx b/frontend/src/app/new-onboarding/components/onboarding-content.tsx index 23d7e043..46886f47 100644 --- a/frontend/src/app/new-onboarding/components/onboarding-content.tsx +++ b/frontend/src/app/new-onboarding/components/onboarding-content.tsx @@ -1,6 +1,7 @@ "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"; @@ -46,7 +47,7 @@ export function OnboardingContent({ setSelectedNudge(nudge); setAssistantMessage(null); setTimeout(async () => { - await sendMessage({ + await sendMessage({ prompt: nudge, previousResponseId: responseId || undefined, }); @@ -57,48 +58,81 @@ export function OnboardingContent({ const displayMessage = streamingMessage || assistantMessage; return ( -

- = 0} - isCompleted={currentStep > 0} - text="Let's get started by setting up your model provider." - > - - + + +
+ = 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." - > -
- -
-
+ = 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} /> - )} + {/* 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 && ( -
+ {/* 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. +

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

- Choose and connect your preferred AI model provider. -

- +
+

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

+ +
+
-
- - = 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/components/chat-renderer.tsx b/frontend/src/components/chat-renderer.tsx index 8721aaa1..df39c52d 100644 --- a/frontend/src/components/chat-renderer.tsx +++ b/frontend/src/components/chat-renderer.tsx @@ -156,7 +156,7 @@ export function ChatRenderer({ showLayout && "p-6 container", showLayout && isSmallWidthPath && "max-w-[850px] ml-0", !showLayout && - "w-full bg-card rounded-lg shadow-2xl p-8 overflow-y-auto", + "w-full bg-card rounded-lg shadow-2xl p-0 py-2 overflow-y-auto", )} > Date: Tue, 21 Oct 2025 18:10:57 -0300 Subject: [PATCH 24/36] Added use stick to bottom on page --- frontend/src/app/chat/page.tsx | 229 ++++++++++++++++----------------- 1 file changed, 112 insertions(+), 117 deletions(-) diff --git a/frontend/src/app/chat/page.tsx b/frontend/src/app/chat/page.tsx index 333faf22..20ee66ca 100644 --- a/frontend/src/app/chat/page.tsx +++ b/frontend/src/app/chat/page.tsx @@ -2,6 +2,7 @@ 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"; @@ -71,8 +72,10 @@ function ChatPage() { x: number; y: number; } | null>(null); - const messagesEndRef = useRef(null); const chatInputRef = useRef(null); + + const { scrollToBottom } = useStickToBottomContext(); + const lastLoadedConversationRef = useRef(null); const { addTask } = useTask(); const { selectedFilter, parsedFilterData, setSelectedFilter } = @@ -82,7 +85,6 @@ function ChatPage() { const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"; const { streamingMessage, - isLoading: isStreamingLoading, sendMessage: sendStreamingMessage, abortStream, } = useChatStreaming({ @@ -90,7 +92,7 @@ function ChatPage() { onComplete: (message, responseId) => { setMessages((prev) => [...prev, message]); setLoading(false); - + if (responseId) { cancelNudges(); setPreviousResponseIds((prev) => ({ @@ -119,10 +121,6 @@ function ChatPage() { }, }); - 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"); @@ -357,21 +355,10 @@ function ChatPage() { } }; - 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(() => { @@ -408,7 +395,7 @@ function ChatPage() { window.removeEventListener("newConversation", handleNewConversation); window.removeEventListener("focusInput", handleFocusInput); }; - }, [abortStream]); + }, [abortStream, setLoading]); // Load conversation only when user explicitly selects a conversation useEffect(() => { @@ -709,7 +696,7 @@ function ChatPage() { handleFileUploadError as EventListener, ); }; - }, [endpoint, setPreviousResponseIds]); + }, [endpoint, setPreviousResponseIds, setLoading]); const { data: nudges = [], cancel: cancelNudges } = useGetNudgesQuery( previousResponseIds[endpoint], @@ -749,6 +736,10 @@ function ChatPage() { limit: parsedFilterData?.limit ?? 10, scoreThreshold: parsedFilterData?.scoreThreshold ?? 0, }); + scrollToBottom({ + animation: "smooth", + duration: 1000, + }); }; const handleSendMessage = async (inputMessage: string) => { @@ -765,6 +756,11 @@ function ChatPage() { setLoading(true); setIsFilterHighlighted(false); + scrollToBottom({ + animation: "smooth", + duration: 1000, + }); + if (asyncMode) { await handleSSEStream(userMessage); } else { @@ -1113,66 +1109,57 @@ function ChatPage() { } }; - return ( -
- {/* Debug header - only show in debug mode */} - {isDebugMode && ( -
-
-
- {/* Async Mode Toggle */} -
- - -
- {/* Endpoint Toggle */} -
- - + return (<> + {/* Debug header - only show in debug mode */} + {isDebugMode && ( +
+
+
+ {/* Async Mode Toggle */} +
+ + +
+ {/* Endpoint Toggle */} +
+ + +
-
- )} + )} -
-
- {/* Messages Area */} -
+ +
{messages.length === 0 && !streamingMessage ? (
@@ -1190,7 +1177,12 @@ function ChatPage() { ) : ( <> {messages.map((message, index) => ( -
+
{message.role === "user" && ( )} @@ -1220,55 +1212,58 @@ function ChatPage() { isStreaming /> )} -
)} + {!streamingMessage && ( + + )}
-
-
+ - {/* 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} - /> -
+ {/* 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 ( +
+ +
); } From c5fac3ec81f759761b18e17cbe09c5ea2bb892bc Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Tue, 21 Oct 2025 18:11:07 -0300 Subject: [PATCH 25/36] fixed nudges design --- frontend/src/app/chat/nudges.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/chat/nudges.tsx b/frontend/src/app/chat/nudges.tsx index 7fdf0598..9341b9c1 100644 --- a/frontend/src/app/chat/nudges.tsx +++ b/frontend/src/app/chat/nudges.tsx @@ -11,7 +11,7 @@ export default function Nudges({ handleSuggestionClick: (suggestion: string) => void; }) { return ( -
+
{nudges.length > 0 && (
-
+
{nudges.map((suggestion: string, index: number) => ( - -
- )} -
- - {/* 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"; From 69c71044db9a7dcb4351cc598a2c98a4220de2a2 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Tue, 21 Oct 2025 18:12:43 -0300 Subject: [PATCH 27/36] fixed nudges design --- frontend/src/app/chat/nudges.tsx | 2 +- frontend/src/app/chat/page.tsx | 283 ++++++++++++++++--------------- 2 files changed, 144 insertions(+), 141 deletions(-) diff --git a/frontend/src/app/chat/nudges.tsx b/frontend/src/app/chat/nudges.tsx index 9341b9c1..c98ead63 100644 --- a/frontend/src/app/chat/nudges.tsx +++ b/frontend/src/app/chat/nudges.tsx @@ -11,7 +11,7 @@ export default function Nudges({ handleSuggestionClick: (suggestion: string) => void; }) { return ( -
+
{nudges.length > 0 && ( { setMessages((prev) => [...prev, message]); setLoading(false); - + if (responseId) { cancelNudges(); setPreviousResponseIds((prev) => ({ @@ -739,7 +739,7 @@ function ChatPage() { scrollToBottom({ animation: "smooth", duration: 1000, - }); + }); }; const handleSendMessage = async (inputMessage: string) => { @@ -759,7 +759,7 @@ function ChatPage() { scrollToBottom({ animation: "smooth", duration: 1000, - }); + }); if (asyncMode) { await handleSSEStream(userMessage); @@ -1109,146 +1109,148 @@ function ChatPage() { } }; - return (<> - {/* Debug header - only show in debug mode */} - {isDebugMode && ( -
-
-
- {/* Async Mode Toggle */} -
- - -
- {/* Endpoint Toggle */} -
- - -
+ 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.length === 0 && !streamingMessage ? ( +
+
+ {isUploading ? ( + <> + +

Processing your document...

+

This may take a few moments

+ + ) : null}
- ) : ( - <> - {messages.map((message, index) => ( -
- {message.role === "user" && ( - - )} +
+ ) : ( + <> + {messages.map((message, index) => ( +
+ {message.role === "user" && ( + + )} - {message.role === "assistant" && ( - handleForkConversation(index, e)} - /> - )} -
- ))} + {message.role === "assistant" && ( + handleForkConversation(index, e)} + /> + )} +
+ ))} - {/* Streaming Message Display */} - {streamingMessage && ( - - )} - - )} - {!streamingMessage && ( + {/* 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} - /> + {/* Input Area - Fixed at bottom */} + setTextareaHeight(height)} + onFilterSelect={handleFilterSelect} + onAtClick={onAtClick} + onFilePickerChange={handleFilePickerChange} + onFilePickerClick={handleFilePickerClick} + setSelectedFilter={setSelectedFilter} + setIsFilterHighlighted={setIsFilterHighlighted} + setIsFilterDropdownOpen={setIsFilterDropdownOpen} + /> + ); } @@ -1256,14 +1258,15 @@ export default function ProtectedChatPage() { return (
- - -
+ + + +
); } From 62e5a8acefe80fb1d561aea92925bed171f7b9ec Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Tue, 21 Oct 2025 18:13:03 -0300 Subject: [PATCH 28/36] made overflow be hidden on main --- frontend/src/components/chat-renderer.tsx | 336 +++++++++++----------- 1 file changed, 168 insertions(+), 168 deletions(-) diff --git a/frontend/src/components/chat-renderer.tsx b/frontend/src/components/chat-renderer.tsx index df39c52d..83e59661 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 && ( + + )} +
+
+
+ + + +
+ + ); } From db47a3d539f4bf2854d4f7d5fa1a5e1dc5ab0acd Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Tue, 21 Oct 2025 18:14:32 -0300 Subject: [PATCH 29/36] Added overflow y auto on other pages --- frontend/src/components/chat-renderer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/chat-renderer.tsx b/frontend/src/components/chat-renderer.tsx index 83e59661..3087b586 100644 --- a/frontend/src/components/chat-renderer.tsx +++ b/frontend/src/components/chat-renderer.tsx @@ -153,7 +153,7 @@ export function ChatRenderer({
Date: Tue, 21 Oct 2025 18:30:19 -0300 Subject: [PATCH 30/36] Put animate on messages --- frontend/src/app/chat/page.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/chat/page.tsx b/frontend/src/app/chat/page.tsx index 98109c39..dc85f43e 100644 --- a/frontend/src/app/chat/page.tsx +++ b/frontend/src/app/chat/page.tsx @@ -1183,7 +1183,7 @@ function ChatPage() { className="space-y-6 group" > {message.role === "user" && ( - + )} {message.role === "assistant" && ( @@ -1195,6 +1195,7 @@ function ChatPage() { onToggle={toggleFunctionCall} showForkButton={endpoint === "chat"} onFork={(e) => handleForkConversation(index, e)} + animate={false} /> )}
@@ -1208,6 +1209,7 @@ function ChatPage() { messageIndex={messages.length} expandedFunctionCalls={expandedFunctionCalls} onToggle={toggleFunctionCall} + delay={0.4} isStreaming /> )} From fdb8669e6067480877d4d13cd8a2695dbf0d016c Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Tue, 21 Oct 2025 18:30:32 -0300 Subject: [PATCH 31/36] Add source to types --- frontend/src/app/chat/types.ts | 1 + 1 file changed, 1 insertion(+) 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 { From fdcc41ea48e5178f234e7729023463711093ec15 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Tue, 21 Oct 2025 18:30:41 -0300 Subject: [PATCH 32/36] Adds animate and delay props to messages --- frontend/src/app/chat/components/assistant-message.tsx | 9 +++++++-- frontend/src/app/chat/components/user-message.tsx | 9 ++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/chat/components/assistant-message.tsx b/frontend/src/app/chat/components/assistant-message.tsx index 93fb77d3..4c600517 100644 --- a/frontend/src/app/chat/components/assistant-message.tsx +++ b/frontend/src/app/chat/components/assistant-message.tsx @@ -17,6 +17,8 @@ interface AssistantMessageProps { showForkButton?: boolean; onFork?: (e: React.MouseEvent) => void; isCompleted?: boolean; + animate?: boolean; + delay?: number; } export function AssistantMessage({ @@ -29,12 +31,15 @@ export function AssistantMessage({ showForkButton = false, onFork, isCompleted = false, + animate = true, + delay = 0.2, }: AssistantMessageProps) { + return ( Date: Wed, 22 Oct 2025 14:03:23 -0300 Subject: [PATCH 33/36] 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 From ad39d88a7b9c3e3d082cdb30a386cd2525cfcdd4 Mon Sep 17 00:00:00 2001 From: Mike Fortman Date: Wed, 22 Oct 2025 12:09:46 -0500 Subject: [PATCH 34/36] init --- frontend/components/knowledge-dropdown.tsx | 138 +++--------------- frontend/lib/upload-utils.ts | 138 ++++++++++++++++++ .../components/onboarding-content.tsx | 119 +++++++++++++-- 3 files changed, 271 insertions(+), 124 deletions(-) create mode 100644 frontend/lib/upload-utils.ts diff --git a/frontend/components/knowledge-dropdown.tsx b/frontend/components/knowledge-dropdown.tsx index 19ddc387..fc522f9b 100644 --- a/frontend/components/knowledge-dropdown.tsx +++ b/frontend/components/knowledge-dropdown.tsx @@ -26,6 +26,10 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useTask } from "@/contexts/task-context"; import { cn } from "@/lib/utils"; +import { + duplicateCheck, + uploadFile as uploadFileUtil, +} from "@/lib/upload-utils"; import type { File as SearchFile } from "@/src/app/api/queries/useGetSearchQuery"; export function KnowledgeDropdown() { @@ -163,8 +167,17 @@ export function KnowledgeDropdown() { fileInputRef.current?.click(); }; - const handleFileChange = async (e: React.ChangeEvent) => { - const files = e.target.files; + const resetFileInput = () => { + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const handleFileChange = async ( + event: React.ChangeEvent + ) => { + const files = event.target.files; + if (files && files.length > 0) { const file = files[0]; @@ -172,37 +185,16 @@ export function KnowledgeDropdown() { setIsOpen(false); try { - // Check if filename already exists (using ORIGINAL filename) console.log("[Duplicate Check] Checking file:", file.name); - const checkResponse = await fetch( - `/api/documents/check-filename?filename=${encodeURIComponent( - file.name - )}` - ); - - console.log("[Duplicate Check] Response status:", checkResponse.status); - - if (!checkResponse.ok) { - const errorText = await checkResponse.text(); - console.error("[Duplicate Check] Error response:", errorText); - throw new Error( - `Failed to check duplicates: ${checkResponse.statusText}` - ); - } - - const checkData = await checkResponse.json(); + const checkData = await duplicateCheck(file); console.log("[Duplicate Check] Result:", checkData); if (checkData.exists) { - // Show duplicate handling dialog console.log("[Duplicate Check] Duplicate detected, showing dialog"); setPendingFile(file); setDuplicateFilename(file.name); setShowDuplicateDialog(true); - // Reset file input - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } + resetFileInput(); return; } @@ -217,105 +209,20 @@ export function KnowledgeDropdown() { } } - // Reset file input - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } + resetFileInput(); }; const uploadFile = async (file: File, replace: boolean) => { setFileUploading(true); - // Trigger the same file upload event as the chat page - window.dispatchEvent( - new CustomEvent("fileUploadStart", { - detail: { filename: file.name }, - }) - ); - try { - const formData = new FormData(); - formData.append("file", file); - formData.append("replace_duplicates", replace.toString()); - - // Use router upload and ingest endpoint (automatically routes based on configuration) - const uploadIngestRes = await fetch("/api/router/upload_ingest", { - method: "POST", - body: formData, - }); - - const uploadIngestJson = await uploadIngestRes.json(); - - if (!uploadIngestRes.ok) { - throw new Error(uploadIngestJson?.error || "Upload and ingest failed"); - } - - // Extract results from the response - handle both unified and simple formats - const fileId = - uploadIngestJson?.upload?.id || - uploadIngestJson?.id || - uploadIngestJson?.task_id; - const filePath = - uploadIngestJson?.upload?.path || uploadIngestJson?.path || "uploaded"; - const runJson = uploadIngestJson?.ingestion; - const deleteResult = uploadIngestJson?.deletion; - console.log("c", uploadIngestJson); - if (!fileId) { - throw new Error("Upload successful but no file id returned"); - } - // Check if ingestion actually succeeded - if ( - runJson && - runJson.status !== "COMPLETED" && - runJson.status !== "SUCCESS" - ) { - const errorMsg = runJson.error || "Ingestion pipeline failed"; - throw new Error( - `Ingestion failed: ${errorMsg}. Try setting DISABLE_INGEST_WITH_LANGFLOW=true if you're experiencing Langflow component issues.` - ); - } - // Log deletion status if provided - if (deleteResult) { - if (deleteResult.status === "deleted") { - console.log( - "File successfully cleaned up from Langflow:", - deleteResult.file_id - ); - } else if (deleteResult.status === "delete_failed") { - console.warn( - "Failed to cleanup file from Langflow:", - deleteResult.error - ); - } - } - // Notify UI - window.dispatchEvent( - new CustomEvent("fileUploaded", { - detail: { - file: file, - result: { - file_id: fileId, - file_path: filePath, - run: runJson, - deletion: deleteResult, - unified: true, - }, - }, - }) - ); - + await uploadFileUtil(file, replace); refetchTasks(); } catch (error) { - window.dispatchEvent( - new CustomEvent("fileUploadError", { - detail: { - filename: file.name, - error: error instanceof Error ? error.message : "Upload failed", - }, - }) - ); + toast.error("Upload failed", { + description: error instanceof Error ? error.message : "Unknown error", + }); } finally { - window.dispatchEvent(new CustomEvent("fileUploadComplete")); setFileUploading(false); } }; @@ -332,6 +239,7 @@ export function KnowledgeDropdown() { }); await uploadFile(pendingFile, true); + setPendingFile(null); setDuplicateFilename(""); } diff --git a/frontend/lib/upload-utils.ts b/frontend/lib/upload-utils.ts new file mode 100644 index 00000000..6a7e7301 --- /dev/null +++ b/frontend/lib/upload-utils.ts @@ -0,0 +1,138 @@ +export interface DuplicateCheckResponse { + exists: boolean; + [key: string]: unknown; +} + +export interface UploadFileResult { + fileId: string; + filePath: string; + run: unknown; + deletion: unknown; + unified: boolean; + raw: unknown; +} + +export async function duplicateCheck( + file: File +): Promise { + const response = await fetch( + `/api/documents/check-filename?filename=${encodeURIComponent(file.name)}` + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + errorText || `Failed to check duplicates: ${response.statusText}` + ); + } + + return response.json(); +} + +export async function uploadFile( + file: File, + replace = false +): Promise { + window.dispatchEvent( + new CustomEvent("fileUploadStart", { + detail: { filename: file.name }, + }) + ); + + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("replace_duplicates", replace.toString()); + + const uploadResponse = await fetch("/api/router/upload_ingest", { + method: "POST", + body: formData, + }); + + let payload: unknown; + try { + payload = await uploadResponse.json(); + } catch (error) { + throw new Error("Upload failed: unable to parse server response"); + } + + const uploadIngestJson = + typeof payload === "object" && payload !== null ? payload : {}; + + if (!uploadResponse.ok) { + const errorMessage = + (uploadIngestJson as { error?: string }).error || + "Upload and ingest failed"; + throw new Error(errorMessage); + } + + const fileId = + (uploadIngestJson as { upload?: { id?: string } }).upload?.id || + (uploadIngestJson as { id?: string }).id || + (uploadIngestJson as { task_id?: string }).task_id; + const filePath = + (uploadIngestJson as { upload?: { path?: string } }).upload?.path || + (uploadIngestJson as { path?: string }).path || + "uploaded"; + const runJson = (uploadIngestJson as { ingestion?: unknown }).ingestion; + const deletionJson = (uploadIngestJson as { deletion?: unknown }).deletion; + + if (!fileId) { + throw new Error("Upload successful but no file id returned"); + } + + if ( + runJson && + typeof runJson === "object" && + "status" in (runJson as Record) && + (runJson as { status?: string }).status !== "COMPLETED" && + (runJson as { status?: string }).status !== "SUCCESS" + ) { + const errorMsg = + (runJson as { error?: string }).error || + "Ingestion pipeline failed"; + throw new Error( + `Ingestion failed: ${errorMsg}. Try setting DISABLE_INGEST_WITH_LANGFLOW=true if you're experiencing Langflow component issues.` + ); + } + + const result: UploadFileResult = { + fileId, + filePath, + run: runJson, + deletion: deletionJson, + unified: true, + raw: uploadIngestJson, + }; + + window.dispatchEvent( + new CustomEvent("fileUploaded", { + detail: { + file, + result: { + file_id: fileId, + file_path: filePath, + run: runJson, + deletion: deletionJson, + unified: true, + }, + }, + }) + ); + + return result; + } catch (error) { + window.dispatchEvent( + new CustomEvent("fileUploadError", { + detail: { + filename: file.name, + error: + error instanceof Error ? error.message : "Upload failed", + }, + }) + ); + throw error; + } finally { + window.dispatchEvent(new CustomEvent("fileUploadComplete")); + } +} diff --git a/frontend/src/app/new-onboarding/components/onboarding-content.tsx b/frontend/src/app/new-onboarding/components/onboarding-content.tsx index 46886f47..a6c28069 100644 --- a/frontend/src/app/new-onboarding/components/onboarding-content.tsx +++ b/frontend/src/app/new-onboarding/components/onboarding-content.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { type ChangeEvent, useRef, 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"; @@ -8,6 +8,9 @@ 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 { DuplicateHandlingDialog } from "@/components/duplicate-handling-dialog"; +import { duplicateCheck, uploadFile as uploadFileUtil } from "@/lib/upload-utils"; +import { toast } from "sonner"; import { OnboardingStep } from "./onboarding-step"; export function OnboardingContent({ @@ -22,6 +25,10 @@ export function OnboardingContent({ const [assistantMessage, setAssistantMessage] = useState( null, ); + const fileInputRef = useRef(null); + const [pendingFile, setPendingFile] = useState(null); + const [showDuplicateDialog, setShowDuplicateDialog] = useState(false); + const [isUploading, setIsUploading] = useState(false); const { streamingMessage, isLoading, sendMessage } = useChatStreaming({ onComplete: (message, newResponseId) => { @@ -54,6 +61,76 @@ export function OnboardingContent({ }, 1500); }; + const resetFileInput = () => { + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; + + const handleDuplicateDialogChange = (open: boolean) => { + if (!open) { + setPendingFile(null); + } + setShowDuplicateDialog(open); + }; + + const performUpload = async (file: File, replace = false) => { + setIsUploading(true); + try { + await uploadFileUtil(file, replace); + toast.success("Document uploaded successfully"); + } catch (error) { + toast.error("Upload failed", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } finally { + setIsUploading(false); + } + }; + + const handleFileChange = async (event: ChangeEvent) => { + const selectedFile = event.target.files?.[0]; + if (!selectedFile) { + resetFileInput(); + return; + } + + try { + const duplicateInfo = await duplicateCheck(selectedFile); + if (duplicateInfo.exists) { + setPendingFile(selectedFile); + setShowDuplicateDialog(true); + return; + } + + await performUpload(selectedFile, false); + } catch (error) { + toast.error("Unable to prepare file for upload", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } finally { + resetFileInput(); + } + }; + + const handleOverwriteFile = async () => { + if (!pendingFile) { + return; + } + + const fileToUpload = pendingFile; + setPendingFile(null); + try { + await performUpload(fileToUpload, true); + } finally { + resetFileInput(); + } + }; + // Determine which message to show (streaming takes precedence) const displayMessage = streamingMessage || assistantMessage; @@ -150,18 +227,42 @@ export function OnboardingContent({ >

- Your account is ready to use. Let's start chatting! + Upload a starter document to begin building your knowledge base + or jump straight into a conversation.

- +
+ + +
+
+ ); From 3ebb1adac73441067a1cb5a3c122fbfd1719d736 Mon Sep 17 00:00:00 2001 From: Mike Fortman Date: Wed, 22 Oct 2025 15:00:40 -0500 Subject: [PATCH 35/36] upload component --- .../components/onboarding-content.tsx | 158 +++--------------- .../components/onboarding-step.tsx | 20 ++- .../components/onboarding-upload.tsx | 81 +++++++++ 3 files changed, 116 insertions(+), 143 deletions(-) create mode 100644 frontend/src/app/new-onboarding/components/onboarding-upload.tsx diff --git a/frontend/src/app/new-onboarding/components/onboarding-content.tsx b/frontend/src/app/new-onboarding/components/onboarding-content.tsx index dd28c81f..f9adf224 100644 --- a/frontend/src/app/new-onboarding/components/onboarding-content.tsx +++ b/frontend/src/app/new-onboarding/components/onboarding-content.tsx @@ -1,6 +1,6 @@ "use client"; -import { type ChangeEvent, useRef, useState } from "react"; +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"; @@ -8,10 +8,9 @@ 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 { DuplicateHandlingDialog } from "@/components/duplicate-handling-dialog"; -import { duplicateCheck, uploadFile as uploadFileUtil } from "@/lib/upload-utils"; -import { toast } from "sonner"; + import { OnboardingStep } from "./onboarding-step"; +import OnboardingUpload from "./onboarding-upload"; export function OnboardingContent({ handleStepComplete, @@ -25,10 +24,6 @@ export function OnboardingContent({ const [assistantMessage, setAssistantMessage] = useState( null, ); - const fileInputRef = useRef(null); - const [pendingFile, setPendingFile] = useState(null); - const [showDuplicateDialog, setShowDuplicateDialog] = useState(false); - const [isUploading, setIsUploading] = useState(false); const { streamingMessage, isLoading, sendMessage } = useChatStreaming({ onComplete: (message, newResponseId) => { @@ -61,76 +56,6 @@ export function OnboardingContent({ }, 1500); }; - const resetFileInput = () => { - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - }; - - const handleUploadClick = () => { - fileInputRef.current?.click(); - }; - - const handleDuplicateDialogChange = (open: boolean) => { - if (!open) { - setPendingFile(null); - } - setShowDuplicateDialog(open); - }; - - const performUpload = async (file: File, replace = false) => { - setIsUploading(true); - try { - await uploadFileUtil(file, replace); - toast.success("Document uploaded successfully"); - } catch (error) { - toast.error("Upload failed", { - description: error instanceof Error ? error.message : "Unknown error", - }); - } finally { - setIsUploading(false); - } - }; - - const handleFileChange = async (event: ChangeEvent) => { - const selectedFile = event.target.files?.[0]; - if (!selectedFile) { - resetFileInput(); - return; - } - - try { - const duplicateInfo = await duplicateCheck(selectedFile); - if (duplicateInfo.exists) { - setPendingFile(selectedFile); - setShowDuplicateDialog(true); - return; - } - - await performUpload(selectedFile, false); - } catch (error) { - toast.error("Unable to prepare file for upload", { - description: error instanceof Error ? error.message : "Unknown error", - }); - } finally { - resetFileInput(); - } - }; - - const handleOverwriteFile = async () => { - if (!pendingFile) { - return; - } - - const fileToUpload = pendingFile; - setPendingFile(null); - try { - await performUpload(fileToUpload, true); - } finally { - resetFileInput(); - } - }; - // Determine which message to show (streaming takes precedence) const displayMessage = streamingMessage || assistantMessage; @@ -177,63 +102,30 @@ export function OnboardingContent({ {currentStep >= 1 && !!selectedNudge && (displayMessage || isLoading) && ( - <> - {}} - isStreaming={!!streamingMessage} - isCompleted={currentStep > 1} - /> - {!isLoading && displayMessage && currentStep === 1 && ( -
- -
- )} - + {}} + isStreaming={!!streamingMessage} + isCompleted={currentStep > 1} + /> )} + + {/* Still kind of part of step 2 */} + 2} + text="Now, let's add your data." + hideIcon={true} + > + + = 2} isCompleted={currentStep > 2} - text="Step 2: Connect your model" - > -
-

- Upload a starter document to begin building your knowledge base - or jump straight into a conversation. -

-
- - -
-
-
- - = 3} - isCompleted={currentStep > 3} text="Step 3: You're all set!" >
@@ -247,12 +139,6 @@ export function OnboardingContent({
- ); diff --git a/frontend/src/app/new-onboarding/components/onboarding-step.tsx b/frontend/src/app/new-onboarding/components/onboarding-step.tsx index 613f08a9..84cfd6f2 100644 --- a/frontend/src/app/new-onboarding/components/onboarding-step.tsx +++ b/frontend/src/app/new-onboarding/components/onboarding-step.tsx @@ -12,6 +12,7 @@ interface OnboardingStepProps { isCompleted?: boolean; icon?: ReactNode; isMarkdown?: boolean; + hideIcon?: boolean; } export function OnboardingStep({ @@ -21,6 +22,7 @@ export function OnboardingStep({ isCompleted = false, icon, isMarkdown = false, + hideIcon = false, }: OnboardingStepProps) { const [displayedText, setDisplayedText] = useState(""); const [showChildren, setShowChildren] = useState(false); @@ -66,13 +68,17 @@ export function OnboardingStep({ > - -
+ hideIcon ? ( +
+ ) : ( + icon || ( +
+ +
+ ) ) } > diff --git a/frontend/src/app/new-onboarding/components/onboarding-upload.tsx b/frontend/src/app/new-onboarding/components/onboarding-upload.tsx new file mode 100644 index 00000000..0c4a1fc9 --- /dev/null +++ b/frontend/src/app/new-onboarding/components/onboarding-upload.tsx @@ -0,0 +1,81 @@ +import { ChangeEvent, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { duplicateCheck, uploadFile } from "@/lib/upload-utils"; + +interface OnboardingUploadProps { + onComplete: () => void; +} + +const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => { + const fileInputRef = useRef(null); + const [isUploading, setIsUploading] = useState(false); + + const resetFileInput = () => { + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; + + + const performUpload = async (file: File, replace = false) => { + setIsUploading(true); + try { + await uploadFile(file, replace); + console.log("Document uploaded successfully"); + } catch (error) { + console.error("Upload failed", (error as Error).message); + } finally { + setIsUploading(false); + onComplete(); + } + }; + + const handleFileChange = async (event: ChangeEvent) => { + const selectedFile = event.target.files?.[0]; + if (!selectedFile) { + resetFileInput(); + return; + } + + try { + const duplicateInfo = await duplicateCheck(selectedFile); + if (duplicateInfo.exists) { + console.log("Duplicate file detected"); + return; + } + + await performUpload(selectedFile, false); + } catch (error) { + console.error("Unable to prepare file for upload", (error as Error).message); + } finally { + resetFileInput(); + } + }; + + + return ( +
+ + +
+ ) +} + +export default OnboardingUpload; From 8c2d58183cacba477acfa28d3372a192b0358282 Mon Sep 17 00:00:00 2001 From: Mike Fortman Date: Wed, 22 Oct 2025 17:05:24 -0500 Subject: [PATCH 36/36] Finish up loading states --- .../components/onboarding-content.tsx | 23 +++++-- .../components/onboarding-upload.tsx | 67 ++++++++++++++----- .../components/animated-provider-steps.tsx | 10 +-- .../onboarding/components/onboarding-card.tsx | 11 ++- 4 files changed, 79 insertions(+), 32 deletions(-) diff --git a/frontend/src/app/new-onboarding/components/onboarding-content.tsx b/frontend/src/app/new-onboarding/components/onboarding-content.tsx index f9adf224..645d6e40 100644 --- a/frontend/src/app/new-onboarding/components/onboarding-content.tsx +++ b/frontend/src/app/new-onboarding/components/onboarding-content.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, 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"; @@ -59,6 +59,12 @@ export function OnboardingContent({ // Determine which message to show (streaming takes precedence) const displayMessage = streamingMessage || assistantMessage; + useEffect(() => { + if (currentStep === 1 && !isLoading && !!displayMessage) { + handleStepComplete(); + } + }, [isLoading, displayMessage, handleStepComplete]); + return (
+ {/* Step 1 */} = 0} isCompleted={currentStep > 0} @@ -76,6 +83,7 @@ export function OnboardingContent({ + {/* Step 2 */} = 1} isCompleted={currentStep > 1 || !!selectedNudge} @@ -94,7 +102,7 @@ export function OnboardingContent({ {currentStep >= 1 && !!selectedNudge && ( 1} + isCompleted={currentStep > 2} /> )} @@ -109,13 +117,13 @@ export function OnboardingContent({ expandedFunctionCalls={new Set()} onToggle={() => {}} isStreaming={!!streamingMessage} - isCompleted={currentStep > 1} + isCompleted={currentStep > 2} /> )} - {/* Still kind of part of step 2 */} + {/* Step 3 */} = 2 && !isLoading && !!displayMessage} isCompleted={currentStep > 2} text="Now, let's add your data." hideIcon={true} @@ -123,9 +131,10 @@ export function OnboardingContent({ + {/* Step 4 */} = 2} - isCompleted={currentStep > 2} + isVisible={currentStep >= 3} + isCompleted={currentStep > 3} text="Step 3: You're all set!" >
diff --git a/frontend/src/app/new-onboarding/components/onboarding-upload.tsx b/frontend/src/app/new-onboarding/components/onboarding-upload.tsx index 0c4a1fc9..00d90097 100644 --- a/frontend/src/app/new-onboarding/components/onboarding-upload.tsx +++ b/frontend/src/app/new-onboarding/components/onboarding-upload.tsx @@ -1,6 +1,8 @@ import { ChangeEvent, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { duplicateCheck, uploadFile } from "@/lib/upload-utils"; +import { AnimatePresence, motion } from "motion/react"; +import { AnimatedProviderSteps } from "@/app/onboarding/components/animated-provider-steps"; interface OnboardingUploadProps { onComplete: () => void; @@ -9,6 +11,12 @@ interface OnboardingUploadProps { const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => { const fileInputRef = useRef(null); const [isUploading, setIsUploading] = useState(false); + const [currentStep, setCurrentStep] = useState(null); + + const STEP_LIST = [ + "Analyzing your document", + "Ingesting your document", + ]; const resetFileInput = () => { if (fileInputRef.current) { @@ -24,12 +32,14 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => { const performUpload = async (file: File, replace = false) => { setIsUploading(true); try { + setCurrentStep(1); await uploadFile(file, replace); console.log("Document uploaded successfully"); } catch (error) { console.error("Upload failed", (error as Error).message); } finally { setIsUploading(false); + setCurrentStep(STEP_LIST.length); onComplete(); } }; @@ -42,6 +52,7 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => { } try { + setCurrentStep(0); const duplicateInfo = await duplicateCheck(selectedFile); if (duplicateInfo.exists) { console.log("Duplicate file detected"); @@ -58,23 +69,45 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => { return ( -
- - -
+ + {currentStep === null ? ( + + + + + ) : ( + + + + )} + ) } diff --git a/frontend/src/app/onboarding/components/animated-provider-steps.tsx b/frontend/src/app/onboarding/components/animated-provider-steps.tsx index cc52b783..f2b48c99 100644 --- a/frontend/src/app/onboarding/components/animated-provider-steps.tsx +++ b/frontend/src/app/onboarding/components/animated-provider-steps.tsx @@ -9,16 +9,12 @@ import { cn } from "@/lib/utils"; export function AnimatedProviderSteps({ currentStep, setCurrentStep, + steps, }: { currentStep: number; setCurrentStep: (step: number) => void; + steps: string[]; }) { - const steps = [ - "Setting up your model provider", - "Defining schema", - "Configuring Langflow", - "Ingesting sample data", - ]; useEffect(() => { if (currentStep < steps.length - 1) { @@ -27,7 +23,7 @@ export function AnimatedProviderSteps({ }, 1000); return () => clearInterval(interval); } - }, [currentStep, setCurrentStep]); + }, [currentStep, setCurrentStep, steps]); const isDone = currentStep >= steps.length; diff --git a/frontend/src/app/onboarding/components/onboarding-card.tsx b/frontend/src/app/onboarding/components/onboarding-card.tsx index 898409a3..10f08d09 100644 --- a/frontend/src/app/onboarding/components/onboarding-card.tsx +++ b/frontend/src/app/onboarding/components/onboarding-card.tsx @@ -34,7 +34,15 @@ interface OnboardingCardProps { onComplete: () => void; } -const TOTAL_PROVIDER_STEPS = 4; + +const STEP_LIST = [ + "Setting up your model provider", + "Defining schema", + "Configuring Langflow", + "Ingesting sample data", +]; + +const TOTAL_PROVIDER_STEPS = STEP_LIST.length; const OnboardingCard = ({ onComplete }: OnboardingCardProps) => { const updatedOnboarding = process.env.UPDATED_ONBOARDING === "true"; @@ -245,6 +253,7 @@ const OnboardingCard = ({ onComplete }: OnboardingCardProps) => { )}