diff --git a/.changeset/ninety-buses-sell.md b/.changeset/ninety-buses-sell.md new file mode 100644 index 0000000..bd0ba54 --- /dev/null +++ b/.changeset/ninety-buses-sell.md @@ -0,0 +1,5 @@ +--- +'@llamaindex/chat-ui': patch +--- + +Add progress bar for agent events diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml index b98ade9..53ef4ca 100644 --- a/.github/workflows/changeset.yml +++ b/.github/workflows/changeset.yml @@ -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 }} diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..3c03207 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18 diff --git a/packages/chat-ui/package.json b/packages/chat-ui/package.json index dd8cb12..babae1e 100644 --- a/packages/chat-ui/package.json +++ b/packages/chat-ui/package.json @@ -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", diff --git a/packages/chat-ui/src/chat/annotation.ts b/packages/chat-ui/src/chat/annotation.ts index 9772823..10607f8 100644 --- a/packages/chat-ui/src/chat/annotation.ts +++ b/packages/chat-ui/src/chat/annotation.ts @@ -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[] diff --git a/packages/chat-ui/src/chat/chat-input.tsx b/packages/chat-ui/src/chat/chat-input.tsx index 3b22cfb..8c04c38 100644 --- a/packages/chat-ui/src/chat/chat-input.tsx +++ b/packages/chat-ui/src/chat/chat-input.tsx @@ -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 + onUpload?: (file: File) => Promise | 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) => { - 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 ( -
- - -
+ ) } function ChatInputSubmit(props: ChatInputSubmitProps) { const { isDisabled } = useChatInput() return ( - ) diff --git a/packages/chat-ui/src/ui/progress.tsx b/packages/chat-ui/src/ui/progress.tsx new file mode 100644 index 0000000..4fe42af --- /dev/null +++ b/packages/chat-ui/src/ui/progress.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/packages/chat-ui/src/widget/chat-agent-events.tsx b/packages/chat-ui/src/widget/chat-agent-events.tsx index 9a24125..2360a3c 100644 --- a/packages/chat-ui/src/widget/chat-agent-events.tsx +++ b/packages/chat-ui/src/widget/chat-agent-events.tsx @@ -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 = { bot: icons.Bot, @@ -22,10 +23,19 @@ const AgentIcons: Record = { 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 ( +
+ {!showMore && {step.text}} + {showMore && ( +
+ {displayText}... + + + Show more + + +
+ )} +
+ ) +} + +function ProgressContent({ step }: { step: StepProgress }) { + const progressValue = + step.progress.total !== 0 + ? Math.round(((step.progress.current + 1) / step.progress.total) * 100) + : 0 + + return ( +
+ {step.text && ( +

{step.text}

+ )} + +

+ Processing {step.progress.current + 1} of {step.progress.total} steps... +

+
+ ) +} + 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 (
@@ -81,26 +147,20 @@ function AgentEventContent({
{agent}
-
    - {texts.map((text, index) => ( -
  • - {text.length <= MAX_TEXT_LENGTH && {text}} - {text.length > MAX_TEXT_LENGTH && ( -
    - {text.slice(0, MAX_TEXT_LENGTH)}... - - - Show more - - -
    - )} -
  • - ))} -
+ {textSteps.length > 0 && ( +
+
    + {textSteps.map((step, index) => ( +
  • + +
  • + ))} +
+ {lastProgressStep && !isFinished && ( + + )} +
+ )} ) } @@ -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, }) } } diff --git a/packages/chat-ui/src/widget/document-info.tsx b/packages/chat-ui/src/widget/document-info.tsx index 6fc58dc..edcfb4d 100644 --- a/packages/chat-ui/src/widget/document-info.tsx +++ b/packages/chat-ui/src/widget/document-info.tsx @@ -73,7 +73,7 @@ function SourceInfo({ node, index }: { node?: SourceNode; index: number }) { className="hover:bg-primary hover:text-white" /> - + diff --git a/packages/chat-ui/src/widget/file-uploader.tsx b/packages/chat-ui/src/widget/file-uploader.tsx new file mode 100644 index 0000000..89238cb --- /dev/null +++ b/packages/chat-ui/src/widget/file-uploader.tsx @@ -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 + 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(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) => { + 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 ( +
+ + +
+ ) +} diff --git a/packages/chat-ui/src/widgets.tsx b/packages/chat-ui/src/widgets.tsx index aeb15fc..2236b7a 100644 --- a/packages/chat-ui/src/widgets.tsx +++ b/packages/chat-ui/src/widgets.tsx @@ -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' diff --git a/packages/config-eslint/react.js b/packages/config-eslint/react.js index ffb4c07..ca34040 100644 --- a/packages/config-eslint/react.js +++ b/packages/config-eslint/react.js @@ -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: [ { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffdf848..e30dca5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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