Merge branch 'main' into docs-flatten-pdf

This commit is contained in:
Mendon Kissling 2025-10-27 10:08:38 -04:00
commit 26b99a4872
9 changed files with 563 additions and 169 deletions

View file

@ -3,20 +3,77 @@ interface DogIconProps extends React.SVGProps<SVGSVGElement> {
} }
const DogIcon = ({ disabled = false, stroke, ...props }: DogIconProps) => { const DogIcon = ({ disabled = false, stroke, ...props }: DogIconProps) => {
const fillColor = disabled ? "#71717A" : (stroke || "#22A7AF"); const fillColor = disabled ? "#71717A" : (stroke || "#773EFF");
// CSS for the stepped animation states
const animationCSS = `
.state1 { animation: showState1 600ms infinite; }
.state2 { animation: showState2 600ms infinite; }
.state3 { animation: showState3 600ms infinite; }
.state4 { animation: showState4 600ms infinite; }
@keyframes showState1 {
0%, 24.99% { opacity: 1; }
25%, 100% { opacity: 0; }
}
@keyframes showState2 {
0%, 24.99% { opacity: 0; }
25%, 49.99% { opacity: 1; }
50%, 100% { opacity: 0; }
}
@keyframes showState3 {
0%, 49.99% { opacity: 0; }
50%, 74.99% { opacity: 1; }
75%, 100% { opacity: 0; }
}
@keyframes showState4 {
0%, 74.99% { opacity: 0; }
75%, 100% { opacity: 1; }
}
`;
return ( return (
disabled ? ( disabled ? (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 18" fill={fillColor}> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 18" fill={fillColor} {...props}>
<path d="M8 18H2V16H8V18Z"/> <path d="M8 18H2V16H8V18Z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20 2H22V6H24V10H20V14H24V16H14V14H2V16H0V8H2V6H8V10H10V12H16V6H14V10H12V8H10V2H12V0H20V2ZM18 6H20V4H18V6Z"/> <path fillRule="evenodd" clipRule="evenodd" d="M20 2H22V6H24V10H20V14H24V16H14V14H2V16H0V8H2V6H8V10H10V12H16V6H14V10H12V8H10V2H12V0H20V2ZM18 6H20V4H18V6Z"/>
</svg> </svg>
) : ( ) : (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="20" viewBox="0 0 24 20" fill={fillColor}> <svg width="105" height="77" viewBox="0 0 105 77" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.0769 10.9091H16.6154V5.45455H14.7692V9.09091H12.9231V7.27273H11.0769V1.81818H12.9231V0H20.3077V1.81818H22.1538V5.45455H24V9.09091H20.3077V14.5455H18.4615V20H14.7692V16.3636H12.9231V14.5455H7.38462V16.3636H5.53846V20H1.84615V10.9091H3.69231V9.09091H11.0769V10.9091ZM18.4615 5.45455H20.3077V3.63636H18.4615V5.45455Z"/> <defs>
<path d="M1.84615 10.9091H0V7.27273H1.84615V10.9091Z"/> <style dangerouslySetInnerHTML={{ __html: animationCSS }} />
<path d="M3.69231 7.27273H1.84615V5.45455H3.69231V7.27273Z"/> </defs>
<path d="M5.53846 5.45455H3.69231V3.63636H5.53846V5.45455Z"/>
{/* State 1 - Add 14px left padding to align with state 3 */}
<g className="state1">
<path fillRule="evenodd" clipRule="evenodd" d="M56 42H77V21H70V35H63V28H56V7H63V0H91V7H98V21H105V35H91V56H84V77H70V63H63V56H42V63H35V77H21V42H28V35H56V42ZM84 21H91V14H84V21Z" fill={fillColor}/>
<path d="M21 42H14V28H21V42Z" fill={fillColor}/>
<path d="M28 28H21V21H28V28Z" fill={fillColor}/>
<path d="M35 21H28V14H35V21Z" fill={fillColor}/>
</g>
{/* State 2 - Add 14px left padding to align with state 3 */}
<g className="state2">
<path fillRule="evenodd" clipRule="evenodd" d="M56 42H77V21H70V35H63V28H56V7H63V0H91V7H98V21H105V35H91V56H84V77H70V63H63V56H42V63H35V77H21V42H28V35H56V42ZM84 21H91V14H84V21Z" fill={fillColor}/>
<path d="M21 42H14V14H21V42Z" fill={fillColor}/>
</g>
{/* State 3 - Already properly positioned */}
<g className="state3">
<path fillRule="evenodd" clipRule="evenodd" d="M56 42H77V21H70V35H63V28H56V7H63V0H91V7H98V21H105V35H91V56H84V77H70V63H63V56H42V63H35V77H21V42H28V35H56V42ZM84 21H91V14H84V21Z" fill={fillColor}/>
<path d="M21 42H14V28H21V42Z" fill={fillColor}/>
<path d="M14 28H7V21H14V28Z" fill={fillColor}/>
<path d="M7 21H0V14H7V21Z" fill={fillColor}/>
</g>
{/* State 4 - Add 14px left padding to align with state 3 */}
<g className="state4">
<path fillRule="evenodd" clipRule="evenodd" d="M56 42H77V21H70V35H63V28H56V7H63V0H91V7H98V21H105V35H91V56H84V77H70V63H63V56H42V63H35V77H21V42H28V35H56V42ZM84 21H91V14H84V21Z" fill={fillColor}/>
<path d="M21 42H14V14H21V42Z" fill={fillColor}/>
</g>
</svg> </svg>
) )
) )

View file

@ -17,6 +17,7 @@ interface AssistantMessageProps {
showForkButton?: boolean; showForkButton?: boolean;
onFork?: (e: React.MouseEvent) => void; onFork?: (e: React.MouseEvent) => void;
isCompleted?: boolean; isCompleted?: boolean;
isInactive?: boolean;
animate?: boolean; animate?: boolean;
delay?: number; delay?: number;
} }
@ -31,6 +32,7 @@ export function AssistantMessage({
showForkButton = false, showForkButton = false,
onFork, onFork,
isCompleted = false, isCompleted = false,
isInactive = false,
animate = true, animate = true,
delay = 0.2, delay = 0.2,
}: AssistantMessageProps) { }: AssistantMessageProps) {
@ -47,7 +49,7 @@ export function AssistantMessage({
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none"> <div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
<DogIcon <DogIcon
className="h-6 w-6 transition-colors duration-300" className="h-6 w-6 transition-colors duration-300"
disabled={isCompleted} disabled={isCompleted || isInactive}
/> />
</div> </div>
} }

View file

@ -1,8 +1,7 @@
import { ArrowRight, Check, Funnel, Loader2, Plus, X } from "lucide-react"; import { ArrowRight, Check, Funnel, Loader2, Plus } from "lucide-react";
import { forwardRef, useImperativeHandle, useRef } from "react"; import { forwardRef, useImperativeHandle, useRef } from "react";
import TextareaAutosize from "react-textarea-autosize"; import TextareaAutosize from "react-textarea-autosize";
import type { FilterColor } from "@/components/filter-icon-popover"; import type { FilterColor } from "@/components/filter-icon-popover";
import { filterAccentClasses } from "@/components/knowledge-filter-panel";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Popover, Popover,
@ -10,6 +9,9 @@ import {
PopoverContent, PopoverContent,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import type { KnowledgeFilterData } from "../types"; import type { KnowledgeFilterData } from "../types";
import { useState } from "react";
import { FilePreview } from "./file-preview";
import { SelectedKnowledgeFilter } from "./selected-knowledge-filter";
export interface ChatInputHandle { export interface ChatInputHandle {
focusInput: () => void; focusInput: () => void;
@ -26,19 +28,18 @@ interface ChatInputProps {
filterSearchTerm: string; filterSearchTerm: string;
selectedFilterIndex: number; selectedFilterIndex: number;
anchorPosition: { x: number; y: number } | null; anchorPosition: { x: number; y: number } | null;
textareaHeight: number;
parsedFilterData: { color?: FilterColor } | null; parsedFilterData: { color?: FilterColor } | null;
uploadedFile: File | null;
onSubmit: (e: React.FormEvent) => void; onSubmit: (e: React.FormEvent) => void;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void; onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void; onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onHeightChange: (height: number) => void;
onFilterSelect: (filter: KnowledgeFilterData | null) => void; onFilterSelect: (filter: KnowledgeFilterData | null) => void;
onAtClick: () => void; onAtClick: () => void;
onFilePickerChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onFilePickerClick: () => void; onFilePickerClick: () => void;
setSelectedFilter: (filter: KnowledgeFilterData | null) => void; setSelectedFilter: (filter: KnowledgeFilterData | null) => void;
setIsFilterHighlighted: (highlighted: boolean) => void; setIsFilterHighlighted: (highlighted: boolean) => void;
setIsFilterDropdownOpen: (open: boolean) => void; setIsFilterDropdownOpen: (open: boolean) => void;
onFileSelected: (file: File | null) => void;
} }
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>( export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
@ -53,24 +54,24 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
filterSearchTerm, filterSearchTerm,
selectedFilterIndex, selectedFilterIndex,
anchorPosition, anchorPosition,
textareaHeight,
parsedFilterData, parsedFilterData,
uploadedFile,
onSubmit, onSubmit,
onChange, onChange,
onKeyDown, onKeyDown,
onHeightChange,
onFilterSelect, onFilterSelect,
onAtClick, onAtClick,
onFilePickerChange,
onFilePickerClick, onFilePickerClick,
setSelectedFilter, setSelectedFilter,
setIsFilterHighlighted, setIsFilterHighlighted,
setIsFilterDropdownOpen, setIsFilterDropdownOpen,
onFileSelected,
}, },
ref, ref,
) => { ) => {
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [textareaHeight, setTextareaHeight] = useState(0);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
focusInput: () => { focusInput: () => {
@ -80,90 +81,143 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
fileInputRef.current?.click(); fileInputRef.current?.click();
}, },
})); }));
const handleFilePickerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
onFileSelected(files[0]);
} else {
onFileSelected(null);
}
};
return ( return (
<div className="w-full"> <div className="w-full">
<form onSubmit={onSubmit} className="relative"> <form onSubmit={onSubmit} className="relative">
<div className="relative flex items-center w-full p-2 gap-2 rounded-xl border border-input focus-within:ring-1 focus-within:ring-ring"> {/* Outer container - flex-col to stack file preview above input */}
{selectedFilter ? ( <div className="flex flex-col w-full gap-2 rounded-xl border border-input focus-within:ring-1 focus-within:ring-ring p-2">
<span {/* File Preview Section - Always above */}
className={`inline-flex items-center p-1 rounded-sm text-xs font-medium transition-colors ${ {uploadedFile && (
filterAccentClasses[parsedFilterData?.color || "zinc"] <FilePreview
}`} uploadedFile={uploadedFile}
> onClear={() => {
{selectedFilter.name} onFileSelected(null);
<button
type="button"
onClick={() => {
setSelectedFilter(null);
setIsFilterHighlighted(false);
}}
className="ml-0.5 rounded-full p-0.5"
>
<X className="h-4 w-4" />
</button>
</span>
) : (
<Button
type="button"
variant="ghost"
size="iconSm"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault();
}} }}
onClick={onAtClick}
data-filter-button
>
<Funnel className="h-4 w-4" />
</Button>
)}
<div
className="relative flex-1"
style={{ height: `${textareaHeight}px` }}
>
<TextareaAutosize
ref={inputRef}
value={input}
onChange={onChange}
onKeyDown={onKeyDown}
onHeightChange={onHeightChange}
maxRows={7}
minRows={1}
placeholder="Ask a question..."
disabled={loading}
className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`}
rows={1}
/> />
)}
{/* Main Input Container - flex-row or flex-col based on textarea height */}
<div className={`relative flex w-full gap-2 ${
textareaHeight > 40 ? 'flex-col' : 'flex-row items-center'
}`}>
{/* Filter + Textarea Section */}
<div className={`flex items-center gap-2 ${textareaHeight > 40 ? 'w-full' : 'flex-1'}`}>
{textareaHeight <= 40 && (
selectedFilter ? (
<SelectedKnowledgeFilter
selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData}
onClear={() => {
setSelectedFilter(null);
setIsFilterHighlighted(false);
}}
/>
) : (
<Button
type="button"
variant="ghost"
size="iconSm"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={onAtClick}
data-filter-button
>
<Funnel className="h-4 w-4" />
</Button>
)
)}
<div
className="relative flex-1"
style={{ height: `${textareaHeight}px` }}
>
<TextareaAutosize
ref={inputRef}
value={input}
onChange={onChange}
onKeyDown={onKeyDown}
onHeightChange={(height) => setTextareaHeight(height)}
maxRows={7}
minRows={1}
placeholder="Ask a question..."
disabled={loading}
className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`}
rows={1}
/>
</div>
</div>
{/* Action Buttons Section */}
<div className={`flex items-center gap-2 ${textareaHeight > 40 ? 'justify-between w-full' : ''}`}>
{textareaHeight > 40 && (
selectedFilter ? (
<SelectedKnowledgeFilter
selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData}
onClear={() => {
setSelectedFilter(null);
setIsFilterHighlighted(false);
}}
/>
) : (
<Button
type="button"
variant="ghost"
size="iconSm"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={onAtClick}
data-filter-button
>
<Funnel className="h-4 w-4" />
</Button>
)
)}
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="iconSm"
onClick={onFilePickerClick}
disabled={isUploading}
className="h-8 w-8 p-0 !rounded-md hover:bg-muted/50"
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="default"
type="submit"
size="iconSm"
disabled={(!input.trim() && !uploadedFile) || loading}
className="!rounded-md h-8 w-8 p-0"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div> </div>
<Button
type="button"
variant="ghost"
size="iconSm"
onClick={onFilePickerClick}
disabled={isUploading}
className="h-8 w-8 p-0 !rounded-md hover:bg-muted/50"
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="default"
type="submit"
size="iconSm"
disabled={!input.trim() || loading}
className="!rounded-md h-8 w-8 p-0"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
</Button>
</div> </div>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
onChange={onFilePickerChange} onChange={handleFilePickerChange}
className="hidden" className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt" accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
/> />

