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
This commit is contained in:
Lucas Oliveira 2025-10-22 14:03:23 -03:00 committed by GitHub
parent c5447f6c5d
commit fcf7a302d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 2660 additions and 2601 deletions

View file

@ -1261,7 +1261,7 @@
"display_name": "as_dataframe", "display_name": "as_dataframe",
"name": "as_dataframe", "name": "as_dataframe",
"readonly": false, "readonly": false,
"status": true, "status": false,
"tags": [ "tags": [
"as_dataframe" "as_dataframe"
] ]
@ -1280,7 +1280,7 @@
"display_name": "as_vector_store", "display_name": "as_vector_store",
"name": "as_vector_store", "name": "as_vector_store",
"readonly": false, "readonly": false,
"status": true, "status": false,
"tags": [ "tags": [
"as_vector_store" "as_vector_store"
] ]
@ -2086,7 +2086,7 @@
"trace_as_input": true, "trace_as_input": true,
"trace_as_metadata": true, "trace_as_metadata": true,
"type": "str", "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": { "tools": {
"_input_type": "HandleInput", "_input_type": "HandleInput",

View file

@ -7,6 +7,7 @@ import CodeComponent from "./code-component";
type MarkdownRendererProps = { type MarkdownRendererProps = {
chatMessage: string; chatMessage: string;
className?: string;
}; };
const preprocessChatMessage = (text: string): string => { const preprocessChatMessage = (text: string): string => {
@ -48,7 +49,7 @@ export const cleanupTableEmptyCells = (text: string): string => {
}) })
.join("\n"); .join("\n");
}; };
export const MarkdownRenderer = ({ chatMessage }: MarkdownRendererProps) => { export const MarkdownRenderer = ({ chatMessage, className }: MarkdownRendererProps) => {
// Process the chat message to handle <think> tags and clean up tables // Process the chat message to handle <think> tags and clean up tables
const processedChatMessage = preprocessChatMessage(chatMessage); const processedChatMessage = preprocessChatMessage(chatMessage);
@ -57,6 +58,7 @@ export const MarkdownRenderer = ({ chatMessage }: MarkdownRendererProps) => {
className={cn( 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", "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", !chatMessage ? "text-muted-foreground" : "text-primary",
className,
)} )}
> >
<Markdown <Markdown
@ -65,11 +67,14 @@ export const MarkdownRenderer = ({ chatMessage }: MarkdownRendererProps) => {
urlTransform={(url) => url} urlTransform={(url) => url}
components={{ components={{
p({ node, ...props }) { p({ node, ...props }) {
return <p className="w-fit max-w-full">{props.children}</p>; return <p className="w-fit max-w-full first:mt-0 last:mb-0 my-2">{props.children}</p>;
}, },
ol({ node, ...props }) { ol({ node, ...props }) {
return <ol className="max-w-full">{props.children}</ol>; return <ol className="max-w-full">{props.children}</ol>;
}, },
strong({ node, ...props }) {
return <strong className="font-bold">{props.children}</strong>;
},
h1({ node, ...props }) { h1({ node, ...props }) {
return <h1 className="mb-6 mt-4">{props.children}</h1>; return <h1 className="mb-6 mt-4">{props.children}</h1>;
}, },

View file

@ -52,6 +52,7 @@
"sonner": "^2.0.6", "sonner": "^2.0.6",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"use-stick-to-bottom": "^1.1.1",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "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": { "node_modules/use-sync-external-store": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",

View file

@ -53,6 +53,7 @@
"sonner": "^2.0.6", "sonner": "^2.0.6",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"use-stick-to-bottom": "^1.1.1",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {

View file

@ -106,7 +106,6 @@ async function proxyRequest(
} }
const response = await fetch(backendUrl, init); const response = await fetch(backendUrl, init);
const responseBody = await response.text();
const responseHeaders = new Headers(); const responseHeaders = new Headers();
// Copy response headers // Copy response headers
@ -117,11 +116,22 @@ async function proxyRequest(
} }
} }
return new NextResponse(responseBody, { // For streaming responses, pass the body directly without buffering
status: response.status, if (response.body) {
statusText: response.statusText, return new NextResponse(response.body, {
headers: responseHeaders, 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) { } catch (error) {
console.error('Proxy error:', error); console.error('Proxy error:', error);
return NextResponse.json( return NextResponse.json(

View file

@ -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 { MarkdownRenderer } from "@/components/markdown-renderer";
import { cn } from "@/lib/utils";
import type { FunctionCall } from "../types";
import { FunctionCalls } from "./function-calls"; import { FunctionCalls } from "./function-calls";
import { Message } from "./message"; import { Message } from "./message";
import type { FunctionCall } from "../types";
import DogIcon from "@/components/logo/dog-icon";
interface AssistantMessageProps { interface AssistantMessageProps {
content: string; content: string;
functionCalls?: FunctionCall[]; functionCalls?: FunctionCall[];
messageIndex?: number; messageIndex?: number;
expandedFunctionCalls: Set<string>; expandedFunctionCalls: Set<string>;
onToggle: (functionCallId: string) => void; onToggle: (functionCallId: string) => void;
isStreaming?: boolean; isStreaming?: boolean;
showForkButton?: boolean; showForkButton?: boolean;
onFork?: (e: React.MouseEvent) => void; onFork?: (e: React.MouseEvent) => void;
isCompleted?: boolean;
animate?: boolean;
delay?: number;
} }
export function AssistantMessage({ export function AssistantMessage({
content, content,
functionCalls = [], functionCalls = [],
messageIndex, messageIndex,
expandedFunctionCalls, expandedFunctionCalls,
onToggle, onToggle,
isStreaming = false, isStreaming = false,
showForkButton = false, showForkButton = false,
onFork, onFork,
isCompleted = false,
animate = true,
delay = 0.2,
}: AssistantMessageProps) { }: AssistantMessageProps) {
const updatedOnboarding = process.env.UPDATED_ONBOARDING === "true";
const IconComponent = updatedOnboarding ? DogIcon : Bot;
return ( return (
<Message <motion.div
icon={ initial={animate ? { opacity: 0, y: -20 } : { opacity: 1, y: 0 }}
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none"> animate={{ opacity: 1, y: 0 }}
<IconComponent className="h-4 w-4 text-accent-foreground" /> transition={animate ? { duration: 0.4, delay: delay, ease: "easeOut" } : { duration: 0 }}
</div> className={isCompleted ? "opacity-50" : ""}
} >
actions={ <Message
showForkButton && onFork ? ( icon={
<button <div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
onClick={onFork} <DogIcon
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-accent rounded text-muted-foreground hover:text-foreground" className="h-6 w-6 transition-colors duration-300"
title="Fork conversation from here" disabled={isCompleted}
> />
<GitBranch className="h-3 w-3" /> </div>
</button> }
) : undefined actions={
} showForkButton && onFork ? (
> <button
<FunctionCalls type="button"
functionCalls={functionCalls} onClick={onFork}
messageIndex={messageIndex} className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-accent rounded text-muted-foreground hover:text-foreground"
expandedFunctionCalls={expandedFunctionCalls} title="Fork conversation from here"
onToggle={onToggle} >
/> <GitBranch className="h-3 w-3" />
<MarkdownRenderer chatMessage={content} /> </button>
{isStreaming && ( ) : undefined
<span className="inline-block w-2 h-4 bg-blue-400 ml-1 animate-pulse"></span> }
)} >
</Message> <FunctionCalls
); functionCalls={functionCalls}
messageIndex={messageIndex}
expandedFunctionCalls={expandedFunctionCalls}
onToggle={onToggle}
/>
<div className="relative">
<MarkdownRenderer
className={cn("text-sm py-1.5 transition-colors duration-300", isCompleted ? "text-placeholder-foreground" : "text-foreground")}
chatMessage={
isStreaming
? content +
' <span class="inline-block w-1 h-4 bg-primary ml-1 animate-pulse"></span>'
: content
}
/>
</div>
</Message>
</motion.div>
);
} }

View file

@ -1,282 +1,284 @@
import { Check, Funnel, Loader2, Plus, X } from "lucide-react"; import { Check, Funnel, Loader2, Plus, X } from "lucide-react";
import TextareaAutosize from "react-textarea-autosize";
import { forwardRef, useImperativeHandle, useRef } from "react"; import { forwardRef, useImperativeHandle, useRef } from "react";
import TextareaAutosize from "react-textarea-autosize";
import type { FilterColor } from "@/components/filter-icon-popover";
import { filterAccentClasses } from "@/components/knowledge-filter-panel"; import { filterAccentClasses } from "@/components/knowledge-filter-panel";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Popover, Popover,
PopoverAnchor, PopoverAnchor,
PopoverContent, PopoverContent,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import type { KnowledgeFilterData } from "../types"; import type { KnowledgeFilterData } from "../types";
import { FilterColor } from "@/components/filter-icon-popover";
export interface ChatInputHandle { export interface ChatInputHandle {
focusInput: () => void; focusInput: () => void;
clickFileInput: () => void; clickFileInput: () => void;
} }
interface ChatInputProps { interface ChatInputProps {
input: string; input: string;
loading: boolean; loading: boolean;
isUploading: boolean; isUploading: boolean;
selectedFilter: KnowledgeFilterData | null; selectedFilter: KnowledgeFilterData | null;
isFilterDropdownOpen: boolean; isFilterDropdownOpen: boolean;
availableFilters: KnowledgeFilterData[]; availableFilters: KnowledgeFilterData[];
filterSearchTerm: string; filterSearchTerm: string;
selectedFilterIndex: number; selectedFilterIndex: number;
anchorPosition: { x: number; y: number } | null; anchorPosition: { x: number; y: number } | null;
textareaHeight: number; textareaHeight: number;
parsedFilterData: { color?: FilterColor } | null; parsedFilterData: { color?: FilterColor } | null;
onSubmit: (e: React.FormEvent) => void; onSubmit: (e: React.FormEvent) => void;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void; onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void; onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onHeightChange: (height: number) => void; onHeightChange: (height: number) => void;
onFilterSelect: (filter: KnowledgeFilterData | null) => void; onFilterSelect: (filter: KnowledgeFilterData | null) => void;
onAtClick: () => void; onAtClick: () => void;
onFilePickerChange: (e: React.ChangeEvent<HTMLInputElement>) => void; onFilePickerChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onFilePickerClick: () => void; onFilePickerClick: () => void;
setSelectedFilter: (filter: KnowledgeFilterData | null) => void; setSelectedFilter: (filter: KnowledgeFilterData | null) => void;
setIsFilterHighlighted: (highlighted: boolean) => void; setIsFilterHighlighted: (highlighted: boolean) => void;
setIsFilterDropdownOpen: (open: boolean) => void; setIsFilterDropdownOpen: (open: boolean) => void;
} }
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(( export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
{ (
input, {
loading, input,
isUploading, loading,
selectedFilter, isUploading,
isFilterDropdownOpen, selectedFilter,
availableFilters, isFilterDropdownOpen,
filterSearchTerm, availableFilters,
selectedFilterIndex, filterSearchTerm,
anchorPosition, selectedFilterIndex,
textareaHeight, anchorPosition,
parsedFilterData, textareaHeight,
onSubmit, parsedFilterData,
onChange, onSubmit,
onKeyDown, onChange,
onHeightChange, onKeyDown,
onFilterSelect, onHeightChange,
onAtClick, onFilterSelect,
onFilePickerChange, onAtClick,
onFilePickerClick, onFilePickerChange,
setSelectedFilter, onFilePickerClick,
setIsFilterHighlighted, setSelectedFilter,
setIsFilterDropdownOpen, setIsFilterHighlighted,
}, setIsFilterDropdownOpen,
ref },
) => { ref,
const inputRef = useRef<HTMLTextAreaElement>(null); ) => {
const fileInputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
focusInput: () => { focusInput: () => {
inputRef.current?.focus(); inputRef.current?.focus();
}, },
clickFileInput: () => { clickFileInput: () => {
fileInputRef.current?.click(); fileInputRef.current?.click();
}, },
})); }));
return ( return (
<div className="pb-8 pt-4 flex px-6"> <div className="pb-8 flex px-6">
<div className="w-full"> <div className="w-full">
<form onSubmit={onSubmit} className="relative"> <form onSubmit={onSubmit} className="relative">
<div className="relative w-full bg-muted/20 rounded-lg border border-border/50 focus-within:ring-1 focus-within:ring-ring"> <div className="relative w-full bg-muted/20 rounded-lg border border-border/50 focus-within:ring-1 focus-within:ring-ring">
{selectedFilter && ( {selectedFilter && (
<div className="flex items-center gap-2 px-4 pt-3 pb-1"> <div className="flex items-center gap-2 px-4 pt-3 pb-1">
<span <span
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-colors ${ className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-colors ${
filterAccentClasses[parsedFilterData?.color || "zinc"] filterAccentClasses[parsedFilterData?.color || "zinc"]
}`} }`}
> >
@filter:{selectedFilter.name} @filter:{selectedFilter.name}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setSelectedFilter(null); setSelectedFilter(null);
setIsFilterHighlighted(false); setIsFilterHighlighted(false);
}} }}
className="ml-1 rounded-full p-0.5" className="ml-1 rounded-full p-0.5"
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
</span> </span>
</div> </div>
)} )}
<div <div
className="relative" className="relative"
style={{ height: `${textareaHeight + 60}px` }} style={{ height: `${textareaHeight + 60}px` }}
> >
<TextareaAutosize <TextareaAutosize
ref={inputRef} ref={inputRef}
value={input} value={input}
onChange={onChange} onChange={onChange}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onHeightChange={onHeightChange} onHeightChange={onHeightChange}
maxRows={7} maxRows={7}
minRows={2} minRows={2}
placeholder="Type to ask a question..." placeholder="Type to ask a question..."
disabled={loading} disabled={loading}
className={`w-full bg-transparent px-4 ${ className={`w-full bg-transparent px-4 ${
selectedFilter ? "pt-2" : "pt-4" selectedFilter ? "pt-2" : "pt-4"
} focus-visible:outline-none resize-none`} } focus-visible:outline-none resize-none`}
rows={2} rows={2}
/> />
{/* Safe area at bottom for buttons */} {/* Safe area at bottom for buttons */}
<div <div
className="absolute bottom-0 left-0 right-0 bg-transparent pointer-events-none" className="absolute bottom-0 left-0 right-0 bg-transparent pointer-events-none"
style={{ height: "60px" }} style={{ height: "60px" }}
/> />
</div> </div>
</div> </div>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
onChange={onFilePickerChange} onChange={onFilePickerChange}
className="hidden" className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt" accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
/> />
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="iconSm" size="iconSm"
className="absolute bottom-3 left-3 h-8 w-8 p-0 rounded-full hover:bg-muted/50" className="absolute bottom-3 left-3 h-8 w-8 p-0 rounded-full hover:bg-muted/50"
onMouseDown={e => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
}} }}
onClick={onAtClick} onClick={onAtClick}
data-filter-button data-filter-button
> >
<Funnel className="h-4 w-4" /> <Funnel className="h-4 w-4" />
</Button> </Button>
<Popover <Popover
open={isFilterDropdownOpen} open={isFilterDropdownOpen}
onOpenChange={open => { onOpenChange={(open) => {
setIsFilterDropdownOpen(open); setIsFilterDropdownOpen(open);
}} }}
> >
{anchorPosition && ( {anchorPosition && (
<PopoverAnchor <PopoverAnchor
asChild asChild
style={{ style={{
position: "fixed", position: "fixed",
left: anchorPosition.x, left: anchorPosition.x,
top: anchorPosition.y, top: anchorPosition.y,
width: 1, width: 1,
height: 1, height: 1,
pointerEvents: "none", pointerEvents: "none",
}} }}
> >
<div /> <div />
</PopoverAnchor> </PopoverAnchor>
)} )}
<PopoverContent <PopoverContent
className="w-64 p-2" className="w-64 p-2"
side="top" side="top"
align="start" align="start"
sideOffset={6} sideOffset={6}
alignOffset={-18} alignOffset={-18}
onOpenAutoFocus={e => { onOpenAutoFocus={(e) => {
// Prevent auto focus on the popover content // Prevent auto focus on the popover content
e.preventDefault(); e.preventDefault();
// Keep focus on the input // Keep focus on the input
}} }}
> >
<div className="space-y-1"> <div className="space-y-1">
{filterSearchTerm && ( {filterSearchTerm && (
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground"> <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Searching: @{filterSearchTerm} Searching: @{filterSearchTerm}
</div> </div>
)} )}
{availableFilters.length === 0 ? ( {availableFilters.length === 0 ? (
<div className="px-2 py-3 text-sm text-muted-foreground"> <div className="px-2 py-3 text-sm text-muted-foreground">
No knowledge filters available No knowledge filters available
</div> </div>
) : ( ) : (
<> <>
{!filterSearchTerm && ( {!filterSearchTerm && (
<button <button
type="button" type="button"
onClick={() => onFilterSelect(null)} onClick={() => onFilterSelect(null)}
className={`w-full text-left px-2 py-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${ className={`w-full text-left px-2 py-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
selectedFilterIndex === -1 ? "bg-muted/50" : "" selectedFilterIndex === -1 ? "bg-muted/50" : ""
}`} }`}
> >
<span>No knowledge filter</span> <span>No knowledge filter</span>
{!selectedFilter && ( {!selectedFilter && (
<Check className="h-4 w-4 shrink-0" /> <Check className="h-4 w-4 shrink-0" />
)} )}
</button> </button>
)} )}
{availableFilters {availableFilters
.filter(filter => .filter((filter) =>
filter.name filter.name
.toLowerCase() .toLowerCase()
.includes(filterSearchTerm.toLowerCase()) .includes(filterSearchTerm.toLowerCase()),
) )
.map((filter, index) => ( .map((filter, index) => (
<button <button
key={filter.id} key={filter.id}
type="button" type="button"
onClick={() => onFilterSelect(filter)} onClick={() => onFilterSelect(filter)}
className={`w-full overflow-hidden text-left px-2 py-2 gap-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${ className={`w-full overflow-hidden text-left px-2 py-2 gap-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
index === selectedFilterIndex ? "bg-muted/50" : "" index === selectedFilterIndex ? "bg-muted/50" : ""
}`} }`}
> >
<div className="overflow-hidden"> <div className="overflow-hidden">
<div className="font-medium truncate"> <div className="font-medium truncate">
{filter.name} {filter.name}
</div> </div>
{filter.description && ( {filter.description && (
<div className="text-xs text-muted-foreground truncate"> <div className="text-xs text-muted-foreground truncate">
{filter.description} {filter.description}
</div> </div>
)} )}
</div> </div>
{selectedFilter?.id === filter.id && ( {selectedFilter?.id === filter.id && (
<Check className="h-4 w-4 shrink-0" /> <Check className="h-4 w-4 shrink-0" />
)} )}
</button> </button>
))} ))}
{availableFilters.filter(filter => {availableFilters.filter((filter) =>
filter.name filter.name
.toLowerCase() .toLowerCase()
.includes(filterSearchTerm.toLowerCase()) .includes(filterSearchTerm.toLowerCase()),
).length === 0 && ).length === 0 &&
filterSearchTerm && ( filterSearchTerm && (
<div className="px-2 py-3 text-sm text-muted-foreground"> <div className="px-2 py-3 text-sm text-muted-foreground">
No filters match &quot;{filterSearchTerm}&quot; No filters match &quot;{filterSearchTerm}&quot;
</div> </div>
)} )}
</> </>
)} )}
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="iconSm" size="iconSm"
onClick={onFilePickerClick} onClick={onFilePickerClick}
disabled={isUploading} disabled={isUploading}
className="absolute bottom-3 left-12 h-8 w-8 p-0 rounded-full hover:bg-muted/50" className="absolute bottom-3 left-12 h-8 w-8 p-0 rounded-full hover:bg-muted/50"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={!input.trim() || loading} disabled={!input.trim() || loading}
className="absolute bottom-3 right-3 rounded-lg h-10 px-4" className="absolute bottom-3 right-3 rounded-lg h-10 px-4"
> >
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Send"} {loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Send"}
</Button> </Button>
</form> </form>
</div> </div>
</div> </div>
); );
}); },
);
ChatInput.displayName = "ChatInput"; ChatInput.displayName = "ChatInput";

