From 78ccde78fc2e94ba9f1468342966825ab869fd1a Mon Sep 17 00:00:00 2001 From: Thuc Pham <51660321+thucpn@users.noreply.github.com> Date: Fri, 1 Nov 2024 12:19:29 +0700 Subject: [PATCH] feat: integrate llamaindex chat-ui (#399) --------- Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de> --- .changeset/nice-garlics-repeat.md | 5 + .../nextjs/app/components/chat-section.tsx | 53 +---- .../app/components/ui/README-template.md | 1 - .../app/components/ui/chat/chat-actions.tsx | 28 --- .../chat/{chat-message => }/chat-avatar.tsx | 6 +- .../app/components/ui/chat/chat-input.tsx | 167 ++++--------- .../ui/chat/chat-message-content.tsx | 30 +++ .../chat/chat-message/chat-agent-events.tsx | 222 ------------------ .../ui/chat/chat-message/chat-events.tsx | 50 ---- .../ui/chat/chat-message/chat-files.tsx | 13 - .../ui/chat/chat-message/chat-image.tsx | 17 -- .../ui/chat/chat-message/chat-sources.tsx | 173 -------------- .../chat-message/chat-suggestedQuestions.tsx | 31 --- .../ui/chat/chat-message/chat-tools.tsx | 40 ---- .../ui/chat/chat-message/codeblock.tsx | 131 ----------- .../components/ui/chat/chat-message/index.tsx | 184 --------------- .../ui/chat/chat-message/markdown.tsx | 172 -------------- .../app/components/ui/chat/chat-messages.tsx | 156 ++---------- .../app/components/ui/chat/chat-starter.tsx | 26 ++ .../app/components/ui/chat/chat.interface.ts | 25 -- .../llama-cloud-selector.tsx} | 8 +- .../components/ui/chat/custom/markdown.tsx | 27 +++ .../app/components/ui/chat/hooks/use-file.ts | 121 ---------- .../nextjs/app/components/ui/chat/index.ts | 139 ----------- .../Artifact.tsx => tools/artifact.tsx} | 15 +- .../components/ui/chat/tools/chat-tools.tsx | 89 +++++++ .../weather-card.tsx} | 0 .../components/ui/chat/widgets/PdfDialog.tsx | 67 ------ .../app/components/ui/document-preview.tsx | 129 ---------- .../app/components/ui/file-uploader.tsx | 136 ----------- .../components/ui/upload-image-preview.tsx | 32 --- .../nextjs/app/observability/index.ts | 1 + templates/types/streaming/nextjs/package.json | 9 +- .../types/streaming/nextjs/tailwind.config.ts | 6 +- 34 files changed, 290 insertions(+), 2019 deletions(-) create mode 100644 .changeset/nice-garlics-repeat.md delete mode 100644 templates/types/streaming/nextjs/app/components/ui/README-template.md delete mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/chat-actions.tsx rename templates/types/streaming/nextjs/app/components/ui/chat/{chat-message => }/chat-avatar.tsx (78%) create mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/chat-message-content.tsx delete mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-agent-events.tsx delete mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-events.tsx delete mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-files.tsx delete mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-image.tsx delete mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-sources.tsx delete mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-suggestedQuestions.tsx delete mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-tools.tsx delete mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/chat-message/codeblock.tsx delete mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/chat-message/index.tsx delete mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/chat-message/markdown.tsx create mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/chat-starter.tsx delete mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/chat.interface.ts rename templates/types/streaming/nextjs/app/components/ui/chat/{widgets/LlamaCloudSelector.tsx => custom/llama-cloud-selector.tsx} (96%) create mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/custom/markdown.tsx delete mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/hooks/use-file.ts delete mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/index.ts rename templates/types/streaming/nextjs/app/components/ui/chat/{widgets/Artifact.tsx => tools/artifact.tsx} (98%) create mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/tools/chat-tools.tsx rename templates/types/streaming/nextjs/app/components/ui/chat/{widgets/WeatherCard.tsx => tools/weather-card.tsx} (100%) delete mode 100644 templates/types/streaming/nextjs/app/components/ui/chat/widgets/PdfDialog.tsx delete mode 100644 templates/types/streaming/nextjs/app/components/ui/document-preview.tsx delete mode 100644 templates/types/streaming/nextjs/app/components/ui/file-uploader.tsx delete mode 100644 templates/types/streaming/nextjs/app/components/ui/upload-image-preview.tsx diff --git a/.changeset/nice-garlics-repeat.md b/.changeset/nice-garlics-repeat.md new file mode 100644 index 00000000..2ce66a1b --- /dev/null +++ b/.changeset/nice-garlics-repeat.md @@ -0,0 +1,5 @@ +--- +"create-llama": patch +--- + +feat: use llamaindex chat-ui for nextjs frontend diff --git a/templates/types/streaming/nextjs/app/components/chat-section.tsx b/templates/types/streaming/nextjs/app/components/chat-section.tsx index e7e489ba..483ca7bb 100644 --- a/templates/types/streaming/nextjs/app/components/chat-section.tsx +++ b/templates/types/streaming/nextjs/app/components/chat-section.tsx @@ -1,57 +1,26 @@ "use client"; +import { ChatSection as ChatSectionUI } from "@llamaindex/chat-ui"; +import "@llamaindex/chat-ui/styles/code.css"; +import "@llamaindex/chat-ui/styles/katex.css"; import { useChat } from "ai/react"; -import { useState } from "react"; -import { ChatInput, ChatMessages } from "./ui/chat"; +import CustomChatInput from "./ui/chat/chat-input"; +import CustomChatMessages from "./ui/chat/chat-messages"; import { useClientConfig } from "./ui/chat/hooks/use-config"; export default function ChatSection() { const { backend } = useClientConfig(); - const [requestData, setRequestData] = useState<any>(); - const { - messages, - input, - isLoading, - handleSubmit, - handleInputChange, - reload, - stop, - append, - setInput, - } = useChat({ - body: { data: requestData }, + const handler = useChat({ api: `${backend}/api/chat`, - headers: { - "Content-Type": "application/json", // using JSON because of vercel/ai 2.2.26 - }, onError: (error: unknown) => { if (!(error instanceof Error)) throw error; - const message = JSON.parse(error.message); - alert(message.detail); + alert(JSON.parse(error.message).detail); }, - sendExtraMessageFields: true, }); - return ( - <div className="space-y-4 w-full h-full flex flex-col"> - <ChatMessages - messages={messages} - isLoading={isLoading} - reload={reload} - stop={stop} - append={append} - /> - <ChatInput - input={input} - handleSubmit={handleSubmit} - handleInputChange={handleInputChange} - isLoading={isLoading} - messages={messages} - append={append} - setInput={setInput} - requestParams={{ params: requestData }} - setRequestData={setRequestData} - /> - </div> + <ChatSectionUI handler={handler} className="w-full h-full"> + <CustomChatMessages /> + <CustomChatInput /> + </ChatSectionUI> ); } diff --git a/templates/types/streaming/nextjs/app/components/ui/README-template.md b/templates/types/streaming/nextjs/app/components/ui/README-template.md deleted file mode 100644 index ebfcf48c..00000000 --- a/templates/types/streaming/nextjs/app/components/ui/README-template.md +++ /dev/null @@ -1 +0,0 @@ -Using the chat component from https://github.com/marcusschiesser/ui (based on https://ui.shadcn.com/) diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-actions.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-actions.tsx deleted file mode 100644 index 151ef61a..00000000 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-actions.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { PauseCircle, RefreshCw } from "lucide-react"; - -import { Button } from "../button"; -import { ChatHandler } from "./chat.interface"; - -export default function ChatActions( - props: Pick<ChatHandler, "stop" | "reload"> & { - showReload?: boolean; - showStop?: boolean; - }, -) { - return ( - <div className="space-x-4"> - {props.showStop && ( - <Button variant="outline" size="sm" onClick={props.stop}> - <PauseCircle className="mr-2 h-4 w-4" /> - Stop generating - </Button> - )} - {props.showReload && ( - <Button variant="outline" size="sm" onClick={props.reload}> - <RefreshCw className="mr-2 h-4 w-4" /> - Regenerate - </Button> - )} - </div> - ); -} diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-avatar.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-avatar.tsx similarity index 78% rename from templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-avatar.tsx rename to templates/types/streaming/nextjs/app/components/ui/chat/chat-avatar.tsx index ce04e306..cfa307cb 100644 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-avatar.tsx +++ b/templates/types/streaming/nextjs/app/components/ui/chat/chat-avatar.tsx @@ -1,8 +1,10 @@ +import { useChatMessage } from "@llamaindex/chat-ui"; import { User2 } from "lucide-react"; import Image from "next/image"; -export default function ChatAvatar({ role }: { role: string }) { - if (role === "user") { +export function ChatMessageAvatar() { + const { message } = useChatMessage(); + if (message.role === "user") { return ( <div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border bg-background shadow"> <User2 className="h-4 w-4" /> diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-input.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-input.tsx index 0e5c318b..8800bfef 100644 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-input.tsx +++ b/templates/types/streaming/nextjs/app/components/ui/chat/chat-input.tsx @@ -1,34 +1,13 @@ -import { JSONValue } from "ai"; -import React from "react"; -import { DocumentFile } from "."; -import { Button } from "../button"; -import { DocumentPreview } from "../document-preview"; -import FileUploader from "../file-uploader"; -import { Textarea } from "../textarea"; -import UploadImagePreview from "../upload-image-preview"; -import { ChatHandler } from "./chat.interface"; -import { useFile } from "./hooks/use-file"; -import { LlamaCloudSelector } from "./widgets/LlamaCloudSelector"; +"use client"; -const ALLOWED_EXTENSIONS = ["png", "jpg", "jpeg", "csv", "pdf", "txt", "docx"]; +import { ChatInput, useChatUI, useFile } from "@llamaindex/chat-ui"; +import { DocumentPreview, ImagePreview } from "@llamaindex/chat-ui/widgets"; +import { LlamaCloudSelector } from "./custom/llama-cloud-selector"; +import { useClientConfig } from "./hooks/use-config"; -export default function ChatInput( - props: Pick< - ChatHandler, - | "isLoading" - | "input" - | "onFileUpload" - | "onFileError" - | "handleSubmit" - | "handleInputChange" - | "messages" - | "setInput" - | "append" - > & { - requestParams?: any; - setRequestData?: React.Dispatch<any>; - }, -) { +export default function CustomChatInput() { + const { requestData, isLoading, input } = useChatUI(); + const { backend } = useClientConfig(); const { imageUrl, setImageUrl, @@ -37,107 +16,65 @@ export default function ChatInput( removeDoc, reset, getAnnotations, - } = useFile(); - - // default submit function does not handle including annotations in the message - // so we need to use append function to submit new message with annotations - const handleSubmitWithAnnotations = ( - e: React.FormEvent<HTMLFormElement>, - annotations: JSONValue[] | undefined, - ) => { - e.preventDefault(); - props.append!({ - content: props.input, - role: "user", - createdAt: new Date(), - annotations, - }); - props.setInput!(""); - }; - - const onSubmit = (e: React.FormEvent<HTMLFormElement>) => { - e.preventDefault(); - const annotations = getAnnotations(); - if (annotations.length) { - handleSubmitWithAnnotations(e, annotations); - return reset(); - } - props.handleSubmit(e); - }; + } = useFile({ uploadAPI: `${backend}/api/chat/upload` }); + /** + * Handles file uploads. Overwrite to hook into the file upload behavior. + * @param file The file to upload + */ const handleUploadFile = async (file: File) => { + // There's already an image uploaded, only allow one image at a time if (imageUrl) { alert("You can only upload one image at a time."); return; } + try { - await uploadFile(file, props.requestParams); - props.onFileUpload?.(file); + // Upload the file and send with it the current request data + await uploadFile(file, requestData); } catch (error: any) { - const onFileUploadError = props.onFileError || window.alert; - onFileUploadError(error.message); + // Show error message if upload fails + alert(error.message); } }; - const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - onSubmit(e as unknown as React.FormEvent<HTMLFormElement>); - } - }; + // Get references to the upload files in message annotations format, see https://github.com/run-llama/chat-ui/blob/main/packages/chat-ui/src/hook/use-file.tsx#L56 + const annotations = getAnnotations(); return ( - <form - onSubmit={onSubmit} - className="rounded-xl bg-white p-4 shadow-xl space-y-4 shrink-0" + <ChatInput + className="shadow-xl rounded-xl" + resetUploadedFiles={reset} + annotations={annotations} > - {imageUrl && ( - <UploadImagePreview url={imageUrl} onRemove={() => setImageUrl(null)} /> - )} - {files.length > 0 && ( - <div className="flex gap-4 w-full overflow-auto py-2"> - {files.map((file: DocumentFile) => ( - <DocumentPreview - key={file.id} - file={file} - onRemove={() => removeDoc(file)} - /> - ))} - </div> - )} - <div className="flex w-full items-start justify-between gap-4 "> - <Textarea - id="chat-input" - autoFocus - name="message" - placeholder="Type a message" - className="flex-1 min-h-0 h-[40px]" - value={props.input} - onChange={props.handleInputChange} - onKeyDown={handleKeyDown} - /> - <FileUploader - onFileUpload={handleUploadFile} - onFileError={props.onFileError} - config={{ - allowedExtensions: ALLOWED_EXTENSIONS, - disabled: props.isLoading, - multiple: true, - }} - /> - {process.env.NEXT_PUBLIC_USE_LLAMACLOUD === "true" && - props.setRequestData && ( - <LlamaCloudSelector setRequestData={props.setRequestData} /> - )} - <Button - type="submit" + <div> + {/* Image preview section */} + {imageUrl && ( + <ImagePreview url={imageUrl} onRemove={() => setImageUrl(null)} /> + )} + {/* Document previews section */} + {files.length > 0 && ( + <div className="flex gap-4 w-full overflow-auto py-2"> + {files.map((file) => ( + <DocumentPreview + key={file.id} + file={file} + onRemove={() => removeDoc(file)} + /> + ))} + </div> + )} + </div> + <ChatInput.Form> + <ChatInput.Field /> + <ChatInput.Upload onUpload={handleUploadFile} /> + <LlamaCloudSelector /> + <ChatInput.Submit disabled={ - props.isLoading || (!props.input.trim() && files.length === 0) + isLoading || (!input.trim() && files.length === 0 && !imageUrl) } - > - Send message - </Button> - </div> - </form> + /> + </ChatInput.Form> + </ChatInput> ); } diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message-content.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-message-content.tsx new file mode 100644 index 00000000..d4ddfbf4 --- /dev/null +++ b/templates/types/streaming/nextjs/app/components/ui/chat/chat-message-content.tsx @@ -0,0 +1,30 @@ +import { + ChatMessage, + ContentPosition, + getSourceAnnotationData, + useChatMessage, +} from "@llamaindex/chat-ui"; +import { Markdown } from "./custom/markdown"; +import { ToolAnnotations } from "./tools/chat-tools"; + +export function ChatMessageContent() { + const { message } = useChatMessage(); + const customContent = [ + { + // override the default markdown component + position: ContentPosition.MARKDOWN, + component: ( + <Markdown + content={message.content} + sources={getSourceAnnotationData(message.annotations)?.[0]} + /> + ), + }, + { + // add the tool annotations after events + position: ContentPosition.AFTER_EVENTS, + component: <ToolAnnotations message={message} />, + }, + ]; + return <ChatMessage.Content content={customContent} />; +} diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-agent-events.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-agent-events.tsx deleted file mode 100644 index a385754b..00000000 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-agent-events.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { icons, LucideIcon } from "lucide-react"; -import { useMemo } from "react"; -import { Button } from "../../button"; -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "../../drawer"; -import { Progress } from "../../progress"; -import { AgentEventData, ProgressData } from "../index"; -import Markdown from "./markdown"; - -const AgentIcons: Record<string, LucideIcon> = { - bot: icons.Bot, - researcher: icons.ScanSearch, - writer: icons.PenLine, - reviewer: icons.MessageCircle, - publisher: icons.BookCheck, -}; - -type StepText = { - text: string; -}; - -type StepProgress = { - text: string; - progress: ProgressData; -}; - -type MergedEvent = { - agent: string; - icon: LucideIcon; - steps: Array<StepText | StepProgress>; -}; - -export function ChatAgentEvents({ - data, - isFinished, -}: { - data: AgentEventData[]; - isFinished: boolean; -}) { - const events = useMemo(() => mergeAdjacentEvents(data), [data]); - return ( - <div className="pl-2"> - <div className="text-sm space-y-4"> - {events.map((eventItem, index) => ( - <AgentEventContent - key={index} - event={eventItem} - isLast={index === events.length - 1} - isFinished={isFinished} - /> - ))} - </div> - </div> - ); -} - -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="font-semibold underline cursor-pointer ml-2"> - 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="space-y-2 mt-2"> - {step.text && ( - <p className="text-sm text-muted-foreground">{step.text}</p> - )} - <Progress value={progressValue} className="w-full h-2" /> - <p className="text-sm text-muted-foreground"> - Processing {step.progress.current + 1} of {step.progress.total} steps... - </p> - </div> - ); -} - -function AgentEventContent({ - event, - isLast, - isFinished, -}: { - event: MergedEvent; - isLast: boolean; - isFinished: boolean; -}) { - 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="flex gap-4 border-b pb-4 items-center fadein-agent"> - <div className="w-[100px] flex flex-col items-center gap-2"> - <div className="relative"> - {isLast && !isFinished && ( - <div className="absolute -top-0 -right-4"> - <span className="relative flex h-3 w-3"> - <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span> - <span className="relative inline-flex rounded-full h-3 w-3 bg-sky-500"></span> - </span> - </div> - )} - <AgentIcon /> - </div> - <span className="font-bold">{agent}</span> - </div> - {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> - ); -} - -type AgentEventDialogProps = { - title: string; - content: string; - children: React.ReactNode; -}; - -function AgentEventDialog(props: AgentEventDialogProps) { - return ( - <Drawer direction="left"> - <DrawerTrigger asChild>{props.children}</DrawerTrigger> - <DrawerContent className="w-3/5 mt-24 h-full max-h-[96%] "> - <DrawerHeader className="flex justify-between"> - <div className="space-y-2"> - <DrawerTitle>{props.title}</DrawerTitle> - </div> - <DrawerClose asChild> - <Button variant="outline">Close</Button> - </DrawerClose> - </DrawerHeader> - <div className="m-4 overflow-auto"> - <Markdown content={props.content} /> - </div> - </DrawerContent> - </Drawer> - ); -} - -function mergeAdjacentEvents(events: AgentEventData[]): MergedEvent[] { - const mergedEvents: 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) { - lastMergedEvent.steps.push(eventStep); - } else { - mergedEvents.push({ - agent: event.agent, - steps: [eventStep], - icon: AgentIcons[event.agent.toLowerCase()] ?? icons.Bot, - }); - } - } - - return mergedEvents; -} diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-events.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-events.tsx deleted file mode 100644 index 3dfad75d..00000000 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-events.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { ChevronDown, ChevronRight, Loader2 } from "lucide-react"; -import { useState } from "react"; -import { Button } from "../../button"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "../../collapsible"; -import { EventData } from "../index"; - -export function ChatEvents({ - data, - isLoading, -}: { - data: EventData[]; - isLoading: boolean; -}) { - const [isOpen, setIsOpen] = useState(false); - - const buttonLabel = isOpen ? "Hide events" : "Show events"; - - const EventIcon = isOpen ? ( - <ChevronDown className="h-4 w-4" /> - ) : ( - <ChevronRight className="h-4 w-4" /> - ); - - return ( - <div className="border-l-2 border-indigo-400 pl-2"> - <Collapsible open={isOpen} onOpenChange={setIsOpen}> - <CollapsibleTrigger asChild> - <Button variant="secondary" className="space-x-2"> - {isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null} - <span>{buttonLabel}</span> - {EventIcon} - </Button> - </CollapsibleTrigger> - <CollapsibleContent asChild> - <div className="mt-4 text-sm space-y-2"> - {data.map((eventItem, index) => ( - <div className="whitespace-break-spaces" key={index}> - {eventItem.title} - </div> - ))} - </div> - </CollapsibleContent> - </Collapsible> - </div> - ); -} diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-files.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-files.tsx deleted file mode 100644 index 0a5859a9..00000000 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-files.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { DocumentPreview } from "../../document-preview"; -import { DocumentFileData } from "../index"; - -export function ChatFiles({ data }: { data: DocumentFileData }) { - if (!data.files.length) return null; - return ( - <div className="flex gap-2 items-center"> - {data.files.map((file, index) => ( - <DocumentPreview key={file.id} file={file} /> - ))} - </div> - ); -} diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-image.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-image.tsx deleted file mode 100644 index 2de28c3d..00000000 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-image.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import Image from "next/image"; -import { type ImageData } from "../index"; - -export function ChatImage({ data }: { data: ImageData }) { - return ( - <div className="rounded-md max-w-[200px] shadow-md"> - <Image - src={data.url} - width={0} - height={0} - sizes="100vw" - style={{ width: "100%", height: "auto" }} - alt="" - /> - </div> - ); -} diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-sources.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-sources.tsx deleted file mode 100644 index c0da0031..00000000 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-sources.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { Check, Copy } from "lucide-react"; -import { useMemo } from "react"; -import { Button } from "../../button"; -import { PreviewCard } from "../../document-preview"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "../../hover-card"; -import { cn } from "../../lib/utils"; -import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard"; -import { DocumentFileType, SourceData, SourceNode } from "../index"; -import PdfDialog from "../widgets/PdfDialog"; - -type Document = { - url: string; - sources: SourceNode[]; -}; - -export function ChatSources({ data }: { data: SourceData }) { - const documents: Document[] = useMemo(() => { - // group nodes by document (a document must have a URL) - const nodesByUrl: Record<string, SourceNode[]> = {}; - data.nodes.forEach((node) => { - const key = node.url; - nodesByUrl[key] ??= []; - nodesByUrl[key].push(node); - }); - - // convert to array of documents - return Object.entries(nodesByUrl).map(([url, sources]) => ({ - url, - sources, - })); - }, [data.nodes]); - - if (documents.length === 0) return null; - - return ( - <div className="space-y-2 text-sm"> - <div className="font-semibold text-lg">Sources:</div> - <div className="flex gap-3 flex-wrap"> - {documents.map((document) => { - return <DocumentInfo key={document.url} document={document} />; - })} - </div> - </div> - ); -} - -function SourceInfo({ node, index }: { node?: SourceNode; index: number }) { - if (!node) return <SourceNumberButton index={index} />; - return ( - <HoverCard> - <HoverCardTrigger - className="cursor-default" - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} - > - <SourceNumberButton - index={index} - className="hover:text-white hover:bg-primary" - /> - </HoverCardTrigger> - <HoverCardContent className="w-[400px]"> - <NodeInfo nodeInfo={node} /> - </HoverCardContent> - </HoverCard> - ); -} - -export function SourceNumberButton({ - index, - className, -}: { - index: number; - className?: string; -}) { - return ( - <span - className={cn( - "text-xs w-5 h-5 rounded-full bg-gray-100 inline-flex items-center justify-center", - className, - )} - > - {index + 1} - </span> - ); -} - -export function DocumentInfo({ - document, - className, -}: { - document: Document; - className?: string; -}) { - const { url, sources } = document; - // Extract filename from URL - const urlParts = url.split("/"); - const fileName = urlParts.length > 0 ? urlParts[urlParts.length - 1] : url; - const fileExt = fileName?.split(".").pop() as DocumentFileType | undefined; - - const previewFile = { - name: fileName, - type: fileExt as DocumentFileType, - }; - - const DocumentDetail = ( - <div className={`relative ${className}`}> - <PreviewCard className={"cursor-pointer"} file={previewFile} /> - <div className="absolute bottom-2 right-2 space-x-2 flex"> - {sources.map((node: SourceNode, index: number) => ( - <div key={node.id}> - <SourceInfo node={node} index={index} /> - </div> - ))} - </div> - </div> - ); - - if (url.endsWith(".pdf")) { - // open internal pdf dialog for pdf files when click document card - return <PdfDialog documentId={url} url={url} trigger={DocumentDetail} />; - } - // open external link when click document card for other file types - return <div onClick={() => window.open(url, "_blank")}>{DocumentDetail}</div>; -} - -function NodeInfo({ nodeInfo }: { nodeInfo: SourceNode }) { - const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 1000 }); - - const pageNumber = - // XXX: page_label is used in Python, but page_number is used by Typescript - (nodeInfo.metadata?.page_number as number) ?? - (nodeInfo.metadata?.page_label as number) ?? - null; - - return ( - <div className="space-y-4"> - <div className="flex justify-between items-center"> - <span className="font-semibold"> - {pageNumber ? `On page ${pageNumber}:` : "Node content:"} - </span> - {nodeInfo.text && ( - <Button - onClick={(e) => { - e.stopPropagation(); - copyToClipboard(nodeInfo.text); - }} - size="icon" - variant="ghost" - className="h-12 w-12 shrink-0" - > - {isCopied ? ( - <Check className="h-4 w-4" /> - ) : ( - <Copy className="h-4 w-4" /> - )} - </Button> - )} - </div> - - {nodeInfo.text && ( - <pre className="max-h-[200px] overflow-auto whitespace-pre-line"> - “{nodeInfo.text}” - </pre> - )} - </div> - ); -} diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-suggestedQuestions.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-suggestedQuestions.tsx deleted file mode 100644 index 56353856..00000000 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-suggestedQuestions.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { ChatHandler, SuggestedQuestionsData } from ".."; - -export function SuggestedQuestions({ - questions, - append, - isLastMessage, -}: { - questions: SuggestedQuestionsData; - append: Pick<ChatHandler, "append">["append"]; - isLastMessage: boolean; -}) { - const showQuestions = isLastMessage && questions.length > 0; - return ( - showQuestions && - append !== undefined && ( - <div className="flex flex-col space-y-2"> - {questions.map((question, index) => ( - <a - key={index} - onClick={() => { - append({ role: "user", content: question }); - }} - className="text-sm italic hover:underline cursor-pointer" - > - {"->"} {question} - </a> - ))} - </div> - ) - ); -} diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-tools.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-tools.tsx deleted file mode 100644 index 9f867470..00000000 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-tools.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { ToolData } from "../index"; -import { Artifact, CodeArtifact } from "../widgets/Artifact"; -import { WeatherCard, WeatherData } from "../widgets/WeatherCard"; - -// TODO: If needed, add displaying more tool outputs here -export default function ChatTools({ - data, - artifactVersion, -}: { - data: ToolData; - artifactVersion?: number; -}) { - if (!data) return null; - const { toolCall, toolOutput } = data; - - if (toolOutput.isError) { - return ( - <div className="border-l-2 border-red-400 pl-2"> - There was an error when calling the tool {toolCall.name} with input:{" "} - <br /> - {JSON.stringify(toolCall.input)} - </div> - ); - } - - switch (toolCall.name) { - case "get_weather_information": - const weatherData = toolOutput.output as unknown as WeatherData; - return <WeatherCard data={weatherData} />; - case "artifact": - return ( - <Artifact - artifact={toolOutput.output as CodeArtifact} - version={artifactVersion} - /> - ); - default: - return null; - } -} diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/codeblock.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/codeblock.tsx deleted file mode 100644 index e61762ae..00000000 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/codeblock.tsx +++ /dev/null @@ -1,131 +0,0 @@ -"use client"; - -import hljs from "highlight.js"; -// instead of atom-one-dark theme, there are a lot of others: https://highlightjs.org/demo -import "highlight.js/styles/atom-one-dark-reasonable.css"; -import { Check, Copy, Download } from "lucide-react"; -import { FC, memo, useEffect, useRef } from "react"; -import { Button } from "../../button"; -import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard"; - -interface Props { - language: string; - value: string; - className?: string; -} - -interface languageMap { - [key: string]: string | undefined; -} - -export const programmingLanguages: languageMap = { - javascript: ".js", - python: ".py", - java: ".java", - c: ".c", - cpp: ".cpp", - "c++": ".cpp", - "c#": ".cs", - ruby: ".rb", - php: ".php", - swift: ".swift", - "objective-c": ".m", - kotlin: ".kt", - typescript: ".ts", - go: ".go", - perl: ".pl", - rust: ".rs", - scala: ".scala", - haskell: ".hs", - lua: ".lua", - shell: ".sh", - sql: ".sql", - html: ".html", - css: ".css", - // add more file extensions here, make sure the key is same as language prop in CodeBlock.tsx component -}; - -export const generateRandomString = (length: number, lowercase = false) => { - const chars = "ABCDEFGHJKLMNPQRSTUVWXY3456789"; // excluding similar looking characters like Z, 2, I, 1, O, 0 - let result = ""; - for (let i = 0; i < length; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return lowercase ? result.toLowerCase() : result; -}; - -const CodeBlock: FC<Props> = memo(({ language, value, className }) => { - const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 }); - const codeRef = useRef<HTMLElement>(null); - - useEffect(() => { - if (codeRef.current && codeRef.current.dataset.highlighted !== "yes") { - hljs.highlightElement(codeRef.current); - } - }, [language, value]); - - const downloadAsFile = () => { - if (typeof window === "undefined") { - return; - } - const fileExtension = programmingLanguages[language] || ".file"; - const suggestedFileName = `file-${generateRandomString( - 3, - true, - )}${fileExtension}`; - const fileName = window.prompt("Enter file name", suggestedFileName); - - if (!fileName) { - // User pressed cancel on prompt. - return; - } - - const blob = new Blob([value], { type: "text/plain" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.download = fileName; - link.href = url; - link.style.display = "none"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - }; - - const onCopy = () => { - if (isCopied) return; - copyToClipboard(value); - }; - - return ( - <div - className={`codeblock relative w-full bg-zinc-950 font-sans ${className}`} - > - <div className="flex w-full items-center justify-between bg-zinc-800 px-6 py-2 pr-4 text-zinc-100"> - <span className="text-xs lowercase">{language}</span> - <div className="flex items-center space-x-1"> - <Button variant="ghost" onClick={downloadAsFile} size="icon"> - <Download /> - <span className="sr-only">Download</span> - </Button> - <Button variant="ghost" size="icon" onClick={onCopy}> - {isCopied ? ( - <Check className="h-4 w-4" /> - ) : ( - <Copy className="h-4 w-4" /> - )} - <span className="sr-only">Copy code</span> - </Button> - </div> - </div> - <pre className="border border-zinc-700"> - <code ref={codeRef} className={`language-${language} font-mono`}> - {value} - </code> - </pre> - </div> - ); -}); -CodeBlock.displayName = "CodeBlock"; - -export { CodeBlock }; diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/index.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/index.tsx deleted file mode 100644 index 47ec2ba8..00000000 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/index.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { Check, Copy } from "lucide-react"; - -import { Message } from "ai"; -import { Fragment } from "react"; -import { Button } from "../../button"; -import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard"; -import { - AgentEventData, - ChatHandler, - DocumentFileData, - EventData, - ImageData, - MessageAnnotation, - MessageAnnotationType, - SuggestedQuestionsData, - ToolData, - getAnnotationData, - getSourceAnnotationData, -} from "../index"; -import { ChatAgentEvents } from "./chat-agent-events"; -import ChatAvatar from "./chat-avatar"; -import { ChatEvents } from "./chat-events"; -import { ChatFiles } from "./chat-files"; -import { ChatImage } from "./chat-image"; -import { ChatSources } from "./chat-sources"; -import { SuggestedQuestions } from "./chat-suggestedQuestions"; -import ChatTools from "./chat-tools"; -import Markdown from "./markdown"; - -type ContentDisplayConfig = { - order: number; - component: JSX.Element | null; -}; - -function ChatMessageContent({ - message, - isLoading, - append, - isLastMessage, - artifactVersion, -}: { - message: Message; - isLoading: boolean; - append: Pick<ChatHandler, "append">["append"]; - isLastMessage: boolean; - artifactVersion: number | undefined; -}) { - const annotations = message.annotations as MessageAnnotation[] | undefined; - if (!annotations?.length) return <Markdown content={message.content} />; - - const imageData = getAnnotationData<ImageData>( - annotations, - MessageAnnotationType.IMAGE, - ); - const contentFileData = getAnnotationData<DocumentFileData>( - annotations, - MessageAnnotationType.DOCUMENT_FILE, - ); - const eventData = getAnnotationData<EventData>( - annotations, - MessageAnnotationType.EVENTS, - ); - const agentEventData = getAnnotationData<AgentEventData>( - annotations, - MessageAnnotationType.AGENT_EVENTS, - ); - - const sourceData = getSourceAnnotationData(annotations); - - const toolData = getAnnotationData<ToolData>( - annotations, - MessageAnnotationType.TOOLS, - ); - const suggestedQuestionsData = getAnnotationData<SuggestedQuestionsData>( - annotations, - MessageAnnotationType.SUGGESTED_QUESTIONS, - ); - - const contents: ContentDisplayConfig[] = [ - { - order: 1, - component: imageData[0] ? <ChatImage data={imageData[0]} /> : null, - }, - { - order: -3, - component: - eventData.length > 0 ? ( - <ChatEvents isLoading={isLoading} data={eventData} /> - ) : null, - }, - { - order: -2, - component: - agentEventData.length > 0 ? ( - <ChatAgentEvents - data={agentEventData} - isFinished={!!message.content} - /> - ) : null, - }, - { - order: 2, - component: contentFileData[0] ? ( - <ChatFiles data={contentFileData[0]} /> - ) : null, - }, - { - order: -1, - component: toolData[0] ? ( - <ChatTools data={toolData[0]} artifactVersion={artifactVersion} /> - ) : null, - }, - { - order: 0, - component: <Markdown content={message.content} sources={sourceData[0]} />, - }, - { - order: 3, - component: sourceData[0] ? <ChatSources data={sourceData[0]} /> : null, - }, - { - order: 4, - component: suggestedQuestionsData[0] ? ( - <SuggestedQuestions - questions={suggestedQuestionsData[0]} - append={append} - isLastMessage={isLastMessage} - /> - ) : null, - }, - ]; - - return ( - <div className="flex-1 gap-4 flex flex-col"> - {contents - .sort((a, b) => a.order - b.order) - .map((content, index) => ( - <Fragment key={index}>{content.component}</Fragment> - ))} - </div> - ); -} - -export default function ChatMessage({ - chatMessage, - isLoading, - append, - isLastMessage, - artifactVersion, -}: { - chatMessage: Message; - isLoading: boolean; - append: Pick<ChatHandler, "append">["append"]; - isLastMessage: boolean; - artifactVersion: number | undefined; -}) { - const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 }); - return ( - <div className="flex items-start gap-4 pr-5 pt-5"> - <ChatAvatar role={chatMessage.role} /> - <div className="group flex flex-1 justify-between gap-2"> - <ChatMessageContent - message={chatMessage} - isLoading={isLoading} - append={append} - isLastMessage={isLastMessage} - artifactVersion={artifactVersion} - /> - <Button - onClick={() => copyToClipboard(chatMessage.content)} - size="icon" - variant="ghost" - className="h-8 w-8 opacity-0 group-hover:opacity-100" - > - {isCopied ? ( - <Check className="h-4 w-4" /> - ) : ( - <Copy className="h-4 w-4" /> - )} - </Button> - </div> - </div> - ); -} diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/markdown.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/markdown.tsx deleted file mode 100644 index 9074e208..00000000 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/markdown.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import "katex/dist/katex.min.css"; -import { FC, memo } from "react"; -import ReactMarkdown, { Options } from "react-markdown"; -import rehypeKatex from "rehype-katex"; -import remarkGfm from "remark-gfm"; -import remarkMath from "remark-math"; - -import { DOCUMENT_FILE_TYPES, DocumentFileType, SourceData } from ".."; -import { useClientConfig } from "../hooks/use-config"; -import { DocumentInfo, SourceNumberButton } from "./chat-sources"; -import { CodeBlock } from "./codeblock"; - -const MemoizedReactMarkdown: FC<Options> = memo( - ReactMarkdown, - (prevProps, nextProps) => - prevProps.children === nextProps.children && - prevProps.className === nextProps.className, -); - -const preprocessLaTeX = (content: string) => { - // Replace block-level LaTeX delimiters \[ \] with $$ $$ - const blockProcessedContent = content.replace( - /\\\[([\s\S]*?)\\\]/g, - (_, equation) => `$$${equation}$$`, - ); - // Replace inline LaTeX delimiters \( \) with $ $ - const inlineProcessedContent = blockProcessedContent.replace( - /\\\[([\s\S]*?)\\\]/g, - (_, equation) => `$${equation}$`, - ); - return inlineProcessedContent; -}; - -const preprocessMedia = (content: string) => { - // Remove `sandbox:` from the beginning of the URL - // to fix OpenAI's models issue appending `sandbox:` to the relative URL - return content.replace(/(sandbox|attachment|snt):/g, ""); -}; - -/** - * Update the citation flag [citation:id]() to the new format [citation:index](url) - */ -const preprocessCitations = (content: string, sources?: SourceData) => { - if (sources) { - const citationRegex = /\[citation:(.+?)\]\(\)/g; - let match; - // Find all the citation references in the content - while ((match = citationRegex.exec(content)) !== null) { - const citationId = match[1]; - // Find the source node with the id equal to the citation-id, also get the index of the source node - const sourceNode = sources.nodes.find((node) => node.id === citationId); - // If the source node is found, replace the citation reference with the new format - if (sourceNode !== undefined) { - content = content.replace( - match[0], - `[citation:${sources.nodes.indexOf(sourceNode)}]()`, - ); - } else { - // If the source node is not found, remove the citation reference - content = content.replace(match[0], ""); - } - } - } - return content; -}; - -const preprocessContent = (content: string, sources?: SourceData) => { - return preprocessCitations( - preprocessMedia(preprocessLaTeX(content)), - sources, - ); -}; - -export default function Markdown({ - content, - sources, -}: { - content: string; - sources?: SourceData; -}) { - const processedContent = preprocessContent(content, sources); - const { backend } = useClientConfig(); - - return ( - <MemoizedReactMarkdown - className="prose dark:prose-invert prose-p:leading-relaxed prose-pre:p-0 break-words custom-markdown" - remarkPlugins={[remarkGfm, remarkMath]} - rehypePlugins={[rehypeKatex as any]} - components={{ - p({ children }) { - return <div className="mb-2 last:mb-0">{children}</div>; - }, - code({ node, inline, className, children, ...props }) { - if (children.length) { - if (children[0] == "â–") { - return ( - <span className="mt-1 animate-pulse cursor-default">â–</span> - ); - } - - children[0] = (children[0] as string).replace("`â–`", "â–"); - } - - const match = /language-(\w+)/.exec(className || ""); - - if (inline) { - return ( - <code className={className} {...props}> - {children} - </code> - ); - } - - return ( - <CodeBlock - key={Math.random()} - language={(match && match[1]) || ""} - value={String(children).replace(/\n$/, "")} - className="mb-2" - {...props} - /> - ); - }, - a({ href, children }) { - // If href starts with `{backend}/api/files`, then it's a local document and we use DocumenInfo for rendering - if (href?.startsWith(backend + "/api/files")) { - // Check if the file is document file type - const fileExtension = href.split(".").pop()?.toLowerCase(); - - if ( - fileExtension && - DOCUMENT_FILE_TYPES.includes(fileExtension as DocumentFileType) - ) { - return ( - <DocumentInfo - document={{ - url: backend - ? new URL(decodeURIComponent(href)).href - : href, - sources: [], - }} - className="mb-2 mt-2" - /> - ); - } - } - // If a text link starts with 'citation:', then render it as a citation reference - if ( - Array.isArray(children) && - typeof children[0] === "string" && - children[0].startsWith("citation:") - ) { - const index = Number(children[0].replace("citation:", "")); - if (!isNaN(index)) { - return <SourceNumberButton index={index} />; - } else { - // citation is not looked up yet, don't render anything - return <></>; - } - } - return ( - <a href={href} target="_blank"> - {children} - </a> - ); - }, - }} - > - {processedContent} - </MemoizedReactMarkdown> - ); -} diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-messages.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-messages.tsx index 2f29def2..17c4e021 100644 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-messages.tsx +++ b/templates/types/streaming/nextjs/app/components/ui/chat/chat-messages.tsx @@ -1,136 +1,30 @@ -import { Loader2 } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +"use client"; -import { ToolData } from "."; -import { Button } from "../button"; -import ChatActions from "./chat-actions"; -import ChatMessage from "./chat-message"; -import { ChatHandler } from "./chat.interface"; -import { useClientConfig } from "./hooks/use-config"; - -export default function ChatMessages( - props: Pick< - ChatHandler, - "messages" | "isLoading" | "reload" | "stop" | "append" - >, -) { - const { backend } = useClientConfig(); - const [starterQuestions, setStarterQuestions] = useState<string[]>(); - - const scrollableChatContainerRef = useRef<HTMLDivElement>(null); - const messageLength = props.messages.length; - const lastMessage = props.messages[messageLength - 1]; - - const scrollToBottom = () => { - if (scrollableChatContainerRef.current) { - scrollableChatContainerRef.current.scrollTop = - scrollableChatContainerRef.current.scrollHeight; - } - }; - - const isLastMessageFromAssistant = - messageLength > 0 && lastMessage?.role !== "user"; - const showReload = - props.reload && !props.isLoading && isLastMessageFromAssistant; - const showStop = props.stop && props.isLoading; - - // `isPending` indicate - // that stream response is not yet received from the server, - // so we show a loading indicator to give a better UX. - const isPending = props.isLoading && !isLastMessageFromAssistant; - - useEffect(() => { - scrollToBottom(); - }, [messageLength, lastMessage]); - - useEffect(() => { - if (!starterQuestions) { - fetch(`${backend}/api/chat/config`) - .then((response) => response.json()) - .then((data) => { - if (data?.starterQuestions) { - setStarterQuestions(data.starterQuestions); - } - }) - .catch((error) => console.error("Error fetching config", error)); - } - }, [starterQuestions, backend]); - - // build a map of message id to artifact version - const artifactVersionMap = useMemo(() => { - const map = new Map<string, number | undefined>(); - let versionIndex = 1; - props.messages.forEach((m) => { - m.annotations?.forEach((annotation) => { - if ( - typeof annotation === "object" && - annotation != null && - "type" in annotation && - annotation.type === "tools" - ) { - const data = annotation.data as ToolData; - if (data?.toolCall?.name === "artifact") { - map.set(m.id, versionIndex); - versionIndex++; - } - } - }); - }); - return map; - }, [props.messages]); +import { ChatMessage, ChatMessages, useChatUI } from "@llamaindex/chat-ui"; +import { ChatMessageAvatar } from "./chat-avatar"; +import { ChatMessageContent } from "./chat-message-content"; +import { ChatStarter } from "./chat-starter"; +export default function CustomChatMessages() { + const { messages } = useChatUI(); return ( - <div - className="flex-1 w-full rounded-xl bg-white p-4 shadow-xl relative overflow-y-auto" - ref={scrollableChatContainerRef} - > - <div className="flex flex-col gap-5 divide-y"> - {props.messages.map((m, i) => { - const isLoadingMessage = i === messageLength - 1 && props.isLoading; - return ( - <ChatMessage - key={m.id} - chatMessage={m} - isLoading={isLoadingMessage} - append={props.append!} - isLastMessage={i === messageLength - 1} - artifactVersion={artifactVersionMap.get(m.id)} - /> - ); - })} - {isPending && ( - <div className="flex justify-center items-center pt-10"> - <Loader2 className="h-4 w-4 animate-spin" /> - </div> - )} - </div> - {(showReload || showStop) && ( - <div className="flex justify-end py-4"> - <ChatActions - reload={props.reload} - stop={props.stop} - showReload={showReload} - showStop={showStop} - /> - </div> - )} - {!messageLength && starterQuestions?.length && props.append && ( - <div className="absolute bottom-6 left-0 w-full"> - <div className="grid grid-cols-2 gap-2 mx-20"> - {starterQuestions.map((question, i) => ( - <Button - variant="outline" - key={i} - onClick={() => - props.append!({ role: "user", content: question }) - } - > - {question} - </Button> - ))} - </div> - </div> - )} - </div> + <ChatMessages className="shadow-xl rounded-xl"> + <ChatMessages.List> + {messages.map((message, index) => ( + <ChatMessage + key={message.id} + message={message} + isLast={index === messages.length - 1} + > + <ChatMessageAvatar /> + <ChatMessageContent /> + <ChatMessage.Actions /> + </ChatMessage> + ))} + <ChatMessages.Loading /> + </ChatMessages.List> + <ChatMessages.Actions /> + <ChatStarter /> + </ChatMessages> ); } diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-starter.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-starter.tsx new file mode 100644 index 00000000..0f455316 --- /dev/null +++ b/templates/types/streaming/nextjs/app/components/ui/chat/chat-starter.tsx @@ -0,0 +1,26 @@ +import { useChatUI } from "@llamaindex/chat-ui"; +import { StarterQuestions } from "@llamaindex/chat-ui/widgets"; +import { useEffect, useState } from "react"; +import { useClientConfig } from "./hooks/use-config"; + +export function ChatStarter() { + const { append } = useChatUI(); + const { backend } = useClientConfig(); + const [starterQuestions, setStarterQuestions] = useState<string[]>(); + + useEffect(() => { + if (!starterQuestions) { + fetch(`${backend}/api/chat/config`) + .then((response) => response.json()) + .then((data) => { + if (data?.starterQuestions) { + setStarterQuestions(data.starterQuestions); + } + }) + .catch((error) => console.error("Error fetching config", error)); + } + }, [starterQuestions, backend]); + + if (!starterQuestions?.length) return null; + return <StarterQuestions append={append} questions={starterQuestions} />; +} diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat.interface.ts b/templates/types/streaming/nextjs/app/components/ui/chat/chat.interface.ts deleted file mode 100644 index 5483abde..00000000 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat.interface.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Message } from "ai"; - -export interface ChatHandler { - messages: Message[]; - input: string; - isLoading: boolean; - handleSubmit: ( - e: React.FormEvent<HTMLFormElement>, - ops?: { - data?: any; - }, - ) => void; - handleInputChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void; - reload?: () => void; - stop?: () => void; - onFileUpload?: (file: File) => Promise<void>; - onFileError?: (errMsg: string) => void; - setInput?: (input: string) => void; - append?: ( - message: Message | Omit<Message, "id">, - ops?: { - data: any; - }, - ) => Promise<string | null | undefined>; -} diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/widgets/LlamaCloudSelector.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/custom/llama-cloud-selector.tsx similarity index 96% rename from templates/types/streaming/nextjs/app/components/ui/chat/widgets/LlamaCloudSelector.tsx rename to templates/types/streaming/nextjs/app/components/ui/chat/custom/llama-cloud-selector.tsx index 6d67081c..f40a33a7 100644 --- a/templates/types/streaming/nextjs/app/components/ui/chat/widgets/LlamaCloudSelector.tsx +++ b/templates/types/streaming/nextjs/app/components/ui/chat/custom/llama-cloud-selector.tsx @@ -1,3 +1,4 @@ +import { useChatUI } from "@llamaindex/chat-ui"; import { Loader2 } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { @@ -35,19 +36,18 @@ type LlamaCloudConfig = { }; export interface LlamaCloudSelectorProps { - setRequestData?: React.Dispatch<any>; onSelect?: (pipeline: PipelineConfig | undefined) => void; defaultPipeline?: PipelineConfig; shouldCheckValid?: boolean; } export function LlamaCloudSelector({ - setRequestData, onSelect, defaultPipeline, shouldCheckValid = false, }: LlamaCloudSelectorProps) { const { backend } = useClientConfig(); + const { setRequestData } = useChatUI(); const [config, setConfig] = useState<LlamaCloudConfig>(); const updateRequestParams = useCallback( @@ -97,6 +97,10 @@ export function LlamaCloudSelector({ setPipeline(JSON.parse(value) as PipelineConfig); }; + if (process.env.NEXT_PUBLIC_USE_LLAMACLOUD !== "true") { + return null; + } + if (!config) { return ( <div className="flex justify-center items-center p-3"> diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/custom/markdown.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/custom/markdown.tsx new file mode 100644 index 00000000..88925e8b --- /dev/null +++ b/templates/types/streaming/nextjs/app/components/ui/chat/custom/markdown.tsx @@ -0,0 +1,27 @@ +import { SourceData } from "@llamaindex/chat-ui"; +import { Markdown as MarkdownUI } from "@llamaindex/chat-ui/widgets"; +import { useClientConfig } from "../hooks/use-config"; + +const preprocessMedia = (content: string) => { + // Remove `sandbox:` from the beginning of the URL before rendering markdown + // OpenAI models sometimes prepend `sandbox:` to relative URLs - this fixes it + return content.replace(/(sandbox|attachment|snt):/g, ""); +}; + +export function Markdown({ + content, + sources, +}: { + content: string; + sources?: SourceData; +}) { + const { backend } = useClientConfig(); + const processedContent = preprocessMedia(content); + return ( + <MarkdownUI + content={processedContent} + backend={backend} + sources={sources} + /> + ); +} diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/hooks/use-file.ts b/templates/types/streaming/nextjs/app/components/ui/chat/hooks/use-file.ts deleted file mode 100644 index 049db6ba..00000000 --- a/templates/types/streaming/nextjs/app/components/ui/chat/hooks/use-file.ts +++ /dev/null @@ -1,121 +0,0 @@ -"use client"; - -import { JSONValue } from "llamaindex"; -import { useState } from "react"; -import { - DocumentFile, - DocumentFileType, - MessageAnnotation, - MessageAnnotationType, -} from ".."; -import { useClientConfig } from "./use-config"; - -const docMineTypeMap: Record<string, DocumentFileType> = { - "text/csv": "csv", - "application/pdf": "pdf", - "text/plain": "txt", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": - "docx", -}; - -export function useFile() { - const { backend } = useClientConfig(); - const [imageUrl, setImageUrl] = useState<string | null>(null); - const [files, setFiles] = useState<DocumentFile[]>([]); - - const addDoc = (file: DocumentFile) => { - const existedFile = files.find((f) => f.id === file.id); - if (!existedFile) { - setFiles((prev) => [...prev, file]); - return true; - } - return false; - }; - - const removeDoc = (file: DocumentFile) => { - setFiles((prev) => prev.filter((f) => f.id !== file.id)); - }; - - const reset = () => { - imageUrl && setImageUrl(null); - files.length && setFiles([]); - }; - - const uploadContent = async ( - file: File, - requestParams: any = {}, - ): Promise<DocumentFile> => { - const base64 = await readContent({ file, asUrl: true }); - const uploadAPI = `${backend}/api/chat/upload`; - const response = await fetch(uploadAPI, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - ...requestParams, - base64, - name: file.name, - }), - }); - if (!response.ok) throw new Error("Failed to upload document."); - return (await response.json()) as DocumentFile; - }; - - const getAnnotations = () => { - const annotations: MessageAnnotation[] = []; - if (imageUrl) { - annotations.push({ - type: MessageAnnotationType.IMAGE, - data: { url: imageUrl }, - }); - } - if (files.length > 0) { - annotations.push({ - type: MessageAnnotationType.DOCUMENT_FILE, - data: { files }, - }); - } - return annotations as JSONValue[]; - }; - - const readContent = async (input: { - file: File; - asUrl?: boolean; - }): Promise<string> => { - const { file, asUrl } = input; - const content = await new Promise<string>((resolve, reject) => { - const reader = new FileReader(); - if (asUrl) { - reader.readAsDataURL(file); - } else { - reader.readAsText(file); - } - reader.onload = () => resolve(reader.result as string); - reader.onerror = (error) => reject(error); - }); - return content; - }; - - const uploadFile = async (file: File, requestParams: any = {}) => { - if (file.type.startsWith("image/")) { - const base64 = await readContent({ file, asUrl: true }); - return setImageUrl(base64); - } - - const filetype = docMineTypeMap[file.type]; - if (!filetype) throw new Error("Unsupported document type."); - const newDoc = await uploadContent(file, requestParams); - return addDoc(newDoc); - }; - - return { - imageUrl, - setImageUrl, - files, - removeDoc, - reset, - getAnnotations, - uploadFile, - }; -} diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/index.ts b/templates/types/streaming/nextjs/app/components/ui/chat/index.ts deleted file mode 100644 index e78a8c27..00000000 --- a/templates/types/streaming/nextjs/app/components/ui/chat/index.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { JSONValue } from "ai"; -import ChatInput from "./chat-input"; -import ChatMessages from "./chat-messages"; - -export { type ChatHandler } from "./chat.interface"; -export { ChatInput, ChatMessages }; - -export enum MessageAnnotationType { - IMAGE = "image", - DOCUMENT_FILE = "document_file", - SOURCES = "sources", - EVENTS = "events", - TOOLS = "tools", - SUGGESTED_QUESTIONS = "suggested_questions", - AGENT_EVENTS = "agent", -} - -export type ImageData = { - url: string; -}; - -export type DocumentFileType = "csv" | "pdf" | "txt" | "docx"; -export const DOCUMENT_FILE_TYPES: DocumentFileType[] = [ - "csv", - "pdf", - "txt", - "docx", -]; - -export type DocumentFile = { - id: string; - name: string; // The uploaded file name in the backend - size: number; // The file size in bytes - type: DocumentFileType; - url: string; // The URL of the uploaded file in the backend - refs?: string[]; // DocumentIDs of the uploaded file in the vector index -}; - -export type DocumentFileData = { - files: DocumentFile[]; -}; - -export type SourceNode = { - id: string; - metadata: Record<string, unknown>; - score?: number; - text: string; - url: string; -}; - -export type SourceData = { - nodes: SourceNode[]; -}; - -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 ToolData = { - toolCall: { - id: string; - name: string; - input: { - [key: string]: JSONValue; - }; - }; - toolOutput: { - output: JSONValue; - isError: boolean; - }; -}; - -export type SuggestedQuestionsData = string[]; - -export type AnnotationData = - | ImageData - | DocumentFileData - | SourceData - | EventData - | AgentEventData - | ToolData - | SuggestedQuestionsData; - -export type MessageAnnotation = { - type: MessageAnnotationType; - data: AnnotationData; -}; - -const NODE_SCORE_THRESHOLD = 0.25; - -export function getAnnotationData<T extends AnnotationData>( - annotations: MessageAnnotation[], - type: MessageAnnotationType, -): T[] { - return annotations.filter((a) => a.type === type).map((a) => a.data as T); -} - -export function getSourceAnnotationData( - annotations: MessageAnnotation[], -): SourceData[] { - const data = getAnnotationData<SourceData>( - annotations, - MessageAnnotationType.SOURCES, - ); - if (data.length > 0) { - const sourceData = data[0] as SourceData; - if (sourceData.nodes) { - sourceData.nodes = preprocessSourceNodes(sourceData.nodes); - } - } - return data; -} - -function preprocessSourceNodes(nodes: SourceNode[]): SourceNode[] { - // Filter source nodes has lower score - nodes = nodes - .filter((node) => (node.score ?? 1) > NODE_SCORE_THRESHOLD) - .filter((node) => node.url && node.url.trim() !== "") - .sort((a, b) => (b.score ?? 1) - (a.score ?? 1)) - .map((node) => { - // remove trailing slash for node url if exists - node.url = node.url.replace(/\/$/, ""); - return node; - }); - return nodes; -} diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/widgets/Artifact.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/tools/artifact.tsx similarity index 98% rename from templates/types/streaming/nextjs/app/components/ui/chat/widgets/Artifact.tsx rename to templates/types/streaming/nextjs/app/components/ui/chat/tools/artifact.tsx index fa2a6059..fe6e8199 100644 --- a/templates/types/streaming/nextjs/app/components/ui/chat/widgets/Artifact.tsx +++ b/templates/types/streaming/nextjs/app/components/ui/chat/tools/artifact.tsx @@ -10,7 +10,7 @@ import { } from "../../collapsible"; import { cn } from "../../lib/utils"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../tabs"; -import Markdown from "../chat-message/markdown"; +import { Markdown } from "../custom/markdown"; import { useClientConfig } from "../hooks/use-config"; import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard"; @@ -29,12 +29,17 @@ export type CodeArtifact = { files?: string[]; }; +type OutputUrl = { + url: string; + filename: string; +}; + type ArtifactResult = { template: string; stdout: string[]; stderr: string[]; runtimeError?: { name: string; value: string; tracebackRaw: string[] }; - outputUrls: Array<{ url: string; filename: string }>; + outputUrls: OutputUrl[]; url: string; }; @@ -272,11 +277,7 @@ function CodeSandboxPreview({ url }: { url: string }) { ); } -function InterpreterOutput({ - outputUrls, -}: { - outputUrls: Array<{ url: string; filename: string }>; -}) { +function InterpreterOutput({ outputUrls }: { outputUrls: OutputUrl[] }) { return ( <ul className="flex flex-col gap-2 mt-4"> {outputUrls.map((url) => ( diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/tools/chat-tools.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/tools/chat-tools.tsx new file mode 100644 index 00000000..c57a6d5b --- /dev/null +++ b/templates/types/streaming/nextjs/app/components/ui/chat/tools/chat-tools.tsx @@ -0,0 +1,89 @@ +import { + getAnnotationData, + MessageAnnotation, + useChatMessage, + useChatUI, +} from "@llamaindex/chat-ui"; +import { JSONValue, Message } from "ai"; +import { useMemo } from "react"; +import { Artifact, CodeArtifact } from "./artifact"; +import { WeatherCard, WeatherData } from "./weather-card"; + +export function ToolAnnotations({ message }: { message: Message }) { + const annotations = message.annotations as MessageAnnotation[] | undefined; + const toolData = annotations + ? (getAnnotationData(annotations, "tools") as unknown as ToolData[]) + : null; + return toolData?.[0] ? <ChatTools data={toolData[0]} /> : null; +} + +// TODO: Used to render outputs of tools. If needed, add more renderers here. +function ChatTools({ data }: { data: ToolData }) { + const { messages } = useChatUI(); + const { message } = useChatMessage(); + + // build a map of message id to artifact version + const artifactVersionMap = useMemo(() => { + const map = new Map<string, number | undefined>(); + let versionIndex = 1; + messages.forEach((m) => { + m.annotations?.forEach((annotation: any) => { + if ( + typeof annotation === "object" && + annotation != null && + "type" in annotation && + annotation.type === "tools" + ) { + const data = annotation.data as ToolData; + if (data?.toolCall?.name === "artifact") { + map.set(m.id, versionIndex); + versionIndex++; + } + } + }); + }); + return map; + }, [messages]); + + if (!data) return null; + const { toolCall, toolOutput } = data; + + if (toolOutput.isError) { + return ( + <div className="border-l-2 border-red-400 pl-2"> + There was an error when calling the tool {toolCall.name} with input:{" "} + <br /> + {JSON.stringify(toolCall.input)} + </div> + ); + } + + switch (toolCall.name) { + case "get_weather_information": + const weatherData = toolOutput.output as unknown as WeatherData; + return <WeatherCard data={weatherData} />; + case "artifact": + return ( + <Artifact + artifact={toolOutput.output as CodeArtifact} + version={artifactVersionMap.get(message.id)} + /> + ); + default: + return null; + } +} + +type ToolData = { + toolCall: { + id: string; + name: string; + input: { + [key: string]: JSONValue; + }; + }; + toolOutput: { + output: JSONValue; + isError: boolean; + }; +}; diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/widgets/WeatherCard.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/tools/weather-card.tsx similarity index 100% rename from templates/types/streaming/nextjs/app/components/ui/chat/widgets/WeatherCard.tsx rename to templates/types/streaming/nextjs/app/components/ui/chat/tools/weather-card.tsx diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/widgets/PdfDialog.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/widgets/PdfDialog.tsx deleted file mode 100644 index 36abc5cf..00000000 --- a/templates/types/streaming/nextjs/app/components/ui/chat/widgets/PdfDialog.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import dynamic from "next/dynamic"; -import { Button } from "../../button"; -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "../../drawer"; - -export interface PdfDialogProps { - documentId: string; - url: string; - trigger: React.ReactNode; -} - -// Dynamic imports for client-side rendering only -const PDFViewer = dynamic( - () => import("@llamaindex/pdf-viewer").then((module) => module.PDFViewer), - { ssr: false }, -); - -const PdfFocusProvider = dynamic( - () => - import("@llamaindex/pdf-viewer").then((module) => module.PdfFocusProvider), - { ssr: false }, -); - -export default function PdfDialog(props: PdfDialogProps) { - return ( - <Drawer direction="left"> - <DrawerTrigger asChild>{props.trigger}</DrawerTrigger> - <DrawerContent className="w-3/5 mt-24 h-full max-h-[96%] "> - <DrawerHeader className="flex justify-between"> - <div className="space-y-2"> - <DrawerTitle>PDF Content</DrawerTitle> - <DrawerDescription> - File URL:{" "} - <a - className="hover:text-blue-900" - href={props.url} - target="_blank" - > - {props.url} - </a> - </DrawerDescription> - </div> - <DrawerClose asChild> - <Button variant="outline">Close</Button> - </DrawerClose> - </DrawerHeader> - <div className="m-4"> - <PdfFocusProvider> - <PDFViewer - file={{ - id: props.documentId, - url: props.url, - }} - /> - </PdfFocusProvider> - </div> - </DrawerContent> - </Drawer> - ); -} diff --git a/templates/types/streaming/nextjs/app/components/ui/document-preview.tsx b/templates/types/streaming/nextjs/app/components/ui/document-preview.tsx deleted file mode 100644 index ee3059a0..00000000 --- a/templates/types/streaming/nextjs/app/components/ui/document-preview.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { XCircleIcon } from "lucide-react"; -import Image from "next/image"; -import DocxIcon from "../ui/icons/docx.svg"; -import PdfIcon from "../ui/icons/pdf.svg"; -import SheetIcon from "../ui/icons/sheet.svg"; -import TxtIcon from "../ui/icons/txt.svg"; -import { Button } from "./button"; -import { DocumentFile, DocumentFileType } from "./chat"; -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "./drawer"; -import { cn } from "./lib/utils"; - -export interface DocumentPreviewProps { - file: DocumentFile; - onRemove?: () => void; -} - -export function DocumentPreview(props: DocumentPreviewProps) { - const { name, size, type, refs } = props.file; - - if (refs?.length) { - return ( - <div title={`Document IDs: ${refs.join(", ")}`}> - <PreviewCard {...props} /> - </div> - ); - } - - return ( - <Drawer direction="left"> - <DrawerTrigger asChild> - <div> - <PreviewCard className="cursor-pointer" {...props} /> - </div> - </DrawerTrigger> - <DrawerContent className="w-3/5 mt-24 h-full max-h-[96%] "> - <DrawerHeader className="flex justify-between"> - <div className="space-y-2"> - <DrawerTitle>{type.toUpperCase()} Raw Content</DrawerTitle> - <DrawerDescription> - {name} ({inKB(size)} KB) - </DrawerDescription> - </div> - <DrawerClose asChild> - <Button variant="outline">Close</Button> - </DrawerClose> - </DrawerHeader> - <div className="m-4 max-h-[80%] overflow-auto"> - {refs?.length && ( - <pre className="bg-secondary rounded-md p-4 block text-sm"> - {refs.join(", ")} - </pre> - )} - </div> - </DrawerContent> - </Drawer> - ); -} - -export const FileIcon: Record<DocumentFileType, string> = { - csv: SheetIcon, - pdf: PdfIcon, - docx: DocxIcon, - txt: TxtIcon, -}; - -export function PreviewCard(props: { - file: { - name: string; - size?: number; - type: DocumentFileType; - }; - onRemove?: () => void; - className?: string; -}) { - const { onRemove, file, className } = props; - return ( - <div - className={cn( - "p-2 w-60 max-w-60 bg-secondary rounded-lg text-sm relative", - className, - )} - > - <div className="flex flex-row items-center gap-2"> - <div className="relative h-8 w-8 shrink-0 overflow-hidden rounded-md flex items-center justify-center"> - <Image - className="h-full w-auto object-contain" - priority - src={FileIcon[file.type]} - alt="Icon" - /> - </div> - <div className="overflow-hidden"> - <div className="truncate font-semibold"> - {file.name} {file.size ? `(${inKB(file.size)} KB)` : ""} - </div> - {file.type && ( - <div className="truncate text-token-text-tertiary flex items-center gap-2"> - <span>{file.type.toUpperCase()} File</span> - </div> - )} - </div> - </div> - {onRemove && ( - <div - className={cn( - "absolute -top-2 -right-2 w-6 h-6 z-10 bg-gray-500 text-white rounded-full", - )} - > - <XCircleIcon - className="w-6 h-6 bg-gray-500 text-white rounded-full" - onClick={onRemove} - /> - </div> - )} - </div> - ); -} - -function inKB(size: number) { - return Math.round((size / 1024) * 10) / 10; -} diff --git a/templates/types/streaming/nextjs/app/components/ui/file-uploader.tsx b/templates/types/streaming/nextjs/app/components/ui/file-uploader.tsx deleted file mode 100644 index 15f9e403..00000000 --- a/templates/types/streaming/nextjs/app/components/ui/file-uploader.tsx +++ /dev/null @@ -1,136 +0,0 @@ -"use client"; - -import { Loader2, Paperclip } from "lucide-react"; -import { ChangeEvent, useState } from "react"; -import { buttonVariants } from "./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 default 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" }), - "cursor-pointer relative", - uploading && "opacity-50", - )} - > - {uploading ? ( - <div className="relative flex items-center justify-center h-full w-full"> - <Loader2 className="h-6 w-6 animate-spin absolute" /> - {remainingFiles > 0 && ( - <span className="text-xs absolute inset-0 flex items-center justify-center"> - {remainingFiles} - </span> - )} - </div> - ) : ( - <Paperclip className="-rotate-45 w-4 h-4" /> - )} - </label> - </div> - ); -} diff --git a/templates/types/streaming/nextjs/app/components/ui/upload-image-preview.tsx b/templates/types/streaming/nextjs/app/components/ui/upload-image-preview.tsx deleted file mode 100644 index 55ef6e9c..00000000 --- a/templates/types/streaming/nextjs/app/components/ui/upload-image-preview.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { XCircleIcon } from "lucide-react"; -import Image from "next/image"; -import { cn } from "./lib/utils"; - -export default function UploadImagePreview({ - url, - onRemove, -}: { - url: string; - onRemove: () => void; -}) { - return ( - <div className="relative w-20 h-20 group"> - <Image - src={url} - alt="Uploaded image" - fill - className="object-cover w-full h-full rounded-xl hover:brightness-75" - /> - <div - className={cn( - "absolute -top-2 -right-2 w-6 h-6 z-10 bg-gray-500 text-white rounded-full hidden group-hover:block", - )} - > - <XCircleIcon - className="w-6 h-6 bg-gray-500 text-white rounded-full" - onClick={onRemove} - /> - </div> - </div> - ); -} diff --git a/templates/types/streaming/nextjs/app/observability/index.ts b/templates/types/streaming/nextjs/app/observability/index.ts index 2e4ce2b1..206eff4d 100644 --- a/templates/types/streaming/nextjs/app/observability/index.ts +++ b/templates/types/streaming/nextjs/app/observability/index.ts @@ -1 +1,2 @@ +// TODO: You can add observability here. For templates re-start `create-llama` with `--pro` flag to generate a new project with observability. export const initObservability = () => {}; diff --git a/templates/types/streaming/nextjs/package.json b/templates/types/streaming/nextjs/package.json index d5422482..3a9de9ff 100644 --- a/templates/types/streaming/nextjs/package.json +++ b/templates/types/streaming/nextjs/package.json @@ -12,7 +12,6 @@ "dependencies": { "@apidevtools/swagger-parser": "^10.1.0", "@e2b/code-interpreter": "0.0.9-beta.3", - "@llamaindex/pdf-viewer": "^1.1.3", "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-progress": "^1.1.0", @@ -32,19 +31,13 @@ "next": "^14.2.4", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-markdown": "^8.0.7", - "rehype-katex": "^7.0.0", - "remark": "^14.0.3", - "remark-code-import": "^1.2.0", - "remark-gfm": "^3.0.1", - "remark-math": "^5.1.1", "supports-color": "^8.1.1", "tailwind-merge": "^2.1.0", "tiktoken": "^1.0.15", "uuid": "^9.0.1", "vaul": "^0.9.1", "marked": "^14.1.2", - "highlight.js": "^11.10.0" + "@llamaindex/chat-ui": "0.0.4" }, "devDependencies": { "@types/node": "^20.10.3", diff --git a/templates/types/streaming/nextjs/tailwind.config.ts b/templates/types/streaming/nextjs/tailwind.config.ts index aa5580af..d441c050 100644 --- a/templates/types/streaming/nextjs/tailwind.config.ts +++ b/templates/types/streaming/nextjs/tailwind.config.ts @@ -3,7 +3,11 @@ import { fontFamily } from "tailwindcss/defaultTheme"; const config: Config = { darkMode: ["class"], - content: ["app/**/*.{ts,tsx}", "components/**/*.{ts,tsx}"], + content: [ + "app/**/*.{ts,tsx}", + "components/**/*.{ts,tsx}", + "node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}", + ], theme: { container: { center: true, -- GitLab