View file

@ -0,0 +1,67 @@
import { X } from "lucide-react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
interface FilePreviewProps {
uploadedFile: File;
onClear: () => void;
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
};
const getFilePreviewUrl = (file: File): string => {
if (file.type.startsWith("image/")) {
return URL.createObjectURL(file);
}
return "";
};
export const FilePreview = ({ uploadedFile, onClear }: FilePreviewProps) => {
return (
<div className="max-w-[250px] flex items-center gap-2 p-2 bg-muted rounded-lg">
{/* File Image Preview */}
<div className="flex-shrink-0 w-8 h-8 bg-background rounded border border-input flex items-center justify-center overflow-hidden">
{getFilePreviewUrl(uploadedFile) ? (
<Image
src={getFilePreviewUrl(uploadedFile)}
alt="File preview"
width={32}
height={32}
className="w-full h-full object-cover"
/>
) : (
<div className="text-xs font-medium text-muted-foreground">
{uploadedFile.name.split(".").pop()?.toUpperCase()}
</div>
)}
</div>
{/* File Info */}
<div className="flex-1 min-w-0">
<div className="text-xs text-muted-foreground font-medium truncate">
{uploadedFile.name}
</div>
<div className="text-xxs text-muted-foreground">
{formatFileSize(uploadedFile.size)}
</div>
</div>
{/* Clear Button */}
<Button
type="button"
variant="ghost"
size="iconSm"
onClick={onClear}
className="flex-shrink-0 h-8 w-8 p-0 rounded-md hover:bg-background/50"
>
<X className="h-4 w-4" />
</Button>
</div>
);
};

