Merge pull request #303 from langflow-ai/chat-input-files
Update chat input to support file uploads along side a message
This commit is contained in:
commit
64ae8211d3
5 changed files with 278 additions and 99 deletions
|
|
@ -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 TextareaAutosize from "react-textarea-autosize";
|
||||
import type { FilterColor } from "@/components/filter-icon-popover";
|
||||
import { filterAccentClasses } from "@/components/knowledge-filter-panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
|
|
@ -10,6 +9,9 @@ import {
|
|||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import type { KnowledgeFilterData } from "../types";
|
||||
import { useState } from "react";
|
||||
import { FilePreview } from "./file-preview";
|
||||
import { SelectedKnowledgeFilter } from "./selected-knowledge-filter";
|
||||
|
||||
export interface ChatInputHandle {
|
||||
focusInput: () => void;
|
||||
|
|
@ -26,19 +28,18 @@ interface ChatInputProps {
|
|||
filterSearchTerm: string;
|
||||
selectedFilterIndex: number;
|
||||
anchorPosition: { x: number; y: number } | null;
|
||||
textareaHeight: number;
|
||||
parsedFilterData: { color?: FilterColor } | null;
|
||||
uploadedFile: File | null;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onHeightChange: (height: number) => void;
|
||||
onFilterSelect: (filter: KnowledgeFilterData | null) => void;
|
||||
onAtClick: () => void;
|
||||
onFilePickerChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onFilePickerClick: () => void;
|
||||
setSelectedFilter: (filter: KnowledgeFilterData | null) => void;
|
||||
setIsFilterHighlighted: (highlighted: boolean) => void;
|
||||
setIsFilterDropdownOpen: (open: boolean) => void;
|
||||
onFileSelected: (file: File | null) => void;
|
||||
}
|
||||
|
||||
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
|
|
@ -53,24 +54,24 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||
filterSearchTerm,
|
||||
selectedFilterIndex,
|
||||
anchorPosition,
|
||||
textareaHeight,
|
||||
parsedFilterData,
|
||||
uploadedFile,
|
||||
onSubmit,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onHeightChange,
|
||||
onFilterSelect,
|
||||
onAtClick,
|
||||
onFilePickerChange,
|
||||
onFilePickerClick,
|
||||
setSelectedFilter,
|
||||
setIsFilterHighlighted,
|
||||
setIsFilterDropdownOpen,
|
||||
onFileSelected,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [textareaHeight, setTextareaHeight] = useState(0);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focusInput: () => {
|
||||
|
|
@ -80,90 +81,143 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||
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 (
|
||||
<div className="w-full">
|
||||
<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">
|
||||
{selectedFilter ? (
|
||||
<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={() => {
|
||||
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();
|
||||
{/* Outer container - flex-col to stack file preview above input */}
|
||||
<div className="flex flex-col w-full gap-2 rounded-xl border border-input focus-within:ring-1 focus-within:ring-ring p-2">
|
||||
{/* File Preview Section - Always above */}
|
||||
{uploadedFile && (
|
||||
<FilePreview
|
||||
uploadedFile={uploadedFile}
|
||||
onClear={() => {
|
||||
onFileSelected(null);
|
||||
}}
|
||||
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>
|
||||
<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>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
onChange={onFilePickerChange}
|
||||
onChange={handleFilePickerChange}
|
||||
className="hidden"
|
||||
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
|
||||
/>
|
||||
|
|
|
|||
67
frontend/src/app/chat/components/file-preview.tsx
Normal file
67
frontend/src/app/chat/components/file-preview.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -60,7 +60,6 @@ function ChatPage() {
|
|||
const [availableFilters, setAvailableFilters] = useState<
|
||||
KnowledgeFilterData[]
|
||||
>([]);
|
||||
const [textareaHeight, setTextareaHeight] = useState(40);
|
||||
const [filterSearchTerm, setFilterSearchTerm] = useState("");
|
||||
const [selectedFilterIndex, setSelectedFilterIndex] = useState(0);
|
||||
const [isFilterHighlighted, setIsFilterHighlighted] = useState(false);
|
||||
|
|
@ -71,6 +70,8 @@ function ChatPage() {
|
|||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
|
||||
const chatInputRef = useRef<ChatInputHandle>(null);
|
||||
|
||||
const { scrollToBottom } = useStickToBottomContext();
|
||||
|
|
@ -127,7 +128,7 @@ function ChatPage() {
|
|||
|
||||
// Copy all computed styles to the hidden div
|
||||
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
|
||||
|
|
@ -247,6 +248,7 @@ function ChatPage() {
|
|||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev.slice(0, -1), pollingMessage]);
|
||||
return null;
|
||||
} else if (response.ok) {
|
||||
// Original flow: Direct response
|
||||
|
||||
|
|
@ -282,6 +284,8 @@ function ChatPage() {
|
|||
// 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}`);
|
||||
|
|
@ -304,13 +308,6 @@ function ChatPage() {
|
|||
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 () => {
|
||||
try {
|
||||
const response = await fetch("/api/knowledge-filter/search", {
|
||||
|
|
@ -601,6 +598,7 @@ function ChatPage() {
|
|||
|
||||
setLoading(true);
|
||||
setIsUploading(true);
|
||||
setUploadedFile(null); // Clear previous file
|
||||
|
||||
// Add initial upload message
|
||||
const uploadStartMessage: Message = {
|
||||
|
|
@ -627,6 +625,7 @@ function ChatPage() {
|
|||
};
|
||||
|
||||
setMessages((prev) => [...prev.slice(0, -1), uploadMessage]);
|
||||
setUploadedFile(null); // Clear file after upload
|
||||
|
||||
// Update the response ID for this endpoint
|
||||
if (result.response_id) {
|
||||
|
|
@ -658,6 +657,7 @@ function ChatPage() {
|
|||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev.slice(0, -1), errorMessage]);
|
||||
setUploadedFile(null); // Clear file on error
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
|
|
@ -724,7 +724,7 @@ function ChatPage() {
|
|||
},
|
||||
);
|
||||
|
||||
const handleSSEStream = async (userMessage: Message) => {
|
||||
const handleSSEStream = async (userMessage: Message, previousResponseId?: string) => {
|
||||
// Prepare filters
|
||||
const processedFilters = parsedFilterData?.filters
|
||||
? (() => {
|
||||
|
|
@ -750,10 +750,13 @@ function ChatPage() {
|
|||
})()
|
||||
: 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: previousResponseIds[endpoint] || undefined,
|
||||
previousResponseId: responseIdToUse || undefined,
|
||||
filters: processedFilters,
|
||||
limit: parsedFilterData?.limit ?? 10,
|
||||
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;
|
||||
|
||||
const userMessage: Message = {
|
||||
|
|
@ -784,7 +787,7 @@ function ChatPage() {
|
|||
});
|
||||
|
||||
if (asyncMode) {
|
||||
await handleSSEStream(userMessage);
|
||||
await handleSSEStream(userMessage, previousResponseId);
|
||||
} else {
|
||||
// Original non-streaming logic
|
||||
try {
|
||||
|
|
@ -891,7 +894,30 @@ function ChatPage() {
|
|||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
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) => {
|
||||
|
|
@ -1286,16 +1312,15 @@ function ChatPage() {
|
|||
filterSearchTerm={filterSearchTerm}
|
||||
selectedFilterIndex={selectedFilterIndex}
|
||||
anchorPosition={anchorPosition}
|
||||
textareaHeight={textareaHeight}
|
||||
parsedFilterData={parsedFilterData}
|
||||
uploadedFile={uploadedFile}
|
||||
onSubmit={handleSubmit}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onHeightChange={(height) => setTextareaHeight(height)}
|
||||
onFilterSelect={handleFilterSelect}
|
||||
onAtClick={onAtClick}
|
||||
onFilePickerChange={handleFilePickerChange}
|
||||
onFilePickerClick={handleFilePickerClick}
|
||||
onFileSelected={setUploadedFile}
|
||||
setSelectedFilter={setSelectedFilter}
|
||||
setIsFilterHighlighted={setIsFilterHighlighted}
|
||||
setIsFilterDropdownOpen={setIsFilterDropdownOpen}
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ export function KnowledgeFilterProvider({
|
|||
},
|
||||
limit: 10,
|
||||
scoreThreshold: 0,
|
||||
color: "zinc",
|
||||
color: "amber",
|
||||
icon: "filter",
|
||||
});
|
||||
setIsPanelOpen(true);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue