feat: add @llamaindex/chat-ui components (#1)

---------
Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de>
This commit is contained in:
Thuc Pham
2024-10-25 15:03:06 +07:00
committed by GitHub
parent ab812393c8
commit db7c1d4ef3
61 changed files with 7731 additions and 273 deletions
+8
View File
@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
+8
View File
@@ -0,0 +1,8 @@
---
'@llamaindex/eslint-config': major
'@llamaindex/chat-ui': major
'docs': major
'web': major
---
release first version
+37
View File
@@ -0,0 +1,37 @@
name: Build
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build:
name: Build and Type Check
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install
- name: Build
run: pnpm build
- name: Type Check
run: pnpm type-check
+34
View File
@@ -0,0 +1,34 @@
name: Changeset
on:
push:
branches:
- main
jobs:
create-release-pr:
name: Create Changeset PR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install
- name: Create Release Pull Request
uses: changesets/action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+33
View File
@@ -0,0 +1,33 @@
name: Format
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
format:
name: Check Formatting
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install
- name: Check formatting
run: pnpm format
+36
View File
@@ -0,0 +1,36 @@
name: Lint
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install
- name: Build
run: pnpm build
- name: Run ESLint
run: pnpm lint
+5
View File
@@ -0,0 +1,5 @@
# Run lint-staged
pnpm run format:write
# Run lint for the entire project
pnpm run lint
+103 -19
View File
@@ -17,46 +17,110 @@ npm install @llamaindex/chat-ui
## Features
- Pre-built chat components (e.g., message bubbles, input fields)
- Customizable styles using Tailwind CSS
- Minimal styling, fully customizable with Tailwind CSS
- TypeScript support for type safety
- Easy integration with LLM backends
## Usage
1. Import the styles in your root layout (e.g. `layout.tsx` for Next.js or `index.tsx` for React)
1. Install the package
```tsx
import '@llamaindex/chat-ui/styles.css'
```sh
npm install @llamaindex/chat-ui
```
2. Import the components and use them
2. Configure your `tailwind.config.ts` to include the chat-ui components
```ts
module.exports = {
content: [
'app/**/*.{ts,tsx}',
'node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}',
],
// ...
}
```
3. Import the components and use them
The easiest way to get started is to connect the whole `ChatSection` component with `useChat` hook from [vercel/ai](https://github.com/vercel/ai):
```tsx
import React from 'react'
import { ChatSection, ChatMessages, ChatInput } from '@llamaindex/chat-ui'
import { ChatSection } from '@llamaindex/chat-ui'
import { useChat } from 'ai/react'
const ChatExample = () => {
const handler = useChat()
return <ChatSection handler={handler} />
}
```
## Component Composition
Components are designed to be composable. You can use them as is:
```tsx
import { ChatSection } from '@llamaindex/chat-ui'
import { useChat } from 'ai/react'
const ChatExample = () => {
const handler = useChat()
return <ChatSection handler={handler} />
}
```
Or you can extend them with your own children components:
```tsx
import { ChatSection, ChatMessages, ChatInput } from '@llamaindex/chat-ui'
import LlamaCloudSelector from './components/LlamaCloudSelector' // your custom component
import { useChat } from 'ai/react'
const ChatExample = () => {
const handler = useChat()
return (
<ChatSection>
<ChatSection handler={handler}>
<ChatMessages />
<ChatInput />
<ChatInput>
<ChatInput.Preview />
<ChatInput.Form className="bg-lime-500">
<ChatInput.Field type="textarea" />
<ChatInput.Upload />
<LlamaCloudSelector /> {/* custom component */}
<ChatInput.Submit />
</ChatInput.Form>
</ChatInput>
</ChatSection>
)
}
export default ChatExample
```
## Custom theme
You can customize the theme by overriding the default styles.
Your custom component can use the `useChatUI` hook to send additional data to the chat API endpoint:
```tsx
import '@llamaindex/chat-ui/styles.css'
import './globals.css' // your custom theme
import { useChatInput } from '@llamaindex/chat-ui'
const LlamaCloudSelector = () => {
const { requestData, setRequestData } = useChatUI()
return (
<div>
<select
value={requestData?.model}
onChange={e => setRequestData({ model: e.target.value })}
>
<option value="llama-3.1-70b-instruct">Pipeline 1</option>
<option value="llama-3.1-8b-instruct">Pipeline 2</option>
</select>
</div>
)
}
```
Inside `globals.css`, you can override the default styles by defining your own CSS variables. Eg:
## Styling
`chat-ui` components are based on [shadcn](https://ui.shadcn.com/) components using Tailwind CSS.
You can override the default styles by changing CSS variables in the `globals.css` file of your Tailwind CSS configuration. For example, to change the background and foreground colors:
```css
:root {
@@ -67,9 +131,29 @@ Inside `globals.css`, you can override the default styles by defining your own C
For a list of all available CSS variables, please refer to the [Shadcn Theme Config](https://ui.shadcn.com/themes).
## Documentation
Additionally, you can also override each component's styles by setting custom classes in the `className` prop. For example, setting the background color of the `ChatInput.Form` component:
For detailed documentation on all available components and their props, please visit our [documentation site](https://docs.llamaindex.ai/chat-ui).
```tsx
import { ChatSection, ChatMessages, ChatInput } from '@llamaindex/chat-ui'
import { useChat } from 'ai/react'
const ChatExample = () => {
const handler = useChat()
return (
<ChatSection handler={handler}>
<ChatMessages />
<ChatInput>
<ChatInput.Preview />
<ChatInput.Form className="bg-lime-500">
<ChatInput.Field type="textarea" />
<ChatInput.Upload />
<ChatInput.Submit />
</ChatInput.Form>
</ChatInput>
</ChatSection>
)
}
```
## License
+2 -2
View File
@@ -1,3 +1,3 @@
module.exports = {
extends: ["@llamaindex/eslint-config/next.js"],
};
extends: ['@llamaindex/eslint-config/next.js'],
}
+2 -2
View File
@@ -1,5 +1,5 @@
/** @type {import('next').NextConfig} */
module.exports = {
reactStrictMode: true,
transpilePackages: ["@llamaindex/chat-ui"],
};
transpilePackages: ['@llamaindex/chat-ui'],
}
+1 -1
View File
@@ -6,4 +6,4 @@ module.exports = {
tailwindcss: {},
autoprefixer: {},
},
};
}
-1
View File
@@ -1,4 +1,3 @@
import '@llamaindex/chat-ui/styles.css'
import './globals.css'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
-3
View File
@@ -1,11 +1,8 @@
import { Card } from '@llamaindex/chat-ui'
export default function Page(): JSX.Element {
return (
<main className="bg-glow-conic flex h-screen flex-col items-center justify-center space-y-4">
<h1 className="text-primary text-6xl font-bold">LlamaIndex ChatUI</h1>
<p className="text-2xl">Build powerful AI chat interfaces with ease</p>
<Card />
</main>
)
}
+1
View File
@@ -0,0 +1 @@
OPENAI_API_KEY=
+2 -2
View File
@@ -1,3 +1,3 @@
module.exports = {
extends: ["@llamaindex/eslint-config/next.js"],
};
extends: ['@llamaindex/eslint-config/next.js'],
}
+1
View File
@@ -32,3 +32,4 @@ yarn-error.log*
# vercel
.vercel
.env
+6
View File
@@ -1,3 +1,9 @@
## Set OpenAI API key in .env
```bash
OPENAI_API_KEY=sk-...
```
## Run the development server
```bash
+47
View File
@@ -0,0 +1,47 @@
import { Message, LlamaIndexAdapter, StreamData } from 'ai'
import {
ChatMessage,
OpenAI,
OpenAIEmbedding,
Settings,
SimpleChatEngine,
} from 'llamaindex'
import { NextResponse, type NextRequest } from 'next/server'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
Settings.llm = new OpenAI({ model: 'gpt-4o-mini' })
Settings.embedModel = new OpenAIEmbedding({
model: 'text-embedding-3-large',
dimensions: 1024,
})
export async function POST(request: NextRequest) {
try {
const body = (await request.json()) as { messages: Message[] }
const messages = body.messages
const lastMessage = messages[messages.length - 1]
const vercelStreamData = new StreamData()
const chatEngine = new SimpleChatEngine()
const response = await chatEngine.chat({
message: lastMessage.content,
chatHistory: messages as ChatMessage[],
stream: true,
})
return LlamaIndexAdapter.toDataStreamResponse(response, {
data: vercelStreamData,
callbacks: {
onFinal: async () => {
await vercelStreamData.close()
},
},
})
} catch (error) {
const detail = (error as Error).message
return NextResponse.json({ detail }, { status: 500 })
}
}
+178
View File
@@ -0,0 +1,178 @@
'use client'
import {
ChatInput,
ChatMessage,
ChatMessages,
ChatSection,
Markdown,
useChatUI,
} from '@llamaindex/chat-ui'
import { Message, useChat } from 'ai/react'
import { User2 } from 'lucide-react'
import { Code } from './ui/code'
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'
const code = `
import {
ChatInput,
ChatMessage,
ChatMessages,
ChatSection,
Markdown,
useChatUI,
} from '@llamaindex/chat-ui'
import { useChat } from 'ai/react'
import { User2 } from 'lucide-react'
export function CustomChat() {
const handler = useChat({ initialMessages })
return (
<ChatSection handler={handler}>
<ChatMessages className="rounded-xl shadow-xl">
<CustomChatMessagesList />
<ChatMessages.Actions />
</ChatMessages>
<ChatInput className="rounded-xl shadow-xl" />
</ChatSection>
)
}
function CustomChatMessagesList() {
const { messages } = useChatUI()
return (
<ChatMessages.List>
{messages.map((message, index) => (
<ChatMessage key={index} message={message} className="items-start">
<ChatMessage.Avatar>
{message.role === 'user' ? (
<User2 className="h-4 w-4" />
) : (
<img alt="LlamaIndex" src="/llama.png" />
)}
</ChatMessage.Avatar>
<ChatMessage.Content>
<Markdown content={message.content} />
<Annotation annotations={message.annotations} />
</ChatMessage.Content>
<ChatMessage.Actions />
</ChatMessage>
))}
</ChatMessages.List>
)
}
function Annotation({ annotations }: { annotations: any }) {
if (annotations && Array.isArray(annotations)) {
return annotations.map((annotation: any) => {
if (annotation.type === 'image' && annotation.url) {
return (
<img
alt="annotation"
className="h-[200px] object-contain"
key={annotation.url}
src={annotation.url}
/>
)
}
return null
})
}
return null
}
`
const initialMessages: Message[] = [
{
id: '1',
content: 'Generate a logo for LlamaIndex',
role: 'user',
},
{
id: '2',
role: 'assistant',
content: 'Got it! Here is the logo for LlamaIndex',
annotations: [
{
type: 'image',
url: '/llama.png',
},
],
},
]
function Annotation({ annotations }: { annotations: any }) {
if (annotations && Array.isArray(annotations)) {
return annotations.map((annotation: any) => {
if (annotation.type === 'image' && annotation.url) {
return (
<img
alt="annotation"
className="h-[200px] object-contain"
key={annotation.url}
src={annotation.url}
/>
)
}
return null
})
}
return null
}
function CustomChatMessagesList() {
const { messages } = useChatUI()
return (
<ChatMessages.List>
{messages.map((message, index) => (
<ChatMessage key={index} message={message} className="items-start">
<ChatMessage.Avatar>
{message.role === 'user' ? (
<User2 className="h-4 w-4" />
) : (
<img alt="LlamaIndex" src="/llama.png" />
)}
</ChatMessage.Avatar>
<ChatMessage.Content className="items-start">
<Markdown content={message.content} />
<Annotation annotations={message.annotations} />
</ChatMessage.Content>
<ChatMessage.Actions />
</ChatMessage>
))}
</ChatMessages.List>
)
}
export function CustomChat() {
const handler = useChat({ initialMessages })
return (
<ChatSection handler={handler}>
<ChatMessages className="rounded-xl shadow-xl">
<CustomChatMessagesList />
<ChatMessages.Actions />
</ChatMessages>
<ChatInput className="rounded-xl shadow-xl" />
</ChatSection>
)
}
export function CustomChatSection() {
return (
<div className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Custom Chat Section</h2>
<Tabs defaultValue="preview" className="w-[800px]">
<TabsList>
<TabsTrigger value="preview">Preview</TabsTrigger>
<TabsTrigger value="code">Code</TabsTrigger>
</TabsList>
<TabsContent value="preview">
<CustomChat />
</TabsContent>
<TabsContent value="code">
<Code content={code} language="jsx" />
</TabsContent>
</Tabs>
</div>
)
}

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+60
View File
@@ -0,0 +1,60 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 262.1 83.3% 57.8%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 262.1 83.3% 57.8%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 263.4 70% 50.4%;
--primary-foreground: 210 20% 98%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 263.4 70% 50.4%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
+7
View File
@@ -0,0 +1,7 @@
'use client'
import { Code } from './ui/code'
export default function Guide(): JSX.Element {
return <Code content="npm install @llamaindex/chat-ui" language="bash" />
}
@@ -1,4 +1,3 @@
import '@llamaindex/chat-ui/styles.css'
import './globals.css'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+22
View File
@@ -0,0 +1,22 @@
import { SimpleChatSection } from './simple-demo'
import { CustomChatSection } from './custom-demo'
import Guide from './guide'
export default function Page(): JSX.Element {
return (
<main className="bg-glow-conic min-h-screen p-20">
<div className="mb-10 flex flex-col items-center justify-center space-y-4">
<h1 className="text-6xl font-bold">LlamaIndex ChatUI</h1>
<p className="text-2xl">Build powerful AI chat interfaces with ease</p>
</div>
<div className="mx-auto w-[72%] space-y-16 divide-y-4 divide-zinc-700">
<div className="mx-auto w-fit rounded-lg bg-zinc-800 p-3">
<Guide />
</div>
<SimpleChatSection />
<CustomChatSection />
</div>
</main>
)
}
+50
View File
@@ -0,0 +1,50 @@
'use client'
import { ChatSection } from '@llamaindex/chat-ui'
import { Message, useChat } from 'ai/react'
import { Code } from './ui/code'
const code = `
import { ChatSection } from '@llamaindex/chat-ui'
import { useChat } from 'ai/react'
function SimpleChat() {
const handler = useChat()
return <ChatSection handler={handler} />
}
`
const initialMessages: Message[] = [
{
id: '1',
content: 'Write simple Javascript hello world code',
role: 'user',
},
{
id: '1',
role: 'assistant',
content:
'Got it! Here\'s the simplest JavaScript code to print "Hello, World!" to the console:\n\n```javascript\nconsole.log("Hello, World!");\n```\n\nYou can run this code in any JavaScript environment, such as a web browser\'s console or a Node.js environment. Just paste the code and execute it to see the output.',
},
]
function SimpleChat() {
const handler = useChat({ initialMessages })
return <ChatSection handler={handler} />
}
export function SimpleChatSection() {
return (
<div className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Primitive Chat Section</h2>
<div className="flex gap-4">
<div>
<Code content={code} language="jsx" />
</div>
<div className="flex-1">
<SimpleChat />
</div>
</div>
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
import hljs from 'highlight.js'
import { useEffect, useRef } from 'react'
import 'highlight.js/styles/atom-one-dark-reasonable.css'
export function Code({
content,
language,
}: {
content: string
language: string
}) {
const codeRef = useRef<HTMLElement>(null)
useEffect(() => {
if (codeRef.current && codeRef.current.dataset.highlighted !== 'yes') {
hljs.highlightElement(codeRef.current)
}
}, [language, content])
return (
<pre className="border border-zinc-700">
<code className={`language-${language} font-mono`} ref={codeRef}>
{content}
</code>
</pre>
)
}
+54
View File
@@ -0,0 +1,54 @@
'use client'
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '../lib/utils'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
className={cn(
'bg-muted text-muted-foreground inline-flex h-10 items-center justify-center rounded-md p-1',
className
)}
ref={ref}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
className={cn(
'ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm',
className
)}
ref={ref}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
className={cn(
'ring-offset-background focus-visible:ring-ring mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
className
)}
ref={ref}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
+2 -2
View File
@@ -1,5 +1,5 @@
/** @type {import('next').NextConfig} */
module.exports = {
reactStrictMode: true,
transpilePackages: ["@llamaindex/chat-ui"],
};
transpilePackages: ['@llamaindex/chat-ui'],
}
+9 -1
View File
@@ -11,21 +11,29 @@
},
"dependencies": {
"@llamaindex/chat-ui": "workspace:*",
"@radix-ui/react-tabs": "^1.1.0",
"highlight.js": "^11.10.0",
"clsx": "^2.1.1",
"lucide-react": "^0.453.0",
"tailwind-merge": "^2.1.0",
"next": "^14.2.3",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@next/eslint-plugin-next": "^14.2.3",
"@llamaindex/eslint-config": "workspace:*",
"@llamaindex/tailwind-config": "workspace:*",
"@llamaindex/typescript-config": "workspace:*",
"@next/eslint-plugin-next": "^14.2.3",
"@types/node": "^20.11.24",
"@types/react": "^18.2.61",
"@types/react-dom": "^18.2.19",
"ai": "3.3.42",
"autoprefixer": "^10.4.18",
"llamaindex": "0.6.22",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"tiktoken": "^1.0.15",
"typescript": "^5.3.3"
}
}
+1 -1
View File
@@ -6,4 +6,4 @@ module.exports = {
tailwindcss: {},
autoprefixer: {},
},
};
}
-17
View File
@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 100 100" width="614" height="614">
<defs xmlns="http://www.w3.org/2000/svg">
<radialGradient id="radial" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff"></stop>
<stop offset="60%" stop-color="#fff" stop-opacity="0"></stop>
</radialGradient>
</defs>
<circle cx="50" cy="50" r="25" stroke-width=".2" style="fill:none; stroke:rgba(255,255,255,.1);">
<animate attributeName="opacity" values="1;0.1;0.1;1" dur="3s" begin="0.2s" repeatCount="indefinite"></animate>
</circle>
<circle cx="50" cy="50" r="25" stroke-width=".2" style="fill:url(#radial); fill-opacity:.1;">
<animate attributeName="opacity" values="1;0.5;0.5;1" dur="3s" repeatCount="indefinite"></animate>
</circle><circle cx="50" cy="50" r="45" stroke-width=".2" style="fill:none; stroke:rgba(255,255,255,.1);">
<animate attributeName="opacity" values="1;0.1;0.1;1" dur="3s" begin="0.4s" repeatCount="indefinite"></animate>
</circle>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-32
View File
@@ -1,32 +0,0 @@
<svg width="104" height="104" viewBox="0 0 104 104" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_17)">
<path d="M26.0192 7C42.0962 -2.28203 61.9038 -2.28203 77.9808 7C94.0577 16.282 103.962 33.4359 103.962 52C103.962 70.5641 94.0577 87.718 77.9808 97C61.9038 106.282 42.0962 106.282 26.0192 97C9.94229 87.718 0.038475 70.5641 0.038475 52C0.038475 33.4359 9.94229 16.282 26.0192 7Z" fill="black" fill-opacity="0.64"/>
<path d="M26.0192 7C42.0962 -2.28203 61.9038 -2.28203 77.9808 7C94.0577 16.282 103.962 33.4359 103.962 52C103.962 70.5641 94.0577 87.718 77.9808 97C61.9038 106.282 42.0962 106.282 26.0192 97C9.94229 87.718 0.038475 70.5641 0.038475 52C0.038475 33.4359 9.94229 16.282 26.0192 7Z" fill="url(#paint0_linear_1_17)" fill-opacity="0.15"/>
<path d="M26.0192 7C42.0962 -2.28203 61.9038 -2.28203 77.9808 7C94.0577 16.282 103.962 33.4359 103.962 52C103.962 70.5641 94.0577 87.718 77.9808 97C61.9038 106.282 42.0962 106.282 26.0192 97C9.94229 87.718 0.038475 70.5641 0.038475 52C0.038475 33.4359 9.94229 16.282 26.0192 7Z" fill="black" fill-opacity="0.5"/>
<path d="M0.538475 52C0.538475 33.6146 10.347 16.6257 26.2692 7.43301C42.1915 -1.7597 61.8085 -1.7597 77.7308 7.43301C93.653 16.6257 103.462 33.6146 103.462 52C103.462 70.3854 93.653 87.3743 77.7308 96.567C61.8085 105.76 42.1915 105.76 26.2692 96.567C10.347 87.3743 0.538475 70.3854 0.538475 52Z" stroke="url(#paint1_radial_1_17)" stroke-opacity="0.15"/>
<path d="M0.538475 52C0.538475 33.6146 10.347 16.6257 26.2692 7.43301C42.1915 -1.7597 61.8085 -1.7597 77.7308 7.43301C93.653 16.6257 103.462 33.6146 103.462 52C103.462 70.3854 93.653 87.3743 77.7308 96.567C61.8085 105.76 42.1915 105.76 26.2692 96.567C10.347 87.3743 0.538475 70.3854 0.538475 52Z" stroke="url(#paint2_linear_1_17)" stroke-opacity="0.5"/>
<path d="M51.8878 37.9262C44.1892 37.9262 37.9258 44.1896 37.9258 51.8882C37.9258 59.5868 44.1892 65.8502 51.8878 65.8502C59.5864 65.8502 65.8498 59.5868 65.8498 51.8882C65.8498 44.1896 59.5864 37.9262 51.8878 37.9262ZM51.8878 59.1136C47.8968 59.1136 44.6624 55.8792 44.6624 51.8882C44.6624 47.8972 47.8968 44.6628 51.8878 44.6628C55.8788 44.6628 59.1132 47.8972 59.1132 51.8882C59.1132 55.8792 55.8788 59.1136 51.8878 59.1136Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M53.0581 35.633V30.42C64.3889 31.0258 73.3901 40.4066 73.3901 51.8882C73.3901 63.3698 64.3889 72.748 53.0581 73.3564V68.1434C61.5029 67.5402 68.1901 60.4838 68.1901 51.8882C68.1901 43.2926 61.5029 36.2362 53.0581 35.633ZM39.5745 62.5482C37.3359 59.9638 35.8929 56.6722 35.6355 53.0582H30.4199C30.6903 58.1152 32.7131 62.7042 35.8825 66.2376L39.5719 62.5482H39.5745ZM50.7182 73.3564V68.1434C47.1016 67.886 43.81 66.4456 41.2256 64.2044L37.5362 67.8938C41.0722 71.0658 45.6612 73.086 50.7156 73.3564H50.7182Z" fill="url(#paint3_linear_1_17)"/>
</g>
<defs>
<linearGradient id="paint0_linear_1_17" x1="52" y1="-8" x2="52" y2="112" gradientUnits="userSpaceOnUse">
<stop stop-color="#3286F1"/>
<stop offset="1" stop-color="#C43AC4"/>
</linearGradient>
<radialGradient id="paint1_radial_1_17" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(52 -7.99999) rotate(90) scale(154.286 154.286)">
<stop stop-color="white"/>
<stop offset="1" stop-color="white"/>
</radialGradient>
<linearGradient id="paint2_linear_1_17" x1="-8" y1="-8" x2="18.25" y2="40.75" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint3_linear_1_17" x1="53.9007" y1="33.4389" x2="32.7679" y2="54.5717" gradientUnits="userSpaceOnUse">
<stop stop-color="#0096FF"/>
<stop offset="1" stop-color="#FF1E56"/>
</linearGradient>
<clipPath id="clip0_1_17">
<rect width="104" height="104" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

Before

Width:  |  Height:  |  Size: 629 B

-3
View File
@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
-8
View File
@@ -1,8 +0,0 @@
export default function Page(): JSX.Element {
return (
<main className="bg-glow-conic flex h-screen flex-col items-center justify-center space-y-4">
<h1 className="text-6xl font-bold">LlamaIndex ChatUI</h1>
<p className="text-2xl">Build powerful AI chat interfaces with ease</p>
</main>
)
}
+4 -1
View File
@@ -2,7 +2,10 @@ import type { Config } from 'tailwindcss'
import sharedConfig from '@llamaindex/tailwind-config'
const config: Pick<Config, 'content' | 'presets' | 'theme'> = {
content: ['./src/app/**/*.tsx'],
content: [
'app/**/*.{ts,tsx}',
'node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}',
],
presets: [sharedConfig],
theme: {
extend: {
+16 -3
View File
@@ -6,16 +6,29 @@
"lint": "turbo lint",
"type-check": "turbo type-check",
"clean": "turbo clean",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
"format": "prettier --ignore-unknown --cache --check \"**/*.{js,ts,tsx,md}\"",
"format:write": "prettier --write \"**/*.{js,ts,tsx,md}\"",
"changeset": "changeset",
"version-packages": "changeset version",
"release": "turbo build --filter=docs^... && changeset publish",
"prepare": "husky install"
},
"devDependencies": {
"@changesets/cli": "^2.27.1",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.11",
"turbo": "^2.1.3"
"turbo": "^2.1.3",
"husky": "^9.0.4",
"lint-staged": "^15.2.10"
},
"packageManager": "pnpm@8.15.6",
"engines": {
"node": ">=18"
},
"name": "@llamaindex/chat-ui"
"name": "@llamaindex/chat-ui",
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json,md}": [
"prettier --write"
]
}
}
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: ['@llamaindex/eslint-config/react.js'],
}
-3
View File
@@ -1,3 +0,0 @@
module.exports = {
extends: ["@llamaindex/eslint-config/react.js"],
};
+13 -8
View File
@@ -11,7 +11,6 @@
"src"
],
"exports": {
"./styles.css": "./dist/index.css",
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
@@ -19,12 +18,12 @@
},
"license": "MIT",
"scripts": {
"build": "pnpm run build:css && pnpm run build:js",
"build:css": "tailwindcss -i ./src/styles.css -o ./dist/index.css",
"build": "pnpm run clean && pnpm run build:js",
"build:js": "tsc --project tsconfig.build.json --outDir dist",
"lint": "eslint src/",
"dev": "concurrently \"pnpm run build:css --watch\" \"pnpm run build:js --watch\"",
"type-check": "tsc --noEmit"
"dev": "pnpm run build:js --watch",
"type-check": "tsc --noEmit",
"clean": "rm -rf dist"
},
"peerDependencies": {
"react": "^18.2.0"
@@ -37,8 +36,7 @@
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"concurrently": "^8.2.0"
"typescript": "^5.3.3"
},
"dependencies": {
"@llamaindex/pdf-viewer": "^1.1.3",
@@ -52,7 +50,14 @@
"clsx": "^2.1.1",
"lucide-react": "^0.453.0",
"tailwind-merge": "^2.1.0",
"vaul": "^0.9.1"
"vaul": "^0.9.1",
"highlight.js": "^11.10.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"
},
"publishConfig": {
"access": "public"
+1 -1
View File
@@ -6,4 +6,4 @@ module.exports = {
tailwindcss: {},
autoprefixer: {},
},
};
}
+215
View File
@@ -0,0 +1,215 @@
import { Loader2, Paperclip } from 'lucide-react'
import type { ChangeEvent } from 'react'
import { createContext, useContext, useState } from 'react'
import { cn } from '../lib/utils'
import { Button, buttonVariants } from '../ui/button'
import { Input } from '../ui/input'
import { Textarea } from '../ui/textarea'
import { useChatUI } from './chat.context'
interface ChatInputProps extends React.PropsWithChildren {
className?: string
}
interface ChatInputPreviewProps extends React.PropsWithChildren {
className?: string
}
interface ChatInputFormProps extends React.PropsWithChildren {
className?: string
}
interface ChatInputFieldProps {
className?: string
type?: 'input' | 'textarea'
}
interface ChatInputUploadProps {
className?: string
inputId?: string
onUpload?: (file: File) => Promise<void>
}
interface ChatInputSubmitProps extends React.PropsWithChildren {
className?: string
}
interface ChatInputContext {
isDisabled: boolean
handleKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void
}
const chatInputContext = createContext<ChatInputContext | null>(null)
const ChatInputProvider = chatInputContext.Provider
export const useChatInput = () => {
const context = useContext(chatInputContext)
if (!context) {
throw new Error('useChatInput must be used within a ChatInputProvider')
}
return context
}
function ChatInput(props: ChatInputProps) {
const { input, chat, isLoading, requestData } = useChatUI()
const isDisabled = isLoading || !input.trim()
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
await chat(input, requestData)
}
const handleKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (isDisabled) return
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
await chat(input, requestData)
}
}
const children = props.children ?? (
<>
<ChatInputPreview />
<ChatInputForm />
</>
)
return (
<ChatInputProvider value={{ isDisabled, handleKeyDown, handleSubmit }}>
<div
className={cn(
'flex shrink-0 flex-col gap-4 bg-white p-4',
props.className
)}
>
{children}
</div>
</ChatInputProvider>
)
}
function ChatInputPreview(props: ChatInputPreviewProps) {
const { requestData } = useChatUI()
if (!requestData) return null
// TODO: render file preview from data
return (
<div className={cn(props.className, 'flex gap-2')}>ChatInputPreview</div>
)
}
function ChatInputForm(props: ChatInputFormProps) {
const { handleSubmit } = useChatInput()
const children = props.children ?? (
<>
<ChatInputField />
<ChatInputUpload />
<ChatInputSubmit />
</>
)
return (
<form onSubmit={handleSubmit} className={cn(props.className, 'flex gap-2')}>
{children}
</form>
)
}
function ChatInputField(props: ChatInputFieldProps) {
const { input, setInput } = useChatUI()
const { handleKeyDown } = useChatInput()
const type = props.type ?? 'textarea'
if (type === 'input') {
return (
<Input
name="input"
placeholder="Type a message"
className={cn(props.className, 'min-h-0')}
value={input}
onChange={e => setInput(e.target.value)}
/>
)
}
return (
<Textarea
name="input"
placeholder="Type a message"
className={cn(props.className, 'h-[40px] min-h-0 flex-1')}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
/>
)
}
function ChatInputUpload(props: ChatInputUploadProps) {
const { requestData, setRequestData, isLoading } = useChatUI()
const [uploading, setUploading] = useState(false)
const inputId = props.inputId || 'fileInput'
const resetInput = () => {
const fileInput = document.getElementById(inputId) as HTMLInputElement
fileInput.value = ''
}
const onFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
if (props.onUpload) {
await props.onUpload(file)
} else {
setRequestData({ ...(requestData || {}), file })
}
resetInput()
setUploading(false)
}
return (
<div className="self-stretch">
<input
id={inputId}
type="file"
className="hidden"
onChange={onFileChange}
disabled={isLoading}
/>
<label
htmlFor={inputId}
className={cn(
buttonVariants({ variant: 'secondary', size: 'icon' }),
'cursor-pointer',
uploading && 'opacity-50',
props.className
)}
>
{uploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Paperclip className="h-4 w-4 -rotate-45" />
)}
</label>
</div>
)
}
function ChatInputSubmit(props: ChatInputSubmitProps) {
const { isDisabled } = useChatInput()
return (
<Button type="submit" disabled={isDisabled} className={cn(props.className)}>
{props.children ?? 'Send message'}
</Button>
)
}
ChatInput.Preview = ChatInputPreview
ChatInput.Form = ChatInputForm
ChatInput.Field = ChatInputField
ChatInput.Upload = ChatInputUpload
ChatInput.Submit = ChatInputSubmit
export default ChatInput
+118
View File
@@ -0,0 +1,118 @@
import { createContext, useContext } from 'react'
import { cn } from '../lib/utils'
import { Message } from './chat.interface'
import { Bot, Check, Copy, MessageCircle, User2 } from 'lucide-react'
import { Button } from '../ui/button'
import { useCopyToClipboard } from '../hook/use-copy-to-clipboard'
import { Markdown } from '../widget/markdown'
interface ChatMessageProps extends React.PropsWithChildren {
message: Message
className?: string
}
interface ChatMessageAvatarProps extends React.PropsWithChildren {
className?: string
}
interface ChatMessageContentProps extends React.PropsWithChildren {
className?: string
}
interface ChatMessageActionsProps extends React.PropsWithChildren {
className?: string
}
interface ChatMessageContext {
message: Message
}
const chatMessageContext = createContext<ChatMessageContext | null>(null)
const ChatMessageProvider = chatMessageContext.Provider
export const useChatMessage = () => {
const context = useContext(chatMessageContext)
if (!context)
throw new Error('useChatMessage must be used within a ChatMessageProvider')
return context
}
function ChatMessage(props: ChatMessageProps) {
const children = props.children ?? (
<>
<ChatMessageAvatar />
<ChatMessageContent />
<ChatMessageActions />
</>
)
return (
<ChatMessageProvider value={{ message: props.message }}>
<div className={cn('group flex gap-4 pr-2 pt-4', props.className)}>
{children}
</div>
</ChatMessageProvider>
)
}
function ChatMessageAvatar(props: ChatMessageAvatarProps) {
const { message } = useChatMessage()
const roleIconMap: Record<string, React.ReactNode> = {
user: <User2 className="h-4 w-4" />,
assistant: <Bot className="h-4 w-4" />,
}
const children = props.children ?? roleIconMap[message.role] ?? (
<MessageCircle className="h-4 w-4" />
)
return (
<div className="bg-background flex h-8 w-8 shrink-0 select-none items-center justify-center border">
{children}
</div>
)
}
function ChatMessageContent(props: ChatMessageContentProps) {
const { message } = useChatMessage()
const children = props.children ?? <Markdown content={message.content} />
return (
<div className={cn('flex flex-1 flex-col gap-4', props.className)}>
{children}
</div>
)
}
function ChatMessageActions(props: ChatMessageActionsProps) {
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 })
const { message } = useChatMessage()
const children = props.children ?? (
<Button
onClick={() => copyToClipboard(message.content)}
size="icon"
variant="ghost"
className="h-8 w-8"
>
{isCopied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
)
return (
<div
className={cn(
'flex shrink-0 flex-col gap-2 opacity-0 group-hover:opacity-100',
props.className
)}
>
{children}
</div>
)
}
ChatMessage.Avatar = ChatMessageAvatar
ChatMessage.Content = ChatMessageContent
ChatMessage.Actions = ChatMessageActions
export default ChatMessage
+173
View File
@@ -0,0 +1,173 @@
import { Loader2, PauseCircle, RefreshCw } from 'lucide-react'
import { createContext, useContext, useEffect, useRef } from 'react'
import { cn } from '../lib/utils'
import { Button } from '../ui/button'
import ChatMessage from './chat-message'
import { useChatUI } from './chat.context'
import type { Message } from './chat.interface'
interface ChatMessagesProps extends React.PropsWithChildren {
className?: string
}
interface ChatMessagesListProps extends React.PropsWithChildren {
className?: string
}
interface ChatMessagesLoadingProps extends React.PropsWithChildren {
className?: string
}
interface ChatActionsProps extends React.PropsWithChildren {
className?: string
}
interface ChatMessagesContext {
isPending: boolean
showReload?: boolean
showStop?: boolean
messageLength: number
lastMessage: Message
}
const chatMessagesContext = createContext<ChatMessagesContext | null>(null)
const ChatMessagesProvider = chatMessagesContext.Provider
export const useChatMessages = () => {
const context = useContext(chatMessagesContext)
if (!context) {
throw new Error(
'useChatMessages must be used within a ChatMessagesProvider'
)
}
return context
}
function ChatMessages(props: ChatMessagesProps) {
const { messages, reload, stop, isLoading } = useChatUI()
const messageLength = messages.length
const lastMessage = messages[messageLength - 1]
const isLastMessageFromAssistant =
messageLength > 0 && lastMessage?.role !== 'user'
const showReload = reload && !isLoading && isLastMessageFromAssistant
const showStop = stop && 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 = isLoading && !isLastMessageFromAssistant
const children = props.children ?? (
<>
<ChatMessagesList />
<ChatActions />
</>
)
return (
<ChatMessagesProvider
value={{ isPending, showReload, showStop, lastMessage, messageLength }}
>
<div
className={cn(
'relative flex-1 space-y-6 bg-white p-4',
props.className
)}
>
{children}
</div>
</ChatMessagesProvider>
)
}
function ChatMessagesList(props: ChatMessagesListProps) {
const scrollableChatContainerRef = useRef<HTMLDivElement>(null)
const { messages } = useChatUI()
const { lastMessage, messageLength } = useChatMessages()
const scrollToBottom = () => {
if (scrollableChatContainerRef.current) {
scrollableChatContainerRef.current.scrollTop =
scrollableChatContainerRef.current.scrollHeight
}
}
useEffect(() => {
scrollToBottom()
}, [messageLength, lastMessage])
const children = props.children ?? (
<>
{messages.map((message, index) => {
return <ChatMessage key={index} message={message} />
})}
<ChatMessagesLoading />
</>
)
return (
<div
className={cn(
'flex h-[400px] flex-col gap-5 divide-y overflow-auto',
props.className
)}
ref={scrollableChatContainerRef}
>
{children}
</div>
)
}
function ChatMessagesLoading(props: ChatMessagesLoadingProps) {
const { isPending } = useChatMessages()
if (!isPending) return null
const children = props.children ?? (
<Loader2 className="h-4 w-4 animate-spin" />
)
return (
<div
className={cn('flex items-center justify-center pt-4', props.className)}
>
{children}
</div>
)
}
function ChatActions(props: ChatActionsProps) {
const { reload, stop } = useChatUI()
const { showReload, showStop } = useChatMessages()
if (!showStop && !showReload) return null
const children = props.children ?? (
<>
{showStop && (
<Button variant="outline" size="sm" onClick={stop}>
<PauseCircle className="mr-2 h-4 w-4" />
Stop generating
</Button>
)}
{showReload && (
<Button variant="outline" size="sm" onClick={reload}>
<RefreshCw className="mr-2 h-4 w-4" />
Regenerate
</Button>
)}
</>
)
return (
<div className={cn('flex justify-end gap-4', props.className)}>
{children}
</div>
)
}
ChatMessages.List = ChatMessagesList
ChatMessages.Loading = ChatMessagesLoading
ChatMessages.Actions = ChatActions
export default ChatMessages
@@ -0,0 +1,43 @@
import { useState } from 'react'
import { cn } from '../lib/utils'
import ChatInput from './chat-input'
import ChatMessages from './chat-messages'
import { ChatProvider } from './chat.context'
import { type ChatHandler } from './chat.interface'
export interface ChatSectionProps extends React.PropsWithChildren {
handler: ChatHandler
className?: string
}
export default function ChatSection(props: ChatSectionProps) {
const { handler, className } = props
const [requestData, setRequestData] = useState<any>()
if (!('chat' in handler) && !('append' in handler)) {
throw new Error(
'Please provide chat or append function to handle submit messages'
)
}
const chat =
'chat' in handler
? handler.chat
: async (input: string, data?: any) => {
props.handler.setInput('')
await handler.append({ content: input, role: 'user' }, { data })
}
const children = props.children ?? (
<>
<ChatMessages />
<ChatInput />
</>
)
return (
<ChatProvider value={{ ...handler, chat, requestData, setRequestData }}>
<div className={cn('flex flex-col gap-4', className)}>{children}</div>
</ChatProvider>
)
}
+14
View File
@@ -0,0 +1,14 @@
import { createContext, useContext } from 'react'
import { type ChatContext } from './chat.interface'
export const chatContext = createContext<ChatContext | null>(null)
export const ChatProvider = chatContext.Provider
export const useChatUI = () => {
const context = useContext(chatContext)
if (!context) {
throw new Error('useChatUI must be used within a ChatProvider')
}
return context
}
@@ -0,0 +1,47 @@
export type MessageRole =
| 'system'
| 'user'
| 'assistant'
| 'function'
| 'data'
| 'tool'
export interface Message {
content: string
role: MessageRole
annotations?: any
}
type ChatHandlerAppend = {
append: (
message: { content: string; role: MessageRole },
chatRequestOptions?: { data?: any }
) => Promise<string | null | undefined>
}
type ChatHandlerChat = {
chat: (input: string, data?: any) => Promise<void>
}
type ChatHandlerBase = {
input: string
setInput: (input: string) => void
isLoading: boolean
messages: Message[]
reload?: () => void
stop?: () => void
}
export type ChatHandler = {
input: string
setInput: (input: string) => void
isLoading: boolean
messages: Message[]
reload?: () => void
stop?: () => void
} & (ChatHandlerAppend | ChatHandlerChat)
export type ChatContext = ChatHandlerBase & {
requestData: any
setRequestData: (data: any) => void
} & ChatHandlerChat
@@ -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 }
}
+16 -1
View File
@@ -1 +1,16 @@
export { Card } from './widget/card'
// Chat components
export * from './chat/chat.interface'
export { default as ChatSection } from './chat/chat-section'
export { default as ChatInput } from './chat/chat-input'
export { default as ChatMessages } from './chat/chat-messages'
export { default as ChatMessage } from './chat/chat-message'
// Other useful components
export { Markdown } from './widget/markdown'
export { CodeBlock } from './widget/codeblock'
// Context Provider Hooks
export { useChatUI } from './chat/chat.context'
export { useChatInput } from './chat/chat-input'
export { useChatMessages } from './chat/chat-messages'
export { useChatMessage } from './chat/chat-message'
-71
View File
@@ -1,71 +0,0 @@
@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;
}
}
-10
View File
@@ -1,10 +0,0 @@
import { Button } from '../ui/button'
export function Card() {
return (
<div>
<Button>Click me</Button>
<Button variant="link">Click me</Button>
</div>
)
}
+131
View File
@@ -0,0 +1,131 @@
'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 '../ui/button'
import { useCopyToClipboard } from '../hook/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 }
+180
View File
@@ -0,0 +1,180 @@
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 { 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 preprocessContent = (
content: string,
transformers?: ((content: string) => string)[]
) => {
let processedContent = preprocessLaTeX(content)
for (const transformer of transformers || []) {
processedContent = transformer(processedContent)
}
return processedContent
}
export function Markdown({
content,
transformers = [],
components = {},
}: {
content: string
transformers?: ((content: string) => string)[]
components?: Options['components']
}) {
const processedContent = preprocessContent(content, transformers)
return (
<div>
<style>{`
.custom-markdown ul {
list-style-type: disc;
margin-left: 20px;
margin-top: 20px;
margin-bottom: 20px;
}
.custom-markdown ol {
list-style-type: decimal;
margin-left: 20px;
margin-top: 20px;
margin-bottom: 20px;
}
.custom-markdown li {
margin-bottom: 5px;
}
.custom-markdown ol ol {
list-style: lower-alpha;
}
.custom-markdown ul ul,
.custom-markdown ol ol {
margin-left: 20px;
}
.custom-markdown img {
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
margin: 10px 0;
}
.custom-markdown a {
text-decoration: underline;
color: #007bff;
}
.custom-markdown h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: bold;
margin-bottom: 20px;
margin-top: 20px;
}
.custom-markdown h6 {
font-size: 16px;
}
.custom-markdown h5 {
font-size: 18px;
}
.custom-markdown h4 {
font-size: 20px;
}
.custom-markdown h3 {
font-size: 22px;
}
.custom-markdown h2 {
font-size: 24px;
}
.custom-markdown h1 {
font-size: 26px;
}
.custom-markdown hr {
border: 0;
border-top: 1px solid #e1e4e8;
margin: 20px 0;
}
`}</style>
<MemoizedReactMarkdown
className="prose dark:prose-invert prose-p:leading-relaxed prose-pre:p-0 custom-markdown break-words"
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex as any]}
components={{
p({ children }) {
return <div className="mb-2 last:mb-0">{children}</div>
},
code({ 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}
/>
)
},
...components,
}}
>
{processedContent}
</MemoizedReactMarkdown>
</div>
)
}
+8 -8
View File
@@ -1,6 +1,6 @@
const { resolve } = require("node:path");
const { resolve } = require('node:path')
const project = resolve(process.cwd(), "tsconfig.json");
const project = resolve(process.cwd(), 'tsconfig.json')
/*
* This is a custom ESLint configuration for use with
@@ -13,8 +13,8 @@ const project = resolve(process.cwd(), "tsconfig.json");
module.exports = {
extends: [
"@vercel/style-guide/eslint/node",
"@vercel/style-guide/eslint/typescript",
'@vercel/style-guide/eslint/node',
'@vercel/style-guide/eslint/typescript',
].map(require.resolve),
parserOptions: {
project,
@@ -24,14 +24,14 @@ module.exports = {
JSX: true,
},
settings: {
"import/resolver": {
'import/resolver': {
typescript: {
project,
},
node: {
extensions: [".mjs", ".js", ".jsx", ".ts", ".tsx"],
extensions: ['.mjs', '.js', '.jsx', '.ts', '.tsx'],
},
},
},
ignorePatterns: ["node_modules/", "dist/"],
};
ignorePatterns: ['node_modules/', 'dist/'],
}
+10
View File
@@ -45,5 +45,15 @@ module.exports = {
'import/no-default-export': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'react/function-component-definition': 'off',
'@typescript-eslint/consistent-type-imports': 'off',
'no-console': 'off',
'import/order': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'no-alert': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'react/no-array-index-key': 'off',
'@next/next/no-img-element': 'off',
'react/jsx-sort-props': 'off',
},
}
+14
View File
@@ -47,6 +47,20 @@ module.exports = {
'@typescript-eslint/no-confusing-void-expression': 'off',
'@typescript-eslint/no-misused-promises': 'off',
'no-console': 'off',
'react/jsx-no-leaked-render': 'off',
'@typescript-eslint/no-unnecessary-condition': 'off',
'react/no-array-index-key': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'no-alert': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/consistent-type-imports': 'off',
'import/order': 'off',
'@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/naming-convention': 'off',
'prefer-named-capture-group': 'off',
'@typescript-eslint/consistent-indexed-object-style': 'off',
'react/no-unstable-nested-components': 'off',
'@typescript-eslint/prefer-optional-chain': 'off',
},
overrides: [
{
+5939 -70
View File
File diff suppressed because it is too large Load Diff