feat: enhance ChatPanel and ChatInput to support image file uploads and display

This commit is contained in:
dayuan.jiang
2025-03-23 11:03:25 +00:00
parent df483ae647
commit 50dc4eda6d
4 changed files with 160 additions and 193 deletions

View File

@@ -3,7 +3,8 @@
import React, { useCallback, useRef, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Loader2, Send, Trash } from "lucide-react"
import { Loader2, Send, Trash, Image as ImageIcon, X } from "lucide-react"
import Image from "next/image"
interface ChatInputProps {
input: string
@@ -11,10 +12,21 @@ interface ChatInputProps {
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
setMessages: (messages: any[]) => void
files?: FileList
onFileChange?: (files: FileList | undefined) => void
}
export function ChatInput({ input, status, onSubmit, onChange, setMessages }: ChatInputProps) {
export function ChatInput({
input,
status,
onSubmit,
onChange,
setMessages,
files,
onFileChange
}: ChatInputProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// Auto-resize textarea based on content
const adjustTextareaHeight = useCallback(() => {
@@ -40,8 +52,63 @@ export function ChatInput({ input, status, onSubmit, onChange, setMessages }: Ch
}
}
// Handle file changes
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (onFileChange) {
onFileChange(e.target.files || undefined)
}
}
// Clear file selection
const clearFiles = () => {
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
if (onFileChange) {
onFileChange(undefined)
}
}
// Trigger file input click
const triggerFileInput = () => {
fileInputRef.current?.click()
}
return (
<form onSubmit={onSubmit} className="w-full space-y-2">
{/* File preview area */}
{files && files.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md">
{Array.from(files).map((file, index) => (
<div key={index} className="relative group">
<div className="w-20 h-20 border rounded-md overflow-hidden bg-muted">
{file.type.startsWith('image/') ? (
<Image
src={URL.createObjectURL(file)}
alt={file.name}
width={80}
height={80}
className="object-cover w-full h-full"
/>
) : (
<div className="flex items-center justify-center h-full text-xs text-center p-1">
{file.name}
</div>
)}
</div>
<button
type="button"
onClick={clearFiles}
className="absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Remove file"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
<Textarea
ref={textareaRef}
value={input}
@@ -52,17 +119,42 @@ export function ChatInput({ input, status, onSubmit, onChange, setMessages }: Ch
aria-label="Chat input"
className="min-h-[80px] resize-none transition-all duration-200"
/>
<div className="flex justify-between gap-2">
<Button
type="button"
variant="outline"
size="default"
onClick={() => setMessages([])}
title="Clear messages"
>
<Trash className="mr-2 h-4 w-4" />
Start a new conversation
</Button>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="default"
onClick={() => setMessages([])}
title="Clear messages"
>
<Trash className="mr-2 h-4 w-4" />
Start a new conversation
</Button>
<Button
type="button"
variant="outline"
size="icon"
onClick={triggerFileInput}
disabled={status === "streaming"}
title="Upload image"
>
<ImageIcon className="h-4 w-4" />
</Button>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*"
multiple
disabled={status === "streaming"}
/>
</div>
<Button
type="submit"
disabled={status === "streaming" || !input.trim()}

View File

@@ -2,6 +2,7 @@
import type React from "react"
import { useRef, useEffect, useState } from "react"
import Image from "next/image"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area"
@@ -17,6 +18,9 @@ interface ChatPanelProps {
export default function ChatPanel({ onDisplayChart, onFetchChart }: ChatPanelProps) {
// Add a step counter to track updates
const stepCounterRef = useRef<number>(0);
// Add state for file attachments
const [files, setFiles] = useState<FileList | undefined>(undefined);
// Remove the currentXmlRef and related useEffect
const { messages, input, handleInputChange, handleSubmit, status, error, setInput, setMessages, data } = useChat({
maxSteps: 5,
@@ -63,13 +67,23 @@ export default function ChatPanel({ onDisplayChart, onFetchChart }: ChatPanelPro
"""
`
)
handleSubmit(e)
handleSubmit(e, {
experimental_attachments: files,
})
// Clear files after submission
setFiles(undefined);
} catch (error) {
console.error("Error fetching chart data:", error);
}
}
}
// Helper function to handle file changes
const handleFileChange = (newFiles: FileList | undefined) => {
setFiles(newFiles);
}
// Helper function to render tool invocations
const renderToolInvocation = (toolInvocation: any) => {
const callId = toolInvocation.toolCallId;
@@ -166,6 +180,25 @@ export default function ChatPanel({ onDisplayChart, onFetchChart }: ChatPanelPro
message.content
)}
</div>
{/* Display image attachments */}
{message?.experimental_attachments?.filter(attachment =>
attachment?.contentType?.startsWith('image/')
).map((attachment, index) => (
<div key={`${message.id}-${index}`} className={`mt-2 ${message.role === "user" ? "text-right" : "text-left"}`}>
<div className="inline-block">
<Image
src={attachment.url}
width={200}
height={200}
alt={attachment.name ?? `attachment-${index}`}
className="rounded-md border"
style={{ objectFit: 'contain' }}
/>
</div>
</div>
))}
{/* Legacy support for function_call format */}
{(message as any).function_call && (
<div className="mt-2 text-left">
@@ -192,6 +225,8 @@ export default function ChatPanel({ onDisplayChart, onFetchChart }: ChatPanelPro
onSubmit={onFormSubmit}
onChange={handleInputChange}
setMessages={setMessages}
files={files}
onFileChange={handleFileChange}
/>
</CardFooter>
</Card>