Compare commits

...
Author SHA1 Message Date
Daniil 060bae06a7 Chatbot Components 2024-05-26 18:36:26 -07:00
artem ash 003082e40c initial 2024-05-22 18:39:51 -07:00
34 changed files with 3460 additions and 2 deletions
@@ -0,0 +1,34 @@
'use client'
import * as React from 'react'
import { cn } from '../lib/utils'
import { useAtBottom } from '../lib/hooks/use-at-bottom'
import { Button, type ButtonProps } from './ui/button'
import { IconArrowDown } from './ui/icons'
export function ButtonScrollToBottom({ className, ...props }: ButtonProps) {
const isAtBottom = useAtBottom()
return (
<Button
variant="outline"
size="icon"
className={cn(
'absolute right-4 top-1 z-10 bg-background transition-opacity duration-300 sm:right-8 md:top-2',
isAtBottom ? 'opacity-0' : 'opacity-100',
className
)}
onClick={() =>
window.scrollTo({
top: document.body.offsetHeight,
behavior: 'smooth'
})
}
{...props}
>
<IconArrowDown />
<span className="sr-only">Scroll to bottom</span>
</Button>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { type Message } from 'ai'
import { type UseChatHelpers } from 'ai/react'
import { Separator } from "@hanzo/ui/primitives"
import { ChatMessage } from './chat-message'
import { useEffect, useState } from 'react'
export interface ChatList extends Pick<
UseChatHelpers,
| 'setMessages'
>{
messages: Message[]
}
export function ChatList({ messages, setMessages }: ChatList) {
if (!messages.length) {
return null
} else {
messages.map((message, index) => {
if (message.role === 'assistant') {
try{
message.content = JSON.parse(message.content).answer
} catch {
}
}
})
setMessages(messages);
}
return (
<div className="relative mx-auto max-w-2xl">
<Separator className="my-4 md:my-8" />
{messages.map((message, index) => (
<div key={index}>
<ChatMessage message={message} />
{index < messages.length - 1 && (
<Separator className="my-4 md:my-8" />
)}
</div>
))}
</div>
)
}
@@ -0,0 +1,40 @@
'use client'
import { type Message } from 'ai'
import { Button } from './ui/button'
import { IconCheck, IconCopy } from './ui/icons'
import { useCopyToClipboard } from '../lib/hooks/use-copy-to-clipboard'
import { cn } from '../lib/utils'
interface ChatMessageActionsProps extends React.ComponentProps<'div'> {
message: Message
}
export function ChatMessageActions({
message,
className,
...props
}: ChatMessageActionsProps) {
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 })
const onCopy = () => {
if (isCopied) return
copyToClipboard(message.content)
}
return (
<div
className={cn(
'flex items-center justify-end transition-opacity group-hover:opacity-100 md:absolute md:-right-10 md:-top-2 md:opacity-0',
className
)}
{...props}
>
<Button variant="ghost" size="icon" onClick={onCopy}>
{isCopied ? <IconCheck /> : <IconCopy />}
<span className="sr-only">Copy message</span>
</Button>
</div>
)
}
+87
View File
@@ -0,0 +1,87 @@
'use client'
import { type Message } from 'ai'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import Reactmarkdown from 'react-markdown'
import { cn } from '../lib/utils'
import { CodeBlock } from './ui/codeblock'
import { MemoizedReactMarkdown } from './markdown'
import { IconOpenAI, IconUser } from './ui/icons'
import { ChatMessageActions } from './chat-message-actions'
import { useEffect, useState } from 'react'
export interface ChatMessageProps {
message: Message
}
export function ChatMessage({ message, ...props }: ChatMessageProps) {
return (
<div
className={cn('group relative mb-2 flex items-start md:mx-4')}
{...props}
>
<div
className={cn(
'flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border shadow',
message.role === 'user'
? 'bg-background'
: 'text-primary-foreground'
)}
>
{message.role === 'user' ? <IconUser /> : <IconOpenAI />}
</div>
<div className={cn("ml-4 flex-1 space-y-2 overflow-hidden px-1 rounded",
message.role === 'user'
? 'bg-zinc-950'
: 'text-primary-foreground')}>
<MemoizedReactMarkdown
className="prose break-words dark:prose-invert prose-p:leading-relaxed prose-pre:p-0"
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}
/>
)
}
}}
>
{message.content}
</MemoizedReactMarkdown>
<ChatMessageActions message={message} />
</div>
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { type UseChatHelpers } from 'ai/react';
import { Button } from './ui/button';
import { PromptForm } from './prompt-form';
import { ButtonScrollToBottom } from './button-scroll-bottom';
import { IconRefresh, IconStop } from './ui/icons';
import { FooterText } from './footer';
export interface ChatPanelProps
extends Pick<
UseChatHelpers,
| 'append'
| 'isLoading'
| 'reload'
| 'messages'
| 'stop'
| 'input'
| 'setInput'
> {
id?: string
}
export function ChatPanel({
id,
isLoading,
stop,
append,
reload,
input,
setInput,
messages
}: ChatPanelProps) {
return (
<div className='relative'>
<ButtonScrollToBottom />
<div className="mx-auto sm:max-w-2xl sm:px-4">
<div className="flex h-10 items-center justify-center">
{isLoading ? (
<Button
variant="outline"
onClick={() => stop()}
className="bg-background"
>
<IconStop className="mr-2" />
Stop generating
</Button>
) : (
messages?.length > 0 && (
<Button
variant="outline"
onClick={() => reload()}
className="bg-background"
>
<IconRefresh className="mr-2" />
Regenerate response
</Button>
)
)}
</div>
<div className="space-y-4 max-sm:border-t bg-transparent px-4 py-2 md:py-4">
<PromptForm
onSubmit={async value => {
await append({
id: id,
content: value,
role: 'user'
})
}
}
input={input}
setInput={setInput}
isLoading={isLoading}
/>
<FooterText className="hidden sm:block" />
</div>
</div>
</div>
)
}
@@ -0,0 +1,30 @@
'use client'
import * as React from 'react'
import { useInView } from 'react-intersection-observer'
import { useAtBottom } from '../lib/hooks/use-at-bottom'
interface ChatScrollAnchorProps {
trackVisibility?: boolean
}
export function ChatScrollAnchor({ trackVisibility }: ChatScrollAnchorProps) {
const isAtBottom = useAtBottom()
const { ref, entry, inView } = useInView({
trackVisibility,
delay: 100,
rootMargin: '0px 0px -150px 0px'
})
React.useEffect(() => {
if (isAtBottom && trackVisibility && !inView) {
entry?.target.scrollIntoView({
block: 'start'
})
}
}, [inView, entry, isAtBottom, trackVisibility])
return <div ref={ref} className="h-px w-full" />
}
export default ChatScrollAnchor
+86
View File
@@ -0,0 +1,86 @@
'use client'
import { useChat, type Message } from 'ai/react'
import * as React from 'react'
import { cn } from '../lib/utils'
import { ChatList } from './chat-list'
import { ChatPanel } from './chat-panel'
import { EmptyScreen } from './empty-screen'
import { ChatScrollAnchor } from './chat-scroll-anchor'
import { useLocalStorage } from '../lib/hooks/use-local-storage'
import {ScrollArea} from '@hanzo/ui/primitives'
import { useState, useEffect } from 'react'
import { toast } from 'react-hot-toast'
const IS_PREVIEW = process.env.VERCEL_ENV === 'preview'
export interface ChatProps extends React.ComponentProps<'div'> {
initialMessages?: Message[]
id?: string
}
export function Chat({ id, initialMessages, className }: ChatProps) {
const [previewToken, setPreviewToken] = useLocalStorage<string | null>(
'ai-token',
null
)
const { messages, append, reload, stop, isLoading, input, setInput, setMessages } =
useChat({
initialMessages,
id,
body: {
id,
previewToken
},
onResponse(response) {
if (response.status === 401) {
toast.error(response.statusText)
}
}
})
useEffect(() => {
if (messages.length) {
console.log(messages);
}
setMessages(messages)
}, [messages])
return (
<div className="relative bg-background w-full border rounded-lg flex flex-col justify-between">
<div
className={cn(
'pt-4 md:pt-10 overflow-y-auto h-full',
className
)}
>
{messages.length ? (
<div className="min-h-[60px] mx-auto max-w-2xl overflow-hidden h-full" >
<ScrollArea className="border bg-[#18181a] p-8 h-full">
<ChatList messages={messages} setMessages={setMessages} />
</ScrollArea>
<ChatScrollAnchor trackVisibility={isLoading} />
</div>
) : (
<EmptyScreen setInput={setInput} />
)}
</div>
<ChatPanel
id={id}
isLoading={isLoading}
stop={stop}
append={append}
reload={reload}
messages={messages}
input={input}
setInput={setInput}
/>
</div>
)
}
export default Chat
+51
View File
@@ -0,0 +1,51 @@
import { type UseChatHelpers } from 'ai/react';
import { Button } from './ui/button';
import { ExternalLink } from './external-link';
import { IconArrowRight } from './ui/icons';
const exampleMessages = [
{
heading: 'Explain technical concepts',
message: `What is a "serverless function"?`
},
{
heading: 'Summarize an article',
message: 'Summarize the following article for a 2nd grader: \n'
},
{
heading: 'Draft an email',
message: `Draft an email to my boss about the following: \n`
}
]
export function EmptyScreen({ setInput }: Pick<UseChatHelpers, 'setInput'>) {
return (
<div className="mx-auto max-w-2xl">
<div className="border bg-[#18181a] p-8">
<h1 className="mb-2 text-lg font-semibold">
Welcome to the LUX AI Chatbot!
</h1>
<p className="mb-2 leading-normal text-muted-foreground">
Lux is a leading AI chatbot designed for blockchain Network.
</p>
<p className="leading-normal text-muted-foreground">
You can start a conversation here or try the following examples:
</p>
<div className="mt-4 flex flex-col items-start space-y-2">
{exampleMessages.map((message, index) => (
<Button
key={index}
variant="link"
className="h-auto p-0 text-base"
onClick={() => setInput(message.message)}
>
<IconArrowRight className="mr-2 text-muted-foreground" />
{message.heading}
</Button>
))}
</div>
</div>
</div>
)
}
@@ -0,0 +1,30 @@
export function ExternalLink({
href,
children
}: {
href: string
children: React.ReactNode
}) {
return (
<a
href={href}
target="_blank"
className="inline-flex flex-1 justify-center gap-1 leading-4 hover:underline"
>
<span>{children}</span>
<svg
aria-hidden="true"
height="7"
viewBox="0 0 6 6"
width="7"
className="opacity-70"
>
<path
d="M1.25215 5.54731L0.622742 4.9179L3.78169 1.75597H1.3834L1.38936 0.890915H5.27615V4.78069H4.40513L4.41109 2.38538L1.25215 5.54731Z"
fill="currentColor"
></path>
</svg>
</a>
)
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react'
import { cn } from '../lib/utils'
import { ExternalLink } from './external-link'
export function FooterText({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
className={cn(
'px-2 text-center text-xs leading-normal text-muted-foreground',
className
)}
{...props}
/>
)
}
+2
View File
@@ -0,0 +1,2 @@
export {default as ChatScrollAnchor} from './chat-scroll-anchor'
export {default as Chat } from './chat'
+9
View File
@@ -0,0 +1,9 @@
import {type FC, memo } from 'react'
import ReactMarkdown, { type Options } from 'react-markdown'
export const MemoizedReactMarkdown: FC<Options> = memo(
ReactMarkdown,
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
prevProps.className === nextProps.className
)
+97
View File
@@ -0,0 +1,97 @@
'use client'
import * as React from 'react'
import Link from 'next/link'
import Textarea from 'react-textarea-autosize'
import { type UseChatHelpers } from 'ai/react'
import { useEnterSubmit } from '../lib/hooks/use-enter-submit'
import { cn } from '../lib/utils'
import { Button, buttonVariants } from './ui/button'
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from './ui/tooltip'
import { IconArrowElbow, IconPlus } from './ui/icons'
export interface PromptProps
extends Pick<UseChatHelpers, 'input' | 'setInput'> {
onSubmit: (value: string) => Promise<void>
isLoading: boolean
}
export function PromptForm({
onSubmit,
input,
setInput,
isLoading
}: PromptProps) {
const { formRef, onKeyDown } = useEnterSubmit()
const inputRef = React.useRef<HTMLTextAreaElement>(null)
React.useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
}
}, [])
return (
<TooltipProvider >
<form
onSubmit={async e => {
e.preventDefault()
if (!input?.trim()) {
return
}
setInput('')
await onSubmit(input)
}}
ref={formRef}
>
<div className="relative flex max-h-60 w-full grow flex-col overflow-hidden bg-[#18181a] border-gray px-8 sm:rounded-md sm:border sm:px-12 max-sm:bg-transparent">
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/"
className={cn(
buttonVariants({ size: 'sm', variant: 'outline' }),
'absolute left-0 top-4 h-8 w-8 rounded-full bg-white text-black p-0 sm:left-4'
)}
>
<IconPlus />
<span className="sr-only">New Chat</span>
</Link>
</TooltipTrigger>
<TooltipContent>New Chat</TooltipContent>
</Tooltip>
<Textarea
ref={inputRef}
tabIndex={0}
onKeyDown={onKeyDown}
rows={1}
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Send a message."
spellCheck={false}
className="min-h-[60px] w-full resize-none bg-transparent px-4 py-[1.3rem] focus-within:outline-none sm:text-sm"
/>
<div className="absolute right-0 top-4 sm:right-4">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="submit"
size="icon"
disabled={isLoading || input === ''}
>
<IconArrowElbow />
<span className="sr-only">Send message</span>
</Button>
</TooltipTrigger>
<TooltipContent>Send message</TooltipContent>
</Tooltip>
</div>
</div>
</form>
</TooltipProvider>
)
}
+57
View File
@@ -0,0 +1,57 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium shadow 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 shadow-md hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'shadow-none hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 shadow-none hover:underline'
},
size: {
default: 'h-8 px-4 py-2',
sm: 'h-8 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-8 w-8 p-0'
}
},
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 }
+143
View File
@@ -0,0 +1,143 @@
'use client'
import {memo } from 'react'
import { type FC } from 'react'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { coldarkDark } from 'react-syntax-highlighter/dist/cjs/styles/prism'
import { useCopyToClipboard } from '../../lib/hooks/use-copy-to-clipboard'
import { IconCheck, IconCopy, IconDownload } from '../ui/icons'
import { Button } from '../ui/button'
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"
className="hover:bg-zinc-800 focus-visible:ring-1 focus-visible:ring-slate-700 focus-visible:ring-offset-0"
onClick={downloadAsFile}
size="icon"
>
<IconDownload />
<span className="sr-only">Download</span>
</Button>
<Button
variant="ghost"
size="icon"
className="text-xs hover:bg-zinc-800 focus-visible:ring-1 focus-visible:ring-slate-700 focus-visible:ring-offset-0"
onClick={onCopy}
>
{isCopied ? <IconCheck /> : <IconCopy />}
<span className="sr-only">Copy code</span>
</Button>
</div>
</div>
<SyntaxHighlighter
language={language}
style={coldarkDark}
PreTag="div"
showLineNumbers
customStyle={{
margin: 0,
width: '100%',
background: 'transparent',
padding: '1.5rem 1rem'
}}
codeTagProps={{
style: {
fontSize: '0.9rem',
fontFamily: 'var(--font-mono)'
}
}}
>
{value}
</SyntaxHighlighter>
</div>
)
})
CodeBlock.displayName = 'CodeBlock'
export { CodeBlock }
+127
View File
@@ -0,0 +1,127 @@
'use client'
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { cn } from '../../lib/utils'
import { IconClose } from './icons'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = ({
children,
...props
}: DialogPrimitive.DialogPortalProps) => (
<DialogPrimitive.Portal {...props}>
<div className="fixed inset-0 z-50 flex items-start justify-center sm:items-center">
{children}
</div>
</DialogPrimitive.Portal>
)
DialogPortal.displayName = DialogPrimitive.Portal.displayName
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in',
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed z-50 grid w-full gap-4 rounded-b-lg border bg-background p-6 shadow-sm animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<IconClose />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription
}
File diff suppressed because one or more lines are too long
+25
View File
@@ -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-9 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-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 }
+31
View File
@@ -0,0 +1,31 @@
'use client'
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '../../lib/utils'
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+30
View File
@@ -0,0 +1,30 @@
'use client'
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '../../lib/utils'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs font-medium text-popover-foreground shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1',
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+6
View File
@@ -0,0 +1,6 @@
namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_GA_MEASUREMENT_ID: string;
NEXT_PUBLIC_FACEBOOK_PIXEL_ID: string;
}
}
+62
View File
@@ -0,0 +1,62 @@
import type { NextApiRequest } from 'next'
import type { NextFetchEvent, NextRequest } from 'next/server'
export const initAnalytics = ({
request,
event
}: {
request: NextRequest | NextApiRequest | Request
event?: NextFetchEvent
}) => {
const endpoint = process.env.VERCEL_URL
return {
track: async (eventName: string, data?: any) => {
try {
if (!endpoint && process.env.NODE_ENV === 'development') {
console.log(
`[Vercel Web Analytics] Track "${eventName}"` +
(data ? ` with data ${JSON.stringify(data || {})}` : '')
)
return
}
const headers: { [key: string]: string } = {}
Object.entries(request.headers).map(([key, value]) => {
headers[key] = value
})
const body = {
o: headers.referer,
ts: new Date().getTime(),
r: '',
en: eventName,
ed: data
}
const promise = fetch(
`https://${process.env.VERCEL_URL}/_vercel/insights/event`,
{
headers: {
'content-type': 'application/json',
'user-agent': headers['user-agent'] as string,
'x-forwarded-for': headers['x-forwarded-for'] as string,
'x-va-server': '1'
},
body: JSON.stringify(body),
method: 'POST'
}
)
if (event) {
event.waitUntil(promise)
}
{
await promise
}
} catch (err) {
console.error(err)
}
}
}
}
+255
View File
@@ -0,0 +1,255 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export interface Database {
graphql_public: {
Tables: {
[_ in never]: never
}
Views: {
[_ in never]: never
}
Functions: {
graphql: {
Args: {
operationName?: string
query?: string
variables?: Json
extensions?: Json
}
Returns: Json
}
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
public: {
Tables: {
chats: {
Row: {
id: string
payload: Json | null
user_id: string | null
}
Insert: {
id: string
payload?: Json | null
user_id?: string | null
}
Update: {
id?: string
payload?: Json | null
user_id?: string | null
}
Relationships: [
{
foreignKeyName: 'chats_user_id_fkey'
columns: ['user_id']
referencedRelation: 'users'
referencedColumns: ['id']
}
]
}
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
storage: {
Tables: {
buckets: {
Row: {
allowed_mime_types: string[] | null
avif_autodetection: boolean | null
created_at: string | null
file_size_limit: number | null
id: string
name: string
owner: string | null
public: boolean | null
updated_at: string | null
}
Insert: {
allowed_mime_types?: string[] | null
avif_autodetection?: boolean | null
created_at?: string | null
file_size_limit?: number | null
id: string
name: string
owner?: string | null
public?: boolean | null
updated_at?: string | null
}
Update: {
allowed_mime_types?: string[] | null
avif_autodetection?: boolean | null
created_at?: string | null
file_size_limit?: number | null
id?: string
name?: string
owner?: string | null
public?: boolean | null
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: 'buckets_owner_fkey'
columns: ['owner']
referencedRelation: 'users'
referencedColumns: ['id']
}
]
}
migrations: {
Row: {
executed_at: string | null
hash: string
id: number
name: string
}
Insert: {
executed_at?: string | null
hash: string
id: number
name: string
}
Update: {
executed_at?: string | null
hash?: string
id?: number
name?: string
}
Relationships: []
}
objects: {
Row: {
bucket_id: string | null
created_at: string | null
id: string
last_accessed_at: string | null
metadata: Json | null
name: string | null
owner: string | null
path_tokens: string[] | null
updated_at: string | null
version: string | null
}
Insert: {
bucket_id?: string | null
created_at?: string | null
id?: string
last_accessed_at?: string | null
metadata?: Json | null
name?: string | null
owner?: string | null
path_tokens?: string[] | null
updated_at?: string | null
version?: string | null
}
Update: {
bucket_id?: string | null
created_at?: string | null
id?: string
last_accessed_at?: string | null
metadata?: Json | null
name?: string | null
owner?: string | null
path_tokens?: string[] | null
updated_at?: string | null
version?: string | null
}
Relationships: [
{
foreignKeyName: 'objects_bucketId_fkey'
columns: ['bucket_id']
referencedRelation: 'buckets'
referencedColumns: ['id']
}
]
}
}
Views: {
[_ in never]: never
}
Functions: {
can_insert_object: {
Args: {
bucketid: string
name: string
owner: string
metadata: Json
}
Returns: undefined
}
extension: {
Args: {
name: string
}
Returns: string
}
filename: {
Args: {
name: string
}
Returns: string
}
foldername: {
Args: {
name: string
}
Returns: unknown
}
get_size_by_bucket: {
Args: Record<PropertyKey, never>
Returns: {
size: number
bucket_id: string
}[]
}
search: {
Args: {
prefix: string
bucketname: string
limits?: number
levels?: number
offsets?: number
search?: string
sortcolumn?: string
sortorder?: string
}
Returns: {
name: string
id: string
updated_at: string
created_at: string
last_accessed_at: string
metadata: Json
}[]
}
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
}
+11
View File
@@ -0,0 +1,11 @@
import { JetBrains_Mono as FontMono, Inter } from 'next/font/google'
export const fontInter = Inter({
subsets: ['latin'],
variable: '--font-inter'
})
export const fontMono = FontMono({
subsets: ['latin'],
variable: '--font-mono'
})
+23
View File
@@ -0,0 +1,23 @@
import * as React from 'react'
export function useAtBottom(offset = 0) {
const [isAtBottom, setIsAtBottom] = React.useState(false)
React.useEffect(() => {
const handleScroll = () => {
setIsAtBottom(
window.innerHeight + window.scrollY >=
document.body.offsetHeight - offset
)
}
window.addEventListener('scroll', handleScroll, { passive: true })
handleScroll()
return () => {
window.removeEventListener('scroll', handleScroll)
}
}, [offset])
return isAtBottom
}
@@ -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,23 @@
import { useRef, type RefObject } from 'react'
export function useEnterSubmit(): {
formRef: RefObject<HTMLFormElement>
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void
} {
const formRef = useRef<HTMLFormElement>(null)
const handleKeyDown = (
event: React.KeyboardEvent<HTMLTextAreaElement>
): void => {
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
formRef.current?.requestSubmit()
event.preventDefault()
}
}
return { formRef, onKeyDown: handleKeyDown }
}
@@ -0,0 +1,24 @@
import { useEffect, useState } from 'react'
export const useLocalStorage = <T>(
key: string,
initialValue: T
): [T, (value: T) => void] => {
const [storedValue, setStoredValue] = useState(initialValue)
useEffect(() => {
// Retrieve from localStorage
const item = window.localStorage.getItem(key)
if (item) {
setStoredValue(JSON.parse(item))
}
}, [key])
const setValue = (value: T) => {
// Save state
setStoredValue(value)
// Save to localStorage
window.localStorage.setItem(key, JSON.stringify(value))
}
return [storedValue, setValue]
}
@@ -0,0 +1,86 @@
import { useCallback, useEffect, useRef, useState } from 'react'
export const useScrollAnchor = () => {
const messagesRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const visibilityRef = useRef<HTMLDivElement>(null)
const [isAtBottom, setIsAtBottom] = useState(true)
const [isVisible, setIsVisible] = useState(false)
const scrollToBottom = useCallback(() => {
if (messagesRef.current) {
messagesRef.current.scrollIntoView({
block: 'end',
behavior: 'smooth'
})
}
}, [])
useEffect(() => {
if (messagesRef.current) {
if (isAtBottom && !isVisible) {
messagesRef.current.scrollIntoView({
block: 'end'
})
}
}
}, [isAtBottom, isVisible])
useEffect(() => {
const { current } = scrollRef
if (current) {
const handleScroll = (event: Event) => {
const target = event.target as HTMLDivElement
const offset = 25
const isAtBottom =
target.scrollTop + target.clientHeight >= target.scrollHeight - offset
setIsAtBottom(isAtBottom)
}
current.addEventListener('scroll', handleScroll, {
passive: true
})
return () => {
current.removeEventListener('scroll', handleScroll)
}
}
}, [])
useEffect(() => {
if (visibilityRef.current) {
let observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
setIsVisible(true)
} else {
setIsVisible(false)
}
})
},
{
rootMargin: '0px 0px -150px 0px'
}
)
observer.observe(visibilityRef.current)
return () => {
observer.disconnect()
}
}
})
return {
messagesRef,
scrollRef,
visibilityRef,
scrollToBottom,
isAtBottom,
isVisible
}
}
+41
View File
@@ -0,0 +1,41 @@
import { type CoreMessage } from 'ai'
export type Message = CoreMessage & {
id: string
}
export interface Chat extends Record<string, any> {
id: string
title: string
createdAt: Date
userId: string
path: string
messages: Message[]
sharePath?: string
}
export type ServerActionResult<Result> = Promise<
| Result
| {
error: string
}
>
export interface Session {
user: {
id: string
email: string
}
}
export interface AuthResult {
type: string
message: string
}
export interface User extends Record<string, any> {
id: string
email: string
password: string
salt: string
}
+43
View File
@@ -0,0 +1,43 @@
import { clsx, type ClassValue } from 'clsx'
import { customAlphabet } from 'nanoid'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export const nanoid = customAlphabet(
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
7
) // 7-character random string
export async function fetcher<JSON = any>(
input: RequestInfo,
init?: RequestInit
): Promise<JSON> {
const res = await fetch(input, init)
if (!res.ok) {
const json = await res.json()
if (json.error) {
const error = new Error(json.error) as Error & {
status: number
}
error.status = res.status
throw error
} else {
throw new Error('An unexpected error occurred')
}
}
return res.json()
}
export function formatDate(input: string | number | Date): string {
const date = new Date(input)
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
})
}
+58
View File
@@ -0,0 +1,58 @@
{
"name": "@hanzo/chatbot",
"version": "1.0.0",
"description": "Library with chatbot widget.",
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public",
"scope": "@hanzo"
},
"author": "Hanzo AI, Inc.",
"license": "BSD-3-Clause",
"repository": {
"type": "git",
"url": "git+https://github.com/hanzoai/react-sdk.git",
"directory": "packages/chat"
},
"keywords": [
"chatbot",
"hanzoai",
"hanzo"
],
"main": "index.ts",
"scripts": {
"lat": "npm show @hanzo/chatbot version",
"pub": "npm publish",
"tc": "tsc"
},
"dependencies": {
"@hanzo/ui": "^3.8.14",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tooltip": "^1.0.7",
"ai": "2.1.6",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"nanoid": "^5.0.7",
"next": "^14.2.3",
"react": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-intersection-observer": "^9.8.2",
"react-markdown": "8.0.7",
"react-syntax-highlighter": "^15.5.0",
"react-textarea-autosize": "^8.5.3",
"remark-gfm": "^3.0.1",
"remark-math": "^6.0.0",
"tailwind-merge": "^2.3.0"
},
"exports": {
".": "./components/index.ts"
},
"devDependencies": {
"@types/react": "^18.3.2",
"@types/react-dom": "^18.3.0",
"@types/react-syntax-highlighter": "^15.5.13",
"typescript": "^5.4.5"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.hanzo.base.json",
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"node_modules",
],
}
+681 -2
View File
File diff suppressed because it is too large Load Diff