View file

@ -1,33 +1,52 @@
import { User } from "lucide-react"; import { User } from "lucide-react";
import { motion } from "motion/react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
import { cn } from "@/lib/utils";
import { Message } from "./message"; import { Message } from "./message";
interface UserMessageProps { interface UserMessageProps {
content: string; content: string;
isCompleted?: boolean;
animate?: boolean;
} }
export function UserMessage({ content }: UserMessageProps) { export function UserMessage({ content, isCompleted, animate = true }: UserMessageProps) {
const { user } = useAuth(); const { user } = useAuth();
return ( console.log("animate", animate);
<Message
icon={ return (
<Avatar className="w-8 h-8 flex-shrink-0 select-none"> <motion.div
<AvatarImage draggable={false} src={user?.picture} alt={user?.name} /> initial={animate ? { opacity: 0, y: -20 } : { opacity: 1, y: 0 }}
<AvatarFallback className="text-sm bg-primary/20 text-primary"> animate={{ opacity: 1, y: 0 }}
{user?.name ? ( transition={animate ? { duration: 0.4, delay: 0.2, ease: "easeOut" } : { duration: 0 }}
user.name.charAt(0).toUpperCase() className={isCompleted ? "opacity-50" : ""}
) : ( >
<User className="h-4 w-4" /> <Message
)} icon={
</AvatarFallback> <Avatar className="w-8 h-8 rounded-lg flex-shrink-0 select-none">
</Avatar> <AvatarImage draggable={false} src={user?.picture} alt={user?.name} />
} <AvatarFallback
> className={cn(
<p className="text-foreground whitespace-pre-wrap break-words overflow-wrap-anywhere"> isCompleted ? "text-placeholder-foreground" : "text-primary",
{content} "text-sm bg-accent/20 rounded-lg transition-colors duration-300",
</p> )}
</Message> >
); {user?.name ? user.name.charAt(0).toUpperCase() : <User className="h-4 w-4" />}
</AvatarFallback>
</Avatar>
}
>
<p
className={cn(
"text-foreground text-sm py-1.5 whitespace-pre-wrap break-words overflow-wrap-anywhere transition-colors duration-300",
isCompleted ? "text-placeholder-foreground" : "text-foreground",
)}
>
{content}
</p>
</Message>
</motion.div>
);
} }

