feat: move fileuploader from CL and able to upload multiple files (#11)

---------
Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de>
This commit is contained in:
Thuc Pham
2024-10-31 17:07:55 +07:00
committed by GitHub
parent 8caaa51d8e
commit 07a0d27f1a
13 changed files with 345 additions and 85 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@llamaindex/chat-ui': patch
---
Add progress bar for agent events
+19 -11
View File
@@ -10,25 +10,33 @@ jobs:
name: Create Changeset PR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- name: Checkout Repo
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
version: 8
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Get changeset status
id: get-changeset-status
run: |
pnpm changeset status --output .changeset/status.json
new_version=$(jq -r '.releases[0].newVersion' < .changeset/status.json)
rm -v .changeset/status.json
echo "new-version=${new_version}" >> "$GITHUB_OUTPUT"
- name: Create Release Pull Request
uses: changesets/action@v1
with:
commit: Release ${{ steps.get-changeset-status.outputs.new-version }}
title: Release ${{ steps.get-changeset-status.outputs.new-version }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -0,0 +1 @@
18
+1
View File
@@ -55,6 +55,7 @@
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-progress": "^1.1.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.453.0",
+8
View File
@@ -48,9 +48,17 @@ export type EventData = {
title: string
}
export type ProgressData = {
id: string
total: number
current: number
}
export type AgentEventData = {
agent: string
text: string
type: 'text' | 'progress'
data?: ProgressData
}
export type SuggestedQuestionsData = string[]
+23 -45
View File
@@ -1,13 +1,14 @@
import { Loader2, Paperclip } from 'lucide-react'
import type { ChangeEvent } from 'react'
import { createContext, useContext, useState } from 'react'
import { createContext, useContext } from 'react'
import { cn } from '../lib/utils'
import { Button, buttonVariants } from '../ui/button'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Textarea } from '../ui/textarea'
import { FileUploader } from '../widget/file-uploader'
import { useChatUI } from './chat.context'
import { Message } from './chat.interface'
const ALLOWED_EXTENSIONS = ['png', 'jpg', 'jpeg', 'csv', 'pdf', 'txt', 'docx']
interface ChatInputProps extends React.PropsWithChildren {
className?: string
resetUploadedFiles?: () => void
@@ -25,12 +26,14 @@ interface ChatInputFieldProps {
interface ChatInputUploadProps {
className?: string
inputId?: string
onUpload?: (file: File) => Promise<void>
onUpload?: (file: File) => Promise<void> | undefined
allowedExtensions?: string[]
multiple?: boolean
}
interface ChatInputSubmitProps extends React.PropsWithChildren {
className?: string
disabled?: boolean
}
interface ChatInputContext {
@@ -144,60 +147,35 @@ function ChatInputField(props: ChatInputFieldProps) {
function ChatInputUpload(props: ChatInputUploadProps) {
const { requestData, setRequestData, isLoading } = useChatUI()
const [uploading, setUploading] = useState(false)
const inputId = props.inputId || 'fileInput'
const resetInput = () => {
const fileInput = document.getElementById(inputId) as HTMLInputElement
fileInput.value = ''
}
const onFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
const onFileUpload = async (file: File) => {
if (props.onUpload) {
await props.onUpload(file)
} else {
setRequestData({ ...(requestData || {}), file })
}
resetInput()
setUploading(false)
}
return (
<div className="self-stretch">
<input
id={inputId}
type="file"
className="hidden"
onChange={onFileChange}
disabled={isLoading}
/>
<label
htmlFor={inputId}
className={cn(
buttonVariants({ variant: 'secondary', size: 'icon' }),
'cursor-pointer',
uploading && 'opacity-50',
props.className
)}
>
{uploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Paperclip className="h-4 w-4 -rotate-45" />
)}
</label>
</div>
<FileUploader
onFileUpload={onFileUpload}
config={{
disabled: isLoading,
allowedExtensions: props.allowedExtensions ?? ALLOWED_EXTENSIONS,
multiple: props.multiple ?? true,
}}
/>
)
}
function ChatInputSubmit(props: ChatInputSubmitProps) {
const { isDisabled } = useChatInput()
return (
<Button type="submit" disabled={isDisabled} className={cn(props.className)}>
<Button
type="submit"
disabled={props.disabled ?? isDisabled}
className={cn(props.className)}
>
{props.children ?? 'Send message'}
</Button>
)
+27
View File
@@ -0,0 +1,27 @@
'use client'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import * as React from 'react'
import { cn } from '../lib/utils'
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
'bg-secondary relative h-4 w-full overflow-hidden rounded-full',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
@@ -12,7 +12,8 @@ import {
DrawerTrigger,
} from '../ui/drawer'
import { Markdown } from './markdown'
import { AgentEventData } from '../chat/annotation'
import { AgentEventData, ProgressData } from '../chat/annotation'
import { Progress } from '../ui/progress'
const AgentIcons: Record<string, LucideIcon> = {
bot: icons.Bot,
@@ -22,10 +23,19 @@ const AgentIcons: Record<string, LucideIcon> = {
publisher: icons.BookCheck,
}
type StepText = {
text: string
}
type StepProgress = {
text: string
progress: ProgressData
}
type MergedEvent = {
agent: string
texts: string[]
icon: LucideIcon
steps: (StepText | StepProgress)[]
}
export function ChatAgentEvents({
@@ -54,6 +64,51 @@ export function ChatAgentEvents({
const MAX_TEXT_LENGTH = 150
function TextContent({ agent, step }: { agent: string; step: StepText }) {
const { displayText, showMore } = useMemo(
() => ({
displayText: step.text.slice(0, MAX_TEXT_LENGTH),
showMore: step.text.length > MAX_TEXT_LENGTH,
}),
[step.text]
)
return (
<div className="whitespace-break-spaces">
{!showMore && <span>{step.text}</span>}
{showMore && (
<div>
<span>{displayText}...</span>
<AgentEventDialog content={step.text} title={`Agent "${agent}"`}>
<span className="ml-2 cursor-pointer font-semibold underline">
Show more
</span>
</AgentEventDialog>
</div>
)}
</div>
)
}
function ProgressContent({ step }: { step: StepProgress }) {
const progressValue =
step.progress.total !== 0
? Math.round(((step.progress.current + 1) / step.progress.total) * 100)
: 0
return (
<div className="mt-2 space-y-2">
{step.text && (
<p className="text-muted-foreground text-sm">{step.text}</p>
)}
<Progress value={progressValue} className="h-2 w-full" />
<p className="text-muted-foreground text-sm">
Processing {step.progress.current + 1} of {step.progress.total} steps...
</p>
</div>
)
}
function AgentEventContent({
event,
isLast,
@@ -63,8 +118,19 @@ function AgentEventContent({
isLast: boolean
isFinished: boolean
}) {
const { agent, texts } = event
const { agent, steps } = event
const AgentIcon = event.icon
const textSteps = steps.filter(step => !('progress' in step))
const progressSteps = steps.filter(
step => 'progress' in step
) as StepProgress[]
// We only show progress at the last step
// TODO: once we support steps that work in parallel, we need to update this
const lastProgressStep =
progressSteps.length > 0
? progressSteps[progressSteps.length - 1]
: undefined
return (
<div className="fadein-agent flex items-center gap-4 border-b pb-4">
<div className="flex w-[100px] flex-col items-center gap-2">
@@ -81,26 +147,20 @@ function AgentEventContent({
</div>
<span className="font-bold">{agent}</span>
</div>
<ul className="flex-1 list-decimal space-y-2">
{texts.map((text, index) => (
<li className="whitespace-break-spaces" key={index}>
{text.length <= MAX_TEXT_LENGTH && <span>{text}</span>}
{text.length > MAX_TEXT_LENGTH && (
<div>
<span>{text.slice(0, MAX_TEXT_LENGTH)}...</span>
<AgentEventDialog
content={text}
title={`Agent "${agent}" - Step: ${index + 1}`}
>
<span className="ml-2 cursor-pointer font-semibold underline">
Show more
</span>
</AgentEventDialog>
</div>
)}
</li>
))}
</ul>
{textSteps.length > 0 && (
<div className="flex-1">
<ul className="list-decimal space-y-2">
{textSteps.map((step, index) => (
<li key={index}>
<TextContent agent={agent} step={step} />
</li>
))}
</ul>
{lastProgressStep && !isFinished && (
<ProgressContent step={lastProgressStep} />
)}
</div>
)}
</div>
)
}
@@ -138,15 +198,22 @@ function mergeAdjacentEvents(events: AgentEventData[]): MergedEvent[] {
for (const event of events) {
const lastMergedEvent = mergedEvents[mergedEvents.length - 1]
const eventStep: StepText | StepProgress = event.data
? ({
text: event.text,
progress: event.data,
} as StepProgress)
: ({
text: event.text,
} as StepText)
if (lastMergedEvent && lastMergedEvent.agent === event.agent) {
// If the last event in mergedEvents has the same non-null agent, add the title to it
lastMergedEvent.texts.push(event.text)
lastMergedEvent.steps.push(eventStep)
} else {
// Otherwise, create a new merged event
mergedEvents.push({
agent: event.agent,
texts: [event.text],
icon: AgentIcons[event.agent] ?? icons.Bot,
steps: [eventStep],
icon: AgentIcons[event.agent.toLowerCase()] ?? icons.Bot,
})
}
}
@@ -73,7 +73,7 @@ function SourceInfo({ node, index }: { node?: SourceNode; index: number }) {
className="hover:bg-primary hover:text-white"
/>
</HoverCardTrigger>
<HoverCardContent className="w-[400px]">
<HoverCardContent className="w-[400px] bg-white p-4">
<NodeInfo nodeInfo={node} />
</HoverCardContent>
</HoverCard>
@@ -0,0 +1,136 @@
'use client'
import { Loader2, Paperclip } from 'lucide-react'
import { ChangeEvent, useState } from 'react'
import { buttonVariants } from '../ui/button'
import { cn } from '../lib/utils'
export interface FileUploaderProps {
config?: {
inputId?: string
fileSizeLimit?: number
allowedExtensions?: string[]
checkExtension?: (extension: string) => string | null
disabled: boolean
multiple?: boolean
}
onFileUpload: (file: File) => Promise<void>
onFileError?: (errMsg: string) => void
}
const DEFAULT_INPUT_ID = 'fileInput'
const DEFAULT_FILE_SIZE_LIMIT = 1024 * 1024 * 50 // 50 MB
export function FileUploader({
config,
onFileUpload,
onFileError,
}: FileUploaderProps) {
const [uploading, setUploading] = useState(false)
const [remainingFiles, setRemainingFiles] = useState<number>(0)
const inputId = config?.inputId || DEFAULT_INPUT_ID
const fileSizeLimit = config?.fileSizeLimit || DEFAULT_FILE_SIZE_LIMIT
const allowedExtensions = config?.allowedExtensions
const defaultCheckExtension = (extension: string) => {
if (allowedExtensions && !allowedExtensions.includes(extension)) {
return `Invalid file type. Please select a file with one of these formats: ${allowedExtensions.join(
','
)}`
}
return null
}
const checkExtension = config?.checkExtension ?? defaultCheckExtension
const isFileSizeExceeded = (file: File) => {
return file.size > fileSizeLimit
}
const resetInput = () => {
const fileInput = document.getElementById(inputId) as HTMLInputElement
fileInput.value = ''
}
const onFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
if (!files.length) return
setUploading(true)
await handleUpload(files)
resetInput()
setUploading(false)
}
const handleUpload = async (files: File[]) => {
const onFileUploadError = onFileError || window.alert
// Validate files
// If multiple files with image or multiple images
if (
files.length > 1 &&
files.some(file => file.type.startsWith('image/'))
) {
onFileUploadError('Multiple files with image are not supported')
return
}
for (const file of files) {
const fileExtension = file.name.split('.').pop() || ''
const extensionFileError = checkExtension(fileExtension)
if (extensionFileError) {
onFileUploadError(extensionFileError)
return
}
if (isFileSizeExceeded(file)) {
onFileUploadError(
`File size exceeded. Limit is ${fileSizeLimit / 1024 / 1024} MB`
)
return
}
}
setRemainingFiles(files.length)
for (const file of files) {
await onFileUpload(file)
setRemainingFiles(prev => prev - 1)
}
setRemainingFiles(0)
}
return (
<div className="self-stretch">
<input
type="file"
id={inputId}
style={{ display: 'none' }}
onChange={onFileChange}
accept={allowedExtensions?.join(',')}
disabled={config?.disabled || uploading}
multiple={config?.multiple}
/>
<label
htmlFor={inputId}
className={cn(
buttonVariants({ variant: 'secondary', size: 'icon' }),
'relative cursor-pointer',
uploading && 'opacity-50'
)}
>
{uploading ? (
<div className="relative flex h-full w-full items-center justify-center">
<Loader2 className="absolute h-6 w-6 animate-spin" />
{remainingFiles > 0 && (
<span className="absolute inset-0 flex items-center justify-center text-xs">
{remainingFiles}
</span>
)}
</div>
) : (
<Paperclip className="h-4 w-4 -rotate-45" />
)}
</label>
</div>
)
}
+1
View File
@@ -11,3 +11,4 @@ export { SuggestedQuestions } from './widget/suggested-questions'
export { StarterQuestions } from './widget/starter-questions'
export { DocumentPreview } from './widget/document-preview'
export { ImagePreview } from './widget/image-preview'
export { FileUploader } from './widget/file-uploader'
+2
View File
@@ -68,6 +68,8 @@ module.exports = {
'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/no-static-element-interactions': 'off',
'jsx-a11y/anchor-is-valid': 'off',
'no-await-in-loop': 'off',
'@typescript-eslint/no-unnecessary-type-assertion': 'off',
},
overrides: [
{
+26
View File
@@ -114,6 +114,9 @@ importers:
'@radix-ui/react-icons':
specifier: ^1.3.0
version: 1.3.0(react@18.2.0)
'@radix-ui/react-progress':
specifier: ^1.1.0
version: 1.1.0(@types/react-dom@18.2.19)(@types/react@18.2.61)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-select':
specifier: ^2.1.1
version: 2.1.2(@types/react-dom@18.2.19)(@types/react@18.2.61)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -1214,6 +1217,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-progress@1.1.0':
resolution: {integrity: sha512-aSzvnYpP725CROcxAOEBVZZSIQVQdHgBr2QQFKySsaD14u8dNT0batuXI+AAGDdAHfXH8rbnHmjYFqVJ21KkRg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.0':
resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==}
peerDependencies:
@@ -7026,6 +7042,16 @@ snapshots:
'@types/react': 18.2.61
'@types/react-dom': 18.2.19
'@radix-ui/react-progress@1.1.0(@types/react-dom@18.2.19)(@types/react@18.2.61)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-context': 1.1.0(@types/react@18.2.61)(react@18.2.0)
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.19)(@types/react@18.2.61)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.61
'@types/react-dom': 18.2.19
'@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.2.19)(@types/react@18.2.61)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.0