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:
parent
c5447f6c5d
commit
fcf7a302d0
18 changed files with 2660 additions and 2601 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 <think> 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,
|
||||
)}
|
||||
>
|
||||
<Markdown
|
||||
|
|
@ -65,11 +67,14 @@ export const MarkdownRenderer = ({ chatMessage }: MarkdownRendererProps) => {
|
|||
urlTransform={(url) => url}
|
||||
components={{
|
||||
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 }) {
|
||||
return <ol className="max-w-full">{props.children}</ol>;
|
||||
},
|
||||
strong({ node, ...props }) {
|
||||
return <strong className="font-bold">{props.children}</strong>;
|
||||
},
|
||||
h1({ node, ...props }) {
|
||||
return <h1 className="mb-6 mt-4">{props.children}</h1>;
|
||||
},
|
||||
|
|
|
|||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
onToggle: (functionCallId: string) => void;
|
||||
isStreaming?: boolean;
|
||||
showForkButton?: boolean;
|
||||
onFork?: (e: React.MouseEvent) => void;
|
||||
content: string;
|
||||
functionCalls?: FunctionCall[];
|
||||
messageIndex?: number;
|
||||
expandedFunctionCalls: Set<string>;
|
||||
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 (
|
||||
<Message
|
||||
icon={
|
||||
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
|
||||
<IconComponent className="h-4 w-4 text-accent-foreground" />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
showForkButton && onFork ? (
|
||||
<button
|
||||
onClick={onFork}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-accent rounded text-muted-foreground hover:text-foreground"
|
||||
title="Fork conversation from here"
|
||||
>
|
||||
<GitBranch className="h-3 w-3" />
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<FunctionCalls
|
||||
functionCalls={functionCalls}
|
||||
messageIndex={messageIndex}
|
||||
expandedFunctionCalls={expandedFunctionCalls}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
<MarkdownRenderer chatMessage={content} />
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-2 h-4 bg-blue-400 ml-1 animate-pulse"></span>
|
||||
)}
|
||||
</Message>
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={animate ? { opacity: 0, y: -20 } : { opacity: 1, y: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={animate ? { duration: 0.4, delay: delay, ease: "easeOut" } : { duration: 0 }}
|
||||
className={isCompleted ? "opacity-50" : ""}
|
||||
>
|
||||
<Message
|
||||
icon={
|
||||
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
|
||||
<DogIcon
|
||||
className="h-6 w-6 transition-colors duration-300"
|
||||
disabled={isCompleted}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
showForkButton && onFork ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onFork}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-accent rounded text-muted-foreground hover:text-foreground"
|
||||
title="Fork conversation from here"
|
||||
>
|
||||
<GitBranch className="h-3 w-3" />
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HTMLTextAreaElement>) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onHeightChange: (height: number) => void;
|
||||
onFilterSelect: (filter: KnowledgeFilterData | null) => void;
|
||||
onAtClick: () => void;
|
||||
onFilePickerChange: (e: React.ChangeEvent<HTMLInputElement>) => 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<HTMLTextAreaElement>) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onHeightChange: (height: number) => void;
|
||||
onFilterSelect: (filter: KnowledgeFilterData | null) => void;
|
||||
onAtClick: () => void;
|
||||
onFilePickerChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onFilePickerClick: () => void;
|
||||
setSelectedFilter: (filter: KnowledgeFilterData | null) => void;
|
||||
setIsFilterHighlighted: (highlighted: boolean) => void;
|
||||
setIsFilterDropdownOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>((
|
||||
{
|
||||
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<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
(
|
||||
{
|
||||
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<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focusInput: () => {
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
clickFileInput: () => {
|
||||
fileInputRef.current?.click();
|
||||
},
|
||||
}));
|
||||
useImperativeHandle(ref, () => ({
|
||||
focusInput: () => {
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
clickFileInput: () => {
|
||||
fileInputRef.current?.click();
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="pb-8 pt-4 flex px-6">
|
||||
<div className="w-full">
|
||||
<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">
|
||||
{selectedFilter && (
|
||||
<div className="flex items-center gap-2 px-4 pt-3 pb-1">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
filterAccentClasses[parsedFilterData?.color || "zinc"]
|
||||
}`}
|
||||
>
|
||||
@filter:{selectedFilter.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedFilter(null);
|
||||
setIsFilterHighlighted(false);
|
||||
}}
|
||||
className="ml-1 rounded-full p-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="relative"
|
||||
style={{ height: `${textareaHeight + 60}px` }}
|
||||
>
|
||||
<TextareaAutosize
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onHeightChange={onHeightChange}
|
||||
maxRows={7}
|
||||
minRows={2}
|
||||
placeholder="Type to ask a question..."
|
||||
disabled={loading}
|
||||
className={`w-full bg-transparent px-4 ${
|
||||
selectedFilter ? "pt-2" : "pt-4"
|
||||
} focus-visible:outline-none resize-none`}
|
||||
rows={2}
|
||||
/>
|
||||
{/* Safe area at bottom for buttons */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-transparent pointer-events-none"
|
||||
style={{ height: "60px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
onChange={onFilePickerChange}
|
||||
className="hidden"
|
||||
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="iconSm"
|
||||
className="absolute bottom-3 left-3 h-8 w-8 p-0 rounded-full hover:bg-muted/50"
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={onAtClick}
|
||||
data-filter-button
|
||||
>
|
||||
<Funnel className="h-4 w-4" />
|
||||
</Button>
|
||||
<Popover
|
||||
open={isFilterDropdownOpen}
|
||||
onOpenChange={open => {
|
||||
setIsFilterDropdownOpen(open);
|
||||
}}
|
||||
>
|
||||
{anchorPosition && (
|
||||
<PopoverAnchor
|
||||
asChild
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: anchorPosition.x,
|
||||
top: anchorPosition.y,
|
||||
width: 1,
|
||||
height: 1,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div />
|
||||
</PopoverAnchor>
|
||||
)}
|
||||
<PopoverContent
|
||||
className="w-64 p-2"
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
alignOffset={-18}
|
||||
onOpenAutoFocus={e => {
|
||||
// Prevent auto focus on the popover content
|
||||
e.preventDefault();
|
||||
// Keep focus on the input
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{filterSearchTerm && (
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Searching: @{filterSearchTerm}
|
||||
</div>
|
||||
)}
|
||||
{availableFilters.length === 0 ? (
|
||||
<div className="px-2 py-3 text-sm text-muted-foreground">
|
||||
No knowledge filters available
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!filterSearchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFilterSelect(null)}
|
||||
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" : ""
|
||||
}`}
|
||||
>
|
||||
<span>No knowledge filter</span>
|
||||
{!selectedFilter && (
|
||||
<Check className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{availableFilters
|
||||
.filter(filter =>
|
||||
filter.name
|
||||
.toLowerCase()
|
||||
.includes(filterSearchTerm.toLowerCase())
|
||||
)
|
||||
.map((filter, index) => (
|
||||
<button
|
||||
key={filter.id}
|
||||
type="button"
|
||||
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 ${
|
||||
index === selectedFilterIndex ? "bg-muted/50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="font-medium truncate">
|
||||
{filter.name}
|
||||
</div>
|
||||
{filter.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{filter.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedFilter?.id === filter.id && (
|
||||
<Check className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{availableFilters.filter(filter =>
|
||||
filter.name
|
||||
.toLowerCase()
|
||||
.includes(filterSearchTerm.toLowerCase())
|
||||
).length === 0 &&
|
||||
filterSearchTerm && (
|
||||
<div className="px-2 py-3 text-sm text-muted-foreground">
|
||||
No filters match "{filterSearchTerm}"
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="iconSm"
|
||||
onClick={onFilePickerClick}
|
||||
disabled={isUploading}
|
||||
className="absolute bottom-3 left-12 h-8 w-8 p-0 rounded-full hover:bg-muted/50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!input.trim() || loading}
|
||||
className="absolute bottom-3 right-3 rounded-lg h-10 px-4"
|
||||
>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Send"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div className="pb-8 flex px-6">
|
||||
<div className="w-full">
|
||||
<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">
|
||||
{selectedFilter && (
|
||||
<div className="flex items-center gap-2 px-4 pt-3 pb-1">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
filterAccentClasses[parsedFilterData?.color || "zinc"]
|
||||
}`}
|
||||
>
|
||||
@filter:{selectedFilter.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedFilter(null);
|
||||
setIsFilterHighlighted(false);
|
||||
}}
|
||||
className="ml-1 rounded-full p-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="relative"
|
||||
style={{ height: `${textareaHeight + 60}px` }}
|
||||
>
|
||||
<TextareaAutosize
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onHeightChange={onHeightChange}
|
||||
maxRows={7}
|
||||
minRows={2}
|
||||
placeholder="Type to ask a question..."
|
||||
disabled={loading}
|
||||
className={`w-full bg-transparent px-4 ${
|
||||
selectedFilter ? "pt-2" : "pt-4"
|
||||
} focus-visible:outline-none resize-none`}
|
||||
rows={2}
|
||||
/>
|
||||
{/* Safe area at bottom for buttons */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-transparent pointer-events-none"
|
||||
style={{ height: "60px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
onChange={onFilePickerChange}
|
||||
className="hidden"
|
||||
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="iconSm"
|
||||
className="absolute bottom-3 left-3 h-8 w-8 p-0 rounded-full hover:bg-muted/50"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={onAtClick}
|
||||
data-filter-button
|
||||
>
|
||||
<Funnel className="h-4 w-4" />
|
||||
</Button>
|
||||
<Popover
|
||||
open={isFilterDropdownOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsFilterDropdownOpen(open);
|
||||
}}
|
||||
>
|
||||
{anchorPosition && (
|
||||
<PopoverAnchor
|
||||
asChild
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: anchorPosition.x,
|
||||
top: anchorPosition.y,
|
||||
width: 1,
|
||||
height: 1,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div />
|
||||
</PopoverAnchor>
|
||||
)}
|
||||
<PopoverContent
|
||||
className="w-64 p-2"
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
alignOffset={-18}
|
||||
onOpenAutoFocus={(e) => {
|
||||
// Prevent auto focus on the popover content
|
||||
e.preventDefault();
|
||||
// Keep focus on the input
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{filterSearchTerm && (
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Searching: @{filterSearchTerm}
|
||||
</div>
|
||||
)}
|
||||
{availableFilters.length === 0 ? (
|
||||
<div className="px-2 py-3 text-sm text-muted-foreground">
|
||||
No knowledge filters available
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!filterSearchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFilterSelect(null)}
|
||||
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" : ""
|
||||
}`}
|
||||
>
|
||||
<span>No knowledge filter</span>
|
||||
{!selectedFilter && (
|
||||
<Check className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{availableFilters
|
||||
.filter((filter) =>
|
||||
filter.name
|
||||
.toLowerCase()
|
||||
.includes(filterSearchTerm.toLowerCase()),
|
||||
)
|
||||
.map((filter, index) => (
|
||||
<button
|
||||
key={filter.id}
|
||||
type="button"
|
||||
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 ${
|
||||
index === selectedFilterIndex ? "bg-muted/50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="font-medium truncate">
|
||||
{filter.name}
|
||||
</div>
|
||||
{filter.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{filter.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedFilter?.id === filter.id && (
|
||||
<Check className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{availableFilters.filter((filter) =>
|
||||
filter.name
|
||||
.toLowerCase()
|
||||
.includes(filterSearchTerm.toLowerCase()),
|
||||
).length === 0 &&
|
||||
filterSearchTerm && (
|
||||
<div className="px-2 py-3 text-sm text-muted-foreground">
|
||||
No filters match "{filterSearchTerm}"
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="iconSm"
|
||||
onClick={onFilePickerClick}
|
||||
disabled={isUploading}
|
||||
className="absolute bottom-3 left-12 h-8 w-8 p-0 rounded-full hover:bg-muted/50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!input.trim() || loading}
|
||||
className="absolute bottom-3 right-3 rounded-lg h-10 px-4"
|
||||
>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Send"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ChatInput.displayName = "ChatInput";
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Message
|
||||
icon={
|
||||
<Avatar className="w-8 h-8 flex-shrink-0 select-none">
|
||||
<AvatarImage draggable={false} src={user?.picture} alt={user?.name} />
|
||||
<AvatarFallback className="text-sm bg-primary/20 text-primary">
|
||||
{user?.name ? (
|
||||
user.name.charAt(0).toUpperCase()
|
||||
) : (
|
||||
<User className="h-4 w-4" />
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<p className="text-foreground whitespace-pre-wrap break-words overflow-wrap-anywhere">
|
||||
{content}
|
||||
</p>
|
||||
</Message>
|
||||
);
|
||||
console.log("animate", animate);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={animate ? { opacity: 0, y: -20 } : { opacity: 1, y: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={animate ? { duration: 0.4, delay: 0.2, ease: "easeOut" } : { duration: 0 }}
|
||||
className={isCompleted ? "opacity-50" : ""}
|
||||
>
|
||||
<Message
|
||||
icon={
|
||||
<Avatar className="w-8 h-8 rounded-lg flex-shrink-0 select-none">
|
||||
<AvatarImage draggable={false} src={user?.picture} alt={user?.name} />
|
||||
<AvatarFallback
|
||||
className={cn(
|
||||
isCompleted ? "text-placeholder-foreground" : "text-primary",
|
||||
"text-sm bg-accent/20 rounded-lg transition-colors duration-300",
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex-shrink-0 h-12 w-full overflow-hidden">
|
||||
<AnimatePresence>
|
||||
{nudges.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<div className="relative px-6 pt-4 flex justify-center">
|
||||
<div className="w-full max-w-[75%]">
|
||||
<div className="flex gap-3 justify-start overflow-x-auto scrollbar-hide">
|
||||
{nudges.map((suggestion: string, index: number) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
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"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
return (
|
||||
<div className="flex-shrink-0 h-12 w-full overflow-hidden">
|
||||
<AnimatePresence>
|
||||
{nudges.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative flex"
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex gap-3 justify-start overflow-x-auto scrollbar-hide">
|
||||
{nudges.map((suggestion: string, index: number) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
className={cn(
|
||||
onboarding
|
||||
? "text-foreground"
|
||||
: "text-placeholder-foreground hover:text-foreground",
|
||||
"bg-background border hover:bg-background/50 px-2 py-1.5 rounded-lg text-sm transition-colors whitespace-nowrap",
|
||||
)}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</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
|
|
@ -4,6 +4,7 @@ export interface Message {
|
|||
timestamp: Date;
|
||||
functionCalls?: FunctionCall[];
|
||||
isStreaming?: boolean;
|
||||
source?: "langflow" | "chat";
|
||||
}
|
||||
|
||||
export interface FunctionCall {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="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>
|
||||
const [responseId, setResponseId] = useState<string | null>(null);
|
||||
const [selectedNudge, setSelectedNudge] = useState<string>("");
|
||||
const [assistantMessage, setAssistantMessage] = useState<Message | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
<OnboardingStep
|
||||
isVisible={currentStep >= 1}
|
||||
isCompleted={currentStep > 1}
|
||||
text="Step 1: Configure your settings"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
Let's configure some basic settings for your account.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleStepComplete}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</OnboardingStep>
|
||||
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(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
<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
|
||||
onClick={handleStepComplete}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</OnboardingStep>
|
||||
const NUDGES = ["What is OpenRAG?"];
|
||||
|
||||
<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
|
||||
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>
|
||||
);
|
||||
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 (
|
||||
<StickToBottom
|
||||
className="flex h-full flex-1 flex-col"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.4, ease: "easeOut" }}
|
||||
className={isCompleted ? "opacity-50" : ""}
|
||||
>
|
||||
<Message
|
||||
icon={
|
||||
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
|
||||
<DogIcon
|
||||
className="h-6 w-6 text-accent-foreground"
|
||||
disabled={isCompleted}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p
|
||||
className={`text-foreground text-sm py-1.5 ${isCompleted ? "text-placeholder-foreground" : ""}`}
|
||||
>
|
||||
{displayedText}
|
||||
{!showChildren && !isCompleted && (
|
||||
<span className="inline-block w-1 h-4 bg-primary ml-1 animate-pulse" />
|
||||
)}
|
||||
</p>
|
||||
<AnimatePresence>
|
||||
{showChildren && !isCompleted && (
|
||||
<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" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</Message>
|
||||
</motion.div>
|
||||
);
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.4, ease: "easeOut" }}
|
||||
className={isCompleted ? "opacity-50" : ""}
|
||||
>
|
||||
<Message
|
||||
icon={
|
||||
icon || (
|
||||
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
|
||||
<DogIcon
|
||||
className="h-6 w-6 text-accent-foreground transition-colors duration-300"
|
||||
disabled={isCompleted}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div>
|
||||
{isMarkdown ? (
|
||||
<MarkdownRenderer
|
||||
className={cn(
|
||||
isCompleted
|
||||
? "text-placeholder-foreground"
|
||||
: "text-foreground",
|
||||
"text-sm py-1.5 transition-colors duration-300",
|
||||
)}
|
||||
chatMessage={text}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
className={`text-foreground text-sm py-1.5 transition-colors duration-300 ${
|
||||
isCompleted ? "text-placeholder-foreground" : ""
|
||||
}`}
|
||||
>
|
||||
{displayedText}
|
||||
{!showChildren && !isCompleted && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<number>(() => {
|
||||
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<number>(() => {
|
||||
if (typeof window === "undefined") return 0;
|
||||
const savedStep = localStorage.getItem(ONBOARDING_STEP_KEY);
|
||||
return savedStep !== null ? parseInt(savedStep, 10) : 0;
|
||||
});
|
||||
|
||||
const [showLayout, setShowLayout] = useState<boolean>(() => {
|
||||
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<boolean>(() => {
|
||||
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 (
|
||||
<>
|
||||
<AnimatedConditional
|
||||
className="[grid-area:header] bg-background border-b"
|
||||
vertical
|
||||
slide
|
||||
isOpen={showLayout}
|
||||
delay={ANIMATION_DURATION / 2}
|
||||
>
|
||||
<Header />
|
||||
</AnimatedConditional>
|
||||
// For all other pages, render with Langflow-styled navigation and task menu
|
||||
return (
|
||||
<>
|
||||
<AnimatedConditional
|
||||
className="[grid-area:header] bg-background border-b"
|
||||
vertical
|
||||
slide
|
||||
isOpen={showLayout}
|
||||
delay={ANIMATION_DURATION / 2}
|
||||
>
|
||||
<Header />
|
||||
</AnimatedConditional>
|
||||
|
||||
{/* Sidebar Navigation */}
|
||||
<AnimatedConditional
|
||||
isOpen={showLayout}
|
||||
slide
|
||||
className={`border-r bg-background overflow-hidden [grid-area:nav] w-[${SIDEBAR_WIDTH}px]`}
|
||||
>
|
||||
<Navigation
|
||||
conversations={conversations}
|
||||
isConversationsLoading={isConversationsLoading}
|
||||
onNewConversation={handleNewConversation}
|
||||
/>
|
||||
</AnimatedConditional>
|
||||
{/* Sidebar Navigation */}
|
||||
<AnimatedConditional
|
||||
isOpen={showLayout}
|
||||
slide
|
||||
className={`border-r bg-background overflow-hidden [grid-area:nav] w-[${SIDEBAR_WIDTH}px]`}
|
||||
>
|
||||
<Navigation
|
||||
conversations={conversations}
|
||||
isConversationsLoading={isConversationsLoading}
|
||||
onNewConversation={handleNewConversation}
|
||||
/>
|
||||
</AnimatedConditional>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="overflow-visible w-full flex items-center justify-center [grid-area:main]">
|
||||
<motion.div
|
||||
initial={{
|
||||
width: showLayout ? "100%" : "100vw",
|
||||
height: showLayout ? "100%" : "100vh",
|
||||
x: x,
|
||||
y: y,
|
||||
translateX: translateX,
|
||||
translateY: translateY,
|
||||
}}
|
||||
animate={{
|
||||
width: showLayout ? "100%" : "850px",
|
||||
borderRadius: showLayout ? "0" : "16px",
|
||||
border: showLayout ? "0" : "1px solid #27272A",
|
||||
height: showLayout ? "100%" : "800px",
|
||||
x: x,
|
||||
y: y,
|
||||
translateX: translateX,
|
||||
translateY: translateY,
|
||||
}}
|
||||
transition={{
|
||||
duration: ANIMATION_DURATION,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-full w-full max-w-full max-h-full items-center justify-center overflow-hidden",
|
||||
!showLayout && "absolute",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full bg-background",
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: showLayout ? 1 : 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: "100%",
|
||||
}}
|
||||
transition={{
|
||||
duration: ANIMATION_DURATION,
|
||||
ease: "easeOut",
|
||||
delay: ANIMATION_DURATION,
|
||||
}}
|
||||
className={cn("w-full h-full 0v")}
|
||||
>
|
||||
<div className={cn("w-full h-full", !showLayout && "hidden")}>
|
||||
{children}
|
||||
</div>
|
||||
{!showLayout && (
|
||||
<OnboardingContent
|
||||
handleStepComplete={handleStepComplete}
|
||||
currentStep={currentStep}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: showLayout ? 0 : 1, y: showLayout ? 20 : 0 }}
|
||||
transition={{ duration: ANIMATION_DURATION, ease: "easeOut" }}
|
||||
className={cn("absolute bottom-10 left-0 right-0")}
|
||||
>
|
||||
<ProgressBar
|
||||
currentStep={currentStep}
|
||||
totalSteps={TOTAL_ONBOARDING_STEPS}
|
||||
/>
|
||||
</motion.div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
{/* Main Content */}
|
||||
<main className="overflow-hidden w-full flex items-center justify-center [grid-area:main]">
|
||||
<motion.div
|
||||
initial={{
|
||||
width: showLayout ? "100%" : "100vw",
|
||||
height: showLayout ? "100%" : "100vh",
|
||||
x: x,
|
||||
y: y,
|
||||
translateX: translateX,
|
||||
translateY: translateY,
|
||||
}}
|
||||
animate={{
|
||||
width: showLayout ? "100%" : "850px",
|
||||
borderRadius: showLayout ? "0" : "16px",
|
||||
border: showLayout ? "0" : "1px solid #27272A",
|
||||
height: showLayout ? "100%" : "800px",
|
||||
x: x,
|
||||
y: y,
|
||||
translateX: translateX,
|
||||
translateY: translateY,
|
||||
}}
|
||||
transition={{
|
||||
duration: ANIMATION_DURATION,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-full w-full max-w-full max-h-full items-center justify-center overflow-hidden",
|
||||
!showLayout && "absolute",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full bg-background w-full",
|
||||
showLayout && !isOnChatPage && "p-6 container overflow-y-auto",
|
||||
showLayout && isSmallWidthPath && "max-w-[850px] ml-0",
|
||||
!showLayout &&
|
||||
"w-full bg-card rounded-lg shadow-2xl p-0 py-2 overflow-y-auto",
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: showLayout ? 1 : 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: "100%",
|
||||
}}
|
||||
transition={{
|
||||
duration: ANIMATION_DURATION,
|
||||
ease: "easeOut",
|
||||
delay: ANIMATION_DURATION,
|
||||
}}
|
||||
className={cn("w-full h-full 0v")}
|
||||
>
|
||||
<div className={cn("w-full h-full", !showLayout && "hidden")}>
|
||||
{children}
|
||||
</div>
|
||||
{!showLayout && (
|
||||
<OnboardingContent
|
||||
handleStepComplete={handleStepComplete}
|
||||
currentStep={currentStep}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: showLayout ? 0 : 1, y: showLayout ? 20 : 0 }}
|
||||
transition={{ duration: ANIMATION_DURATION, ease: "easeOut" }}
|
||||
className={cn("absolute bottom-10 left-0 right-0")}
|
||||
>
|
||||
<ProgressBar
|
||||
currentStep={currentStep}
|
||||
totalSteps={TOTAL_ONBOARDING_STEPS}
|
||||
/>
|
||||
</motion.div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
492
frontend/src/hooks/useChatStreaming.ts
Normal file
492
frontend/src/hooks/useChatStreaming.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue