From f6b87c48902b158e394ab100c2945ffa5180bae0 Mon Sep 17 00:00:00 2001 From: Cole Goldsmith Date: Tue, 11 Nov 2025 11:27:33 -0600 Subject: [PATCH 1/5] add component --- frontend/components/ui/dropzone.tsx | 202 ++++++++++++++++++++++++++++ frontend/package-lock.json | 165 ++++++++++++++++++++++- frontend/package.json | 3 +- 3 files changed, 363 insertions(+), 7 deletions(-) create mode 100644 frontend/components/ui/dropzone.tsx diff --git a/frontend/components/ui/dropzone.tsx b/frontend/components/ui/dropzone.tsx new file mode 100644 index 00000000..0d74a783 --- /dev/null +++ b/frontend/components/ui/dropzone.tsx @@ -0,0 +1,202 @@ +'use client'; + +import { UploadIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { createContext, useContext } from 'react'; +import type { DropEvent, DropzoneOptions, FileRejection } from 'react-dropzone'; +import { useDropzone } from 'react-dropzone'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +type DropzoneContextType = { + src?: File[]; + accept?: DropzoneOptions['accept']; + maxSize?: DropzoneOptions['maxSize']; + minSize?: DropzoneOptions['minSize']; + maxFiles?: DropzoneOptions['maxFiles']; +}; + +const renderBytes = (bytes: number) => { + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(2)}${units[unitIndex]}`; +}; + +const DropzoneContext = createContext( + undefined +); + +export type DropzoneProps = Omit & { + src?: File[]; + className?: string; + onDrop?: ( + acceptedFiles: File[], + fileRejections: FileRejection[], + event: DropEvent + ) => void; + children?: ReactNode; +}; + +export const Dropzone = ({ + accept, + maxFiles = 1, + maxSize, + minSize, + onDrop, + onError, + disabled, + src, + className, + children, + ...props +}: DropzoneProps) => { + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + accept, + maxFiles, + maxSize, + minSize, + onError, + disabled, + onDrop: (acceptedFiles, fileRejections, event) => { + if (fileRejections.length > 0) { + const message = fileRejections.at(0)?.errors.at(0)?.message; + onError?.(new Error(message)); + return; + } + + onDrop?.(acceptedFiles, fileRejections, event); + }, + ...props, + }); + + return ( + + + + ); +}; + +const useDropzoneContext = () => { + const context = useContext(DropzoneContext); + + if (!context) { + throw new Error('useDropzoneContext must be used within a Dropzone'); + } + + return context; +}; + +export type DropzoneContentProps = { + children?: ReactNode; + className?: string; +}; + +const maxLabelItems = 3; + +export const DropzoneContent = ({ + children, + className, +}: DropzoneContentProps) => { + const { src } = useDropzoneContext(); + + if (!src) { + return null; + } + + if (children) { + return children; + } + + return ( +
+
+ +
+

+ {src.length > maxLabelItems + ? `${new Intl.ListFormat('en').format( + src.slice(0, maxLabelItems).map((file) => file.name) + )} and ${src.length - maxLabelItems} more` + : new Intl.ListFormat('en').format(src.map((file) => file.name))} +

+

+ Drag and drop or click to replace +

+
+ ); +}; + +export type DropzoneEmptyStateProps = { + children?: ReactNode; + className?: string; +}; + +export const DropzoneEmptyState = ({ + children, + className, +}: DropzoneEmptyStateProps) => { + const { src, accept, maxSize, minSize, maxFiles } = useDropzoneContext(); + + if (src) { + return null; + } + + if (children) { + return children; + } + + let caption = ''; + + if (accept) { + caption += 'Accepts '; + caption += new Intl.ListFormat('en').format(Object.keys(accept)); + } + + if (minSize && maxSize) { + caption += ` between ${renderBytes(minSize)} and ${renderBytes(maxSize)}`; + } else if (minSize) { + caption += ` at least ${renderBytes(minSize)}`; + } else if (maxSize) { + caption += ` less than ${renderBytes(maxSize)}`; + } + + return ( +
+
+ +
+

+ Upload {maxFiles === 1 ? 'a file' : 'files'} +

+

+ Drag and drop or click to upload +

+ {caption && ( +

{caption}.

+ )} +
+ ); +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d890e136..492766a0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,7 +23,7 @@ "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slider": "^1.3.6", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", @@ -42,6 +42,7 @@ "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-dropzone": "^14.3.8", "react-hook-form": "^7.65.0", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", @@ -1390,6 +1391,23 @@ } } }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -1456,6 +1474,23 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -1648,6 +1683,23 @@ } } }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-navigation-menu": { "version": "1.2.14", "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", @@ -1721,6 +1773,23 @@ } } }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", @@ -1824,6 +1893,23 @@ } } }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-radio-group": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", @@ -1930,6 +2016,23 @@ } } }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", @@ -1987,10 +2090,9 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, @@ -2097,6 +2199,23 @@ } } }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -3381,6 +3500,14 @@ "node": ">= 0.4" } }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "engines": { + "node": ">=4" + } + }, "node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -4890,6 +5017,17 @@ "node": ">=16.0.0" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -6526,7 +6664,6 @@ "version": "0.525.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz", "integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==", - "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -8349,6 +8486,22 @@ "react": "^19.1.1" } }, + "node_modules/react-dropzone": { + "version": "14.3.8", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", + "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, "node_modules/react-hook-form": { "version": "7.65.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 517e36da..08b16b89 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,7 +24,7 @@ "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slider": "^1.3.6", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", @@ -43,6 +43,7 @@ "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-dropzone": "^14.3.8", "react-hook-form": "^7.65.0", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", From 3ad90f7293ba54099c69ec43531a6590cb832fb3 Mon Sep 17 00:00:00 2001 From: Cole Goldsmith Date: Tue, 18 Nov 2025 14:21:09 -0600 Subject: [PATCH 2/5] use react dropzone on chat input --- frontend/app/chat/_components/chat-input.tsx | 701 ++++++++++--------- frontend/components/ui/dropzone.tsx | 202 ------ frontend/hooks/use-file-drag.ts | 53 ++ 3 files changed, 442 insertions(+), 514 deletions(-) delete mode 100644 frontend/components/ui/dropzone.tsx create mode 100644 frontend/hooks/use-file-drag.ts diff --git a/frontend/app/chat/_components/chat-input.tsx b/frontend/app/chat/_components/chat-input.tsx index aeb44073..242f19d2 100644 --- a/frontend/app/chat/_components/chat-input.tsx +++ b/frontend/app/chat/_components/chat-input.tsx @@ -1,339 +1,416 @@ import { ArrowRight, Check, Funnel, Loader2, Plus } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; import { forwardRef, useImperativeHandle, useRef, useState } from "react"; +import { useDropzone } from "react-dropzone"; import TextareaAutosize from "react-textarea-autosize"; import type { FilterColor } from "@/components/filter-icon-popover"; import { Button } from "@/components/ui/button"; import { - Popover, - PopoverAnchor, - PopoverContent, + Popover, + PopoverAnchor, + PopoverContent, } from "@/components/ui/popover"; +import { useFileDrag } from "@/hooks/use-file-drag"; +import { cn } from "@/lib/utils"; import type { KnowledgeFilterData } from "../_types/types"; import { FilePreview } from "./file-preview"; import { SelectedKnowledgeFilter } from "./selected-knowledge-filter"; 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; - parsedFilterData: { color?: FilterColor } | null; - uploadedFile: File | null; - onSubmit: (e: React.FormEvent) => void; - onChange: (e: React.ChangeEvent) => void; - onKeyDown: (e: React.KeyboardEvent) => void; - onFilterSelect: (filter: KnowledgeFilterData | null) => void; - onAtClick: () => void; - onFilePickerClick: () => void; - setSelectedFilter: (filter: KnowledgeFilterData | null) => void; - setIsFilterHighlighted: (highlighted: boolean) => void; - setIsFilterDropdownOpen: (open: boolean) => void; - onFileSelected: (file: File | null) => void; + input: string; + loading: boolean; + isUploading: boolean; + selectedFilter: KnowledgeFilterData | null; + isFilterDropdownOpen: boolean; + availableFilters: KnowledgeFilterData[]; + filterSearchTerm: string; + selectedFilterIndex: number; + anchorPosition: { x: number; y: number } | null; + parsedFilterData: { color?: FilterColor } | null; + uploadedFile: File | null; + onSubmit: (e: React.FormEvent) => void; + onChange: (e: React.ChangeEvent) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onFilterSelect: (filter: KnowledgeFilterData | null) => void; + onAtClick: () => void; + onFilePickerClick: () => void; + setSelectedFilter: (filter: KnowledgeFilterData | null) => void; + setIsFilterHighlighted: (highlighted: boolean) => void; + setIsFilterDropdownOpen: (open: boolean) => void; + onFileSelected: (file: File | null) => void; } export const ChatInput = forwardRef( - ( - { - input, - loading, - isUploading, - selectedFilter, - isFilterDropdownOpen, - availableFilters, - filterSearchTerm, - selectedFilterIndex, - anchorPosition, - parsedFilterData, - uploadedFile, - onSubmit, - onChange, - onKeyDown, - onFilterSelect, - onAtClick, - onFilePickerClick, - setSelectedFilter, - setIsFilterHighlighted, - setIsFilterDropdownOpen, - onFileSelected, - }, - ref, - ) => { - const inputRef = useRef(null); - const fileInputRef = useRef(null); - const [textareaHeight, setTextareaHeight] = useState(0); + ( + { + input, + loading, + isUploading, + selectedFilter, + isFilterDropdownOpen, + availableFilters, + filterSearchTerm, + selectedFilterIndex, + anchorPosition, + parsedFilterData, + uploadedFile, + onSubmit, + onChange, + onKeyDown, + onFilterSelect, + onAtClick, + onFilePickerClick, + setSelectedFilter, + setIsFilterHighlighted, + setIsFilterDropdownOpen, + onFileSelected, + }, + ref, + ) => { + const inputRef = useRef(null); + const fileInputRef = useRef(null); + const [textareaHeight, setTextareaHeight] = useState(0); + const [fileUploadError, setFileUploadError] = useState(null); + const isDragging = useFileDrag(); - useImperativeHandle(ref, () => ({ - focusInput: () => { - inputRef.current?.focus(); - }, - clickFileInput: () => { - fileInputRef.current?.click(); - }, - })); + const { getRootProps, getInputProps } = useDropzone({ + accept: { + "application/pdf": [".pdf"], + "application/msword": [".doc"], + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + [".docx"], + "text/markdown": [".md"], + }, + maxFiles: 1, + disabled: !isDragging, + onDrop: (acceptedFiles, fileRejections) => { + setFileUploadError(null); + if (fileRejections.length > 0) { + console.log(fileRejections); + const message = fileRejections.at(0)?.errors.at(0)?.message; + setFileUploadError(new Error(message)); + return; + } + onFileSelected(acceptedFiles[0]); + }, + }); - const handleFilePickerChange = (e: React.ChangeEvent) => { - const files = e.target.files; - if (files && files.length > 0) { - onFileSelected(files[0]); - } else { - onFileSelected(null); - } - }; + useImperativeHandle(ref, () => ({ + focusInput: () => { + inputRef.current?.focus(); + }, + clickFileInput: () => { + fileInputRef.current?.click(); + }, + })); - return ( -
-
- {/* Outer container - flex-col to stack file preview above input */} -
- {/* File Preview Section - Always above */} - {uploadedFile && ( - { - onFileSelected(null); - }} - /> - )} + const handleFilePickerChange = (e: React.ChangeEvent) => { + setFileUploadError(null); + const files = e.target.files; + if (files && files.length > 0) { + onFileSelected(files[0]); + } else { + onFileSelected(null); + } + }; - {/* Main Input Container - flex-row or flex-col based on textarea height */} -
40 ? "flex-col" : "flex-row items-center" - }`} - > - {/* Filter + Textarea Section */} -
40 ? "w-full" : "flex-1"}`} - > - {textareaHeight <= 40 && - (selectedFilter ? ( - { - setSelectedFilter(null); - setIsFilterHighlighted(false); - }} - /> - ) : ( - - ))} -
- setTextareaHeight(height)} - maxRows={7} - autoComplete="off" - minRows={1} - placeholder="Ask a question..." - disabled={loading} - className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`} - rows={1} - /> -
-
+ return ( +
+ + {/* Outer container - flex-col to stack file preview above input */} +
+ + {/* File Preview Section - Always above */} + + {uploadedFile && ( + + { + onFileSelected(null); + }} + /> + + )} + + + {fileUploadError && ( + + {fileUploadError.message} + + )} + + + {isDragging && ( + +

+ Add files to conversation +

+

+ Text formats and image files.{" "} + 10 files per chat,{" "} + 150 MB each. +

+
+ )} +
+ {/* Main Input Container - flex-row or flex-col based on textarea height */} +
40 ? "flex-col" : "flex-row items-center" + }`} + > + {/* Filter + Textarea Section */} +
40 ? "w-full" : "flex-1"}`} + > + {textareaHeight <= 40 && + (selectedFilter ? ( + { + setSelectedFilter(null); + setIsFilterHighlighted(false); + }} + /> + ) : ( + + ))} +
+ setTextareaHeight(height)} + maxRows={7} + autoComplete="off" + minRows={1} + placeholder="Ask a question..." + disabled={loading} + className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`} + rows={1} + /> +
+
- {/* Action Buttons Section */} -
40 ? "justify-between w-full" : ""}`} - > - {textareaHeight > 40 && - (selectedFilter ? ( - { - setSelectedFilter(null); - setIsFilterHighlighted(false); - }} - /> - ) : ( - - ))} -
- - -
-
-
-
- + {/* Action Buttons Section */} +
40 ? "justify-between w-full" : ""}`} + > + {textareaHeight > 40 && + (selectedFilter ? ( + { + setSelectedFilter(null); + setIsFilterHighlighted(false); + }} + /> + ) : ( + + ))} +
+ + +
+
+
+
+ - { - setIsFilterDropdownOpen(open); - }} - > - {anchorPosition && ( - -
- - )} - { - // Prevent auto focus on the popover content - e.preventDefault(); - // Keep focus on the input - }} - > -
- {filterSearchTerm && ( -
- Searching: @{filterSearchTerm} -
- )} - {availableFilters.length === 0 ? ( -
- No knowledge filters available -
- ) : ( - <> - {!filterSearchTerm && ( - - )} - {availableFilters - .filter((filter) => - filter.name - .toLowerCase() - .includes(filterSearchTerm.toLowerCase()), - ) - .map((filter, index) => ( - - ))} - {availableFilters.filter((filter) => - filter.name - .toLowerCase() - .includes(filterSearchTerm.toLowerCase()), - ).length === 0 && - filterSearchTerm && ( -
- No filters match "{filterSearchTerm}" -
- )} - - )} -
-
- - -
- ); - }, + { + setIsFilterDropdownOpen(open); + }} + > + {anchorPosition && ( + +
+ + )} + { + // Prevent auto focus on the popover content + e.preventDefault(); + // Keep focus on the input + }} + > +
+ {filterSearchTerm && ( +
+ Searching: @{filterSearchTerm} +
+ )} + {availableFilters.length === 0 ? ( +
+ No knowledge filters available +
+ ) : ( + <> + {!filterSearchTerm && ( + + )} + {availableFilters + .filter((filter) => + filter.name + .toLowerCase() + .includes(filterSearchTerm.toLowerCase()), + ) + .map((filter, index) => ( + + ))} + {availableFilters.filter((filter) => + filter.name + .toLowerCase() + .includes(filterSearchTerm.toLowerCase()), + ).length === 0 && + filterSearchTerm && ( +
+ No filters match "{filterSearchTerm}" +
+ )} + + )} +
+
+ + +
+ ); + }, ); ChatInput.displayName = "ChatInput"; diff --git a/frontend/components/ui/dropzone.tsx b/frontend/components/ui/dropzone.tsx deleted file mode 100644 index 0d74a783..00000000 --- a/frontend/components/ui/dropzone.tsx +++ /dev/null @@ -1,202 +0,0 @@ -'use client'; - -import { UploadIcon } from 'lucide-react'; -import type { ReactNode } from 'react'; -import { createContext, useContext } from 'react'; -import type { DropEvent, DropzoneOptions, FileRejection } from 'react-dropzone'; -import { useDropzone } from 'react-dropzone'; -import { Button } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; - -type DropzoneContextType = { - src?: File[]; - accept?: DropzoneOptions['accept']; - maxSize?: DropzoneOptions['maxSize']; - minSize?: DropzoneOptions['minSize']; - maxFiles?: DropzoneOptions['maxFiles']; -}; - -const renderBytes = (bytes: number) => { - const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; - let size = bytes; - let unitIndex = 0; - - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex++; - } - - return `${size.toFixed(2)}${units[unitIndex]}`; -}; - -const DropzoneContext = createContext( - undefined -); - -export type DropzoneProps = Omit & { - src?: File[]; - className?: string; - onDrop?: ( - acceptedFiles: File[], - fileRejections: FileRejection[], - event: DropEvent - ) => void; - children?: ReactNode; -}; - -export const Dropzone = ({ - accept, - maxFiles = 1, - maxSize, - minSize, - onDrop, - onError, - disabled, - src, - className, - children, - ...props -}: DropzoneProps) => { - const { getRootProps, getInputProps, isDragActive } = useDropzone({ - accept, - maxFiles, - maxSize, - minSize, - onError, - disabled, - onDrop: (acceptedFiles, fileRejections, event) => { - if (fileRejections.length > 0) { - const message = fileRejections.at(0)?.errors.at(0)?.message; - onError?.(new Error(message)); - return; - } - - onDrop?.(acceptedFiles, fileRejections, event); - }, - ...props, - }); - - return ( - - - - ); -}; - -const useDropzoneContext = () => { - const context = useContext(DropzoneContext); - - if (!context) { - throw new Error('useDropzoneContext must be used within a Dropzone'); - } - - return context; -}; - -export type DropzoneContentProps = { - children?: ReactNode; - className?: string; -}; - -const maxLabelItems = 3; - -export const DropzoneContent = ({ - children, - className, -}: DropzoneContentProps) => { - const { src } = useDropzoneContext(); - - if (!src) { - return null; - } - - if (children) { - return children; - } - - return ( -
-
- -
-

- {src.length > maxLabelItems - ? `${new Intl.ListFormat('en').format( - src.slice(0, maxLabelItems).map((file) => file.name) - )} and ${src.length - maxLabelItems} more` - : new Intl.ListFormat('en').format(src.map((file) => file.name))} -

-

- Drag and drop or click to replace -

-
- ); -}; - -export type DropzoneEmptyStateProps = { - children?: ReactNode; - className?: string; -}; - -export const DropzoneEmptyState = ({ - children, - className, -}: DropzoneEmptyStateProps) => { - const { src, accept, maxSize, minSize, maxFiles } = useDropzoneContext(); - - if (src) { - return null; - } - - if (children) { - return children; - } - - let caption = ''; - - if (accept) { - caption += 'Accepts '; - caption += new Intl.ListFormat('en').format(Object.keys(accept)); - } - - if (minSize && maxSize) { - caption += ` between ${renderBytes(minSize)} and ${renderBytes(maxSize)}`; - } else if (minSize) { - caption += ` at least ${renderBytes(minSize)}`; - } else if (maxSize) { - caption += ` less than ${renderBytes(maxSize)}`; - } - - return ( -
-
- -
-

- Upload {maxFiles === 1 ? 'a file' : 'files'} -

-

- Drag and drop or click to upload -

- {caption && ( -

{caption}.

- )} -
- ); -}; diff --git a/frontend/hooks/use-file-drag.ts b/frontend/hooks/use-file-drag.ts new file mode 100644 index 00000000..8e7fa1cd --- /dev/null +++ b/frontend/hooks/use-file-drag.ts @@ -0,0 +1,53 @@ +import { useEffect, useState } from "react"; + +/** + * Hook to detect when files are being dragged into the browser window + * @returns isDragging - true when files are being dragged over the window + */ +export function useFileDrag() { + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + let dragCounter = 0; + + const handleDragEnter = (e: DragEvent) => { + // Only detect file drags + if (e.dataTransfer?.types.includes("Files")) { + dragCounter++; + if (dragCounter === 1) { + setIsDragging(true); + } + } + }; + + const handleDragLeave = () => { + dragCounter--; + if (dragCounter === 0) { + setIsDragging(false); + } + }; + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + }; + + const handleDrop = () => { + dragCounter = 0; + setIsDragging(false); + }; + + window.addEventListener("dragenter", handleDragEnter); + window.addEventListener("dragleave", handleDragLeave); + window.addEventListener("dragover", handleDragOver); + window.addEventListener("drop", handleDrop); + + return () => { + window.removeEventListener("dragenter", handleDragEnter); + window.removeEventListener("dragleave", handleDragLeave); + window.removeEventListener("dragover", handleDragOver); + window.removeEventListener("drop", handleDrop); + }; + }, []); + + return isDragging; +} From 6df076379cda5c1af290fbd7c687ab10e0a0b2ee Mon Sep 17 00:00:00 2001 From: Cole Goldsmith Date: Tue, 18 Nov 2025 14:26:32 -0600 Subject: [PATCH 3/5] format chat page --- frontend/app/chat/page.tsx | 2682 ++++++++++++++++++------------------ 1 file changed, 1341 insertions(+), 1341 deletions(-) diff --git a/frontend/app/chat/page.tsx b/frontend/app/chat/page.tsx index d63fc92a..cd613af4 100644 --- a/frontend/app/chat/page.tsx +++ b/frontend/app/chat/page.tsx @@ -17,1351 +17,1351 @@ import { ChatInput, type ChatInputHandle } from "./_components/chat-input"; import Nudges from "./_components/nudges"; import { UserMessage } from "./_components/user-message"; import type { - FunctionCall, - KnowledgeFilterData, - Message, - RequestBody, - SelectedFilters, - ToolCallResult, + FunctionCall, + KnowledgeFilterData, + Message, + RequestBody, + SelectedFilters, + ToolCallResult, } from "./_types/types"; function ChatPage() { - const isDebugMode = process.env.NEXT_PUBLIC_OPENRAG_DEBUG === "true"; - const { - endpoint, - setEndpoint, - currentConversationId, - conversationData, - setCurrentConversationId, - addConversationDoc, - forkFromResponse, - refreshConversations, - refreshConversationsSilent, - previousResponseIds, - setPreviousResponseIds, - placeholderConversation, - } = useChat(); - const [messages, setMessages] = useState([ - { - role: "assistant", - content: "How can I assist?", - timestamp: new Date(), - }, - ]); - const [input, setInput] = useState(""); - const { loading, setLoading } = useLoadingStore(); - const [asyncMode, setAsyncMode] = useState(true); - const [expandedFunctionCalls, setExpandedFunctionCalls] = useState< - Set - >(new Set()); - // previousResponseIds now comes from useChat context - const [isUploading, setIsUploading] = useState(false); - const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); - const [availableFilters, setAvailableFilters] = useState< - KnowledgeFilterData[] - >([]); - const [filterSearchTerm, setFilterSearchTerm] = useState(""); - const [selectedFilterIndex, setSelectedFilterIndex] = useState(0); - const [isFilterHighlighted, setIsFilterHighlighted] = useState(false); - const [dropdownDismissed, setDropdownDismissed] = useState(false); - const [isUserInteracting, setIsUserInteracting] = useState(false); - const [isForkingInProgress, setIsForkingInProgress] = useState(false); - const [anchorPosition, setAnchorPosition] = useState<{ - x: number; - y: number; - } | null>(null); - const [uploadedFile, setUploadedFile] = useState(null); - - const chatInputRef = useRef(null); - - const { scrollToBottom } = useStickToBottomContext(); - - const lastLoadedConversationRef = useRef(null); - const { addTask } = useTask(); - const { selectedFilter, parsedFilterData, setSelectedFilter } = - useKnowledgeFilter(); - - // Use the chat streaming hook - const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"; - const { - streamingMessage, - sendMessage: sendStreamingMessage, - abortStream, - } = useChatStreaming({ - endpoint: apiEndpoint, - onComplete: (message, responseId) => { - setMessages((prev) => [...prev, message]); - setLoading(false); - - if (responseId) { - cancelNudges(); - setPreviousResponseIds((prev) => ({ - ...prev, - [endpoint]: responseId, - })); - - if (!currentConversationId) { - setCurrentConversationId(responseId); - refreshConversations(true); - } else { - refreshConversationsSilent(); - } - } - }, - onError: (error) => { - console.error("Streaming error:", error); - setLoading(false); - const errorMessage: Message = { - role: "assistant", - content: - "Sorry, I couldn't connect to the chat service. Please try again.", - timestamp: new Date(), - }; - setMessages((prev) => [...prev, errorMessage]); - }, - }); - - const getCursorPosition = (textarea: HTMLTextAreaElement) => { - // Create a hidden div with the same styles as the textarea - const div = document.createElement("div"); - const computedStyle = getComputedStyle(textarea); - - // Copy all computed styles to the hidden div - for (const style of computedStyle) { - (div.style as unknown as Record)[style] = - computedStyle.getPropertyValue(style); - } - - // Set the div to be hidden but not un-rendered - div.style.position = "absolute"; - div.style.visibility = "hidden"; - div.style.whiteSpace = "pre-wrap"; - div.style.wordWrap = "break-word"; - div.style.overflow = "hidden"; - div.style.height = "auto"; - div.style.width = `${textarea.getBoundingClientRect().width}px`; - - // Get the text up to the cursor position - const cursorPos = textarea.selectionStart || 0; - const textBeforeCursor = textarea.value.substring(0, cursorPos); - - // Add the text before cursor - div.textContent = textBeforeCursor; - - // Create a span to mark the end position - const span = document.createElement("span"); - span.textContent = "|"; // Cursor marker - div.appendChild(span); - - // Add the text after cursor to handle word wrapping - const textAfterCursor = textarea.value.substring(cursorPos); - div.appendChild(document.createTextNode(textAfterCursor)); - - // Add the div to the document temporarily - document.body.appendChild(div); - - // Get positions - const inputRect = textarea.getBoundingClientRect(); - const divRect = div.getBoundingClientRect(); - const spanRect = span.getBoundingClientRect(); - - // Calculate the cursor position relative to the input - const x = inputRect.left + (spanRect.left - divRect.left); - const y = inputRect.top + (spanRect.top - divRect.top); - - // Clean up - document.body.removeChild(div); - - return { x, y }; - }; - - const handleEndpointChange = (newEndpoint: EndpointType) => { - setEndpoint(newEndpoint); - // Clear the conversation when switching endpoints to avoid response ID conflicts - setMessages([]); - setPreviousResponseIds({ chat: null, langflow: null }); - }; - - const handleFileUpload = async (file: File) => { - console.log("handleFileUpload called with file:", file.name); - - if (isUploading) return; - - setIsUploading(true); - setLoading(true); - - try { - const formData = new FormData(); - formData.append("file", file); - formData.append("endpoint", endpoint); - - // Add previous_response_id if we have one for this endpoint - const currentResponseId = previousResponseIds[endpoint]; - if (currentResponseId) { - formData.append("previous_response_id", currentResponseId); - } - - const response = await fetch("/api/upload_context", { - method: "POST", - body: formData, - }); - - console.log("Upload response status:", response.status); - - if (!response.ok) { - const errorText = await response.text(); - console.error( - "Upload failed with status:", - response.status, - "Response:", - errorText, - ); - throw new Error("Failed to process document"); - } - - const result = await response.json(); - console.log("Upload result:", result); - - if (response.status === 201) { - // New flow: Got task ID, start tracking with centralized system - const taskId = result.task_id || result.id; - - if (!taskId) { - console.error("No task ID in 201 response:", result); - throw new Error("No task ID received from server"); - } - - // Add task to centralized tracking - addTask(taskId); - - return null; - } else if (response.ok) { - // Original flow: Direct response - - const uploadMessage: Message = { - role: "user", - content: `I'm uploading a document called "${result.filename}". Here is its content:`, - timestamp: new Date(), - }; - - const confirmationMessage: Message = { - role: "assistant", - content: `Confirmed`, - timestamp: new Date(), - }; - - setMessages((prev) => [...prev, uploadMessage, confirmationMessage]); - - // Add file to conversation docs - if (result.filename) { - addConversationDoc(result.filename); - } - - // Update the response ID for this endpoint - if (result.response_id) { - setPreviousResponseIds((prev) => ({ - ...prev, - [endpoint]: result.response_id, - })); - - // If this is a new conversation (no currentConversationId), set it now - if (!currentConversationId) { - setCurrentConversationId(result.response_id); - refreshConversations(true); - } else { - // For existing conversations, do a silent refresh to keep backend in sync - refreshConversationsSilent(); - } - - return result.response_id; - } - } else { - throw new Error(`Upload failed: ${response.status}`); - } - } catch (error) { - console.error("Upload failed:", error); - const errorMessage: Message = { - role: "assistant", - content: `❌ Failed to process document. Please try again.`, - timestamp: new Date(), - }; - setMessages((prev) => [...prev.slice(0, -1), errorMessage]); - } finally { - setIsUploading(false); - setLoading(false); - } - }; - - const handleFilePickerClick = () => { - chatInputRef.current?.clickFileInput(); - }; - - const loadAvailableFilters = async () => { - try { - const response = await fetch("/api/knowledge-filter/search", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - query: "", - limit: 20, - }), - }); - - const result = await response.json(); - if (response.ok && result.success) { - setAvailableFilters(result.filters); - } else { - console.error("Failed to load knowledge filters:", result.error); - setAvailableFilters([]); - } - } catch (error) { - console.error("Failed to load knowledge filters:", error); - setAvailableFilters([]); - } - }; - - const handleFilterSelect = (filter: KnowledgeFilterData | null) => { - setSelectedFilter(filter); - setIsFilterDropdownOpen(false); - setFilterSearchTerm(""); - setIsFilterHighlighted(false); - - // Remove the @searchTerm from the input and replace with filter pill - const words = input.split(" "); - const lastWord = words[words.length - 1]; - - if (lastWord.startsWith("@")) { - // Remove the @search term - words.pop(); - setInput(words.join(" ") + (words.length > 0 ? " " : "")); - } - }; - - // Reset selected index when search term changes - useEffect(() => { - setSelectedFilterIndex(0); - }, []); - - // Auto-focus the input on component mount - useEffect(() => { - chatInputRef.current?.focusInput(); - }, []); - - // Explicitly handle external new conversation trigger - useEffect(() => { - const handleNewConversation = () => { - // Abort any in-flight streaming so it doesn't bleed into new chat - abortStream(); - // Reset chat UI even if context state was already 'new' - setMessages([ - { - role: "assistant", - content: "How can I assist?", - timestamp: new Date(), - }, - ]); - setInput(""); - setExpandedFunctionCalls(new Set()); - setIsFilterHighlighted(false); - setLoading(false); - lastLoadedConversationRef.current = null; - }; - - const handleFocusInput = () => { - chatInputRef.current?.focusInput(); - }; - - window.addEventListener("newConversation", handleNewConversation); - window.addEventListener("focusInput", handleFocusInput); - return () => { - window.removeEventListener("newConversation", handleNewConversation); - window.removeEventListener("focusInput", handleFocusInput); - }; - }, [abortStream, setLoading]); - - // Load conversation only when user explicitly selects a conversation - useEffect(() => { - // Only load conversation data when: - // 1. conversationData exists AND - // 2. It's different from the last loaded conversation AND - // 3. User is not in the middle of an interaction - if ( - conversationData && - conversationData.messages && - lastLoadedConversationRef.current !== conversationData.response_id && - !isUserInteracting && - !isForkingInProgress - ) { - console.log( - "Loading conversation with", - conversationData.messages.length, - "messages", - ); - // Convert backend message format to frontend Message interface - const convertedMessages: Message[] = conversationData.messages.map( - (msg: { - role: string; - content: string; - timestamp?: string; - response_id?: string; - chunks?: Array<{ - item?: { - type?: string; - tool_name?: string; - id?: string; - inputs?: unknown; - results?: unknown; - status?: string; - }; - delta?: { - tool_calls?: Array<{ - id?: string; - function?: { name?: string; arguments?: string }; - type?: string; - }>; - }; - type?: string; - result?: unknown; - output?: unknown; - response?: unknown; - }>; - response_data?: unknown; - }) => { - const message: Message = { - role: msg.role as "user" | "assistant", - content: msg.content, - timestamp: new Date(msg.timestamp || new Date()), - }; - - // Extract function calls from chunks or response_data - if (msg.role === "assistant" && (msg.chunks || msg.response_data)) { - const functionCalls: FunctionCall[] = []; - console.log("Processing assistant message for function calls:", { - hasChunks: !!msg.chunks, - chunksLength: msg.chunks?.length, - hasResponseData: !!msg.response_data, - }); - - // Process chunks (streaming data) - if (msg.chunks && Array.isArray(msg.chunks)) { - for (const chunk of msg.chunks) { - // Handle Langflow format: chunks[].item.tool_call - if (chunk.item && chunk.item.type === "tool_call") { - const toolCall = chunk.item; - console.log("Found Langflow tool call:", toolCall); - functionCalls.push({ - id: toolCall.id || "", - name: toolCall.tool_name || "unknown", - arguments: - (toolCall.inputs as Record) || {}, - argumentsString: JSON.stringify(toolCall.inputs || {}), - result: toolCall.results as - | Record - | ToolCallResult[], - status: - (toolCall.status as "pending" | "completed" | "error") || - "completed", - type: "tool_call", - }); - } - // Handle OpenAI format: chunks[].delta.tool_calls - else if (chunk.delta?.tool_calls) { - for (const toolCall of chunk.delta.tool_calls) { - if (toolCall.function) { - functionCalls.push({ - id: toolCall.id || "", - name: toolCall.function.name || "unknown", - arguments: toolCall.function.arguments - ? JSON.parse(toolCall.function.arguments) - : {}, - argumentsString: toolCall.function.arguments || "", - status: "completed", - type: toolCall.type || "function", - }); - } - } - } - // Process tool call results from chunks - if ( - chunk.type === "response.tool_call.result" || - chunk.type === "tool_call_result" - ) { - const lastCall = functionCalls[functionCalls.length - 1]; - if (lastCall) { - lastCall.result = - (chunk.result as - | Record - | ToolCallResult[]) || - (chunk as Record); - lastCall.status = "completed"; - } - } - } - } - - // Process response_data (non-streaming data) - if (msg.response_data && typeof msg.response_data === "object") { - // Look for tool_calls in various places in the response data - const responseData = - typeof msg.response_data === "string" - ? JSON.parse(msg.response_data) - : msg.response_data; - - if ( - responseData.tool_calls && - Array.isArray(responseData.tool_calls) - ) { - for (const toolCall of responseData.tool_calls) { - functionCalls.push({ - id: toolCall.id, - name: toolCall.function?.name || toolCall.name, - arguments: - toolCall.function?.arguments || toolCall.arguments, - argumentsString: - typeof ( - toolCall.function?.arguments || toolCall.arguments - ) === "string" - ? toolCall.function?.arguments || toolCall.arguments - : JSON.stringify( - toolCall.function?.arguments || toolCall.arguments, - ), - result: toolCall.result, - status: "completed", - type: toolCall.type || "function", - }); - } - } - } - - if (functionCalls.length > 0) { - console.log("Setting functionCalls on message:", functionCalls); - message.functionCalls = functionCalls; - } else { - console.log("No function calls found in message"); - } - } - - return message; - }, - ); - - setMessages(convertedMessages); - lastLoadedConversationRef.current = conversationData.response_id; - - // Set the previous response ID for this conversation - setPreviousResponseIds((prev) => ({ - ...prev, - [conversationData.endpoint]: conversationData.response_id, - })); - } - }, [ - conversationData, - isUserInteracting, - isForkingInProgress, - setPreviousResponseIds, - ]); - - // Handle new conversation creation - only reset messages when placeholderConversation is set - useEffect(() => { - if (placeholderConversation && currentConversationId === null) { - console.log("Starting new conversation"); - setMessages([ - { - role: "assistant", - content: "How can I assist?", - timestamp: new Date(), - }, - ]); - lastLoadedConversationRef.current = null; - } - }, [placeholderConversation, currentConversationId]); - - // Listen for file upload events from navigation - useEffect(() => { - const handleFileUploadStart = (event: CustomEvent) => { - const { filename } = event.detail; - console.log("Chat page received file upload start event:", filename); - - setLoading(true); - setIsUploading(true); - setUploadedFile(null); // Clear previous file - }; - - const handleFileUploaded = (event: CustomEvent) => { - const { result } = event.detail; - console.log("Chat page received file upload event:", result); - - setUploadedFile(null); // Clear file after upload - - // Update the response ID for this endpoint - if (result.response_id) { - setPreviousResponseIds((prev) => ({ - ...prev, - [endpoint]: result.response_id, - })); - } - }; - - const handleFileUploadComplete = () => { - console.log("Chat page received file upload complete event"); - setLoading(false); - setIsUploading(false); - }; - - const handleFileUploadError = (event: CustomEvent) => { - const { filename, error } = event.detail; - console.log( - "Chat page received file upload error event:", - filename, - error, - ); - - // Replace the last message with error message - const errorMessage: Message = { - role: "assistant", - content: `❌ Upload failed for **${filename}**: ${error}`, - timestamp: new Date(), - }; - setMessages((prev) => [...prev.slice(0, -1), errorMessage]); - setUploadedFile(null); // Clear file on error - }; - - window.addEventListener( - "fileUploadStart", - handleFileUploadStart as EventListener, - ); - window.addEventListener( - "fileUploaded", - handleFileUploaded as EventListener, - ); - window.addEventListener( - "fileUploadComplete", - handleFileUploadComplete as EventListener, - ); - window.addEventListener( - "fileUploadError", - handleFileUploadError as EventListener, - ); - - return () => { - window.removeEventListener( - "fileUploadStart", - handleFileUploadStart as EventListener, - ); - window.removeEventListener( - "fileUploaded", - handleFileUploaded as EventListener, - ); - window.removeEventListener( - "fileUploadComplete", - handleFileUploadComplete as EventListener, - ); - window.removeEventListener( - "fileUploadError", - handleFileUploadError as EventListener, - ); - }; - }, [endpoint, setPreviousResponseIds, setLoading]); - - // Check if onboarding is complete by looking at local storage - const [isOnboardingComplete, setIsOnboardingComplete] = useState(() => { - if (typeof window === "undefined") return false; - return localStorage.getItem("onboarding-step") === null; - }); - - // Listen for storage changes to detect when onboarding completes - useEffect(() => { - const checkOnboarding = () => { - if (typeof window !== "undefined") { - setIsOnboardingComplete( - localStorage.getItem("onboarding-step") === null, - ); - } - }; - - // Check periodically since storage events don't fire in the same tab - const interval = setInterval(checkOnboarding, 500); - - return () => clearInterval(interval); - }, []); - - // Prepare filters for nudges (same as chat) - const processedFiltersForNudges = parsedFilterData?.filters - ? (() => { - const filters = parsedFilterData.filters; - const processed: SelectedFilters = { - data_sources: [], - document_types: [], - owners: [], - }; - processed.data_sources = filters.data_sources.includes("*") - ? [] - : filters.data_sources; - processed.document_types = filters.document_types.includes("*") - ? [] - : filters.document_types; - processed.owners = filters.owners.includes("*") ? [] : filters.owners; - - const hasFilters = - processed.data_sources.length > 0 || - processed.document_types.length > 0 || - processed.owners.length > 0; - return hasFilters ? processed : undefined; - })() - : undefined; - - const { data: nudges = [], cancel: cancelNudges } = useGetNudgesQuery( - { - chatId: previousResponseIds[endpoint], - filters: processedFiltersForNudges, - limit: parsedFilterData?.limit ?? 3, - scoreThreshold: parsedFilterData?.scoreThreshold ?? 0, - }, - { - enabled: isOnboardingComplete, // Only fetch nudges after onboarding is complete - }, - ); - - const handleSSEStream = async ( - userMessage: Message, - previousResponseId?: string, - ) => { - // Prepare filters - const processedFilters = parsedFilterData?.filters - ? (() => { - const filters = parsedFilterData.filters; - const processed: SelectedFilters = { - data_sources: [], - document_types: [], - owners: [], - }; - processed.data_sources = filters.data_sources.includes("*") - ? [] - : filters.data_sources; - processed.document_types = filters.document_types.includes("*") - ? [] - : filters.document_types; - processed.owners = filters.owners.includes("*") ? [] : filters.owners; - - const hasFilters = - processed.data_sources.length > 0 || - processed.document_types.length > 0 || - processed.owners.length > 0; - return hasFilters ? processed : undefined; - })() - : undefined; - - // Use passed previousResponseId if available, otherwise fall back to state - const responseIdToUse = previousResponseId || previousResponseIds[endpoint]; - - // Use the hook to send the message - await sendStreamingMessage({ - prompt: userMessage.content, - previousResponseId: responseIdToUse || undefined, - filters: processedFilters, - limit: parsedFilterData?.limit ?? 10, - scoreThreshold: parsedFilterData?.scoreThreshold ?? 0, - }); - scrollToBottom({ - animation: "smooth", - duration: 1000, - }); - }; - - const handleSendMessage = async ( - inputMessage: string, - previousResponseId?: string, - ) => { - if (!inputMessage.trim() || loading) return; - - const userMessage: Message = { - role: "user", - content: inputMessage.trim(), - timestamp: new Date(), - }; - - setMessages((prev) => [...prev, userMessage]); - setInput(""); - setLoading(true); - setIsFilterHighlighted(false); - - scrollToBottom({ - animation: "smooth", - duration: 1000, - }); - - if (asyncMode) { - await handleSSEStream(userMessage, previousResponseId); - } else { - // Original non-streaming logic - try { - const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"; - - const requestBody: RequestBody = { - prompt: userMessage.content, - ...(parsedFilterData?.filters && - (() => { - const filters = parsedFilterData.filters; - const processed: SelectedFilters = { - data_sources: [], - document_types: [], - owners: [], - }; - // Only copy non-wildcard arrays - processed.data_sources = filters.data_sources.includes("*") - ? [] - : filters.data_sources; - processed.document_types = filters.document_types.includes("*") - ? [] - : filters.document_types; - processed.owners = filters.owners.includes("*") - ? [] - : filters.owners; - - // Only include filters if any array has values - const hasFilters = - processed.data_sources.length > 0 || - processed.document_types.length > 0 || - processed.owners.length > 0; - return hasFilters ? { filters: processed } : {}; - })()), - limit: parsedFilterData?.limit ?? 10, - scoreThreshold: parsedFilterData?.scoreThreshold ?? 0, - }; - - // Add previous_response_id if we have one for this endpoint - const currentResponseId = previousResponseIds[endpoint]; - if (currentResponseId) { - requestBody.previous_response_id = currentResponseId; - } - - const response = await fetch(apiEndpoint, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - const result = await response.json(); - - if (response.ok) { - const assistantMessage: Message = { - role: "assistant", - content: result.response, - timestamp: new Date(), - }; - setMessages((prev) => [...prev, assistantMessage]); - if (result.response_id) { - cancelNudges(); - } - - // Store the response ID if present for this endpoint - if (result.response_id) { - setPreviousResponseIds((prev) => ({ - ...prev, - [endpoint]: result.response_id, - })); - - // If this is a new conversation (no currentConversationId), set it now - if (!currentConversationId) { - setCurrentConversationId(result.response_id); - refreshConversations(true); - } else { - // For existing conversations, do a silent refresh to keep backend in sync - refreshConversationsSilent(); - } - } - } else { - console.error("Chat failed:", result.error); - const errorMessage: Message = { - role: "assistant", - content: "Sorry, I encountered an error. Please try again.", - timestamp: new Date(), - }; - setMessages((prev) => [...prev, errorMessage]); - } - } catch (error) { - console.error("Chat error:", error); - const errorMessage: Message = { - role: "assistant", - content: - "Sorry, I couldn't connect to the chat service. Please try again.", - timestamp: new Date(), - }; - setMessages((prev) => [...prev, errorMessage]); - } - } - - setLoading(false); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - // Check if there's an uploaded file and upload it first - let uploadedResponseId: string | null = null; - if (uploadedFile) { - // Upload the file first - const responseId = await handleFileUpload(uploadedFile); - // Clear the file after upload - setUploadedFile(null); - - // If the upload resulted in a new conversation, store the response ID - if (responseId) { - uploadedResponseId = responseId; - setPreviousResponseIds((prev) => ({ - ...prev, - [endpoint]: responseId, - })); - } - } - - // Only send message if there's input text - if (input.trim() || uploadedFile) { - // Pass the responseId from upload (if any) to handleSendMessage - handleSendMessage( - !input.trim() ? FILE_CONFIRMATION : input, - uploadedResponseId || undefined, - ); - } - }; - - const toggleFunctionCall = (functionCallId: string) => { - setExpandedFunctionCalls((prev) => { - const newSet = new Set(prev); - if (newSet.has(functionCallId)) { - newSet.delete(functionCallId); - } else { - newSet.add(functionCallId); - } - return newSet; - }); - }; - - const handleForkConversation = ( - messageIndex: number, - event?: React.MouseEvent, - ) => { - // Prevent any default behavior and stop event propagation - if (event) { - event.preventDefault(); - event.stopPropagation(); - } - - // Set interaction state to prevent auto-scroll interference - setIsUserInteracting(true); - setIsForkingInProgress(true); - - console.log("Fork conversation called for message index:", messageIndex); - - // Get messages up to and including the selected assistant message - const messagesToKeep = messages.slice(0, messageIndex + 1); - - // The selected message should be an assistant message (since fork button is only on assistant messages) - const forkedMessage = messages[messageIndex]; - if (forkedMessage.role !== "assistant") { - console.error("Fork button should only be on assistant messages"); - setIsUserInteracting(false); - setIsForkingInProgress(false); - return; - } - - // For forking, we want to continue from the response_id of the assistant message we're forking from - // Since we don't store individual response_ids per message yet, we'll use the current conversation's response_id - // This means we're continuing the conversation thread from that point - const responseIdToForkFrom = - currentConversationId || previousResponseIds[endpoint]; - - // Create a new conversation by properly forking - setMessages(messagesToKeep); - - // Use the chat context's fork method which handles creating a new conversation properly - if (forkFromResponse) { - forkFromResponse(responseIdToForkFrom || ""); - } else { - // Fallback to manual approach - setCurrentConversationId(null); // This creates a new conversation thread - - // Set the response_id we want to continue from as the previous response ID - // This tells the backend to continue the conversation from this point - setPreviousResponseIds((prev) => ({ - ...prev, - [endpoint]: responseIdToForkFrom, - })); - } - - console.log("Forked conversation with", messagesToKeep.length, "messages"); - - // Reset interaction state after a longer delay to ensure all effects complete - setTimeout(() => { - setIsUserInteracting(false); - setIsForkingInProgress(false); - console.log("Fork interaction complete, re-enabling auto effects"); - }, 500); - - // The original conversation remains unchanged in the sidebar - // This new forked conversation will get its own response_id when the user sends the next message - }; - - const handleSuggestionClick = (suggestion: string) => { - handleSendMessage(suggestion); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - // Handle backspace for filter clearing - if (e.key === "Backspace" && selectedFilter && input.trim() === "") { - e.preventDefault(); - - if (isFilterHighlighted) { - // Second backspace - remove the filter - setSelectedFilter(null); - setIsFilterHighlighted(false); - } else { - // First backspace - highlight the filter - setIsFilterHighlighted(true); - } - return; - } - - if (isFilterDropdownOpen) { - const filteredFilters = availableFilters.filter((filter) => - filter.name.toLowerCase().includes(filterSearchTerm.toLowerCase()), - ); - - if (e.key === "Escape") { - e.preventDefault(); - setIsFilterDropdownOpen(false); - setFilterSearchTerm(""); - setSelectedFilterIndex(0); - setDropdownDismissed(true); - - // Keep focus on the textarea so user can continue typing normally - chatInputRef.current?.focusInput(); - return; - } - - if (e.key === "ArrowDown") { - e.preventDefault(); - setSelectedFilterIndex((prev) => - prev < filteredFilters.length - 1 ? prev + 1 : 0, - ); - return; - } - - if (e.key === "ArrowUp") { - e.preventDefault(); - setSelectedFilterIndex((prev) => - prev > 0 ? prev - 1 : filteredFilters.length - 1, - ); - return; - } - - if (e.key === "Enter") { - // Check if we're at the end of an @ mention (space before cursor or end of input) - const cursorPos = e.currentTarget.selectionStart || 0; - const textBeforeCursor = input.slice(0, cursorPos); - const words = textBeforeCursor.split(" "); - const lastWord = words[words.length - 1]; - - if (lastWord.startsWith("@") && filteredFilters[selectedFilterIndex]) { - e.preventDefault(); - handleFilterSelect(filteredFilters[selectedFilterIndex]); - return; - } - } - - if (e.key === " ") { - // Select filter on space if we're typing an @ mention - const cursorPos = e.currentTarget.selectionStart || 0; - const textBeforeCursor = input.slice(0, cursorPos); - const words = textBeforeCursor.split(" "); - const lastWord = words[words.length - 1]; - - if (lastWord.startsWith("@") && filteredFilters[selectedFilterIndex]) { - e.preventDefault(); - handleFilterSelect(filteredFilters[selectedFilterIndex]); - return; - } - } - } - - if (e.key === "Enter" && !e.shiftKey && !isFilterDropdownOpen) { - e.preventDefault(); - if (input.trim() && !loading) { - // Trigger form submission by finding the form and calling submit - const form = e.currentTarget.closest("form"); - if (form) { - form.requestSubmit(); - } - } - } - }; - - const onChange = (e: React.ChangeEvent) => { - const newValue = e.target.value; - setInput(newValue); - - // Clear filter highlight when user starts typing - if (isFilterHighlighted) { - setIsFilterHighlighted(false); - } - - // Find if there's an @ at the start of the last word - const words = newValue.split(" "); - const lastWord = words[words.length - 1]; - - if (lastWord.startsWith("@") && !dropdownDismissed) { - const searchTerm = lastWord.slice(1); // Remove the @ - console.log("Setting search term:", searchTerm); - setFilterSearchTerm(searchTerm); - setSelectedFilterIndex(0); - - // Only set anchor position when @ is first detected (search term is empty) - if (searchTerm === "") { - const pos = getCursorPosition(e.target); - setAnchorPosition(pos); - } - - if (!isFilterDropdownOpen) { - loadAvailableFilters(); - setIsFilterDropdownOpen(true); - } - } else if (isFilterDropdownOpen) { - // Close dropdown if @ is no longer present - console.log("Closing dropdown - no @ found"); - setIsFilterDropdownOpen(false); - setFilterSearchTerm(""); - } - - // Reset dismissed flag when user moves to a different word - if (dropdownDismissed && !lastWord.startsWith("@")) { - setDropdownDismissed(false); - } - }; - - const onAtClick = () => { - if (!isFilterDropdownOpen) { - loadAvailableFilters(); - setIsFilterDropdownOpen(true); - setFilterSearchTerm(""); - setSelectedFilterIndex(0); - - // Get button position for popover anchoring - const button = document.querySelector( - "[data-filter-button]", - ) as HTMLElement; - if (button) { - const rect = button.getBoundingClientRect(); - setAnchorPosition({ - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2 - 12, - }); - } - } else { - setIsFilterDropdownOpen(false); - setAnchorPosition(null); - } - }; - - return ( - <> - {/* Debug header - only show in debug mode */} - {isDebugMode && ( -
-
-
- {/* Async Mode Toggle */} -
- - -
- {/* Endpoint Toggle */} -
- - -
-
-
- )} - - -
- {messages.length === 0 && !streamingMessage ? ( -
-
- {isUploading ? ( - <> - -

Processing your document...

-

This may take a few moments

- - ) : null} -
-
- ) : ( - <> - {messages.map((message, index) => - message.role === "user" - ? (messages[index]?.content.match(FILES_REGEX)?.[0] ?? - null) === null && ( -
- = 2 && - (messages[index - 2]?.content.match( - FILES_REGEX, - )?.[0] ?? - undefined) && - message.content === FILE_CONFIRMATION - ? undefined - : message.content - } - files={ - index >= 2 - ? (messages[index - 2]?.content.match( - FILES_REGEX, - )?.[0] ?? undefined) - : undefined - } - /> -
- ) - : message.role === "assistant" && - (index < 1 || - (messages[index - 1]?.content.match(FILES_REGEX)?.[0] ?? - null) === null) && ( -
- handleForkConversation(index, e)} - animate={false} - isInactive={index < messages.length - 1} - /> -
- ), - )} - - {/* Streaming Message Display */} - {streamingMessage && ( - - )} - - )} - {!streamingMessage && ( -
- -
- )} -
-
-
- {/* Input Area - Fixed at bottom */} - -
- - ); + const isDebugMode = process.env.NEXT_PUBLIC_OPENRAG_DEBUG === "true"; + const { + endpoint, + setEndpoint, + currentConversationId, + conversationData, + setCurrentConversationId, + addConversationDoc, + forkFromResponse, + refreshConversations, + refreshConversationsSilent, + previousResponseIds, + setPreviousResponseIds, + placeholderConversation, + } = useChat(); + const [messages, setMessages] = useState([ + { + role: "assistant", + content: "How can I assist?", + timestamp: new Date(), + }, + ]); + const [input, setInput] = useState(""); + const { loading, setLoading } = useLoadingStore(); + const [asyncMode, setAsyncMode] = useState(true); + const [expandedFunctionCalls, setExpandedFunctionCalls] = useState< + Set + >(new Set()); + // previousResponseIds now comes from useChat context + const [isUploading, setIsUploading] = useState(false); + const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); + const [availableFilters, setAvailableFilters] = useState< + KnowledgeFilterData[] + >([]); + const [filterSearchTerm, setFilterSearchTerm] = useState(""); + const [selectedFilterIndex, setSelectedFilterIndex] = useState(0); + const [isFilterHighlighted, setIsFilterHighlighted] = useState(false); + const [dropdownDismissed, setDropdownDismissed] = useState(false); + const [isUserInteracting, setIsUserInteracting] = useState(false); + const [isForkingInProgress, setIsForkingInProgress] = useState(false); + const [anchorPosition, setAnchorPosition] = useState<{ + x: number; + y: number; + } | null>(null); + const [uploadedFile, setUploadedFile] = useState(null); + + const chatInputRef = useRef(null); + + const { scrollToBottom } = useStickToBottomContext(); + + const lastLoadedConversationRef = useRef(null); + const { addTask } = useTask(); + const { selectedFilter, parsedFilterData, setSelectedFilter } = + useKnowledgeFilter(); + + // Use the chat streaming hook + const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"; + const { + streamingMessage, + sendMessage: sendStreamingMessage, + abortStream, + } = useChatStreaming({ + endpoint: apiEndpoint, + onComplete: (message, responseId) => { + setMessages((prev) => [...prev, message]); + setLoading(false); + + if (responseId) { + cancelNudges(); + setPreviousResponseIds((prev) => ({ + ...prev, + [endpoint]: responseId, + })); + + if (!currentConversationId) { + setCurrentConversationId(responseId); + refreshConversations(true); + } else { + refreshConversationsSilent(); + } + } + }, + onError: (error) => { + console.error("Streaming error:", error); + setLoading(false); + const errorMessage: Message = { + role: "assistant", + content: + "Sorry, I couldn't connect to the chat service. Please try again.", + timestamp: new Date(), + }; + setMessages((prev) => [...prev, errorMessage]); + }, + }); + + const getCursorPosition = (textarea: HTMLTextAreaElement) => { + // Create a hidden div with the same styles as the textarea + const div = document.createElement("div"); + const computedStyle = getComputedStyle(textarea); + + // Copy all computed styles to the hidden div + for (const style of computedStyle) { + (div.style as unknown as Record)[style] = + computedStyle.getPropertyValue(style); + } + + // Set the div to be hidden but not un-rendered + div.style.position = "absolute"; + div.style.visibility = "hidden"; + div.style.whiteSpace = "pre-wrap"; + div.style.wordWrap = "break-word"; + div.style.overflow = "hidden"; + div.style.height = "auto"; + div.style.width = `${textarea.getBoundingClientRect().width}px`; + + // Get the text up to the cursor position + const cursorPos = textarea.selectionStart || 0; + const textBeforeCursor = textarea.value.substring(0, cursorPos); + + // Add the text before cursor + div.textContent = textBeforeCursor; + + // Create a span to mark the end position + const span = document.createElement("span"); + span.textContent = "|"; // Cursor marker + div.appendChild(span); + + // Add the text after cursor to handle word wrapping + const textAfterCursor = textarea.value.substring(cursorPos); + div.appendChild(document.createTextNode(textAfterCursor)); + + // Add the div to the document temporarily + document.body.appendChild(div); + + // Get positions + const inputRect = textarea.getBoundingClientRect(); + const divRect = div.getBoundingClientRect(); + const spanRect = span.getBoundingClientRect(); + + // Calculate the cursor position relative to the input + const x = inputRect.left + (spanRect.left - divRect.left); + const y = inputRect.top + (spanRect.top - divRect.top); + + // Clean up + document.body.removeChild(div); + + return { x, y }; + }; + + const handleEndpointChange = (newEndpoint: EndpointType) => { + setEndpoint(newEndpoint); + // Clear the conversation when switching endpoints to avoid response ID conflicts + setMessages([]); + setPreviousResponseIds({ chat: null, langflow: null }); + }; + + const handleFileUpload = async (file: File) => { + console.log("handleFileUpload called with file:", file.name); + + if (isUploading) return; + + setIsUploading(true); + setLoading(true); + + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("endpoint", endpoint); + + // Add previous_response_id if we have one for this endpoint + const currentResponseId = previousResponseIds[endpoint]; + if (currentResponseId) { + formData.append("previous_response_id", currentResponseId); + } + + const response = await fetch("/api/upload_context", { + method: "POST", + body: formData, + }); + + console.log("Upload response status:", response.status); + + if (!response.ok) { + const errorText = await response.text(); + console.error( + "Upload failed with status:", + response.status, + "Response:", + errorText, + ); + throw new Error("Failed to process document"); + } + + const result = await response.json(); + console.log("Upload result:", result); + + if (response.status === 201) { + // New flow: Got task ID, start tracking with centralized system + const taskId = result.task_id || result.id; + + if (!taskId) { + console.error("No task ID in 201 response:", result); + throw new Error("No task ID received from server"); + } + + // Add task to centralized tracking + addTask(taskId); + + return null; + } else if (response.ok) { + // Original flow: Direct response + + const uploadMessage: Message = { + role: "user", + content: `I'm uploading a document called "${result.filename}". Here is its content:`, + timestamp: new Date(), + }; + + const confirmationMessage: Message = { + role: "assistant", + content: `Confirmed`, + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, uploadMessage, confirmationMessage]); + + // Add file to conversation docs + if (result.filename) { + addConversationDoc(result.filename); + } + + // Update the response ID for this endpoint + if (result.response_id) { + setPreviousResponseIds((prev) => ({ + ...prev, + [endpoint]: result.response_id, + })); + + // If this is a new conversation (no currentConversationId), set it now + if (!currentConversationId) { + setCurrentConversationId(result.response_id); + refreshConversations(true); + } else { + // For existing conversations, do a silent refresh to keep backend in sync + refreshConversationsSilent(); + } + + return result.response_id; + } + } else { + throw new Error(`Upload failed: ${response.status}`); + } + } catch (error) { + console.error("Upload failed:", error); + const errorMessage: Message = { + role: "assistant", + content: `❌ Failed to process document. Please try again.`, + timestamp: new Date(), + }; + setMessages((prev) => [...prev.slice(0, -1), errorMessage]); + } finally { + setIsUploading(false); + setLoading(false); + } + }; + + const handleFilePickerClick = () => { + chatInputRef.current?.clickFileInput(); + }; + + const loadAvailableFilters = async () => { + try { + const response = await fetch("/api/knowledge-filter/search", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: "", + limit: 20, + }), + }); + + const result = await response.json(); + if (response.ok && result.success) { + setAvailableFilters(result.filters); + } else { + console.error("Failed to load knowledge filters:", result.error); + setAvailableFilters([]); + } + } catch (error) { + console.error("Failed to load knowledge filters:", error); + setAvailableFilters([]); + } + }; + + const handleFilterSelect = (filter: KnowledgeFilterData | null) => { + setSelectedFilter(filter); + setIsFilterDropdownOpen(false); + setFilterSearchTerm(""); + setIsFilterHighlighted(false); + + // Remove the @searchTerm from the input and replace with filter pill + const words = input.split(" "); + const lastWord = words[words.length - 1]; + + if (lastWord.startsWith("@")) { + // Remove the @search term + words.pop(); + setInput(words.join(" ") + (words.length > 0 ? " " : "")); + } + }; + + // Reset selected index when search term changes + useEffect(() => { + setSelectedFilterIndex(0); + }, []); + + // Auto-focus the input on component mount + useEffect(() => { + chatInputRef.current?.focusInput(); + }, []); + + // Explicitly handle external new conversation trigger + useEffect(() => { + const handleNewConversation = () => { + // Abort any in-flight streaming so it doesn't bleed into new chat + abortStream(); + // Reset chat UI even if context state was already 'new' + setMessages([ + { + role: "assistant", + content: "How can I assist?", + timestamp: new Date(), + }, + ]); + setInput(""); + setExpandedFunctionCalls(new Set()); + setIsFilterHighlighted(false); + setLoading(false); + lastLoadedConversationRef.current = null; + }; + + const handleFocusInput = () => { + chatInputRef.current?.focusInput(); + }; + + window.addEventListener("newConversation", handleNewConversation); + window.addEventListener("focusInput", handleFocusInput); + return () => { + window.removeEventListener("newConversation", handleNewConversation); + window.removeEventListener("focusInput", handleFocusInput); + }; + }, [abortStream, setLoading]); + + // Load conversation only when user explicitly selects a conversation + useEffect(() => { + // Only load conversation data when: + // 1. conversationData exists AND + // 2. It's different from the last loaded conversation AND + // 3. User is not in the middle of an interaction + if ( + conversationData && + conversationData.messages && + lastLoadedConversationRef.current !== conversationData.response_id && + !isUserInteracting && + !isForkingInProgress + ) { + console.log( + "Loading conversation with", + conversationData.messages.length, + "messages", + ); + // Convert backend message format to frontend Message interface + const convertedMessages: Message[] = conversationData.messages.map( + (msg: { + role: string; + content: string; + timestamp?: string; + response_id?: string; + chunks?: Array<{ + item?: { + type?: string; + tool_name?: string; + id?: string; + inputs?: unknown; + results?: unknown; + status?: string; + }; + delta?: { + tool_calls?: Array<{ + id?: string; + function?: { name?: string; arguments?: string }; + type?: string; + }>; + }; + type?: string; + result?: unknown; + output?: unknown; + response?: unknown; + }>; + response_data?: unknown; + }) => { + const message: Message = { + role: msg.role as "user" | "assistant", + content: msg.content, + timestamp: new Date(msg.timestamp || new Date()), + }; + + // Extract function calls from chunks or response_data + if (msg.role === "assistant" && (msg.chunks || msg.response_data)) { + const functionCalls: FunctionCall[] = []; + console.log("Processing assistant message for function calls:", { + hasChunks: !!msg.chunks, + chunksLength: msg.chunks?.length, + hasResponseData: !!msg.response_data, + }); + + // Process chunks (streaming data) + if (msg.chunks && Array.isArray(msg.chunks)) { + for (const chunk of msg.chunks) { + // Handle Langflow format: chunks[].item.tool_call + if (chunk.item && chunk.item.type === "tool_call") { + const toolCall = chunk.item; + console.log("Found Langflow tool call:", toolCall); + functionCalls.push({ + id: toolCall.id || "", + name: toolCall.tool_name || "unknown", + arguments: + (toolCall.inputs as Record) || {}, + argumentsString: JSON.stringify(toolCall.inputs || {}), + result: toolCall.results as + | Record + | ToolCallResult[], + status: + (toolCall.status as "pending" | "completed" | "error") || + "completed", + type: "tool_call", + }); + } + // Handle OpenAI format: chunks[].delta.tool_calls + else if (chunk.delta?.tool_calls) { + for (const toolCall of chunk.delta.tool_calls) { + if (toolCall.function) { + functionCalls.push({ + id: toolCall.id || "", + name: toolCall.function.name || "unknown", + arguments: toolCall.function.arguments + ? JSON.parse(toolCall.function.arguments) + : {}, + argumentsString: toolCall.function.arguments || "", + status: "completed", + type: toolCall.type || "function", + }); + } + } + } + // Process tool call results from chunks + if ( + chunk.type === "response.tool_call.result" || + chunk.type === "tool_call_result" + ) { + const lastCall = functionCalls[functionCalls.length - 1]; + if (lastCall) { + lastCall.result = + (chunk.result as + | Record + | ToolCallResult[]) || + (chunk as Record); + lastCall.status = "completed"; + } + } + } + } + + // Process response_data (non-streaming data) + if (msg.response_data && typeof msg.response_data === "object") { + // Look for tool_calls in various places in the response data + const responseData = + typeof msg.response_data === "string" + ? JSON.parse(msg.response_data) + : msg.response_data; + + if ( + responseData.tool_calls && + Array.isArray(responseData.tool_calls) + ) { + for (const toolCall of responseData.tool_calls) { + functionCalls.push({ + id: toolCall.id, + name: toolCall.function?.name || toolCall.name, + arguments: + toolCall.function?.arguments || toolCall.arguments, + argumentsString: + typeof ( + toolCall.function?.arguments || toolCall.arguments + ) === "string" + ? toolCall.function?.arguments || toolCall.arguments + : JSON.stringify( + toolCall.function?.arguments || toolCall.arguments, + ), + result: toolCall.result, + status: "completed", + type: toolCall.type || "function", + }); + } + } + } + + if (functionCalls.length > 0) { + console.log("Setting functionCalls on message:", functionCalls); + message.functionCalls = functionCalls; + } else { + console.log("No function calls found in message"); + } + } + + return message; + }, + ); + + setMessages(convertedMessages); + lastLoadedConversationRef.current = conversationData.response_id; + + // Set the previous response ID for this conversation + setPreviousResponseIds((prev) => ({ + ...prev, + [conversationData.endpoint]: conversationData.response_id, + })); + } + }, [ + conversationData, + isUserInteracting, + isForkingInProgress, + setPreviousResponseIds, + ]); + + // Handle new conversation creation - only reset messages when placeholderConversation is set + useEffect(() => { + if (placeholderConversation && currentConversationId === null) { + console.log("Starting new conversation"); + setMessages([ + { + role: "assistant", + content: "How can I assist?", + timestamp: new Date(), + }, + ]); + lastLoadedConversationRef.current = null; + } + }, [placeholderConversation, currentConversationId]); + + // Listen for file upload events from navigation + useEffect(() => { + const handleFileUploadStart = (event: CustomEvent) => { + const { filename } = event.detail; + console.log("Chat page received file upload start event:", filename); + + setLoading(true); + setIsUploading(true); + setUploadedFile(null); // Clear previous file + }; + + const handleFileUploaded = (event: CustomEvent) => { + const { result } = event.detail; + console.log("Chat page received file upload event:", result); + + setUploadedFile(null); // Clear file after upload + + // Update the response ID for this endpoint + if (result.response_id) { + setPreviousResponseIds((prev) => ({ + ...prev, + [endpoint]: result.response_id, + })); + } + }; + + const handleFileUploadComplete = () => { + console.log("Chat page received file upload complete event"); + setLoading(false); + setIsUploading(false); + }; + + const handleFileUploadError = (event: CustomEvent) => { + const { filename, error } = event.detail; + console.log( + "Chat page received file upload error event:", + filename, + error, + ); + + // Replace the last message with error message + const errorMessage: Message = { + role: "assistant", + content: `❌ Upload failed for **${filename}**: ${error}`, + timestamp: new Date(), + }; + setMessages((prev) => [...prev.slice(0, -1), errorMessage]); + setUploadedFile(null); // Clear file on error + }; + + window.addEventListener( + "fileUploadStart", + handleFileUploadStart as EventListener, + ); + window.addEventListener( + "fileUploaded", + handleFileUploaded as EventListener, + ); + window.addEventListener( + "fileUploadComplete", + handleFileUploadComplete as EventListener, + ); + window.addEventListener( + "fileUploadError", + handleFileUploadError as EventListener, + ); + + return () => { + window.removeEventListener( + "fileUploadStart", + handleFileUploadStart as EventListener, + ); + window.removeEventListener( + "fileUploaded", + handleFileUploaded as EventListener, + ); + window.removeEventListener( + "fileUploadComplete", + handleFileUploadComplete as EventListener, + ); + window.removeEventListener( + "fileUploadError", + handleFileUploadError as EventListener, + ); + }; + }, [endpoint, setPreviousResponseIds, setLoading]); + + // Check if onboarding is complete by looking at local storage + const [isOnboardingComplete, setIsOnboardingComplete] = useState(() => { + if (typeof window === "undefined") return false; + return localStorage.getItem("onboarding-step") === null; + }); + + // Listen for storage changes to detect when onboarding completes + useEffect(() => { + const checkOnboarding = () => { + if (typeof window !== "undefined") { + setIsOnboardingComplete( + localStorage.getItem("onboarding-step") === null, + ); + } + }; + + // Check periodically since storage events don't fire in the same tab + const interval = setInterval(checkOnboarding, 500); + + return () => clearInterval(interval); + }, []); + + // Prepare filters for nudges (same as chat) + const processedFiltersForNudges = parsedFilterData?.filters + ? (() => { + const filters = parsedFilterData.filters; + const processed: SelectedFilters = { + data_sources: [], + document_types: [], + owners: [], + }; + processed.data_sources = filters.data_sources.includes("*") + ? [] + : filters.data_sources; + processed.document_types = filters.document_types.includes("*") + ? [] + : filters.document_types; + processed.owners = filters.owners.includes("*") ? [] : filters.owners; + + const hasFilters = + processed.data_sources.length > 0 || + processed.document_types.length > 0 || + processed.owners.length > 0; + return hasFilters ? processed : undefined; + })() + : undefined; + + const { data: nudges = [], cancel: cancelNudges } = useGetNudgesQuery( + { + chatId: previousResponseIds[endpoint], + filters: processedFiltersForNudges, + limit: parsedFilterData?.limit ?? 3, + scoreThreshold: parsedFilterData?.scoreThreshold ?? 0, + }, + { + enabled: isOnboardingComplete, // Only fetch nudges after onboarding is complete + }, + ); + + const handleSSEStream = async ( + userMessage: Message, + previousResponseId?: string, + ) => { + // Prepare filters + const processedFilters = parsedFilterData?.filters + ? (() => { + const filters = parsedFilterData.filters; + const processed: SelectedFilters = { + data_sources: [], + document_types: [], + owners: [], + }; + processed.data_sources = filters.data_sources.includes("*") + ? [] + : filters.data_sources; + processed.document_types = filters.document_types.includes("*") + ? [] + : filters.document_types; + processed.owners = filters.owners.includes("*") ? [] : filters.owners; + + const hasFilters = + processed.data_sources.length > 0 || + processed.document_types.length > 0 || + processed.owners.length > 0; + return hasFilters ? processed : undefined; + })() + : undefined; + + // Use passed previousResponseId if available, otherwise fall back to state + const responseIdToUse = previousResponseId || previousResponseIds[endpoint]; + + // Use the hook to send the message + await sendStreamingMessage({ + prompt: userMessage.content, + previousResponseId: responseIdToUse || undefined, + filters: processedFilters, + limit: parsedFilterData?.limit ?? 10, + scoreThreshold: parsedFilterData?.scoreThreshold ?? 0, + }); + scrollToBottom({ + animation: "smooth", + duration: 1000, + }); + }; + + const handleSendMessage = async ( + inputMessage: string, + previousResponseId?: string, + ) => { + if (!inputMessage.trim() || loading) return; + + const userMessage: Message = { + role: "user", + content: inputMessage.trim(), + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, userMessage]); + setInput(""); + setLoading(true); + setIsFilterHighlighted(false); + + scrollToBottom({ + animation: "smooth", + duration: 1000, + }); + + if (asyncMode) { + await handleSSEStream(userMessage, previousResponseId); + } else { + // Original non-streaming logic + try { + const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"; + + const requestBody: RequestBody = { + prompt: userMessage.content, + ...(parsedFilterData?.filters && + (() => { + const filters = parsedFilterData.filters; + const processed: SelectedFilters = { + data_sources: [], + document_types: [], + owners: [], + }; + // Only copy non-wildcard arrays + processed.data_sources = filters.data_sources.includes("*") + ? [] + : filters.data_sources; + processed.document_types = filters.document_types.includes("*") + ? [] + : filters.document_types; + processed.owners = filters.owners.includes("*") + ? [] + : filters.owners; + + // Only include filters if any array has values + const hasFilters = + processed.data_sources.length > 0 || + processed.document_types.length > 0 || + processed.owners.length > 0; + return hasFilters ? { filters: processed } : {}; + })()), + limit: parsedFilterData?.limit ?? 10, + scoreThreshold: parsedFilterData?.scoreThreshold ?? 0, + }; + + // Add previous_response_id if we have one for this endpoint + const currentResponseId = previousResponseIds[endpoint]; + if (currentResponseId) { + requestBody.previous_response_id = currentResponseId; + } + + const response = await fetch(apiEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + const result = await response.json(); + + if (response.ok) { + const assistantMessage: Message = { + role: "assistant", + content: result.response, + timestamp: new Date(), + }; + setMessages((prev) => [...prev, assistantMessage]); + if (result.response_id) { + cancelNudges(); + } + + // Store the response ID if present for this endpoint + if (result.response_id) { + setPreviousResponseIds((prev) => ({ + ...prev, + [endpoint]: result.response_id, + })); + + // If this is a new conversation (no currentConversationId), set it now + if (!currentConversationId) { + setCurrentConversationId(result.response_id); + refreshConversations(true); + } else { + // For existing conversations, do a silent refresh to keep backend in sync + refreshConversationsSilent(); + } + } + } else { + console.error("Chat failed:", result.error); + const errorMessage: Message = { + role: "assistant", + content: "Sorry, I encountered an error. Please try again.", + timestamp: new Date(), + }; + setMessages((prev) => [...prev, errorMessage]); + } + } catch (error) { + console.error("Chat error:", error); + const errorMessage: Message = { + role: "assistant", + content: + "Sorry, I couldn't connect to the chat service. Please try again.", + timestamp: new Date(), + }; + setMessages((prev) => [...prev, errorMessage]); + } + } + + setLoading(false); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Check if there's an uploaded file and upload it first + let uploadedResponseId: string | null = null; + if (uploadedFile) { + // Upload the file first + const responseId = await handleFileUpload(uploadedFile); + // Clear the file after upload + setUploadedFile(null); + + // If the upload resulted in a new conversation, store the response ID + if (responseId) { + uploadedResponseId = responseId; + setPreviousResponseIds((prev) => ({ + ...prev, + [endpoint]: responseId, + })); + } + } + + // Only send message if there's input text + if (input.trim() || uploadedFile) { + // Pass the responseId from upload (if any) to handleSendMessage + handleSendMessage( + !input.trim() ? FILE_CONFIRMATION : input, + uploadedResponseId || undefined, + ); + } + }; + + const toggleFunctionCall = (functionCallId: string) => { + setExpandedFunctionCalls((prev) => { + const newSet = new Set(prev); + if (newSet.has(functionCallId)) { + newSet.delete(functionCallId); + } else { + newSet.add(functionCallId); + } + return newSet; + }); + }; + + const handleForkConversation = ( + messageIndex: number, + event?: React.MouseEvent, + ) => { + // Prevent any default behavior and stop event propagation + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + + // Set interaction state to prevent auto-scroll interference + setIsUserInteracting(true); + setIsForkingInProgress(true); + + console.log("Fork conversation called for message index:", messageIndex); + + // Get messages up to and including the selected assistant message + const messagesToKeep = messages.slice(0, messageIndex + 1); + + // The selected message should be an assistant message (since fork button is only on assistant messages) + const forkedMessage = messages[messageIndex]; + if (forkedMessage.role !== "assistant") { + console.error("Fork button should only be on assistant messages"); + setIsUserInteracting(false); + setIsForkingInProgress(false); + return; + } + + // For forking, we want to continue from the response_id of the assistant message we're forking from + // Since we don't store individual response_ids per message yet, we'll use the current conversation's response_id + // This means we're continuing the conversation thread from that point + const responseIdToForkFrom = + currentConversationId || previousResponseIds[endpoint]; + + // Create a new conversation by properly forking + setMessages(messagesToKeep); + + // Use the chat context's fork method which handles creating a new conversation properly + if (forkFromResponse) { + forkFromResponse(responseIdToForkFrom || ""); + } else { + // Fallback to manual approach + setCurrentConversationId(null); // This creates a new conversation thread + + // Set the response_id we want to continue from as the previous response ID + // This tells the backend to continue the conversation from this point + setPreviousResponseIds((prev) => ({ + ...prev, + [endpoint]: responseIdToForkFrom, + })); + } + + console.log("Forked conversation with", messagesToKeep.length, "messages"); + + // Reset interaction state after a longer delay to ensure all effects complete + setTimeout(() => { + setIsUserInteracting(false); + setIsForkingInProgress(false); + console.log("Fork interaction complete, re-enabling auto effects"); + }, 500); + + // The original conversation remains unchanged in the sidebar + // This new forked conversation will get its own response_id when the user sends the next message + }; + + const handleSuggestionClick = (suggestion: string) => { + handleSendMessage(suggestion); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Handle backspace for filter clearing + if (e.key === "Backspace" && selectedFilter && input.trim() === "") { + e.preventDefault(); + + if (isFilterHighlighted) { + // Second backspace - remove the filter + setSelectedFilter(null); + setIsFilterHighlighted(false); + } else { + // First backspace - highlight the filter + setIsFilterHighlighted(true); + } + return; + } + + if (isFilterDropdownOpen) { + const filteredFilters = availableFilters.filter((filter) => + filter.name.toLowerCase().includes(filterSearchTerm.toLowerCase()), + ); + + if (e.key === "Escape") { + e.preventDefault(); + setIsFilterDropdownOpen(false); + setFilterSearchTerm(""); + setSelectedFilterIndex(0); + setDropdownDismissed(true); + + // Keep focus on the textarea so user can continue typing normally + chatInputRef.current?.focusInput(); + return; + } + + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedFilterIndex((prev) => + prev < filteredFilters.length - 1 ? prev + 1 : 0, + ); + return; + } + + if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedFilterIndex((prev) => + prev > 0 ? prev - 1 : filteredFilters.length - 1, + ); + return; + } + + if (e.key === "Enter") { + // Check if we're at the end of an @ mention (space before cursor or end of input) + const cursorPos = e.currentTarget.selectionStart || 0; + const textBeforeCursor = input.slice(0, cursorPos); + const words = textBeforeCursor.split(" "); + const lastWord = words[words.length - 1]; + + if (lastWord.startsWith("@") && filteredFilters[selectedFilterIndex]) { + e.preventDefault(); + handleFilterSelect(filteredFilters[selectedFilterIndex]); + return; + } + } + + if (e.key === " ") { + // Select filter on space if we're typing an @ mention + const cursorPos = e.currentTarget.selectionStart || 0; + const textBeforeCursor = input.slice(0, cursorPos); + const words = textBeforeCursor.split(" "); + const lastWord = words[words.length - 1]; + + if (lastWord.startsWith("@") && filteredFilters[selectedFilterIndex]) { + e.preventDefault(); + handleFilterSelect(filteredFilters[selectedFilterIndex]); + return; + } + } + } + + if (e.key === "Enter" && !e.shiftKey && !isFilterDropdownOpen) { + e.preventDefault(); + if (input.trim() && !loading) { + // Trigger form submission by finding the form and calling submit + const form = e.currentTarget.closest("form"); + if (form) { + form.requestSubmit(); + } + } + } + }; + + const onChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInput(newValue); + + // Clear filter highlight when user starts typing + if (isFilterHighlighted) { + setIsFilterHighlighted(false); + } + + // Find if there's an @ at the start of the last word + const words = newValue.split(" "); + const lastWord = words[words.length - 1]; + + if (lastWord.startsWith("@") && !dropdownDismissed) { + const searchTerm = lastWord.slice(1); // Remove the @ + console.log("Setting search term:", searchTerm); + setFilterSearchTerm(searchTerm); + setSelectedFilterIndex(0); + + // Only set anchor position when @ is first detected (search term is empty) + if (searchTerm === "") { + const pos = getCursorPosition(e.target); + setAnchorPosition(pos); + } + + if (!isFilterDropdownOpen) { + loadAvailableFilters(); + setIsFilterDropdownOpen(true); + } + } else if (isFilterDropdownOpen) { + // Close dropdown if @ is no longer present + console.log("Closing dropdown - no @ found"); + setIsFilterDropdownOpen(false); + setFilterSearchTerm(""); + } + + // Reset dismissed flag when user moves to a different word + if (dropdownDismissed && !lastWord.startsWith("@")) { + setDropdownDismissed(false); + } + }; + + const onAtClick = () => { + if (!isFilterDropdownOpen) { + loadAvailableFilters(); + setIsFilterDropdownOpen(true); + setFilterSearchTerm(""); + setSelectedFilterIndex(0); + + // Get button position for popover anchoring + const button = document.querySelector( + "[data-filter-button]", + ) as HTMLElement; + if (button) { + const rect = button.getBoundingClientRect(); + setAnchorPosition({ + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2 - 12, + }); + } + } else { + setIsFilterDropdownOpen(false); + setAnchorPosition(null); + } + }; + + return ( + <> + {/* Debug header - only show in debug mode */} + {isDebugMode && ( +
+
+
+ {/* Async Mode Toggle */} +
+ + +
+ {/* Endpoint Toggle */} +
+ + +
+
+
+ )} + + +
+ {messages.length === 0 && !streamingMessage ? ( +
+
+ {isUploading ? ( + <> + +

Processing your document...

+

This may take a few moments

+ + ) : null} +
+
+ ) : ( + <> + {messages.map((message, index) => + message.role === "user" + ? (messages[index]?.content.match(FILES_REGEX)?.[0] ?? + null) === null && ( +
+ = 2 && + (messages[index - 2]?.content.match( + FILES_REGEX, + )?.[0] ?? + undefined) && + message.content === FILE_CONFIRMATION + ? undefined + : message.content + } + files={ + index >= 2 + ? (messages[index - 2]?.content.match( + FILES_REGEX, + )?.[0] ?? undefined) + : undefined + } + /> +
+ ) + : message.role === "assistant" && + (index < 1 || + (messages[index - 1]?.content.match(FILES_REGEX)?.[0] ?? + null) === null) && ( +
+ handleForkConversation(index, e)} + animate={false} + isInactive={index < messages.length - 1} + /> +
+ ), + )} + + {/* Streaming Message Display */} + {streamingMessage && ( + + )} + + )} + {!streamingMessage && ( +
+ +
+ )} +
+
+
+ {/* Input Area - Fixed at bottom */} + +
+ + ); } export default function ProtectedChatPage() { - return ( - -
- - - -
-
- ); + return ( + +
+ + + +
+
+ ); } From 08428bc12d29835451e26c0c6bb7ee8c3d2e7894 Mon Sep 17 00:00:00 2001 From: Cole Goldsmith Date: Tue, 18 Nov 2025 14:34:46 -0600 Subject: [PATCH 4/5] format --- frontend/app/chat/_components/chat-input.tsx | 768 ++--- frontend/app/chat/page.tsx | 2682 +++++++++--------- 2 files changed, 1725 insertions(+), 1725 deletions(-) diff --git a/frontend/app/chat/_components/chat-input.tsx b/frontend/app/chat/_components/chat-input.tsx index 242f19d2..5796474a 100644 --- a/frontend/app/chat/_components/chat-input.tsx +++ b/frontend/app/chat/_components/chat-input.tsx @@ -6,9 +6,9 @@ import TextareaAutosize from "react-textarea-autosize"; import type { FilterColor } from "@/components/filter-icon-popover"; import { Button } from "@/components/ui/button"; import { - Popover, - PopoverAnchor, - PopoverContent, + Popover, + PopoverAnchor, + PopoverContent, } from "@/components/ui/popover"; import { useFileDrag } from "@/hooks/use-file-drag"; import { cn } from "@/lib/utils"; @@ -17,400 +17,400 @@ import { FilePreview } from "./file-preview"; import { SelectedKnowledgeFilter } from "./selected-knowledge-filter"; 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; - parsedFilterData: { color?: FilterColor } | null; - uploadedFile: File | null; - onSubmit: (e: React.FormEvent) => void; - onChange: (e: React.ChangeEvent) => void; - onKeyDown: (e: React.KeyboardEvent) => void; - onFilterSelect: (filter: KnowledgeFilterData | null) => void; - onAtClick: () => void; - onFilePickerClick: () => void; - setSelectedFilter: (filter: KnowledgeFilterData | null) => void; - setIsFilterHighlighted: (highlighted: boolean) => void; - setIsFilterDropdownOpen: (open: boolean) => void; - onFileSelected: (file: File | null) => void; + input: string; + loading: boolean; + isUploading: boolean; + selectedFilter: KnowledgeFilterData | null; + isFilterDropdownOpen: boolean; + availableFilters: KnowledgeFilterData[]; + filterSearchTerm: string; + selectedFilterIndex: number; + anchorPosition: { x: number; y: number } | null; + parsedFilterData: { color?: FilterColor } | null; + uploadedFile: File | null; + onSubmit: (e: React.FormEvent) => void; + onChange: (e: React.ChangeEvent) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onFilterSelect: (filter: KnowledgeFilterData | null) => void; + onAtClick: () => void; + onFilePickerClick: () => void; + setSelectedFilter: (filter: KnowledgeFilterData | null) => void; + setIsFilterHighlighted: (highlighted: boolean) => void; + setIsFilterDropdownOpen: (open: boolean) => void; + onFileSelected: (file: File | null) => void; } export const ChatInput = forwardRef( - ( - { - input, - loading, - isUploading, - selectedFilter, - isFilterDropdownOpen, - availableFilters, - filterSearchTerm, - selectedFilterIndex, - anchorPosition, - parsedFilterData, - uploadedFile, - onSubmit, - onChange, - onKeyDown, - onFilterSelect, - onAtClick, - onFilePickerClick, - setSelectedFilter, - setIsFilterHighlighted, - setIsFilterDropdownOpen, - onFileSelected, - }, - ref, - ) => { - const inputRef = useRef(null); - const fileInputRef = useRef(null); - const [textareaHeight, setTextareaHeight] = useState(0); - const [fileUploadError, setFileUploadError] = useState(null); - const isDragging = useFileDrag(); + ( + { + input, + loading, + isUploading, + selectedFilter, + isFilterDropdownOpen, + availableFilters, + filterSearchTerm, + selectedFilterIndex, + anchorPosition, + parsedFilterData, + uploadedFile, + onSubmit, + onChange, + onKeyDown, + onFilterSelect, + onAtClick, + onFilePickerClick, + setSelectedFilter, + setIsFilterHighlighted, + setIsFilterDropdownOpen, + onFileSelected, + }, + ref, + ) => { + const inputRef = useRef(null); + const fileInputRef = useRef(null); + const [textareaHeight, setTextareaHeight] = useState(0); + const [fileUploadError, setFileUploadError] = useState(null); + const isDragging = useFileDrag(); - const { getRootProps, getInputProps } = useDropzone({ - accept: { - "application/pdf": [".pdf"], - "application/msword": [".doc"], - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": - [".docx"], - "text/markdown": [".md"], - }, - maxFiles: 1, - disabled: !isDragging, - onDrop: (acceptedFiles, fileRejections) => { - setFileUploadError(null); - if (fileRejections.length > 0) { - console.log(fileRejections); - const message = fileRejections.at(0)?.errors.at(0)?.message; - setFileUploadError(new Error(message)); - return; - } - onFileSelected(acceptedFiles[0]); - }, - }); + const { getRootProps, getInputProps } = useDropzone({ + accept: { + "application/pdf": [".pdf"], + "application/msword": [".doc"], + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + [".docx"], + "text/markdown": [".md"], + }, + maxFiles: 1, + disabled: !isDragging, + onDrop: (acceptedFiles, fileRejections) => { + setFileUploadError(null); + if (fileRejections.length > 0) { + console.log(fileRejections); + const message = fileRejections.at(0)?.errors.at(0)?.message; + setFileUploadError(new Error(message)); + return; + } + onFileSelected(acceptedFiles[0]); + }, + }); - useImperativeHandle(ref, () => ({ - focusInput: () => { - inputRef.current?.focus(); - }, - clickFileInput: () => { - fileInputRef.current?.click(); - }, - })); + useImperativeHandle(ref, () => ({ + focusInput: () => { + inputRef.current?.focus(); + }, + clickFileInput: () => { + fileInputRef.current?.click(); + }, + })); - const handleFilePickerChange = (e: React.ChangeEvent) => { + const handleFilePickerChange = (e: React.ChangeEvent) => { setFileUploadError(null); - const files = e.target.files; - if (files && files.length > 0) { - onFileSelected(files[0]); - } else { - onFileSelected(null); - } - }; + const files = e.target.files; + if (files && files.length > 0) { + onFileSelected(files[0]); + } else { + onFileSelected(null); + } + }; - return ( -
-
- {/* Outer container - flex-col to stack file preview above input */} -
- - {/* File Preview Section - Always above */} - - {uploadedFile && ( - - { - onFileSelected(null); - }} - /> - - )} - - - {fileUploadError && ( - - {fileUploadError.message} - - )} - - - {isDragging && ( - -

- Add files to conversation -

-

- Text formats and image files.{" "} - 10 files per chat,{" "} - 150 MB each. -

-
- )} -
- {/* Main Input Container - flex-row or flex-col based on textarea height */} -
40 ? "flex-col" : "flex-row items-center" - }`} - > - {/* Filter + Textarea Section */} -
40 ? "w-full" : "flex-1"}`} - > - {textareaHeight <= 40 && - (selectedFilter ? ( - { - setSelectedFilter(null); - setIsFilterHighlighted(false); - }} - /> - ) : ( - - ))} -
- setTextareaHeight(height)} - maxRows={7} - autoComplete="off" - minRows={1} - placeholder="Ask a question..." - disabled={loading} - className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`} - rows={1} - /> -
-
+ return ( +
+ + {/* Outer container - flex-col to stack file preview above input */} +
+ + {/* File Preview Section - Always above */} + + {uploadedFile && ( + + { + onFileSelected(null); + }} + /> + + )} + + + {fileUploadError && ( + + {fileUploadError.message} + + )} + + + {isDragging && ( + +

+ Add files to conversation +

+

+ Text formats and image files.{" "} + 10 files per chat,{" "} + 150 MB each. +

+
+ )} +
+ {/* Main Input Container - flex-row or flex-col based on textarea height */} +
40 ? "flex-col" : "flex-row items-center" + }`} + > + {/* Filter + Textarea Section */} +
40 ? "w-full" : "flex-1"}`} + > + {textareaHeight <= 40 && + (selectedFilter ? ( + { + setSelectedFilter(null); + setIsFilterHighlighted(false); + }} + /> + ) : ( + + ))} +
+ setTextareaHeight(height)} + maxRows={7} + autoComplete="off" + minRows={1} + placeholder="Ask a question..." + disabled={loading} + className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`} + rows={1} + /> +
+
- {/* Action Buttons Section */} -
40 ? "justify-between w-full" : ""}`} - > - {textareaHeight > 40 && - (selectedFilter ? ( - { - setSelectedFilter(null); - setIsFilterHighlighted(false); - }} - /> - ) : ( - - ))} -
- - -
-
-
-
- + {/* Action Buttons Section */} +
40 ? "justify-between w-full" : ""}`} + > + {textareaHeight > 40 && + (selectedFilter ? ( + { + setSelectedFilter(null); + setIsFilterHighlighted(false); + }} + /> + ) : ( + + ))} +
+ + +
+
+
+
+ - { - setIsFilterDropdownOpen(open); - }} - > - {anchorPosition && ( - -
- - )} - { - // Prevent auto focus on the popover content - e.preventDefault(); - // Keep focus on the input - }} - > -
- {filterSearchTerm && ( -
- Searching: @{filterSearchTerm} -
- )} - {availableFilters.length === 0 ? ( -
- No knowledge filters available -
- ) : ( - <> - {!filterSearchTerm && ( - - )} - {availableFilters - .filter((filter) => - filter.name - .toLowerCase() - .includes(filterSearchTerm.toLowerCase()), - ) - .map((filter, index) => ( - - ))} - {availableFilters.filter((filter) => - filter.name - .toLowerCase() - .includes(filterSearchTerm.toLowerCase()), - ).length === 0 && - filterSearchTerm && ( -
- No filters match "{filterSearchTerm}" -
- )} - - )} -
-
- - -
- ); - }, + { + setIsFilterDropdownOpen(open); + }} + > + {anchorPosition && ( + +
+ + )} + { + // Prevent auto focus on the popover content + e.preventDefault(); + // Keep focus on the input + }} + > +
+ {filterSearchTerm && ( +
+ Searching: @{filterSearchTerm} +
+ )} + {availableFilters.length === 0 ? ( +
+ No knowledge filters available +
+ ) : ( + <> + {!filterSearchTerm && ( + + )} + {availableFilters + .filter((filter) => + filter.name + .toLowerCase() + .includes(filterSearchTerm.toLowerCase()), + ) + .map((filter, index) => ( + + ))} + {availableFilters.filter((filter) => + filter.name + .toLowerCase() + .includes(filterSearchTerm.toLowerCase()), + ).length === 0 && + filterSearchTerm && ( +
+ No filters match "{filterSearchTerm}" +
+ )} + + )} +
+
+ + +
+ ); + }, ); ChatInput.displayName = "ChatInput"; diff --git a/frontend/app/chat/page.tsx b/frontend/app/chat/page.tsx index cd613af4..d63fc92a 100644 --- a/frontend/app/chat/page.tsx +++ b/frontend/app/chat/page.tsx @@ -17,1351 +17,1351 @@ import { ChatInput, type ChatInputHandle } from "./_components/chat-input"; import Nudges from "./_components/nudges"; import { UserMessage } from "./_components/user-message"; import type { - FunctionCall, - KnowledgeFilterData, - Message, - RequestBody, - SelectedFilters, - ToolCallResult, + FunctionCall, + KnowledgeFilterData, + Message, + RequestBody, + SelectedFilters, + ToolCallResult, } from "./_types/types"; function ChatPage() { - const isDebugMode = process.env.NEXT_PUBLIC_OPENRAG_DEBUG === "true"; - const { - endpoint, - setEndpoint, - currentConversationId, - conversationData, - setCurrentConversationId, - addConversationDoc, - forkFromResponse, - refreshConversations, - refreshConversationsSilent, - previousResponseIds, - setPreviousResponseIds, - placeholderConversation, - } = useChat(); - const [messages, setMessages] = useState([ - { - role: "assistant", - content: "How can I assist?", - timestamp: new Date(), - }, - ]); - const [input, setInput] = useState(""); - const { loading, setLoading } = useLoadingStore(); - const [asyncMode, setAsyncMode] = useState(true); - const [expandedFunctionCalls, setExpandedFunctionCalls] = useState< - Set - >(new Set()); - // previousResponseIds now comes from useChat context - const [isUploading, setIsUploading] = useState(false); - const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); - const [availableFilters, setAvailableFilters] = useState< - KnowledgeFilterData[] - >([]); - const [filterSearchTerm, setFilterSearchTerm] = useState(""); - const [selectedFilterIndex, setSelectedFilterIndex] = useState(0); - const [isFilterHighlighted, setIsFilterHighlighted] = useState(false); - const [dropdownDismissed, setDropdownDismissed] = useState(false); - const [isUserInteracting, setIsUserInteracting] = useState(false); - const [isForkingInProgress, setIsForkingInProgress] = useState(false); - const [anchorPosition, setAnchorPosition] = useState<{ - x: number; - y: number; - } | null>(null); - const [uploadedFile, setUploadedFile] = useState(null); - - const chatInputRef = useRef(null); - - const { scrollToBottom } = useStickToBottomContext(); - - const lastLoadedConversationRef = useRef(null); - const { addTask } = useTask(); - const { selectedFilter, parsedFilterData, setSelectedFilter } = - useKnowledgeFilter(); - - // Use the chat streaming hook - const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"; - const { - streamingMessage, - sendMessage: sendStreamingMessage, - abortStream, - } = useChatStreaming({ - endpoint: apiEndpoint, - onComplete: (message, responseId) => { - setMessages((prev) => [...prev, message]); - setLoading(false); - - if (responseId) { - cancelNudges(); - setPreviousResponseIds((prev) => ({ - ...prev, - [endpoint]: responseId, - })); - - if (!currentConversationId) { - setCurrentConversationId(responseId); - refreshConversations(true); - } else { - refreshConversationsSilent(); - } - } - }, - onError: (error) => { - console.error("Streaming error:", error); - setLoading(false); - const errorMessage: Message = { - role: "assistant", - content: - "Sorry, I couldn't connect to the chat service. Please try again.", - timestamp: new Date(), - }; - setMessages((prev) => [...prev, errorMessage]); - }, - }); - - const getCursorPosition = (textarea: HTMLTextAreaElement) => { - // Create a hidden div with the same styles as the textarea - const div = document.createElement("div"); - const computedStyle = getComputedStyle(textarea); - - // Copy all computed styles to the hidden div - for (const style of computedStyle) { - (div.style as unknown as Record)[style] = - computedStyle.getPropertyValue(style); - } - - // Set the div to be hidden but not un-rendered - div.style.position = "absolute"; - div.style.visibility = "hidden"; - div.style.whiteSpace = "pre-wrap"; - div.style.wordWrap = "break-word"; - div.style.overflow = "hidden"; - div.style.height = "auto"; - div.style.width = `${textarea.getBoundingClientRect().width}px`; - - // Get the text up to the cursor position - const cursorPos = textarea.selectionStart || 0; - const textBeforeCursor = textarea.value.substring(0, cursorPos); - - // Add the text before cursor - div.textContent = textBeforeCursor; - - // Create a span to mark the end position - const span = document.createElement("span"); - span.textContent = "|"; // Cursor marker - div.appendChild(span); - - // Add the text after cursor to handle word wrapping - const textAfterCursor = textarea.value.substring(cursorPos); - div.appendChild(document.createTextNode(textAfterCursor)); - - // Add the div to the document temporarily - document.body.appendChild(div); - - // Get positions - const inputRect = textarea.getBoundingClientRect(); - const divRect = div.getBoundingClientRect(); - const spanRect = span.getBoundingClientRect(); - - // Calculate the cursor position relative to the input - const x = inputRect.left + (spanRect.left - divRect.left); - const y = inputRect.top + (spanRect.top - divRect.top); - - // Clean up - document.body.removeChild(div); - - return { x, y }; - }; - - const handleEndpointChange = (newEndpoint: EndpointType) => { - setEndpoint(newEndpoint); - // Clear the conversation when switching endpoints to avoid response ID conflicts - setMessages([]); - setPreviousResponseIds({ chat: null, langflow: null }); - }; - - const handleFileUpload = async (file: File) => { - console.log("handleFileUpload called with file:", file.name); - - if (isUploading) return; - - setIsUploading(true); - setLoading(true); - - try { - const formData = new FormData(); - formData.append("file", file); - formData.append("endpoint", endpoint); - - // Add previous_response_id if we have one for this endpoint - const currentResponseId = previousResponseIds[endpoint]; - if (currentResponseId) { - formData.append("previous_response_id", currentResponseId); - } - - const response = await fetch("/api/upload_context", { - method: "POST", - body: formData, - }); - - console.log("Upload response status:", response.status); - - if (!response.ok) { - const errorText = await response.text(); - console.error( - "Upload failed with status:", - response.status, - "Response:", - errorText, - ); - throw new Error("Failed to process document"); - } - - const result = await response.json(); - console.log("Upload result:", result); - - if (response.status === 201) { - // New flow: Got task ID, start tracking with centralized system - const taskId = result.task_id || result.id; - - if (!taskId) { - console.error("No task ID in 201 response:", result); - throw new Error("No task ID received from server"); - } - - // Add task to centralized tracking - addTask(taskId); - - return null; - } else if (response.ok) { - // Original flow: Direct response - - const uploadMessage: Message = { - role: "user", - content: `I'm uploading a document called "${result.filename}". Here is its content:`, - timestamp: new Date(), - }; - - const confirmationMessage: Message = { - role: "assistant", - content: `Confirmed`, - timestamp: new Date(), - }; - - setMessages((prev) => [...prev, uploadMessage, confirmationMessage]); - - // Add file to conversation docs - if (result.filename) { - addConversationDoc(result.filename); - } - - // Update the response ID for this endpoint - if (result.response_id) { - setPreviousResponseIds((prev) => ({ - ...prev, - [endpoint]: result.response_id, - })); - - // If this is a new conversation (no currentConversationId), set it now - if (!currentConversationId) { - setCurrentConversationId(result.response_id); - refreshConversations(true); - } else { - // For existing conversations, do a silent refresh to keep backend in sync - refreshConversationsSilent(); - } - - return result.response_id; - } - } else { - throw new Error(`Upload failed: ${response.status}`); - } - } catch (error) { - console.error("Upload failed:", error); - const errorMessage: Message = { - role: "assistant", - content: `❌ Failed to process document. Please try again.`, - timestamp: new Date(), - }; - setMessages((prev) => [...prev.slice(0, -1), errorMessage]); - } finally { - setIsUploading(false); - setLoading(false); - } - }; - - const handleFilePickerClick = () => { - chatInputRef.current?.clickFileInput(); - }; - - const loadAvailableFilters = async () => { - try { - const response = await fetch("/api/knowledge-filter/search", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - query: "", - limit: 20, - }), - }); - - const result = await response.json(); - if (response.ok && result.success) { - setAvailableFilters(result.filters); - } else { - console.error("Failed to load knowledge filters:", result.error); - setAvailableFilters([]); - } - } catch (error) { - console.error("Failed to load knowledge filters:", error); - setAvailableFilters([]); - } - }; - - const handleFilterSelect = (filter: KnowledgeFilterData | null) => { - setSelectedFilter(filter); - setIsFilterDropdownOpen(false); - setFilterSearchTerm(""); - setIsFilterHighlighted(false); - - // Remove the @searchTerm from the input and replace with filter pill - const words = input.split(" "); - const lastWord = words[words.length - 1]; - - if (lastWord.startsWith("@")) { - // Remove the @search term - words.pop(); - setInput(words.join(" ") + (words.length > 0 ? " " : "")); - } - }; - - // Reset selected index when search term changes - useEffect(() => { - setSelectedFilterIndex(0); - }, []); - - // Auto-focus the input on component mount - useEffect(() => { - chatInputRef.current?.focusInput(); - }, []); - - // Explicitly handle external new conversation trigger - useEffect(() => { - const handleNewConversation = () => { - // Abort any in-flight streaming so it doesn't bleed into new chat - abortStream(); - // Reset chat UI even if context state was already 'new' - setMessages([ - { - role: "assistant", - content: "How can I assist?", - timestamp: new Date(), - }, - ]); - setInput(""); - setExpandedFunctionCalls(new Set()); - setIsFilterHighlighted(false); - setLoading(false); - lastLoadedConversationRef.current = null; - }; - - const handleFocusInput = () => { - chatInputRef.current?.focusInput(); - }; - - window.addEventListener("newConversation", handleNewConversation); - window.addEventListener("focusInput", handleFocusInput); - return () => { - window.removeEventListener("newConversation", handleNewConversation); - window.removeEventListener("focusInput", handleFocusInput); - }; - }, [abortStream, setLoading]); - - // Load conversation only when user explicitly selects a conversation - useEffect(() => { - // Only load conversation data when: - // 1. conversationData exists AND - // 2. It's different from the last loaded conversation AND - // 3. User is not in the middle of an interaction - if ( - conversationData && - conversationData.messages && - lastLoadedConversationRef.current !== conversationData.response_id && - !isUserInteracting && - !isForkingInProgress - ) { - console.log( - "Loading conversation with", - conversationData.messages.length, - "messages", - ); - // Convert backend message format to frontend Message interface - const convertedMessages: Message[] = conversationData.messages.map( - (msg: { - role: string; - content: string; - timestamp?: string; - response_id?: string; - chunks?: Array<{ - item?: { - type?: string; - tool_name?: string; - id?: string; - inputs?: unknown; - results?: unknown; - status?: string; - }; - delta?: { - tool_calls?: Array<{ - id?: string; - function?: { name?: string; arguments?: string }; - type?: string; - }>; - }; - type?: string; - result?: unknown; - output?: unknown; - response?: unknown; - }>; - response_data?: unknown; - }) => { - const message: Message = { - role: msg.role as "user" | "assistant", - content: msg.content, - timestamp: new Date(msg.timestamp || new Date()), - }; - - // Extract function calls from chunks or response_data - if (msg.role === "assistant" && (msg.chunks || msg.response_data)) { - const functionCalls: FunctionCall[] = []; - console.log("Processing assistant message for function calls:", { - hasChunks: !!msg.chunks, - chunksLength: msg.chunks?.length, - hasResponseData: !!msg.response_data, - }); - - // Process chunks (streaming data) - if (msg.chunks && Array.isArray(msg.chunks)) { - for (const chunk of msg.chunks) { - // Handle Langflow format: chunks[].item.tool_call - if (chunk.item && chunk.item.type === "tool_call") { - const toolCall = chunk.item; - console.log("Found Langflow tool call:", toolCall); - functionCalls.push({ - id: toolCall.id || "", - name: toolCall.tool_name || "unknown", - arguments: - (toolCall.inputs as Record) || {}, - argumentsString: JSON.stringify(toolCall.inputs || {}), - result: toolCall.results as - | Record - | ToolCallResult[], - status: - (toolCall.status as "pending" | "completed" | "error") || - "completed", - type: "tool_call", - }); - } - // Handle OpenAI format: chunks[].delta.tool_calls - else if (chunk.delta?.tool_calls) { - for (const toolCall of chunk.delta.tool_calls) { - if (toolCall.function) { - functionCalls.push({ - id: toolCall.id || "", - name: toolCall.function.name || "unknown", - arguments: toolCall.function.arguments - ? JSON.parse(toolCall.function.arguments) - : {}, - argumentsString: toolCall.function.arguments || "", - status: "completed", - type: toolCall.type || "function", - }); - } - } - } - // Process tool call results from chunks - if ( - chunk.type === "response.tool_call.result" || - chunk.type === "tool_call_result" - ) { - const lastCall = functionCalls[functionCalls.length - 1]; - if (lastCall) { - lastCall.result = - (chunk.result as - | Record - | ToolCallResult[]) || - (chunk as Record); - lastCall.status = "completed"; - } - } - } - } - - // Process response_data (non-streaming data) - if (msg.response_data && typeof msg.response_data === "object") { - // Look for tool_calls in various places in the response data - const responseData = - typeof msg.response_data === "string" - ? JSON.parse(msg.response_data) - : msg.response_data; - - if ( - responseData.tool_calls && - Array.isArray(responseData.tool_calls) - ) { - for (const toolCall of responseData.tool_calls) { - functionCalls.push({ - id: toolCall.id, - name: toolCall.function?.name || toolCall.name, - arguments: - toolCall.function?.arguments || toolCall.arguments, - argumentsString: - typeof ( - toolCall.function?.arguments || toolCall.arguments - ) === "string" - ? toolCall.function?.arguments || toolCall.arguments - : JSON.stringify( - toolCall.function?.arguments || toolCall.arguments, - ), - result: toolCall.result, - status: "completed", - type: toolCall.type || "function", - }); - } - } - } - - if (functionCalls.length > 0) { - console.log("Setting functionCalls on message:", functionCalls); - message.functionCalls = functionCalls; - } else { - console.log("No function calls found in message"); - } - } - - return message; - }, - ); - - setMessages(convertedMessages); - lastLoadedConversationRef.current = conversationData.response_id; - - // Set the previous response ID for this conversation - setPreviousResponseIds((prev) => ({ - ...prev, - [conversationData.endpoint]: conversationData.response_id, - })); - } - }, [ - conversationData, - isUserInteracting, - isForkingInProgress, - setPreviousResponseIds, - ]); - - // Handle new conversation creation - only reset messages when placeholderConversation is set - useEffect(() => { - if (placeholderConversation && currentConversationId === null) { - console.log("Starting new conversation"); - setMessages([ - { - role: "assistant", - content: "How can I assist?", - timestamp: new Date(), - }, - ]); - lastLoadedConversationRef.current = null; - } - }, [placeholderConversation, currentConversationId]); - - // Listen for file upload events from navigation - useEffect(() => { - const handleFileUploadStart = (event: CustomEvent) => { - const { filename } = event.detail; - console.log("Chat page received file upload start event:", filename); - - setLoading(true); - setIsUploading(true); - setUploadedFile(null); // Clear previous file - }; - - const handleFileUploaded = (event: CustomEvent) => { - const { result } = event.detail; - console.log("Chat page received file upload event:", result); - - setUploadedFile(null); // Clear file after upload - - // Update the response ID for this endpoint - if (result.response_id) { - setPreviousResponseIds((prev) => ({ - ...prev, - [endpoint]: result.response_id, - })); - } - }; - - const handleFileUploadComplete = () => { - console.log("Chat page received file upload complete event"); - setLoading(false); - setIsUploading(false); - }; - - const handleFileUploadError = (event: CustomEvent) => { - const { filename, error } = event.detail; - console.log( - "Chat page received file upload error event:", - filename, - error, - ); - - // Replace the last message with error message - const errorMessage: Message = { - role: "assistant", - content: `❌ Upload failed for **${filename}**: ${error}`, - timestamp: new Date(), - }; - setMessages((prev) => [...prev.slice(0, -1), errorMessage]); - setUploadedFile(null); // Clear file on error - }; - - window.addEventListener( - "fileUploadStart", - handleFileUploadStart as EventListener, - ); - window.addEventListener( - "fileUploaded", - handleFileUploaded as EventListener, - ); - window.addEventListener( - "fileUploadComplete", - handleFileUploadComplete as EventListener, - ); - window.addEventListener( - "fileUploadError", - handleFileUploadError as EventListener, - ); - - return () => { - window.removeEventListener( - "fileUploadStart", - handleFileUploadStart as EventListener, - ); - window.removeEventListener( - "fileUploaded", - handleFileUploaded as EventListener, - ); - window.removeEventListener( - "fileUploadComplete", - handleFileUploadComplete as EventListener, - ); - window.removeEventListener( - "fileUploadError", - handleFileUploadError as EventListener, - ); - }; - }, [endpoint, setPreviousResponseIds, setLoading]); - - // Check if onboarding is complete by looking at local storage - const [isOnboardingComplete, setIsOnboardingComplete] = useState(() => { - if (typeof window === "undefined") return false; - return localStorage.getItem("onboarding-step") === null; - }); - - // Listen for storage changes to detect when onboarding completes - useEffect(() => { - const checkOnboarding = () => { - if (typeof window !== "undefined") { - setIsOnboardingComplete( - localStorage.getItem("onboarding-step") === null, - ); - } - }; - - // Check periodically since storage events don't fire in the same tab - const interval = setInterval(checkOnboarding, 500); - - return () => clearInterval(interval); - }, []); - - // Prepare filters for nudges (same as chat) - const processedFiltersForNudges = parsedFilterData?.filters - ? (() => { - const filters = parsedFilterData.filters; - const processed: SelectedFilters = { - data_sources: [], - document_types: [], - owners: [], - }; - processed.data_sources = filters.data_sources.includes("*") - ? [] - : filters.data_sources; - processed.document_types = filters.document_types.includes("*") - ? [] - : filters.document_types; - processed.owners = filters.owners.includes("*") ? [] : filters.owners; - - const hasFilters = - processed.data_sources.length > 0 || - processed.document_types.length > 0 || - processed.owners.length > 0; - return hasFilters ? processed : undefined; - })() - : undefined; - - const { data: nudges = [], cancel: cancelNudges } = useGetNudgesQuery( - { - chatId: previousResponseIds[endpoint], - filters: processedFiltersForNudges, - limit: parsedFilterData?.limit ?? 3, - scoreThreshold: parsedFilterData?.scoreThreshold ?? 0, - }, - { - enabled: isOnboardingComplete, // Only fetch nudges after onboarding is complete - }, - ); - - const handleSSEStream = async ( - userMessage: Message, - previousResponseId?: string, - ) => { - // Prepare filters - const processedFilters = parsedFilterData?.filters - ? (() => { - const filters = parsedFilterData.filters; - const processed: SelectedFilters = { - data_sources: [], - document_types: [], - owners: [], - }; - processed.data_sources = filters.data_sources.includes("*") - ? [] - : filters.data_sources; - processed.document_types = filters.document_types.includes("*") - ? [] - : filters.document_types; - processed.owners = filters.owners.includes("*") ? [] : filters.owners; - - const hasFilters = - processed.data_sources.length > 0 || - processed.document_types.length > 0 || - processed.owners.length > 0; - return hasFilters ? processed : undefined; - })() - : undefined; - - // Use passed previousResponseId if available, otherwise fall back to state - const responseIdToUse = previousResponseId || previousResponseIds[endpoint]; - - // Use the hook to send the message - await sendStreamingMessage({ - prompt: userMessage.content, - previousResponseId: responseIdToUse || undefined, - filters: processedFilters, - limit: parsedFilterData?.limit ?? 10, - scoreThreshold: parsedFilterData?.scoreThreshold ?? 0, - }); - scrollToBottom({ - animation: "smooth", - duration: 1000, - }); - }; - - const handleSendMessage = async ( - inputMessage: string, - previousResponseId?: string, - ) => { - if (!inputMessage.trim() || loading) return; - - const userMessage: Message = { - role: "user", - content: inputMessage.trim(), - timestamp: new Date(), - }; - - setMessages((prev) => [...prev, userMessage]); - setInput(""); - setLoading(true); - setIsFilterHighlighted(false); - - scrollToBottom({ - animation: "smooth", - duration: 1000, - }); - - if (asyncMode) { - await handleSSEStream(userMessage, previousResponseId); - } else { - // Original non-streaming logic - try { - const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"; - - const requestBody: RequestBody = { - prompt: userMessage.content, - ...(parsedFilterData?.filters && - (() => { - const filters = parsedFilterData.filters; - const processed: SelectedFilters = { - data_sources: [], - document_types: [], - owners: [], - }; - // Only copy non-wildcard arrays - processed.data_sources = filters.data_sources.includes("*") - ? [] - : filters.data_sources; - processed.document_types = filters.document_types.includes("*") - ? [] - : filters.document_types; - processed.owners = filters.owners.includes("*") - ? [] - : filters.owners; - - // Only include filters if any array has values - const hasFilters = - processed.data_sources.length > 0 || - processed.document_types.length > 0 || - processed.owners.length > 0; - return hasFilters ? { filters: processed } : {}; - })()), - limit: parsedFilterData?.limit ?? 10, - scoreThreshold: parsedFilterData?.scoreThreshold ?? 0, - }; - - // Add previous_response_id if we have one for this endpoint - const currentResponseId = previousResponseIds[endpoint]; - if (currentResponseId) { - requestBody.previous_response_id = currentResponseId; - } - - const response = await fetch(apiEndpoint, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - const result = await response.json(); - - if (response.ok) { - const assistantMessage: Message = { - role: "assistant", - content: result.response, - timestamp: new Date(), - }; - setMessages((prev) => [...prev, assistantMessage]); - if (result.response_id) { - cancelNudges(); - } - - // Store the response ID if present for this endpoint - if (result.response_id) { - setPreviousResponseIds((prev) => ({ - ...prev, - [endpoint]: result.response_id, - })); - - // If this is a new conversation (no currentConversationId), set it now - if (!currentConversationId) { - setCurrentConversationId(result.response_id); - refreshConversations(true); - } else { - // For existing conversations, do a silent refresh to keep backend in sync - refreshConversationsSilent(); - } - } - } else { - console.error("Chat failed:", result.error); - const errorMessage: Message = { - role: "assistant", - content: "Sorry, I encountered an error. Please try again.", - timestamp: new Date(), - }; - setMessages((prev) => [...prev, errorMessage]); - } - } catch (error) { - console.error("Chat error:", error); - const errorMessage: Message = { - role: "assistant", - content: - "Sorry, I couldn't connect to the chat service. Please try again.", - timestamp: new Date(), - }; - setMessages((prev) => [...prev, errorMessage]); - } - } - - setLoading(false); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - // Check if there's an uploaded file and upload it first - let uploadedResponseId: string | null = null; - if (uploadedFile) { - // Upload the file first - const responseId = await handleFileUpload(uploadedFile); - // Clear the file after upload - setUploadedFile(null); - - // If the upload resulted in a new conversation, store the response ID - if (responseId) { - uploadedResponseId = responseId; - setPreviousResponseIds((prev) => ({ - ...prev, - [endpoint]: responseId, - })); - } - } - - // Only send message if there's input text - if (input.trim() || uploadedFile) { - // Pass the responseId from upload (if any) to handleSendMessage - handleSendMessage( - !input.trim() ? FILE_CONFIRMATION : input, - uploadedResponseId || undefined, - ); - } - }; - - const toggleFunctionCall = (functionCallId: string) => { - setExpandedFunctionCalls((prev) => { - const newSet = new Set(prev); - if (newSet.has(functionCallId)) { - newSet.delete(functionCallId); - } else { - newSet.add(functionCallId); - } - return newSet; - }); - }; - - const handleForkConversation = ( - messageIndex: number, - event?: React.MouseEvent, - ) => { - // Prevent any default behavior and stop event propagation - if (event) { - event.preventDefault(); - event.stopPropagation(); - } - - // Set interaction state to prevent auto-scroll interference - setIsUserInteracting(true); - setIsForkingInProgress(true); - - console.log("Fork conversation called for message index:", messageIndex); - - // Get messages up to and including the selected assistant message - const messagesToKeep = messages.slice(0, messageIndex + 1); - - // The selected message should be an assistant message (since fork button is only on assistant messages) - const forkedMessage = messages[messageIndex]; - if (forkedMessage.role !== "assistant") { - console.error("Fork button should only be on assistant messages"); - setIsUserInteracting(false); - setIsForkingInProgress(false); - return; - } - - // For forking, we want to continue from the response_id of the assistant message we're forking from - // Since we don't store individual response_ids per message yet, we'll use the current conversation's response_id - // This means we're continuing the conversation thread from that point - const responseIdToForkFrom = - currentConversationId || previousResponseIds[endpoint]; - - // Create a new conversation by properly forking - setMessages(messagesToKeep); - - // Use the chat context's fork method which handles creating a new conversation properly - if (forkFromResponse) { - forkFromResponse(responseIdToForkFrom || ""); - } else { - // Fallback to manual approach - setCurrentConversationId(null); // This creates a new conversation thread - - // Set the response_id we want to continue from as the previous response ID - // This tells the backend to continue the conversation from this point - setPreviousResponseIds((prev) => ({ - ...prev, - [endpoint]: responseIdToForkFrom, - })); - } - - console.log("Forked conversation with", messagesToKeep.length, "messages"); - - // Reset interaction state after a longer delay to ensure all effects complete - setTimeout(() => { - setIsUserInteracting(false); - setIsForkingInProgress(false); - console.log("Fork interaction complete, re-enabling auto effects"); - }, 500); - - // The original conversation remains unchanged in the sidebar - // This new forked conversation will get its own response_id when the user sends the next message - }; - - const handleSuggestionClick = (suggestion: string) => { - handleSendMessage(suggestion); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - // Handle backspace for filter clearing - if (e.key === "Backspace" && selectedFilter && input.trim() === "") { - e.preventDefault(); - - if (isFilterHighlighted) { - // Second backspace - remove the filter - setSelectedFilter(null); - setIsFilterHighlighted(false); - } else { - // First backspace - highlight the filter - setIsFilterHighlighted(true); - } - return; - } - - if (isFilterDropdownOpen) { - const filteredFilters = availableFilters.filter((filter) => - filter.name.toLowerCase().includes(filterSearchTerm.toLowerCase()), - ); - - if (e.key === "Escape") { - e.preventDefault(); - setIsFilterDropdownOpen(false); - setFilterSearchTerm(""); - setSelectedFilterIndex(0); - setDropdownDismissed(true); - - // Keep focus on the textarea so user can continue typing normally - chatInputRef.current?.focusInput(); - return; - } - - if (e.key === "ArrowDown") { - e.preventDefault(); - setSelectedFilterIndex((prev) => - prev < filteredFilters.length - 1 ? prev + 1 : 0, - ); - return; - } - - if (e.key === "ArrowUp") { - e.preventDefault(); - setSelectedFilterIndex((prev) => - prev > 0 ? prev - 1 : filteredFilters.length - 1, - ); - return; - } - - if (e.key === "Enter") { - // Check if we're at the end of an @ mention (space before cursor or end of input) - const cursorPos = e.currentTarget.selectionStart || 0; - const textBeforeCursor = input.slice(0, cursorPos); - const words = textBeforeCursor.split(" "); - const lastWord = words[words.length - 1]; - - if (lastWord.startsWith("@") && filteredFilters[selectedFilterIndex]) { - e.preventDefault(); - handleFilterSelect(filteredFilters[selectedFilterIndex]); - return; - } - } - - if (e.key === " ") { - // Select filter on space if we're typing an @ mention - const cursorPos = e.currentTarget.selectionStart || 0; - const textBeforeCursor = input.slice(0, cursorPos); - const words = textBeforeCursor.split(" "); - const lastWord = words[words.length - 1]; - - if (lastWord.startsWith("@") && filteredFilters[selectedFilterIndex]) { - e.preventDefault(); - handleFilterSelect(filteredFilters[selectedFilterIndex]); - return; - } - } - } - - if (e.key === "Enter" && !e.shiftKey && !isFilterDropdownOpen) { - e.preventDefault(); - if (input.trim() && !loading) { - // Trigger form submission by finding the form and calling submit - const form = e.currentTarget.closest("form"); - if (form) { - form.requestSubmit(); - } - } - } - }; - - const onChange = (e: React.ChangeEvent) => { - const newValue = e.target.value; - setInput(newValue); - - // Clear filter highlight when user starts typing - if (isFilterHighlighted) { - setIsFilterHighlighted(false); - } - - // Find if there's an @ at the start of the last word - const words = newValue.split(" "); - const lastWord = words[words.length - 1]; - - if (lastWord.startsWith("@") && !dropdownDismissed) { - const searchTerm = lastWord.slice(1); // Remove the @ - console.log("Setting search term:", searchTerm); - setFilterSearchTerm(searchTerm); - setSelectedFilterIndex(0); - - // Only set anchor position when @ is first detected (search term is empty) - if (searchTerm === "") { - const pos = getCursorPosition(e.target); - setAnchorPosition(pos); - } - - if (!isFilterDropdownOpen) { - loadAvailableFilters(); - setIsFilterDropdownOpen(true); - } - } else if (isFilterDropdownOpen) { - // Close dropdown if @ is no longer present - console.log("Closing dropdown - no @ found"); - setIsFilterDropdownOpen(false); - setFilterSearchTerm(""); - } - - // Reset dismissed flag when user moves to a different word - if (dropdownDismissed && !lastWord.startsWith("@")) { - setDropdownDismissed(false); - } - }; - - const onAtClick = () => { - if (!isFilterDropdownOpen) { - loadAvailableFilters(); - setIsFilterDropdownOpen(true); - setFilterSearchTerm(""); - setSelectedFilterIndex(0); - - // Get button position for popover anchoring - const button = document.querySelector( - "[data-filter-button]", - ) as HTMLElement; - if (button) { - const rect = button.getBoundingClientRect(); - setAnchorPosition({ - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2 - 12, - }); - } - } else { - setIsFilterDropdownOpen(false); - setAnchorPosition(null); - } - }; - - return ( - <> - {/* Debug header - only show in debug mode */} - {isDebugMode && ( -
-
-
- {/* Async Mode Toggle */} -
- - -
- {/* Endpoint Toggle */} -
- - -
-
-
- )} - - -
- {messages.length === 0 && !streamingMessage ? ( -
-
- {isUploading ? ( - <> - -

Processing your document...

-

This may take a few moments

- - ) : null} -
-
- ) : ( - <> - {messages.map((message, index) => - message.role === "user" - ? (messages[index]?.content.match(FILES_REGEX)?.[0] ?? - null) === null && ( -
- = 2 && - (messages[index - 2]?.content.match( - FILES_REGEX, - )?.[0] ?? - undefined) && - message.content === FILE_CONFIRMATION - ? undefined - : message.content - } - files={ - index >= 2 - ? (messages[index - 2]?.content.match( - FILES_REGEX, - )?.[0] ?? undefined) - : undefined - } - /> -
- ) - : message.role === "assistant" && - (index < 1 || - (messages[index - 1]?.content.match(FILES_REGEX)?.[0] ?? - null) === null) && ( -
- handleForkConversation(index, e)} - animate={false} - isInactive={index < messages.length - 1} - /> -
- ), - )} - - {/* Streaming Message Display */} - {streamingMessage && ( - - )} - - )} - {!streamingMessage && ( -
- -
- )} -
-
-
- {/* Input Area - Fixed at bottom */} - -
- - ); + const isDebugMode = process.env.NEXT_PUBLIC_OPENRAG_DEBUG === "true"; + const { + endpoint, + setEndpoint, + currentConversationId, + conversationData, + setCurrentConversationId, + addConversationDoc, + forkFromResponse, + refreshConversations, + refreshConversationsSilent, + previousResponseIds, + setPreviousResponseIds, + placeholderConversation, + } = useChat(); + const [messages, setMessages] = useState([ + { + role: "assistant", + content: "How can I assist?", + timestamp: new Date(), + }, + ]); + const [input, setInput] = useState(""); + const { loading, setLoading } = useLoadingStore(); + const [asyncMode, setAsyncMode] = useState(true); + const [expandedFunctionCalls, setExpandedFunctionCalls] = useState< + Set + >(new Set()); + // previousResponseIds now comes from useChat context + const [isUploading, setIsUploading] = useState(false); + const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); + const [availableFilters, setAvailableFilters] = useState< + KnowledgeFilterData[] + >([]); + const [filterSearchTerm, setFilterSearchTerm] = useState(""); + const [selectedFilterIndex, setSelectedFilterIndex] = useState(0); + const [isFilterHighlighted, setIsFilterHighlighted] = useState(false); + const [dropdownDismissed, setDropdownDismissed] = useState(false); + const [isUserInteracting, setIsUserInteracting] = useState(false); + const [isForkingInProgress, setIsForkingInProgress] = useState(false); + const [anchorPosition, setAnchorPosition] = useState<{ + x: number; + y: number; + } | null>(null); + const [uploadedFile, setUploadedFile] = useState(null); + + const chatInputRef = useRef(null); + + const { scrollToBottom } = useStickToBottomContext(); + + const lastLoadedConversationRef = useRef(null); + const { addTask } = useTask(); + const { selectedFilter, parsedFilterData, setSelectedFilter } = + useKnowledgeFilter(); + + // Use the chat streaming hook + const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"; + const { + streamingMessage, + sendMessage: sendStreamingMessage, + abortStream, + } = useChatStreaming({ + endpoint: apiEndpoint, + onComplete: (message, responseId) => { + setMessages((prev) => [...prev, message]); + setLoading(false); + + if (responseId) { + cancelNudges(); + setPreviousResponseIds((prev) => ({ + ...prev, + [endpoint]: responseId, + })); + + if (!currentConversationId) { + setCurrentConversationId(responseId); + refreshConversations(true); + } else { + refreshConversationsSilent(); + } + } + }, + onError: (error) => { + console.error("Streaming error:", error); + setLoading(false); + const errorMessage: Message = { + role: "assistant", + content: + "Sorry, I couldn't connect to the chat service. Please try again.", + timestamp: new Date(), + }; + setMessages((prev) => [...prev, errorMessage]); + }, + }); + + const getCursorPosition = (textarea: HTMLTextAreaElement) => { + // Create a hidden div with the same styles as the textarea + const div = document.createElement("div"); + const computedStyle = getComputedStyle(textarea); + + // Copy all computed styles to the hidden div + for (const style of computedStyle) { + (div.style as unknown as Record)[style] = + computedStyle.getPropertyValue(style); + } + + // Set the div to be hidden but not un-rendered + div.style.position = "absolute"; + div.style.visibility = "hidden"; + div.style.whiteSpace = "pre-wrap"; + div.style.wordWrap = "break-word"; + div.style.overflow = "hidden"; + div.style.height = "auto"; + div.style.width = `${textarea.getBoundingClientRect().width}px`; + + // Get the text up to the cursor position + const cursorPos = textarea.selectionStart || 0; + const textBeforeCursor = textarea.value.substring(0, cursorPos); + + // Add the text before cursor + div.textContent = textBeforeCursor; + + // Create a span to mark the end position + const span = document.createElement("span"); + span.textContent = "|"; // Cursor marker + div.appendChild(span); + + // Add the text after cursor to handle word wrapping + const textAfterCursor = textarea.value.substring(cursorPos); + div.appendChild(document.createTextNode(textAfterCursor)); + + // Add the div to the document temporarily + document.body.appendChild(div); + + // Get positions + const inputRect = textarea.getBoundingClientRect(); + const divRect = div.getBoundingClientRect(); + const spanRect = span.getBoundingClientRect(); + + // Calculate the cursor position relative to the input + const x = inputRect.left + (spanRect.left - divRect.left); + const y = inputRect.top + (spanRect.top - divRect.top); + + // Clean up + document.body.removeChild(div); + + return { x, y }; + }; + + const handleEndpointChange = (newEndpoint: EndpointType) => { + setEndpoint(newEndpoint); + // Clear the conversation when switching endpoints to avoid response ID conflicts + setMessages([]); + setPreviousResponseIds({ chat: null, langflow: null }); + }; + + const handleFileUpload = async (file: File) => { + console.log("handleFileUpload called with file:", file.name); + + if (isUploading) return; + + setIsUploading(true); + setLoading(true); + + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("endpoint", endpoint); + + // Add previous_response_id if we have one for this endpoint + const currentResponseId = previousResponseIds[endpoint]; + if (currentResponseId) { + formData.append("previous_response_id", currentResponseId); + } + + const response = await fetch("/api/upload_context", { + method: "POST", + body: formData, + }); + + console.log("Upload response status:", response.status); + + if (!response.ok) { + const errorText = await response.text(); + console.error( + "Upload failed with status:", + response.status, + "Response:", + errorText, + ); + throw new Error("Failed to process document"); + } + + const result = await response.json(); + console.log("Upload result:", result); + + if (response.status === 201) { + // New flow: Got task ID, start tracking with centralized system + const taskId = result.task_id || result.id; + + if (!taskId) { + console.error("No task ID in 201 response:", result); + throw new Error("No task ID received from server"); + } + + // Add task to centralized tracking + addTask(taskId); + + return null; + } else if (response.ok) { + // Original flow: Direct response + + const uploadMessage: Message = { + role: "user", + content: `I'm uploading a document called "${result.filename}". Here is its content:`, + timestamp: new Date(), + }; + + const confirmationMessage: Message = { + role: "assistant", + content: `Confirmed`, + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, uploadMessage, confirmationMessage]); + + // Add file to conversation docs + if (result.filename) { + addConversationDoc(result.filename); + } + + // Update the response ID for this endpoint + if (result.response_id) { + setPreviousResponseIds((prev) => ({ + ...prev, + [endpoint]: result.response_id, + })); + + // If this is a new conversation (no currentConversationId), set it now + if (!currentConversationId) { + setCurrentConversationId(result.response_id); + refreshConversations(true); + } else { + // For existing conversations, do a silent refresh to keep backend in sync + refreshConversationsSilent(); + } + + return result.response_id; + } + } else { + throw new Error(`Upload failed: ${response.status}`); + } + } catch (error) { + console.error("Upload failed:", error); + const errorMessage: Message = { + role: "assistant", + content: `❌ Failed to process document. Please try again.`, + timestamp: new Date(), + }; + setMessages((prev) => [...prev.slice(0, -1), errorMessage]); + } finally { + setIsUploading(false); + setLoading(false); + } + }; + + const handleFilePickerClick = () => { + chatInputRef.current?.clickFileInput(); + }; + + const loadAvailableFilters = async () => { + try { + const response = await fetch("/api/knowledge-filter/search", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: "", + limit: 20, + }), + }); + + const result = await response.json(); + if (response.ok && result.success) { + setAvailableFilters(result.filters); + } else { + console.error("Failed to load knowledge filters:", result.error); + setAvailableFilters([]); + } + } catch (error) { + console.error("Failed to load knowledge filters:", error); + setAvailableFilters([]); + } + }; + + const handleFilterSelect = (filter: KnowledgeFilterData | null) => { + setSelectedFilter(filter); + setIsFilterDropdownOpen(false); + setFilterSearchTerm(""); + setIsFilterHighlighted(false); + + // Remove the @searchTerm from the input and replace with filter pill + const words = input.split(" "); + const lastWord = words[words.length - 1]; + + if (lastWord.startsWith("@")) { + // Remove the @search term + words.pop(); + setInput(words.join(" ") + (words.length > 0 ? " " : "")); + } + }; + + // Reset selected index when search term changes + useEffect(() => { + setSelectedFilterIndex(0); + }, []); + + // Auto-focus the input on component mount + useEffect(() => { + chatInputRef.current?.focusInput(); + }, []); + + // Explicitly handle external new conversation trigger + useEffect(() => { + const handleNewConversation = () => { + // Abort any in-flight streaming so it doesn't bleed into new chat + abortStream(); + // Reset chat UI even if context state was already 'new' + setMessages([ + { + role: "assistant", + content: "How can I assist?", + timestamp: new Date(), + }, + ]); + setInput(""); + setExpandedFunctionCalls(new Set()); + setIsFilterHighlighted(false); + setLoading(false); + lastLoadedConversationRef.current = null; + }; + + const handleFocusInput = () => { + chatInputRef.current?.focusInput(); + }; + + window.addEventListener("newConversation", handleNewConversation); + window.addEventListener("focusInput", handleFocusInput); + return () => { + window.removeEventListener("newConversation", handleNewConversation); + window.removeEventListener("focusInput", handleFocusInput); + }; + }, [abortStream, setLoading]); + + // Load conversation only when user explicitly selects a conversation + useEffect(() => { + // Only load conversation data when: + // 1. conversationData exists AND + // 2. It's different from the last loaded conversation AND + // 3. User is not in the middle of an interaction + if ( + conversationData && + conversationData.messages && + lastLoadedConversationRef.current !== conversationData.response_id && + !isUserInteracting && + !isForkingInProgress + ) { + console.log( + "Loading conversation with", + conversationData.messages.length, + "messages", + ); + // Convert backend message format to frontend Message interface + const convertedMessages: Message[] = conversationData.messages.map( + (msg: { + role: string; + content: string; + timestamp?: string; + response_id?: string; + chunks?: Array<{ + item?: { + type?: string; + tool_name?: string; + id?: string; + inputs?: unknown; + results?: unknown; + status?: string; + }; + delta?: { + tool_calls?: Array<{ + id?: string; + function?: { name?: string; arguments?: string }; + type?: string; + }>; + }; + type?: string; + result?: unknown; + output?: unknown; + response?: unknown; + }>; + response_data?: unknown; + }) => { + const message: Message = { + role: msg.role as "user" | "assistant", + content: msg.content, + timestamp: new Date(msg.timestamp || new Date()), + }; + + // Extract function calls from chunks or response_data + if (msg.role === "assistant" && (msg.chunks || msg.response_data)) { + const functionCalls: FunctionCall[] = []; + console.log("Processing assistant message for function calls:", { + hasChunks: !!msg.chunks, + chunksLength: msg.chunks?.length, + hasResponseData: !!msg.response_data, + }); + + // Process chunks (streaming data) + if (msg.chunks && Array.isArray(msg.chunks)) { + for (const chunk of msg.chunks) { + // Handle Langflow format: chunks[].item.tool_call + if (chunk.item && chunk.item.type === "tool_call") { + const toolCall = chunk.item; + console.log("Found Langflow tool call:", toolCall); + functionCalls.push({ + id: toolCall.id || "", + name: toolCall.tool_name || "unknown", + arguments: + (toolCall.inputs as Record) || {}, + argumentsString: JSON.stringify(toolCall.inputs || {}), + result: toolCall.results as + | Record + | ToolCallResult[], + status: + (toolCall.status as "pending" | "completed" | "error") || + "completed", + type: "tool_call", + }); + } + // Handle OpenAI format: chunks[].delta.tool_calls + else if (chunk.delta?.tool_calls) { + for (const toolCall of chunk.delta.tool_calls) { + if (toolCall.function) { + functionCalls.push({ + id: toolCall.id || "", + name: toolCall.function.name || "unknown", + arguments: toolCall.function.arguments + ? JSON.parse(toolCall.function.arguments) + : {}, + argumentsString: toolCall.function.arguments || "", + status: "completed", + type: toolCall.type || "function", + }); + } + } + } + // Process tool call results from chunks + if ( + chunk.type === "response.tool_call.result" || + chunk.type === "tool_call_result" + ) { + const lastCall = functionCalls[functionCalls.length - 1]; + if (lastCall) { + lastCall.result = + (chunk.result as + | Record + | ToolCallResult[]) || + (chunk as Record); + lastCall.status = "completed"; + } + } + } + } + + // Process response_data (non-streaming data) + if (msg.response_data && typeof msg.response_data === "object") { + // Look for tool_calls in various places in the response data + const responseData = + typeof msg.response_data === "string" + ? JSON.parse(msg.response_data) + : msg.response_data; + + if ( + responseData.tool_calls && + Array.isArray(responseData.tool_calls) + ) { + for (const toolCall of responseData.tool_calls) { + functionCalls.push({ + id: toolCall.id, + name: toolCall.function?.name || toolCall.name, + arguments: + toolCall.function?.arguments || toolCall.arguments, + argumentsString: + typeof ( + toolCall.function?.arguments || toolCall.arguments + ) === "string" + ? toolCall.function?.arguments || toolCall.arguments + : JSON.stringify( + toolCall.function?.arguments || toolCall.arguments, + ), + result: toolCall.result, + status: "completed", + type: toolCall.type || "function", + }); + } + } + } + + if (functionCalls.length > 0) { + console.log("Setting functionCalls on message:", functionCalls); + message.functionCalls = functionCalls; + } else { + console.log("No function calls found in message"); + } + } + + return message; + }, + ); + + setMessages(convertedMessages); + lastLoadedConversationRef.current = conversationData.response_id; + + // Set the previous response ID for this conversation + setPreviousResponseIds((prev) => ({ + ...prev, + [conversationData.endpoint]: conversationData.response_id, + })); + } + }, [ + conversationData, + isUserInteracting, + isForkingInProgress, + setPreviousResponseIds, + ]); + + // Handle new conversation creation - only reset messages when placeholderConversation is set + useEffect(() => { + if (placeholderConversation && currentConversationId === null) { + console.log("Starting new conversation"); + setMessages([ + { + role: "assistant", + content: "How can I assist?", + timestamp: new Date(), + }, + ]); + lastLoadedConversationRef.current = null; + } + }, [placeholderConversation, currentConversationId]); + + // Listen for file upload events from navigation + useEffect(() => { + const handleFileUploadStart = (event: CustomEvent) => { + const { filename } = event.detail; + console.log("Chat page received file upload start event:", filename); + + setLoading(true); + setIsUploading(true); + setUploadedFile(null); // Clear previous file + }; + + const handleFileUploaded = (event: CustomEvent) => { + const { result } = event.detail; + console.log("Chat page received file upload event:", result); + + setUploadedFile(null); // Clear file after upload + + // Update the response ID for this endpoint + if (result.response_id) { + setPreviousResponseIds((prev) => ({ + ...prev, + [endpoint]: result.response_id, + })); + } + }; + + const handleFileUploadComplete = () => { + console.log("Chat page received file upload complete event"); + setLoading(false); + setIsUploading(false); + }; + + const handleFileUploadError = (event: CustomEvent) => { + const { filename, error } = event.detail; + console.log( + "Chat page received file upload error event:", + filename, + error, + ); + + // Replace the last message with error message + const errorMessage: Message = { + role: "assistant", + content: `❌ Upload failed for **${filename}**: ${error}`, + timestamp: new Date(), + }; + setMessages((prev) => [...prev.slice(0, -1), errorMessage]); + setUploadedFile(null); // Clear file on error + }; + + window.addEventListener( + "fileUploadStart", + handleFileUploadStart as EventListener, + ); + window.addEventListener( + "fileUploaded", + handleFileUploaded as EventListener, + ); + window.addEventListener( + "fileUploadComplete", + handleFileUploadComplete as EventListener, + ); + window.addEventListener( + "fileUploadError", + handleFileUploadError as EventListener, + ); + + return () => { + window.removeEventListener( + "fileUploadStart", + handleFileUploadStart as EventListener, + ); + window.removeEventListener( + "fileUploaded", + handleFileUploaded as EventListener, + ); + window.removeEventListener( + "fileUploadComplete", + handleFileUploadComplete as EventListener, + ); + window.removeEventListener( + "fileUploadError", + handleFileUploadError as EventListener, + ); + }; + }, [endpoint, setPreviousResponseIds, setLoading]); + + // Check if onboarding is complete by looking at local storage + const [isOnboardingComplete, setIsOnboardingComplete] = useState(() => { + if (typeof window === "undefined") return false; + return localStorage.getItem("onboarding-step") === null; + }); + + // Listen for storage changes to detect when onboarding completes + useEffect(() => { + const checkOnboarding = () => { + if (typeof window !== "undefined") { + setIsOnboardingComplete( + localStorage.getItem("onboarding-step") === null, + ); + } + }; + + // Check periodically since storage events don't fire in the same tab + const interval = setInterval(checkOnboarding, 500); + + return () => clearInterval(interval); + }, []); + + // Prepare filters for nudges (same as chat) + const processedFiltersForNudges = parsedFilterData?.filters + ? (() => { + const filters = parsedFilterData.filters; + const processed: SelectedFilters = { + data_sources: [], + document_types: [], + owners: [], + }; + processed.data_sources = filters.data_sources.includes("*") + ? [] + : filters.data_sources; + processed.document_types = filters.document_types.includes("*") + ? [] + : filters.document_types; + processed.owners = filters.owners.includes("*") ? [] : filters.owners; + + const hasFilters = + processed.data_sources.length > 0 || + processed.document_types.length > 0 || + processed.owners.length > 0; + return hasFilters ? processed : undefined; + })() + : undefined; + + const { data: nudges = [], cancel: cancelNudges } = useGetNudgesQuery( + { + chatId: previousResponseIds[endpoint], + filters: processedFiltersForNudges, + limit: parsedFilterData?.limit ?? 3, + scoreThreshold: parsedFilterData?.scoreThreshold ?? 0, + }, + { + enabled: isOnboardingComplete, // Only fetch nudges after onboarding is complete + }, + ); + + const handleSSEStream = async ( + userMessage: Message, + previousResponseId?: string, + ) => { + // Prepare filters + const processedFilters = parsedFilterData?.filters + ? (() => { + const filters = parsedFilterData.filters; + const processed: SelectedFilters = { + data_sources: [], + document_types: [], + owners: [], + }; + processed.data_sources = filters.data_sources.includes("*") + ? [] + : filters.data_sources; + processed.document_types = filters.document_types.includes("*") + ? [] + : filters.document_types; + processed.owners = filters.owners.includes("*") ? [] : filters.owners; + + const hasFilters = + processed.data_sources.length > 0 || + processed.document_types.length > 0 || + processed.owners.length > 0; + return hasFilters ? processed : undefined; + })() + : undefined; + + // Use passed previousResponseId if available, otherwise fall back to state + const responseIdToUse = previousResponseId || previousResponseIds[endpoint]; + + // Use the hook to send the message + await sendStreamingMessage({ + prompt: userMessage.content, + previousResponseId: responseIdToUse || undefined, + filters: processedFilters, + limit: parsedFilterData?.limit ?? 10, + scoreThreshold: parsedFilterData?.scoreThreshold ?? 0, + }); + scrollToBottom({ + animation: "smooth", + duration: 1000, + }); + }; + + const handleSendMessage = async ( + inputMessage: string, + previousResponseId?: string, + ) => { + if (!inputMessage.trim() || loading) return; + + const userMessage: Message = { + role: "user", + content: inputMessage.trim(), + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, userMessage]); + setInput(""); + setLoading(true); + setIsFilterHighlighted(false); + + scrollToBottom({ + animation: "smooth", + duration: 1000, + }); + + if (asyncMode) { + await handleSSEStream(userMessage, previousResponseId); + } else { + // Original non-streaming logic + try { + const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"; + + const requestBody: RequestBody = { + prompt: userMessage.content, + ...(parsedFilterData?.filters && + (() => { + const filters = parsedFilterData.filters; + const processed: SelectedFilters = { + data_sources: [], + document_types: [], + owners: [], + }; + // Only copy non-wildcard arrays + processed.data_sources = filters.data_sources.includes("*") + ? [] + : filters.data_sources; + processed.document_types = filters.document_types.includes("*") + ? [] + : filters.document_types; + processed.owners = filters.owners.includes("*") + ? [] + : filters.owners; + + // Only include filters if any array has values + const hasFilters = + processed.data_sources.length > 0 || + processed.document_types.length > 0 || + processed.owners.length > 0; + return hasFilters ? { filters: processed } : {}; + })()), + limit: parsedFilterData?.limit ?? 10, + scoreThreshold: parsedFilterData?.scoreThreshold ?? 0, + }; + + // Add previous_response_id if we have one for this endpoint + const currentResponseId = previousResponseIds[endpoint]; + if (currentResponseId) { + requestBody.previous_response_id = currentResponseId; + } + + const response = await fetch(apiEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + const result = await response.json(); + + if (response.ok) { + const assistantMessage: Message = { + role: "assistant", + content: result.response, + timestamp: new Date(), + }; + setMessages((prev) => [...prev, assistantMessage]); + if (result.response_id) { + cancelNudges(); + } + + // Store the response ID if present for this endpoint + if (result.response_id) { + setPreviousResponseIds((prev) => ({ + ...prev, + [endpoint]: result.response_id, + })); + + // If this is a new conversation (no currentConversationId), set it now + if (!currentConversationId) { + setCurrentConversationId(result.response_id); + refreshConversations(true); + } else { + // For existing conversations, do a silent refresh to keep backend in sync + refreshConversationsSilent(); + } + } + } else { + console.error("Chat failed:", result.error); + const errorMessage: Message = { + role: "assistant", + content: "Sorry, I encountered an error. Please try again.", + timestamp: new Date(), + }; + setMessages((prev) => [...prev, errorMessage]); + } + } catch (error) { + console.error("Chat error:", error); + const errorMessage: Message = { + role: "assistant", + content: + "Sorry, I couldn't connect to the chat service. Please try again.", + timestamp: new Date(), + }; + setMessages((prev) => [...prev, errorMessage]); + } + } + + setLoading(false); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Check if there's an uploaded file and upload it first + let uploadedResponseId: string | null = null; + if (uploadedFile) { + // Upload the file first + const responseId = await handleFileUpload(uploadedFile); + // Clear the file after upload + setUploadedFile(null); + + // If the upload resulted in a new conversation, store the response ID + if (responseId) { + uploadedResponseId = responseId; + setPreviousResponseIds((prev) => ({ + ...prev, + [endpoint]: responseId, + })); + } + } + + // Only send message if there's input text + if (input.trim() || uploadedFile) { + // Pass the responseId from upload (if any) to handleSendMessage + handleSendMessage( + !input.trim() ? FILE_CONFIRMATION : input, + uploadedResponseId || undefined, + ); + } + }; + + const toggleFunctionCall = (functionCallId: string) => { + setExpandedFunctionCalls((prev) => { + const newSet = new Set(prev); + if (newSet.has(functionCallId)) { + newSet.delete(functionCallId); + } else { + newSet.add(functionCallId); + } + return newSet; + }); + }; + + const handleForkConversation = ( + messageIndex: number, + event?: React.MouseEvent, + ) => { + // Prevent any default behavior and stop event propagation + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + + // Set interaction state to prevent auto-scroll interference + setIsUserInteracting(true); + setIsForkingInProgress(true); + + console.log("Fork conversation called for message index:", messageIndex); + + // Get messages up to and including the selected assistant message + const messagesToKeep = messages.slice(0, messageIndex + 1); + + // The selected message should be an assistant message (since fork button is only on assistant messages) + const forkedMessage = messages[messageIndex]; + if (forkedMessage.role !== "assistant") { + console.error("Fork button should only be on assistant messages"); + setIsUserInteracting(false); + setIsForkingInProgress(false); + return; + } + + // For forking, we want to continue from the response_id of the assistant message we're forking from + // Since we don't store individual response_ids per message yet, we'll use the current conversation's response_id + // This means we're continuing the conversation thread from that point + const responseIdToForkFrom = + currentConversationId || previousResponseIds[endpoint]; + + // Create a new conversation by properly forking + setMessages(messagesToKeep); + + // Use the chat context's fork method which handles creating a new conversation properly + if (forkFromResponse) { + forkFromResponse(responseIdToForkFrom || ""); + } else { + // Fallback to manual approach + setCurrentConversationId(null); // This creates a new conversation thread + + // Set the response_id we want to continue from as the previous response ID + // This tells the backend to continue the conversation from this point + setPreviousResponseIds((prev) => ({ + ...prev, + [endpoint]: responseIdToForkFrom, + })); + } + + console.log("Forked conversation with", messagesToKeep.length, "messages"); + + // Reset interaction state after a longer delay to ensure all effects complete + setTimeout(() => { + setIsUserInteracting(false); + setIsForkingInProgress(false); + console.log("Fork interaction complete, re-enabling auto effects"); + }, 500); + + // The original conversation remains unchanged in the sidebar + // This new forked conversation will get its own response_id when the user sends the next message + }; + + const handleSuggestionClick = (suggestion: string) => { + handleSendMessage(suggestion); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Handle backspace for filter clearing + if (e.key === "Backspace" && selectedFilter && input.trim() === "") { + e.preventDefault(); + + if (isFilterHighlighted) { + // Second backspace - remove the filter + setSelectedFilter(null); + setIsFilterHighlighted(false); + } else { + // First backspace - highlight the filter + setIsFilterHighlighted(true); + } + return; + } + + if (isFilterDropdownOpen) { + const filteredFilters = availableFilters.filter((filter) => + filter.name.toLowerCase().includes(filterSearchTerm.toLowerCase()), + ); + + if (e.key === "Escape") { + e.preventDefault(); + setIsFilterDropdownOpen(false); + setFilterSearchTerm(""); + setSelectedFilterIndex(0); + setDropdownDismissed(true); + + // Keep focus on the textarea so user can continue typing normally + chatInputRef.current?.focusInput(); + return; + } + + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedFilterIndex((prev) => + prev < filteredFilters.length - 1 ? prev + 1 : 0, + ); + return; + } + + if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedFilterIndex((prev) => + prev > 0 ? prev - 1 : filteredFilters.length - 1, + ); + return; + } + + if (e.key === "Enter") { + // Check if we're at the end of an @ mention (space before cursor or end of input) + const cursorPos = e.currentTarget.selectionStart || 0; + const textBeforeCursor = input.slice(0, cursorPos); + const words = textBeforeCursor.split(" "); + const lastWord = words[words.length - 1]; + + if (lastWord.startsWith("@") && filteredFilters[selectedFilterIndex]) { + e.preventDefault(); + handleFilterSelect(filteredFilters[selectedFilterIndex]); + return; + } + } + + if (e.key === " ") { + // Select filter on space if we're typing an @ mention + const cursorPos = e.currentTarget.selectionStart || 0; + const textBeforeCursor = input.slice(0, cursorPos); + const words = textBeforeCursor.split(" "); + const lastWord = words[words.length - 1]; + + if (lastWord.startsWith("@") && filteredFilters[selectedFilterIndex]) { + e.preventDefault(); + handleFilterSelect(filteredFilters[selectedFilterIndex]); + return; + } + } + } + + if (e.key === "Enter" && !e.shiftKey && !isFilterDropdownOpen) { + e.preventDefault(); + if (input.trim() && !loading) { + // Trigger form submission by finding the form and calling submit + const form = e.currentTarget.closest("form"); + if (form) { + form.requestSubmit(); + } + } + } + }; + + const onChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInput(newValue); + + // Clear filter highlight when user starts typing + if (isFilterHighlighted) { + setIsFilterHighlighted(false); + } + + // Find if there's an @ at the start of the last word + const words = newValue.split(" "); + const lastWord = words[words.length - 1]; + + if (lastWord.startsWith("@") && !dropdownDismissed) { + const searchTerm = lastWord.slice(1); // Remove the @ + console.log("Setting search term:", searchTerm); + setFilterSearchTerm(searchTerm); + setSelectedFilterIndex(0); + + // Only set anchor position when @ is first detected (search term is empty) + if (searchTerm === "") { + const pos = getCursorPosition(e.target); + setAnchorPosition(pos); + } + + if (!isFilterDropdownOpen) { + loadAvailableFilters(); + setIsFilterDropdownOpen(true); + } + } else if (isFilterDropdownOpen) { + // Close dropdown if @ is no longer present + console.log("Closing dropdown - no @ found"); + setIsFilterDropdownOpen(false); + setFilterSearchTerm(""); + } + + // Reset dismissed flag when user moves to a different word + if (dropdownDismissed && !lastWord.startsWith("@")) { + setDropdownDismissed(false); + } + }; + + const onAtClick = () => { + if (!isFilterDropdownOpen) { + loadAvailableFilters(); + setIsFilterDropdownOpen(true); + setFilterSearchTerm(""); + setSelectedFilterIndex(0); + + // Get button position for popover anchoring + const button = document.querySelector( + "[data-filter-button]", + ) as HTMLElement; + if (button) { + const rect = button.getBoundingClientRect(); + setAnchorPosition({ + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2 - 12, + }); + } + } else { + setIsFilterDropdownOpen(false); + setAnchorPosition(null); + } + }; + + return ( + <> + {/* Debug header - only show in debug mode */} + {isDebugMode && ( +
+
+
+ {/* Async Mode Toggle */} +
+ + +
+ {/* Endpoint Toggle */} +
+ + +
+
+
+ )} + + +
+ {messages.length === 0 && !streamingMessage ? ( +
+
+ {isUploading ? ( + <> + +

Processing your document...

+

This may take a few moments

+ + ) : null} +
+
+ ) : ( + <> + {messages.map((message, index) => + message.role === "user" + ? (messages[index]?.content.match(FILES_REGEX)?.[0] ?? + null) === null && ( +
+ = 2 && + (messages[index - 2]?.content.match( + FILES_REGEX, + )?.[0] ?? + undefined) && + message.content === FILE_CONFIRMATION + ? undefined + : message.content + } + files={ + index >= 2 + ? (messages[index - 2]?.content.match( + FILES_REGEX, + )?.[0] ?? undefined) + : undefined + } + /> +
+ ) + : message.role === "assistant" && + (index < 1 || + (messages[index - 1]?.content.match(FILES_REGEX)?.[0] ?? + null) === null) && ( +
+ handleForkConversation(index, e)} + animate={false} + isInactive={index < messages.length - 1} + /> +
+ ), + )} + + {/* Streaming Message Display */} + {streamingMessage && ( + + )} + + )} + {!streamingMessage && ( +
+ +
+ )} +
+
+
+ {/* Input Area - Fixed at bottom */} + +
+ + ); } export default function ProtectedChatPage() { - return ( - -
- - - -
-
- ); + return ( + +
+ + + +
+
+ ); } From c1a6713bd8a7df17f6b83c3236940487e241ff23 Mon Sep 17 00:00:00 2001 From: Cole Goldsmith Date: Tue, 18 Nov 2025 14:59:32 -0600 Subject: [PATCH 5/5] use toast for error instead --- frontend/app/chat/_components/chat-input.tsx | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/frontend/app/chat/_components/chat-input.tsx b/frontend/app/chat/_components/chat-input.tsx index 5796474a..50a6097d 100644 --- a/frontend/app/chat/_components/chat-input.tsx +++ b/frontend/app/chat/_components/chat-input.tsx @@ -3,6 +3,7 @@ import { AnimatePresence, motion } from "motion/react"; import { forwardRef, useImperativeHandle, useRef, useState } from "react"; import { useDropzone } from "react-dropzone"; import TextareaAutosize from "react-textarea-autosize"; +import { toast } from "sonner"; import type { FilterColor } from "@/components/filter-icon-popover"; import { Button } from "@/components/ui/button"; import { @@ -75,7 +76,6 @@ export const ChatInput = forwardRef( const inputRef = useRef(null); const fileInputRef = useRef(null); const [textareaHeight, setTextareaHeight] = useState(0); - const [fileUploadError, setFileUploadError] = useState(null); const isDragging = useFileDrag(); const { getRootProps, getInputProps } = useDropzone({ @@ -89,11 +89,9 @@ export const ChatInput = forwardRef( maxFiles: 1, disabled: !isDragging, onDrop: (acceptedFiles, fileRejections) => { - setFileUploadError(null); if (fileRejections.length > 0) { - console.log(fileRejections); const message = fileRejections.at(0)?.errors.at(0)?.message; - setFileUploadError(new Error(message)); + toast.error(message || "Failed to upload file"); return; } onFileSelected(acceptedFiles[0]); @@ -110,7 +108,6 @@ export const ChatInput = forwardRef( })); const handleFilePickerChange = (e: React.ChangeEvent) => { - setFileUploadError(null); const files = e.target.files; if (files && files.length > 0) { onFileSelected(files[0]); @@ -151,18 +148,6 @@ export const ChatInput = forwardRef( )} - - {fileUploadError && ( - - {fileUploadError.message} - - )} - {isDragging && (