feat: add xmas demo

This commit is contained in:
Marcus Schiesser
2023-12-25 12:32:54 +07:00
parent 5c136d5b56
commit bdd1497322
42 changed files with 14855 additions and 0 deletions
+3
View File
@@ -146,3 +146,6 @@ Pipfile.lock
# pyright
pyrightconfig.json
# vscode
.vscode/
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
+35
View File
@@ -0,0 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+41
View File
@@ -0,0 +1,41 @@
This is a [LlamaIndex](https://www.llamaindex.ai/) project using [Next.js](https://nextjs.org/) bootstrapped with [`create-llama`](https://github.com/run-llama/LlamaIndexTS/tree/main/packages/create-llama).
## Introduction
This example allows you to have a chat using the [GPT4 Vision model](https://platform.openai.com/docs/guides/vision) from OpenAI. You can upload files and ask the model to describe them.
To keep the example simple, we are not using a database or any other kind of storage for the images.
Instead, they are sent to the model in base64 encoding. This is not very efficient and only works for small images
like the ones in the `./data` folder.
We recommended implementing a server upload and sending just the URL of the image instead.
A straightforward way is to use [Vercel Blob](https://vercel.com/docs/storage/vercel-blob/quickstart#server-uploads) which is a file storage service that is easy to integrate with Next.js.
## Getting Started
First, install the dependencies:
```
npm install
```
Second, run the development server:
```
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about LlamaIndex, take a look at the following resources:
- [LlamaIndex Documentation](https://docs.llamaindex.ai) - learn about LlamaIndex (Python features).
- [LlamaIndexTS Documentation](https://ts.llamaindex.ai) - learn about LlamaIndex (Typescript features).
You can check out [the LlamaIndexTS GitHub repository](https://github.com/run-llama/LlamaIndexTS) - your feedback and contributions are welcome!
@@ -0,0 +1,7 @@
import { LLM, SimpleChatEngine } from "llamaindex";
export async function createChatEngine(llm: LLM) {
return new SimpleChatEngine({
llm,
});
}
@@ -0,0 +1,81 @@
import {
createCallbacksTransformer,
createStreamDataTransformer,
trimStartOfStreamHelper,
type AIStreamCallbacksAndOptions,
experimental_StreamData,
JSONValue,
} from "ai";
import OpenAI from 'openai';
type ParserOptions = {
image_url?: string;
};
function wrapUrl(image_url: string | undefined): JSONValue {
if (image_url) {
// if image_url is provided, send it via the data stream
return {
type: "image_url",
image_url: {
url: image_url,
},
}
}
return {}; // send an empty image response for the user's message
}
function createParser(
res: AsyncGenerator<any>,
data: experimental_StreamData,
opts?: ParserOptions,
) {
const openai = new OpenAI();
const trimStartOfStream = trimStartOfStreamHelper();
let prompt = "";
return new ReadableStream<string>({
start() {
data.append(wrapUrl(opts?.image_url));
},
async pull(controller): Promise<void> {
const {value, done} = await res.next();
if (done) {
const response = await openai.images.generate({
model: "dall-e-3",
prompt,
n: 1,
size: "1024x1024",
});
const imageUrl = response.data.at(0)?.url;
controller.close();
data.append(wrapUrl(imageUrl)); // send an empty image response for the assistant's message
await data.close();
return;
}
const text = trimStartOfStream(value ?? "");
if (text) {
prompt = prompt + text;
controller.enqueue(text);
}
},
}
)
;
}
export function LlamaIndexStream(
res: AsyncGenerator<any>,
opts?: {
callbacks?: AIStreamCallbacksAndOptions;
parserOptions?: ParserOptions;
},
): { stream: ReadableStream; data: experimental_StreamData } {
const data = new experimental_StreamData();
return {
stream: createParser(res, data, opts?.parserOptions)
.pipeThrough(createCallbacksTransformer(opts?.callbacks))
.pipeThrough(createStreamDataTransformer(true)),
data,
};
}
@@ -0,0 +1,84 @@
import { MODEL } from "@/constants";
import { Message, StreamingTextResponse } from "ai";
import { ChatMessage, MessageContent, OpenAI } from "llamaindex";
import { NextRequest, NextResponse } from "next/server";
import { createChatEngine } from "./engine";
import { LlamaIndexStream } from "./llamaindex-stream";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const getLastMessageContent = (
textMessage: string,
imageUrl: string | undefined,
): MessageContent => {
if (!imageUrl) return textMessage;
return [
{
type: "text",
text: textMessage,
},
{
type: "image_url",
image_url: {
url: imageUrl,
},
},
];
};
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { messages, data }: { messages: Message[]; data: any } = body;
const lastMessage = messages.pop();
if (!messages || !lastMessage || lastMessage.role !== "user") {
return NextResponse.json(
{
error:
"messages are required in the request body and the last message must be from the user",
},
{ status: 400 },
);
}
const llm = new OpenAI({
model: MODEL,
maxTokens: 512,
});
const chatEngine = await createChatEngine(llm);
const prompt="Please give me a cute and vivid description of this image. Modify the description so it has a christmas theme.";
const lastMessageContent = getLastMessageContent(
prompt,
data?.imageUrl,
);
const response = await chatEngine.chat(
lastMessageContent as MessageContent,
messages as ChatMessage[],
true,
);
// Transform the response into a readable stream
const stream = LlamaIndexStream(response, {
parserOptions: {
image_url: data?.imageUrl,
},
});
// Return a StreamingTextResponse, which can be consumed by the client
return new StreamingTextResponse(stream.stream, {}, stream.data);
} catch (error) {
console.error("[LlamaIndex]", error);
return NextResponse.json(
{
error: (error as Error).message,
},
{
status: 500,
},
);
}
}
@@ -0,0 +1,44 @@
"use client";
import { MODEL } from "@/constants";
import { useChat } from "ai/react";
import { ChatInput, ChatMessages } from "./ui/chat";
import { useMemo } from "react";
import { transformMessages } from "./transform";
export default function ChatSection() {
const {
messages,
input,
isLoading,
handleSubmit,
handleInputChange,
reload,
stop,
data,
} = useChat({
api: process.env.NEXT_PUBLIC_CHAT_API,
});
const transformedMessages = useMemo(() => {
return transformMessages(messages, data);
}, [messages, data]);
return (
<div className="space-y-4 max-w-5xl w-full">
<ChatMessages
messages={transformedMessages}
isLoading={isLoading}
reload={reload}
stop={stop}
/>
<ChatInput
input={input}
handleSubmit={handleSubmit}
handleInputChange={handleInputChange}
isLoading={isLoading}
multiModal={MODEL === "gpt-4-vision-preview"}
/>
</div>
);
}
@@ -0,0 +1,30 @@
import Image from "next/image";
export default function Header() {
return (
<div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex">
<p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30">
<b>Multi-Modal Chat Demo</b> <br />
</p>
<p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30">
Upload images and ask questions related to the images!
</p>
<div className="fixed bottom-0 left-0 flex h-48 w-full items-end justify-center bg-gradient-to-t from-white via-white dark:from-black dark:via-black lg:static lg:h-auto lg:w-auto lg:bg-none">
<a
href="https://www.llamaindex.ai/"
className="flex items-center justify-center font-nunito text-lg font-bold gap-2"
>
<span>Built by LlamaIndex</span>
<Image
className="rounded-xl"
src="/llama.png"
alt="Llama Logo"
width={40}
height={40}
priority
/>
</a>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
import { JSONValue } from "ai";
import { MessageContentDetail, RawMessage, Message } from "./ui/chat/index";
const transformMessage = (
message: RawMessage,
data: JSONValue | undefined,
): Message => {
const msg = {
...message,
content: [
{
type: "text",
text: message.content,
},
],
} as Message;
if (data && typeof data === "object" && Object.keys(data).length > 0) {
// if the server sends an non-empty data object, it must be of type MessageContentDetail
// add it to the message's content
const content = data as unknown as MessageContentDetail;
if (content["type"] === "image_url") {
msg.content.push(content);
}
}
return msg;
};
export const transformMessages = (
messages: RawMessage[],
data: JSONValue[] | undefined,
) => {
const result = messages.map((message, index) =>
transformMessage(message, data?.at(index)),
);
return result;
};
@@ -0,0 +1 @@
Using the chat component from https://github.com/marcusschiesser/ui (based on https://ui.shadcn.com/)
@@ -0,0 +1,56 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "./lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
@@ -0,0 +1,28 @@
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>
);
}
@@ -0,0 +1,25 @@
import { Shell, User2 } from "lucide-react";
import Image from "next/image";
export default function ChatAvatar({ role }: { role: string }) {
if (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" />
</div>
);
}
return (
<div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border bg-black text-white shadow">
<Image
className="rounded-md"
src="/llama.png"
alt="Llama Logo"
width={24}
height={24}
priority
/>
</div>
);
}
@@ -0,0 +1,84 @@
import { useState } from "react";
import { Button } from "../button";
import FileUploader from "../file-uploader";
import { Input } from "../input";
import UploadImagePreview from "../upload-image-preview";
import { ChatHandler } from "./chat.interface";
export default function ChatInput(
props: Pick<
ChatHandler,
| "isLoading"
| "input"
| "onFileUpload"
| "onFileError"
| "handleSubmit"
| "handleInputChange"
> & {
multiModal?: boolean;
},
) {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
if (imageUrl) {
props.handleSubmit(e, {
data: { imageUrl: imageUrl },
});
setImageUrl(null);
return;
}
props.handleSubmit(e);
};
const onRemovePreviewImage = () => setImageUrl(null);
const handleUploadImageFile = async (file: File) => {
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
setImageUrl(base64);
};
const handleUploadFile = async (file: File) => {
try {
if (props.multiModal && file.type.startsWith("image/")) {
return await handleUploadImageFile(file);
}
props.onFileUpload?.(file);
} catch (error: any) {
props.onFileError?.(error.message);
}
};
return (
<form
onSubmit={onSubmit}
className="rounded-xl bg-white p-4 shadow-xl space-y-4"
>
{imageUrl && (
<UploadImagePreview url={imageUrl} onRemove={onRemovePreviewImage} />
)}
<div className="flex w-full items-start justify-between gap-4 ">
<Input
autoFocus
name="message"
placeholder="Type a message"
className="flex-1"
value={props.input}
onChange={props.handleInputChange}
/>
<FileUploader
onFileUpload={handleUploadFile}
onFileError={props.onFileError}
/>
<Button type="submit" disabled={props.isLoading}>
Send message
</Button>
</div>
</form>
);
}
@@ -0,0 +1,71 @@
/* eslint-disable @next/next/no-img-element */
import { Check, Copy } from "lucide-react";
import { Button } from "../button";
import ChatAvatar from "./chat-avatar";
import { Message, MessageContentDetail } from "./chat.interface";
import Markdown from "./markdown";
import { useCopyToClipboard } from "./use-copy-to-clipboard";
function ChatMessageContents({
contents,
}: {
contents: MessageContentDetail[];
}) {
const mediaContents = contents.filter(
(c) => c.type === "image_url" && c.image_url?.url,
);
const textContent = contents.find((c) => c.type === "text");
return (
<>
{mediaContents.length > 0 && (
<div className="flex gap-4 flex-wrap">
{mediaContents.map((content, index) => {
const image_url = content.image_url?.url;
return (
<div key={index}>
<img
src={image_url}
className="rounded-md max-w-[200px] shadow-md"
alt=""
/>
</div>
);
})}
</div>
)}
{textContent && <Markdown content={textContent.text!} />}
</>
);
}
export default function ChatMessage(chatMessage: Message) {
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 });
const onCopy = () => {
const pureText = chatMessage.content.find((c) => c.text)?.text;
if (pureText) copyToClipboard(pureText);
};
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">
<div className="flex-1 space-y-4">
<ChatMessageContents contents={chatMessage.content} />
</div>
<Button
onClick={onCopy}
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>
);
}
@@ -0,0 +1,62 @@
import { Loader2 } from "lucide-react";
import { useEffect, useRef } from "react";
import ChatActions from "./chat-actions";
import ChatMessage from "./chat-message";
import { ChatHandler } from "./chat.interface";
export default function ChatMessages(
props: Pick<ChatHandler, "messages" | "isLoading" | "reload" | "stop">,
) {
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]);
return (
<div className="w-full rounded-xl bg-white p-4 shadow-xl pb-0">
<div
className="flex h-[50vh] flex-col gap-5 divide-y overflow-y-auto pb-4"
ref={scrollableChatContainerRef}
>
{props.messages.map((m) => (
<ChatMessage key={m.id} {...m} />
))}
{isPending && (
<div className="flex justify-center items-center pt-10">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
</div>
<div className="flex justify-end py-4">
<ChatActions
reload={props.reload}
stop={props.stop}
showReload={showReload}
showStop={showStop}
/>
</div>
</div>
);
}
@@ -0,0 +1,35 @@
export interface MessageContentDetail {
type: "text" | "image_url";
text?: string;
image_url?: { url: string };
role?: "user" | "assistant";
}
export interface RawMessage {
id: string;
content: string;
role: string;
}
export interface Message {
id: string;
role: string;
content: MessageContentDetail[];
}
export interface ChatHandler {
messages: Message[];
input: string;
isLoading: boolean;
handleSubmit: (
e: React.FormEvent<HTMLFormElement>,
ops?: {
data?: any;
},
) => void;
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
reload?: () => void;
stop?: () => void;
onFileUpload?: (file: File) => Promise<void>;
onFileError?: (errMsg: string) => void;
}
@@ -0,0 +1,139 @@
"use client";
import { Check, Copy, Download } from "lucide-react";
import { FC, memo } from "react";
import { Prism, SyntaxHighlighterProps } from "react-syntax-highlighter";
import { coldarkDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { Button } from "../button";
import { useCopyToClipboard } from "./use-copy-to-clipboard";
// TODO: Remove this when @type/react-syntax-highlighter is updated
const SyntaxHighlighter = Prism as unknown as FC<SyntaxHighlighterProps>;
interface Props {
language: string;
value: 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 }) => {
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 });
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">
<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>
<SyntaxHighlighter
language={language}
style={coldarkDark}
PreTag="div"
showLineNumbers
customStyle={{
width: "100%",
background: "transparent",
padding: "1.5rem 1rem",
borderRadius: "0.5rem",
}}
codeTagProps={{
style: {
fontSize: "0.9rem",
fontFamily: "var(--font-mono)",
},
}}
>
{value}
</SyntaxHighlighter>
</div>
);
});
CodeBlock.displayName = "CodeBlock";
export { CodeBlock };
@@ -0,0 +1,10 @@
import ChatInput from "./chat-input";
import ChatMessages from "./chat-messages";
export {
type ChatHandler,
type Message,
type RawMessage,
type MessageContentDetail,
} from "./chat.interface";
export { ChatInput, ChatMessages };
@@ -0,0 +1,59 @@
import { FC, memo } from "react";
import ReactMarkdown, { Options } from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { CodeBlock } from "./codeblock";
const MemoizedReactMarkdown: FC<Options> = memo(
ReactMarkdown,
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
prevProps.className === nextProps.className,
);
export default function Markdown({ content }: { content: string }) {
return (
<MemoizedReactMarkdown
className="prose dark:prose-invert prose-p:leading-relaxed prose-pre:p-0 break-words"
remarkPlugins={[remarkGfm, remarkMath]}
components={{
p({ children }) {
return <p className="mb-2 last:mb-0">{children}</p>;
},
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$/, "")}
{...props}
/>
);
},
}}
>
{content}
</MemoizedReactMarkdown>
);
}
@@ -0,0 +1,33 @@
"use client";
import * as React from "react";
export interface useCopyToClipboardProps {
timeout?: number;
}
export function useCopyToClipboard({
timeout = 2000,
}: useCopyToClipboardProps) {
const [isCopied, setIsCopied] = React.useState<Boolean>(false);
const copyToClipboard = (value: string) => {
if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
return;
}
if (!value) {
return;
}
navigator.clipboard.writeText(value).then(() => {
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, timeout);
});
};
return { isCopied, copyToClipboard };
}
@@ -0,0 +1,105 @@
"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;
};
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 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 file = e.target.files?.[0];
if (!file) return;
setUploading(true);
await handleUpload(file);
resetInput();
setUploading(false);
};
const handleUpload = async (file: File) => {
const onFileUploadError = onFileError || window.alert;
const fileExtension = file.name.split(".").pop() || "";
const extensionFileError = checkExtension(fileExtension);
if (extensionFileError) {
return onFileUploadError(extensionFileError);
}
if (isFileSizeExceeded(file)) {
return onFileUploadError(
`File size exceeded. Limit is ${fileSizeLimit / 1024 / 1024} MB`,
);
}
await onFileUpload(file);
};
return (
<div className="self-stretch">
<input
type="file"
id={inputId}
style={{ display: "none" }}
onChange={onFileChange}
accept={allowedExtensions?.join(",")}
disabled={config?.disabled || uploading}
/>
<label
htmlFor={inputId}
className={cn(
buttonVariants({ variant: "secondary", size: "icon" }),
"cursor-pointer",
uploading && "opacity-50",
)}
>
{uploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Paperclip className="-rotate-45 w-4 h-4" />
)}
</label>
</div>
);
}
@@ -0,0 +1,25 @@
import * as React from "react";
import { cn } from "./lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -0,0 +1,32 @@
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>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+94
View File
@@ -0,0 +1,94 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 47.4% 11.2%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--card: 0 0% 100%;
--card-foreground: 222.2 47.4% 11.2%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 100% 50%;
--destructive-foreground: 210 40% 98%;
--ring: 215 20.2% 65.1%;
--radius: 0.5rem;
}
.dark {
--background: 224 71% 4%;
--foreground: 213 31% 91%;
--muted: 223 47% 11%;
--muted-foreground: 215.4 16.3% 56.9%;
--accent: 216 34% 17%;
--accent-foreground: 210 40% 98%;
--popover: 224 71% 4%;
--popover-foreground: 215 20.2% 65.1%;
--border: 216 34% 17%;
--input: 216 34% 17%;
--card: 224 71% 4%;
--card-foreground: 213 31% 91%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 1.2%;
--secondary: 222.2 47.4% 11.2%;
--secondary-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--ring: 216 34% 17%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings:
"rlig" 1,
"calt" 1;
}
.background-gradient {
background-color: #fff;
background-image: radial-gradient(
at 21% 11%,
rgba(186, 186, 233, 0.53) 0,
transparent 50%
),
radial-gradient(at 85% 0, hsla(46, 57%, 78%, 0.52) 0, transparent 50%),
radial-gradient(at 91% 36%, rgba(194, 213, 255, 0.68) 0, transparent 50%),
radial-gradient(at 8% 40%, rgba(251, 218, 239, 0.46) 0, transparent 50%);
}
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Llama App",
description: "Generated by create-llama",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}
+11
View File
@@ -0,0 +1,11 @@
import Header from "@/app/components/header";
import ChatSection from "./components/chat-section";
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center gap-10 p-24 background-gradient">
<Header />
<ChatSection />
</main>
);
}
+1
View File
@@ -0,0 +1 @@
export const MODEL = "gpt-4-vision-preview";
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

+21
View File
@@ -0,0 +1,21 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config) => {
// See https://webpack.js.org/configuration/resolve/#resolvealias
config.resolve.alias = {
...config.resolve.alias,
sharp$: false,
"onnxruntime-node$": false,
mongodb$: false,
};
return config;
},
experimental: {
serverComponentsExternalPackages: ["llamaindex"],
outputFileTracingIncludes: {
"/*": ["./cache/**/*"],
},
},
};
module.exports = nextConfig;
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "nextjs-multi-modal-xmas",
"version": "0.1.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,md}\""
},
"dependencies": {
"@radix-ui/react-slot": "^1.0.2",
"ai": "^2.2.29",
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",
"dotenv": "^16.3.1",
"llamaindex": "0.0.38",
"lucide-react": "^0.294.0",
"next": "^14.0.3",
"openai": "^4.24.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^8.0.7",
"react-syntax-highlighter": "^15.5.0",
"remark": "^14.0.3",
"remark-code-import": "^1.2.0",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"supports-color": "^9.4.0",
"tailwind-merge": "^2.1.0"
},
"devDependencies": {
"@types/node": "^20.10.3",
"@types/react": "^18.2.42",
"@types/react-dom": "^18.2.17",
"@types/react-syntax-highlighter": "^15.5.11",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-config-next": "^14.0.3",
"postcss": "^8.4.32",
"prettier": "^3.1.0",
"tailwindcss": "^3.3.6",
"typescript": "^5.3.2"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -0,0 +1,78 @@
import type { Config } from "tailwindcss";
import { fontFamily } from "tailwindcss/defaultTheme";
const config: Config = {
darkMode: ["class"],
content: ["app/**/*.{ts,tsx}", "components/**/*.{ts,tsx}"],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
xl: `calc(var(--radius) + 4px)`,
lg: `var(--radius)`,
md: `calc(var(--radius) - 2px)`,
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [],
};
export default config;
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"forceConsistentCasingInFileNames": true,
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}