View file

@ -0,0 +1,33 @@
import { X } from "lucide-react";
import type { KnowledgeFilterData } from "../types";
import { filterAccentClasses } from "@/components/knowledge-filter-panel";
import type { FilterColor } from "@/components/filter-icon-popover";
interface SelectedKnowledgeFilterProps {
selectedFilter: KnowledgeFilterData;
parsedFilterData: { color?: FilterColor } | null;
onClear: () => void;
}
export const SelectedKnowledgeFilter = ({
selectedFilter,
parsedFilterData,
onClear,
}: SelectedKnowledgeFilterProps) => {
return (
<span
className={`inline-flex items-center p-1 rounded-sm text-xs font-medium transition-colors ${
filterAccentClasses[parsedFilterData?.color || "zinc"]
}`}
>
{selectedFilter.name}
<button
type="button"
onClick={onClear}
className="ml-0.5 rounded-full p-0.5"
>
<X className="h-4 w-4" />
</button>
</span>
);
};

View file

@ -60,7 +60,6 @@ function ChatPage() {
const [availableFilters, setAvailableFilters] = useState< const [availableFilters, setAvailableFilters] = useState<
KnowledgeFilterData[] KnowledgeFilterData[]
>([]); >([]);
const [textareaHeight, setTextareaHeight] = useState(40);
const [filterSearchTerm, setFilterSearchTerm] = useState(""); const [filterSearchTerm, setFilterSearchTerm] = useState("");
const [selectedFilterIndex, setSelectedFilterIndex] = useState(0); const [selectedFilterIndex, setSelectedFilterIndex] = useState(0);
const [isFilterHighlighted, setIsFilterHighlighted] = useState(false); const [isFilterHighlighted, setIsFilterHighlighted] = useState(false);
@ -71,6 +70,8 @@ function ChatPage() {
x: number; x: number;
y: number; y: number;
} | null>(null); } | null>(null);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const chatInputRef = useRef<ChatInputHandle>(null); const chatInputRef = useRef<ChatInputHandle>(null);
const { scrollToBottom } = useStickToBottomContext(); const { scrollToBottom } = useStickToBottomContext();
@ -127,7 +128,7 @@ function ChatPage() {
// Copy all computed styles to the hidden div // Copy all computed styles to the hidden div
for (const style of computedStyle) { for (const style of computedStyle) {
(div.style as any)[style] = computedStyle.getPropertyValue(style); (div.style as unknown as Record<string, string>)[style] = computedStyle.getPropertyValue(style);
} }
// Set the div to be hidden but not un-rendered // Set the div to be hidden but not un-rendered
@ -247,6 +248,7 @@ function ChatPage() {
timestamp: new Date(), timestamp: new Date(),
}; };
setMessages((prev) => [...prev.slice(0, -1), pollingMessage]); setMessages((prev) => [...prev.slice(0, -1), pollingMessage]);
return null;
} else if (response.ok) { } else if (response.ok) {
// Original flow: Direct response // Original flow: Direct response
@ -282,6 +284,8 @@ function ChatPage() {
// For existing conversations, do a silent refresh to keep backend in sync // For existing conversations, do a silent refresh to keep backend in sync
refreshConversationsSilent(); refreshConversationsSilent();
} }
return result.response_id;
} }
} else { } else {
throw new Error(`Upload failed: ${response.status}`); throw new Error(`Upload failed: ${response.status}`);
@ -304,13 +308,6 @@ function ChatPage() {
chatInputRef.current?.clickFileInput(); chatInputRef.current?.clickFileInput();
}; };
const handleFilePickerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
handleFileUpload(files[0]);
}
};
const loadAvailableFilters = async () => { const loadAvailableFilters = async () => {
try { try {
const response = await fetch("/api/knowledge-filter/search", { const response = await fetch("/api/knowledge-filter/search", {
@ -601,6 +598,7 @@ function ChatPage() {
setLoading(true); setLoading(true);
setIsUploading(true); setIsUploading(true);
setUploadedFile(null); // Clear previous file
// Add initial upload message // Add initial upload message
const uploadStartMessage: Message = { const uploadStartMessage: Message = {
@ -627,6 +625,7 @@ function ChatPage() {
}; };
setMessages((prev) => [...prev.slice(0, -1), uploadMessage]); setMessages((prev) => [...prev.slice(0, -1), uploadMessage]);
setUploadedFile(null); // Clear file after upload
// Update the response ID for this endpoint // Update the response ID for this endpoint
if (result.response_id) { if (result.response_id) {
@ -658,6 +657,7 @@ function ChatPage() {
timestamp: new Date(), timestamp: new Date(),
}; };
setMessages((prev) => [...prev.slice(0, -1), errorMessage]); setMessages((prev) => [...prev.slice(0, -1), errorMessage]);
setUploadedFile(null); // Clear file on error
}; };
window.addEventListener( window.addEventListener(
@ -724,7 +724,7 @@ function ChatPage() {
}, },
); );
const handleSSEStream = async (userMessage: Message) => { const handleSSEStream = async (userMessage: Message, previousResponseId?: string) => {
// Prepare filters // Prepare filters
const processedFilters = parsedFilterData?.filters const processedFilters = parsedFilterData?.filters
? (() => { ? (() => {
@ -750,10 +750,13 @@ function ChatPage() {
})() })()
: undefined; : undefined;
// Use passed previousResponseId if available, otherwise fall back to state
const responseIdToUse = previousResponseId || previousResponseIds[endpoint];
// Use the hook to send the message // Use the hook to send the message
await sendStreamingMessage({ await sendStreamingMessage({
prompt: userMessage.content, prompt: userMessage.content,
previousResponseId: previousResponseIds[endpoint] || undefined, previousResponseId: responseIdToUse || undefined,
filters: processedFilters, filters: processedFilters,
limit: parsedFilterData?.limit ?? 10, limit: parsedFilterData?.limit ?? 10,
scoreThreshold: parsedFilterData?.scoreThreshold ?? 0, scoreThreshold: parsedFilterData?.scoreThreshold ?? 0,
@ -764,7 +767,7 @@ function ChatPage() {
}); });
}; };
const handleSendMessage = async (inputMessage: string) => { const handleSendMessage = async (inputMessage: string, previousResponseId?: string) => {
if (!inputMessage.trim() || loading) return; if (!inputMessage.trim() || loading) return;
const userMessage: Message = { const userMessage: Message = {
@ -784,7 +787,7 @@ function ChatPage() {
}); });
if (asyncMode) { if (asyncMode) {
await handleSSEStream(userMessage); await handleSSEStream(userMessage, previousResponseId);
} else { } else {
// Original non-streaming logic // Original non-streaming logic
try { try {
@ -891,7 +894,30 @@ function ChatPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
handleSendMessage(input);
// 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()) {
// Pass the responseId from upload (if any) to handleSendMessage
handleSendMessage(input, uploadedResponseId || undefined);
}
}; };
const toggleFunctionCall = (functionCallId: string) => { const toggleFunctionCall = (functionCallId: string) => {
@ -1209,7 +1235,7 @@ function ChatPage() {
className="space-y-6 group" className="space-y-6 group"
> >
<UserMessage <UserMessage
animate={message.source !== "langflow"} animate={message.source ? message.source !== "langflow" : false}
content={message.content} content={message.content}
files={ files={
index >= 2 index >= 2
@ -1241,6 +1267,7 @@ function ChatPage() {
showForkButton={endpoint === "chat"} showForkButton={endpoint === "chat"}
onFork={(e) => handleForkConversation(index, e)} onFork={(e) => handleForkConversation(index, e)}
animate={false} animate={false}
isInactive={index < messages.length - 1}
/> />
</div> </div>
)} )}
@ -1257,6 +1284,7 @@ function ChatPage() {
onToggle={toggleFunctionCall} onToggle={toggleFunctionCall}
delay={0.4} delay={0.4}
isStreaming isStreaming
isCompleted={false}
/> />
)} )}
</> </>
@ -1284,16 +1312,15 @@ function ChatPage() {
filterSearchTerm={filterSearchTerm} filterSearchTerm={filterSearchTerm}
selectedFilterIndex={selectedFilterIndex} selectedFilterIndex={selectedFilterIndex}
anchorPosition={anchorPosition} anchorPosition={anchorPosition}
textareaHeight={textareaHeight}
parsedFilterData={parsedFilterData} parsedFilterData={parsedFilterData}
uploadedFile={uploadedFile}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onChange={onChange} onChange={onChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onHeightChange={(height) => setTextareaHeight(height)}
onFilterSelect={handleFilterSelect} onFilterSelect={handleFilterSelect}
onAtClick={onAtClick} onAtClick={onAtClick}
onFilePickerChange={handleFilePickerChange}
onFilePickerClick={handleFilePickerClick} onFilePickerClick={handleFilePickerClick}
onFileSelected={setUploadedFile}
setSelectedFilter={setSelectedFilter} setSelectedFilter={setSelectedFilter}
setIsFilterHighlighted={setIsFilterHighlighted} setIsFilterHighlighted={setIsFilterHighlighted}
setIsFilterDropdownOpen={setIsFilterDropdownOpen} setIsFilterDropdownOpen={setIsFilterDropdownOpen}

View file

@ -14,7 +14,7 @@ export function ProgressBar({ currentStep, totalSteps }: ProgressBarProps) {
className="h-full transition-all duration-300 ease-in-out" className="h-full transition-all duration-300 ease-in-out"
style={{ style={{
width: `${progressPercentage}%`, width: `${progressPercentage}%`,
background: 'linear-gradient(to right, #818CF8, #22A7AF)' background: 'linear-gradient(to right, #773EFF, #22A7AF)'
}} }}
/> />
</div> </div>

View file

@ -1,70 +1,224 @@
import { cn } from "@/lib/utils"; const AnimatedProcessingIcon = ({
import { motion, easeInOut } from "framer-motion";
export const AnimatedProcessingIcon = ({
className, className,
props,
}: { }: {
className?: string; className?: string;
props?: React.SVGProps<SVGSVGElement>;
}) => { }) => {
const createAnimationFrames = (delay: number) => ({ // CSS for the stepped animation states
opacity: [1, 1, 0.5, 0], // Opacity Steps const animationCSS = `
transition: { .state-1 { opacity: 1; animation: showState1 1.5s infinite steps(1, end); }
delay, .state-2 { opacity: 0; animation: showState2 1.5s infinite steps(1, end); }
duration: 1, .state-3 { opacity: 0; animation: showState3 1.5s infinite steps(1, end); }
ease: easeInOut, .state-4 { opacity: 0; animation: showState4 1.5s infinite steps(1, end); }
repeat: Infinity, .state-5 { opacity: 0; animation: showState5 1.5s infinite steps(1, end); }
times: [0, 0.33, 0.66, 1], // Duration Percentages that Correspond to opacity Array .state-6 { opacity: 0; animation: showState6 1.5s infinite steps(1, end); }
},
}); @keyframes showState1 {
0%, 16.66% { opacity: 1; }
16.67%, 100% { opacity: 0; }
}
@keyframes showState2 {
0%, 16.66% { opacity: 0; }
16.67%, 33.33% { opacity: 1; }
33.34%, 100% { opacity: 0; }
}
@keyframes showState3 {
0%, 33.33% { opacity: 0; }
33.34%, 50% { opacity: 1; }
50.01%, 100% { opacity: 0; }
}
@keyframes showState4 {
0%, 50% { opacity: 0; }
50.01%, 66.66% { opacity: 1; }
66.67%, 100% { opacity: 0; }
}
@keyframes showState5 {
0%, 66.66% { opacity: 0; }
66.67%, 83.33% { opacity: 1; }
83.34%, 100% { opacity: 0; }
}
@keyframes showState6 {
0%, 83.33% { opacity: 0; }
83.34%, 100% { opacity: 1; }
}
`;
return ( return (
<svg <svg
data-testid="rotating-dot-animation" width="16"
className={cn("h-[10px] w-[6px]", className)} height="16"
viewBox="0 0 6 10" viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
{...props}
> >
<motion.circle {/* Inject animation styles into the SVG's shadow */}
animate={createAnimationFrames(0)} <style dangerouslySetInnerHTML={{ __html: animationCSS }} />
fill="currentColor"
cx="1" {/* State 1 */}
cy="1" <g className="state-1">
r="1" <rect
/> x="-19.5"
<motion.circle y="-19.5"
animate={createAnimationFrames(0.16)} width="230.242"
fill="currentColor" height="63"
cx="1" rx="4.5"
cy="5" stroke="#9747FF"
r="1" strokeDasharray="10 5"
/> />
<motion.circle </g>
animate={createAnimationFrames(0.33)}
fill="currentColor" {/* State 2 */}
cx="1" <g className="state-2">
cy="9" <rect
r="1" x="-53.5"
/> y="-19.5"
<motion.circle width="230.242"
animate={createAnimationFrames(0.83)} height="63"
fill="currentColor" rx="4.5"
cx="5" stroke="#9747FF"
cy="1" strokeDasharray="10 5"
r="1" />
/> <path
<motion.circle d="M7.625 20.375H8V20.75H8.375V22.25H8V23H5V22.25H4.625V20.75H5V20.375H5.375V19.625H7.625V20.375Z"
animate={createAnimationFrames(0.66)} fill="currentColor"
fill="currentColor" />
cx="5" <path d="M4.625 20H3.125V18.5H4.625V20Z" fill="currentColor" />
cy="5" <path d="M9.875 20H8.375V18.5H9.875V20Z" fill="currentColor" />
r="1" <path d="M6.125 18.5H4.625V17H6.125V18.5Z" fill="currentColor" />
/> <path d="M8.375 18.5H6.875V17H8.375V18.5Z" fill="currentColor" />
<motion.circle </g>
animate={createAnimationFrames(0.5)}
fill="currentColor" {/* State 3 */}
cx="5" <g className="state-3">
cy="9" <rect
r="1" x="-87.5"
/> y="-19.5"
width="230.242"
height="63"
rx="4.5"
stroke="#9747FF"
strokeDasharray="10 5"
/>
<path
d="M7.625 20.375H8V20.75H8.375V22.25H8V23H5V22.25H4.625V20.75H5V20.375H5.375V19.625H7.625V20.375Z"
fill="currentColor"
/>
<path d="M4.625 20H3.125V18.5H4.625V20Z" fill="currentColor" />
<path d="M9.875 20H8.375V18.5H9.875V20Z" fill="currentColor" />
<path d="M6.125 18.5H4.625V17H6.125V18.5Z" fill="currentColor" />
<path d="M8.375 18.5H6.875V17H8.375V18.5Z" fill="currentColor" />
<path
d="M18.625 12.375H19V12.75H19.375V14.25H19V15H16V14.25H15.625V12.75H16V12.375H16.375V11.625H18.625V12.375Z"
fill="currentColor"
/>
<path d="M15.625 12H14.125V10.5H15.625V12Z" fill="currentColor" />
<path d="M20.875 12H19.375V10.5H20.875V12Z" fill="currentColor" />
<path d="M17.125 10.5H15.625V9H17.125V10.5Z" fill="currentColor" />
<path d="M19.375 10.5H17.875V9H19.375V10.5Z" fill="currentColor" />
</g>
{/* State 4 */}
<g className="state-4">
<rect
x="-122.5"
y="-19.5"
width="230.242"
height="63"
rx="4.5"
stroke="#9747FF"
strokeDasharray="10 5"
/>
<path
d="M7.625 4.375H8V4.75H8.375V6.25H8V7H5V6.25H4.625V4.75H5V4.375H5.375V3.625H7.625V4.375Z"
fill="currentColor"
/>
<path d="M4.625 4H3.125V2.5H4.625V4Z" fill="currentColor" />
<path d="M9.875 4H8.375V2.5H9.875V4Z" fill="currentColor" />
<path d="M6.125 2.5H4.625V1H6.125V2.5Z" fill="currentColor" />
<path d="M8.375 2.5H6.875V1H8.375V2.5Z" fill="currentColor" />
<g opacity="0.25">
<path
d="M7.625 20.375H8V20.75H8.375V22.25H8V23H5V22.25H4.625V20.75H5V20.375H5.375V19.625H7.625V20.375Z"
fill="currentColor"
/>
<path d="M4.625 20H3.125V18.5H4.625V20Z" fill="currentColor" />
<path d="M9.875 20H8.375V18.5H9.875V20Z" fill="currentColor" />
<path d="M6.125 18.5H4.625V17H6.125V18.5Z" fill="currentColor" />
<path d="M8.375 18.5H6.875V17H8.375V18.5Z" fill="currentColor" />
</g>
<path
d="M18.625 12.375H19V12.75H19.375V14.25H19V15H16V14.25H15.625V12.75H16V12.375H16.375V11.625H18.625V12.375Z"
fill="currentColor"
/>
<path d="M15.625 12H14.125V10.5H15.625V12Z" fill="currentColor" />
<path d="M20.875 12H19.375V10.5H20.875V12Z" fill="currentColor" />
<path d="M17.125 10.5H15.625V9H17.125V10.5Z" fill="currentColor" />
<path d="M19.375 10.5H17.875V9H19.375V10.5Z" fill="currentColor" />
</g>
{/* State 5 */}
<g className="state-5">
<rect
x="-156.5"
y="-19.5"
width="230.242"
height="63"
rx="4.5"
stroke="#9747FF"
strokeDasharray="10 5"
/>
<path
d="M7.625 4.375H8V4.75H8.375V6.25H8V7H5V6.25H4.625V4.75H5V4.375H5.375V3.625H7.625V4.375Z"
fill="currentColor"
/>
<path d="M4.625 4H3.125V2.5H4.625V4Z" fill="currentColor" />
<path d="M9.875 4H8.375V2.5H9.875V4Z" fill="currentColor" />
<path d="M6.125 2.5H4.625V1H6.125V2.5Z" fill="currentColor" />
<path d="M8.375 2.5H6.875V1H8.375V2.5Z" fill="currentColor" />
<g opacity="0.25">
<path
d="M18.625 12.375H19V12.75H19.375V14.25H19V15H16V14.25H15.625V12.75H16V12.375H16.375V11.625H18.625V12.375Z"
fill="currentColor"
/>
<path d="M15.625 12H14.125V10.5H15.625V12Z" fill="currentColor" />
<path d="M20.875 12H19.375V10.5H20.875V12Z" fill="currentColor" />
<path d="M17.125 10.5H15.625V9H17.125V10.5Z" fill="currentColor" />
<path d="M19.375 10.5H17.875V9H19.375V10.5Z" fill="currentColor" />
</g>
</g>
{/* State 6 */}
<g className="state-6">
<rect
x="-190.5"
y="-19.5"
width="230.242"
height="63"
rx="4.5"
stroke="#9747FF"
strokeDasharray="10 5"
/>
<g opacity="0.25">
<path
d="M7.625 4.375H8V4.75H8.375V6.25H8V7H5V6.25H4.625V4.75H5V4.375H5.375V3.625H7.625V4.375Z"
fill="currentColor"
/>
<path d="M4.625 4H3.125V2.5H4.625V4Z" fill="currentColor" />
<path d="M9.875 4H8.375V2.5H9.875V4Z" fill="currentColor" />
<path d="M6.125 2.5H4.625V1H6.125V2.5Z" fill="currentColor" />
<path d="M8.375 2.5H6.875V1H8.375V2.5Z" fill="currentColor" />
</g>
</g>
</svg> </svg>
); );
}; };
export default AnimatedProcessingIcon;

View file

@ -130,7 +130,7 @@ export function KnowledgeFilterProvider({
}, },
limit: 10, limit: 10,
scoreThreshold: 0, scoreThreshold: 0,
color: "zinc", color: "amber",
icon: "filter", icon: "filter",
}); });
setIsPanelOpen(true); setIsPanelOpen(true);