View file

@ -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({ export default function Nudges({
nudges, nudges,
handleSuggestionClick, onboarding,
handleSuggestionClick,
}: { }: {
nudges: string[]; nudges: string[];
onboarding?: boolean;
handleSuggestionClick: (suggestion: string) => void; handleSuggestionClick: (suggestion: string) => void;
}) { }) {
return ( return (
<div className="flex-shrink-0 h-12 w-full overflow-hidden"> <div className="flex-shrink-0 h-12 w-full overflow-hidden">
<AnimatePresence> <AnimatePresence>
{nudges.length > 0 && ( {nudges.length > 0 && (
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }} exit={{ opacity: 0, y: 20 }}
transition={{ transition={{
duration: 0.2, duration: 0.2,
ease: "easeInOut", ease: "easeInOut",
}} }}
> >
<div className="relative px-6 pt-4 flex justify-center"> <div
<div className="w-full max-w-[75%]"> className="relative flex"
<div className="flex gap-3 justify-start overflow-x-auto scrollbar-hide"> >
{nudges.map((suggestion: string, index: number) => ( <div className="w-full">
<button <div className="flex gap-3 justify-start overflow-x-auto scrollbar-hide">
key={index} {nudges.map((suggestion: string, index: number) => (
onClick={() => handleSuggestionClick(suggestion)} <button
className="px-2 py-1.5 bg-muted hover:bg-muted/50 rounded-lg text-sm text-placeholder-foreground hover:text-foreground transition-colors whitespace-nowrap" key={index}
> onClick={() => handleSuggestionClick(suggestion)}
{suggestion} className={cn(
</button> onboarding
))} ? "text-foreground"
</div> : "text-placeholder-foreground hover:text-foreground",
{/* Fade out gradient on the right */} "bg-background border hover:bg-background/50 px-2 py-1.5 rounded-lg text-sm transition-colors whitespace-nowrap",
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none"></div> )}
</div> >
</div> {suggestion}
</motion.div> </button>
)} ))}
</AnimatePresence> </div>
</div> {/* Fade out gradient on the right */}
); <div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none"></div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
} }

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@ export interface Message {
timestamp: Date; timestamp: Date;
functionCalls?: FunctionCall[]; functionCalls?: FunctionCall[];
isStreaming?: boolean; isStreaming?: boolean;
source?: "langflow" | "chat";
} }
export interface FunctionCall { export interface FunctionCall {

View file

@ -345,6 +345,15 @@
@apply text-xs opacity-70; @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 { .box-shadow-inner::after {
content: " "; content: " ";
position: absolute; position: absolute;

View file

@ -1,78 +1,168 @@
"use client"; "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 OnboardingCard from "@/app/onboarding/components/onboarding-card";
import { useChatStreaming } from "@/hooks/useChatStreaming";
import { OnboardingStep } from "./onboarding-step"; import { OnboardingStep } from "./onboarding-step";
export function OnboardingContent({ export function OnboardingContent({
handleStepComplete, handleStepComplete,
currentStep, currentStep,
}: { }: {
handleStepComplete: () => void; handleStepComplete: () => void;
currentStep: number; currentStep: number;
}) { }) {
return ( const [responseId, setResponseId] = useState<string | null>(null);
<div className="space-y-6"> const [selectedNudge, setSelectedNudge] = useState<string>("");
<OnboardingStep const [assistantMessage, setAssistantMessage] = useState<Message | null>(
isVisible={currentStep >= 0} null,
isCompleted={currentStep > 0} );
text="Let's get started by setting up your model provider."
>
<OnboardingCard onComplete={handleStepComplete} />
</OnboardingStep>
<OnboardingStep const { streamingMessage, isLoading, sendMessage } = useChatStreaming({
isVisible={currentStep >= 1} onComplete: (message, newResponseId) => {
isCompleted={currentStep > 1} setAssistantMessage(message);
text="Step 1: Configure your settings" if (newResponseId) {
> setResponseId(newResponseId);
<div className="space-y-4"> }
<p className="text-muted-foreground"> },
Let's configure some basic settings for your account. onError: (error) => {
</p> console.error("Chat error:", error);
<button setAssistantMessage({
onClick={handleStepComplete} role: "assistant",
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90" content:
> "Sorry, I couldn't connect to the chat service. Please try again.",
Continue timestamp: new Date(),
</button> });
</div> },
</OnboardingStep> });
<OnboardingStep const NUDGES = ["What is OpenRAG?"];
isVisible={currentStep >= 2}
isCompleted={currentStep > 2}
text="Step 2: Connect your model"
>
<div className="space-y-4">
<p className="text-muted-foreground">
Choose and connect your preferred AI model provider.
</p>
<button
onClick={handleStepComplete}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
>
Continue
</button>
</div>
</OnboardingStep>
<OnboardingStep const handleNudgeClick = async (nudge: string) => {
isVisible={currentStep >= 3} setSelectedNudge(nudge);
isCompleted={currentStep > 3} setAssistantMessage(null);
text="Step 3: You're all set!" setTimeout(async () => {
> await sendMessage({
<div className="space-y-4"> prompt: nudge,
<p className="text-muted-foreground"> previousResponseId: responseId || undefined,
Your account is ready to use. Let's start chatting! });
</p> }, 1500);
<button };
onClick={handleStepComplete}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90" // Determine which message to show (streaming takes precedence)
> const displayMessage = streamingMessage || assistantMessage;
Go to Chat
</button> return (
</div> <StickToBottom
</OnboardingStep> className="flex h-full flex-1 flex-col"
</div> resize="smooth"
); initial="instant"
mass={1}
>
<StickToBottom.Content className="flex flex-col min-h-full overflow-x-hidden px-8 py-6">
<div className="flex flex-col place-self-center w-full space-y-6">
<OnboardingStep
isVisible={currentStep >= 0}
isCompleted={currentStep > 0}
text="Let's get started by setting up your model provider."
>
<OnboardingCard onComplete={handleStepComplete} />
</OnboardingStep>
<OnboardingStep
isVisible={currentStep >= 1}
isCompleted={currentStep > 1 || !!selectedNudge}
text="Excellent, let's move on to learning the basics."
>
<div className="py-2">
<Nudges
onboarding
nudges={NUDGES}
handleSuggestionClick={handleNudgeClick}
/>
</div>
</OnboardingStep>
{/* User message - show when nudge is selected */}
{currentStep >= 1 && !!selectedNudge && (
<UserMessage
content={selectedNudge}
isCompleted={currentStep > 1}
/>
)}
{/* Assistant message - show streaming or final message */}
{currentStep >= 1 &&
!!selectedNudge &&
(displayMessage || isLoading) && (
<>
<AssistantMessage
content={displayMessage?.content || ""}
functionCalls={displayMessage?.functionCalls}
messageIndex={0}
expandedFunctionCalls={new Set()}
onToggle={() => {}}
isStreaming={!!streamingMessage}
isCompleted={currentStep > 1}
/>
{!isLoading && displayMessage && currentStep === 1 && (
<div className="mt-4">
<button
type="button"
onClick={handleStepComplete}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
>
Continue
</button>
</div>
)}
</>
)}
<OnboardingStep
isVisible={currentStep >= 2}
isCompleted={currentStep > 2}
text="Step 2: Connect your model"
>
<div className="space-y-4">
<p className="text-muted-foreground">
Choose and connect your preferred AI model provider.
</p>
<button
type="button"
onClick={handleStepComplete}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
>
Continue
</button>
</div>
</OnboardingStep>
<OnboardingStep
isVisible={currentStep >= 3}
isCompleted={currentStep > 3}
text="Step 3: You're all set!"
>
<div className="space-y-4">
<p className="text-muted-foreground">
Your account is ready to use. Let's start chatting!
</p>
<button
type="button"
onClick={handleStepComplete}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
>
Go to Chat
</button>
</div>
</OnboardingStep>
</div>
</StickToBottom.Content>
</StickToBottom>
);
} }

View file

@ -2,89 +2,120 @@ import { AnimatePresence, motion } from "motion/react";
import { type ReactNode, useEffect, useState } from "react"; import { type ReactNode, useEffect, useState } from "react";
import { Message } from "@/app/chat/components/message"; import { Message } from "@/app/chat/components/message";
import DogIcon from "@/components/logo/dog-icon"; import DogIcon from "@/components/logo/dog-icon";
import { MarkdownRenderer } from "@/components/markdown-renderer";
import { cn } from "@/lib/utils";
interface OnboardingStepProps { interface OnboardingStepProps {
text: string; text: string;
children: ReactNode; children?: ReactNode;
isVisible: boolean; isVisible: boolean;
isCompleted?: boolean; isCompleted?: boolean;
icon?: ReactNode;
isMarkdown?: boolean;
} }
export function OnboardingStep({ export function OnboardingStep({
text, text,
children, children,
isVisible, isVisible,
isCompleted = false, isCompleted = false,
icon,
isMarkdown = false,
}: OnboardingStepProps) { }: OnboardingStepProps) {
const [displayedText, setDisplayedText] = useState(""); const [displayedText, setDisplayedText] = useState("");
const [showChildren, setShowChildren] = useState(false); const [showChildren, setShowChildren] = useState(false);
useEffect(() => { useEffect(() => {
if (!isVisible) { if (!isVisible) {
setDisplayedText(""); setDisplayedText("");
setShowChildren(false); setShowChildren(false);
return; return;
} }
let currentIndex = 0; if (isCompleted) {
setDisplayedText(""); setDisplayedText(text);
setShowChildren(false); setShowChildren(true);
return;
}
const interval = setInterval(() => { let currentIndex = 0;
if (currentIndex < text.length) { setDisplayedText("");
setDisplayedText(text.slice(0, currentIndex + 1)); setShowChildren(false);
currentIndex++;
} else {
clearInterval(interval);
setShowChildren(true);
}
}, 20); // 20ms per character
return () => clearInterval(interval); const interval = setInterval(() => {
}, [text, isVisible]); 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 ( if (!isVisible) return null;
<motion.div
initial={{ opacity: 0, y: -20 }} return (
animate={{ opacity: 1, y: 0 }} <motion.div
transition={{ duration: 0.4, delay: 0.4, ease: "easeOut" }} initial={{ opacity: 0, y: -20 }}
className={isCompleted ? "opacity-50" : ""} animate={{ opacity: 1, y: 0 }}
> transition={{ duration: 0.4, delay: 0.4, ease: "easeOut" }}
<Message className={isCompleted ? "opacity-50" : ""}
icon={ >
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none"> <Message
<DogIcon icon={
className="h-6 w-6 text-accent-foreground" icon || (
disabled={isCompleted} <div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
/> <DogIcon
</div> className="h-6 w-6 text-accent-foreground transition-colors duration-300"
} disabled={isCompleted}
> />
<div className="space-y-4"> </div>
<p )
className={`text-foreground text-sm py-1.5 ${isCompleted ? "text-placeholder-foreground" : ""}`} }
> >
{displayedText} <div>
{!showChildren && !isCompleted && ( {isMarkdown ? (
<span className="inline-block w-1 h-4 bg-primary ml-1 animate-pulse" /> <MarkdownRenderer
)} className={cn(
</p> isCompleted
<AnimatePresence> ? "text-placeholder-foreground"
{showChildren && !isCompleted && ( : "text-foreground",
<motion.div "text-sm py-1.5 transition-colors duration-300",
initial={{ opacity: 0, y: -10 }} )}
animate={{ opacity: 1, y: 0 }} chatMessage={text}
exit={{ opacity: 0, height: 0 }} />
transition={{ duration: 0.3, delay: 0.3, ease: "easeOut" }} ) : (
> <p
{children} className={`text-foreground text-sm py-1.5 transition-colors duration-300 ${
</motion.div> isCompleted ? "text-placeholder-foreground" : ""
)} }`}
</AnimatePresence> >
</div> {displayedText}
</Message> {!showChildren && !isCompleted && (
</motion.div> <span className="inline-block w-1 h-3.5 bg-primary ml-1 animate-pulse" />
); )}
</p>
)}
{children && (
<AnimatePresence>
{((showChildren && !isCompleted) || isMarkdown) && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, delay: 0.3, ease: "easeOut" }}
>
<div className="pt-2">
{children}</div>
</motion.div>
)}
</AnimatePresence>
)}
</div>
</Message>
</motion.div>
);
} }

View file

@ -4,8 +4,8 @@ import { motion } from "framer-motion";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
type ChatConversation, type ChatConversation,
useGetConversationsQuery, useGetConversationsQuery,
} from "@/app/api/queries/useGetConversationsQuery"; } from "@/app/api/queries/useGetConversationsQuery";
import type { Settings } from "@/app/api/queries/useGetSettingsQuery"; import type { Settings } from "@/app/api/queries/useGetSettingsQuery";
import { OnboardingContent } from "@/app/new-onboarding/components/onboarding-content"; 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 { useAuth } from "@/contexts/auth-context";
import { useChat } from "@/contexts/chat-context"; import { useChat } from "@/contexts/chat-context";
import { import {
ANIMATION_DURATION, ANIMATION_DURATION,
HEADER_HEIGHT, HEADER_HEIGHT,
ONBOARDING_STEP_KEY, ONBOARDING_STEP_KEY,
SIDEBAR_WIDTH, SIDEBAR_WIDTH,
TOTAL_ONBOARDING_STEPS, TOTAL_ONBOARDING_STEPS,
} from "@/lib/constants"; } from "@/lib/constants";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function ChatRenderer({ export function ChatRenderer({
settings, settings,
children, children,
}: { }: {
settings: Settings; settings: Settings;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const { isAuthenticated, isNoAuthMode } = useAuth(); const { isAuthenticated, isNoAuthMode } = useAuth();
const { const {
endpoint, endpoint,
refreshTrigger, refreshTrigger,
refreshConversations, refreshConversations,
startNewConversation, startNewConversation,
} = useChat(); } = useChat();
// Initialize onboarding state based on local storage and settings // Initialize onboarding state based on local storage and settings
const [currentStep, setCurrentStep] = useState<number>(() => { const [currentStep, setCurrentStep] = useState<number>(() => {
if (typeof window === "undefined") return 0; if (typeof window === "undefined") return 0;
const savedStep = localStorage.getItem(ONBOARDING_STEP_KEY); const savedStep = localStorage.getItem(ONBOARDING_STEP_KEY);
return savedStep !== null ? parseInt(savedStep, 10) : 0; return savedStep !== null ? parseInt(savedStep, 10) : 0;
}); });
const [showLayout, setShowLayout] = useState<boolean>(() => { const [showLayout, setShowLayout] = useState<boolean>(() => {
if (typeof window === "undefined") return false; if (typeof window === "undefined") return false;
const savedStep = localStorage.getItem(ONBOARDING_STEP_KEY); const savedStep = localStorage.getItem(ONBOARDING_STEP_KEY);
// Show layout if settings.edited is true and if no onboarding step is saved // Show layout if settings.edited is true and if no onboarding step is saved
return !!settings?.edited && savedStep === null; return !!settings?.edited && savedStep === null;
}); });
// Only fetch conversations on chat page // Only fetch conversations on chat page
const isOnChatPage = pathname === "/" || pathname === "/chat"; const isOnChatPage = pathname === "/" || pathname === "/chat";
const { data: conversations = [], isLoading: isConversationsLoading } = const { data: conversations = [], isLoading: isConversationsLoading } =
useGetConversationsQuery(endpoint, refreshTrigger, { useGetConversationsQuery(endpoint, refreshTrigger, {
enabled: isOnChatPage && (isAuthenticated || isNoAuthMode), enabled: isOnChatPage && (isAuthenticated || isNoAuthMode),
}) as { data: ChatConversation[]; isLoading: boolean }; }) as { data: ChatConversation[]; isLoading: boolean };
const handleNewConversation = () => { const handleNewConversation = () => {
refreshConversations(); refreshConversations();
startNewConversation(); startNewConversation();
}; };
// Save current step to local storage whenever it changes // Save current step to local storage whenever it changes
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined" && !showLayout) { if (typeof window !== "undefined" && !showLayout) {
localStorage.setItem(ONBOARDING_STEP_KEY, currentStep.toString()); localStorage.setItem(ONBOARDING_STEP_KEY, currentStep.toString());
} }
}, [currentStep, showLayout]); }, [currentStep, showLayout]);
const handleStepComplete = () => { const handleStepComplete = () => {
if (currentStep < TOTAL_ONBOARDING_STEPS - 1) { if (currentStep < TOTAL_ONBOARDING_STEPS - 1) {
setCurrentStep(currentStep + 1); setCurrentStep(currentStep + 1);
} else { } else {
// Onboarding is complete - remove from local storage and show layout // Onboarding is complete - remove from local storage and show layout
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.removeItem(ONBOARDING_STEP_KEY); localStorage.removeItem(ONBOARDING_STEP_KEY);
} }
setShowLayout(true); setShowLayout(true);
} }
}; };
// List of paths with smaller max-width // List of paths with smaller max-width
const smallWidthPaths = ["/settings/connector/new"]; const smallWidthPaths = ["/settings/connector/new"];
const isSmallWidthPath = smallWidthPaths.includes(pathname); const isSmallWidthPath = smallWidthPaths.includes(pathname);
const x = showLayout ? "0px" : `calc(-${SIDEBAR_WIDTH / 2}px + 50vw)`; const x = showLayout ? "0px" : `calc(-${SIDEBAR_WIDTH / 2}px + 50vw)`;
const y = showLayout ? "0px" : `calc(-${HEADER_HEIGHT / 2}px + 50vh)`; const y = showLayout ? "0px" : `calc(-${HEADER_HEIGHT / 2}px + 50vh)`;
const translateY = showLayout ? "0px" : `-50vh`; const translateY = showLayout ? "0px" : `-50vh`;
const translateX = showLayout ? "0px" : `-50vw`; const translateX = showLayout ? "0px" : `-50vw`;
// For all other pages, render with Langflow-styled navigation and task menu // For all other pages, render with Langflow-styled navigation and task menu
return ( return (
<> <>
<AnimatedConditional <AnimatedConditional
className="[grid-area:header] bg-background border-b" className="[grid-area:header] bg-background border-b"
vertical vertical
slide slide
isOpen={showLayout} isOpen={showLayout}
delay={ANIMATION_DURATION / 2} delay={ANIMATION_DURATION / 2}
> >
<Header /> <Header />
</AnimatedConditional> </AnimatedConditional>
{/* Sidebar Navigation */} {/* Sidebar Navigation */}
<AnimatedConditional <AnimatedConditional
isOpen={showLayout} isOpen={showLayout}
slide slide
className={`border-r bg-background overflow-hidden [grid-area:nav] w-[${SIDEBAR_WIDTH}px]`} className={`border-r bg-background overflow-hidden [grid-area:nav] w-[${SIDEBAR_WIDTH}px]`}
> >
<Navigation <Navigation
conversations={conversations} conversations={conversations}
isConversationsLoading={isConversationsLoading} isConversationsLoading={isConversationsLoading}
onNewConversation={handleNewConversation} onNewConversation={handleNewConversation}
/> />
</AnimatedConditional> </AnimatedConditional>
{/* Main Content */} {/* Main Content */}
<main className="overflow-visible w-full flex items-center justify-center [grid-area:main]"> <main className="overflow-hidden w-full flex items-center justify-center [grid-area:main]">
<motion.div <motion.div
initial={{ initial={{
width: showLayout ? "100%" : "100vw", width: showLayout ? "100%" : "100vw",
height: showLayout ? "100%" : "100vh", height: showLayout ? "100%" : "100vh",
x: x, x: x,
y: y, y: y,
translateX: translateX, translateX: translateX,
translateY: translateY, translateY: translateY,
}} }}
animate={{ animate={{
width: showLayout ? "100%" : "850px", width: showLayout ? "100%" : "850px",
borderRadius: showLayout ? "0" : "16px", borderRadius: showLayout ? "0" : "16px",
border: showLayout ? "0" : "1px solid #27272A", border: showLayout ? "0" : "1px solid #27272A",
height: showLayout ? "100%" : "800px", height: showLayout ? "100%" : "800px",
x: x, x: x,
y: y, y: y,
translateX: translateX, translateX: translateX,
translateY: translateY, translateY: translateY,
}} }}
transition={{ transition={{
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
ease: "easeOut", ease: "easeOut",
}} }}
className={cn( className={cn(
"flex h-full w-full max-w-full max-h-full items-center justify-center overflow-hidden", "flex h-full w-full max-w-full max-h-full items-center justify-center overflow-hidden",
!showLayout && "absolute", !showLayout && "absolute",
)} )}
> >
<div <div
className={cn( className={cn(
"h-full bg-background", "h-full bg-background w-full",
showLayout && "p-6 container", showLayout && !isOnChatPage && "p-6 container overflow-y-auto",
showLayout && isSmallWidthPath && "max-w-[850px] ml-0", showLayout && isSmallWidthPath && "max-w-[850px] ml-0",
!showLayout && !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",
)} )}
> >
<motion.div <motion.div
initial={{ initial={{
opacity: showLayout ? 1 : 0, opacity: showLayout ? 1 : 0,
}} }}
animate={{ animate={{
opacity: "100%", opacity: "100%",
}} }}
transition={{ transition={{
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
ease: "easeOut", ease: "easeOut",
delay: ANIMATION_DURATION, delay: ANIMATION_DURATION,
}} }}
className={cn("w-full h-full 0v")} className={cn("w-full h-full 0v")}
> >
<div className={cn("w-full h-full", !showLayout && "hidden")}> <div className={cn("w-full h-full", !showLayout && "hidden")}>
{children} {children}
</div> </div>
{!showLayout && ( {!showLayout && (
<OnboardingContent <OnboardingContent
handleStepComplete={handleStepComplete} handleStepComplete={handleStepComplete}
currentStep={currentStep} currentStep={currentStep}
/> />
)} )}
</motion.div> </motion.div>
</div> </div>
</motion.div> </motion.div>
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: showLayout ? 0 : 1, y: showLayout ? 20 : 0 }} animate={{ opacity: showLayout ? 0 : 1, y: showLayout ? 20 : 0 }}
transition={{ duration: ANIMATION_DURATION, ease: "easeOut" }} transition={{ duration: ANIMATION_DURATION, ease: "easeOut" }}
className={cn("absolute bottom-10 left-0 right-0")} className={cn("absolute bottom-10 left-0 right-0")}
> >
<ProgressBar <ProgressBar
currentStep={currentStep} currentStep={currentStep}
totalSteps={TOTAL_ONBOARDING_STEPS} totalSteps={TOTAL_ONBOARDING_STEPS}
/> />
</motion.div> </motion.div>
</main> </main>
</> </>
); );
} }

View file

@ -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<Message | null>(
null,
);
const [isLoading, setIsLoading] = useState(false);
const streamAbortRef = useRef<AbortController | null>(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,
};
}

View file

@ -3,7 +3,7 @@
*/ */
export const DEFAULT_AGENT_SETTINGS = { export const DEFAULT_AGENT_SETTINGS = {
llm_model: "gpt-4o-mini", 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; } as const;
/** /**

View file

@ -34,7 +34,7 @@ def get_conversation_thread(user_id: str, previous_response_id: str = None):
"messages": [ "messages": [
{ {
"role": "system", "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 "previous_response_id": previous_response_id, # Parent response_id for branching