feat: adding conversation history / new chat in chat playground (#780)
* feat: adding conversation history / new chat * fix: format * fix: use user's first question as titlerelease-fix-intellij-update-support-version-range
parent
618009373b
commit
184ccc81e9
|
|
@ -39,9 +39,8 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
|||
<Toaster />
|
||||
<Providers attribute="class" defaultTheme="system" enableSystem>
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* @ts-ignore */}
|
||||
<Header />
|
||||
<main className="flex flex-1 flex-col bg-muted/50">{children}</main>
|
||||
<main className="bg-muted/50 flex flex-1 flex-col">{children}</main>
|
||||
</div>
|
||||
<TailwindIndicator />
|
||||
</Providers>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Chat } from '@/components/chat'
|
||||
import { useChatStore } from '@/lib/stores/chat-store'
|
||||
import { getChatById } from '@/lib/stores/utils'
|
||||
import { ChatSessions } from '@/components/chat-sessions'
|
||||
import { useStore } from '@/lib/hooks/use-store'
|
||||
import type { Message } from 'ai'
|
||||
|
||||
const emptyMessages: Message[] = []
|
||||
|
||||
export default function Chats() {
|
||||
const _hasHydrated = useStore(useChatStore, state => state._hasHydrated)
|
||||
const chats = useStore(useChatStore, state => state.chats)
|
||||
const activeChatId = useStore(useChatStore, state => state.activeChatId)
|
||||
|
||||
const chatId = activeChatId
|
||||
const chat = getChatById(chats, chatId)
|
||||
|
||||
return (
|
||||
<div className="grid flex-1 overflow-hidden lg:grid-cols-[280px_1fr]">
|
||||
<ChatSessions className="hidden w-[280px] border-r bg-zinc-100/40 dark:bg-zinc-800/40 lg:block" />
|
||||
<Chat
|
||||
loading={!_hasHydrated}
|
||||
id={chatId}
|
||||
key={chatId}
|
||||
initialMessages={chat?.messages ?? emptyMessages}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,10 @@
|
|||
import { nanoid } from '@/lib/utils'
|
||||
import { Chat } from '@/components/chat'
|
||||
import { Metadata } from 'next'
|
||||
import Chats from './components/chats'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Playground'
|
||||
}
|
||||
|
||||
export default function IndexPage() {
|
||||
const id = nanoid()
|
||||
|
||||
return <Chat id={id} />
|
||||
return <Chats />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Message, UseChatHelpers, useChat } from 'ai/react'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
export interface ChatContextValue extends UseChatHelpers {
|
||||
id: string | undefined
|
||||
}
|
||||
|
||||
export const ChatContext = React.createContext({} as ChatContextValue)
|
||||
export interface ChatContextProviderProps {
|
||||
id: string | undefined
|
||||
initialMessages?: Message[]
|
||||
}
|
||||
|
||||
export const ChatContextProvider: React.FC<
|
||||
React.PropsWithChildren<ChatContextProviderProps>
|
||||
> = ({ children, id, initialMessages }) => {
|
||||
const chatHelpers = useChat({
|
||||
initialMessages,
|
||||
id,
|
||||
body: {
|
||||
id
|
||||
},
|
||||
onResponse(response) {
|
||||
if (response.status === 401) {
|
||||
toast.error(response.statusText)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<ChatContext.Provider value={{ ...chatHelpers, id }}>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,12 +2,14 @@ import { type Message } from 'ai'
|
|||
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { ChatMessage } from '@/components/chat-message'
|
||||
import { MessageActionType } from '@/lib/types'
|
||||
|
||||
export interface ChatList {
|
||||
messages: Message[]
|
||||
handleMessageAction: (messageId: string, action: MessageActionType) => void
|
||||
}
|
||||
|
||||
export function ChatList({ messages }: ChatList) {
|
||||
export function ChatList({ messages, handleMessageAction }: ChatList) {
|
||||
if (!messages.length) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -16,7 +18,10 @@ export function ChatList({ messages }: ChatList) {
|
|||
<div className="relative mx-auto max-w-2xl px-4">
|
||||
{messages.map((message, index) => (
|
||||
<div key={index}>
|
||||
<ChatMessage message={message} />
|
||||
<ChatMessage
|
||||
message={message}
|
||||
handleMessageAction={handleMessageAction}
|
||||
/>
|
||||
{index < messages.length - 1 && (
|
||||
<Separator className="my-4 md:my-8" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,17 +3,26 @@
|
|||
import { type Message } from 'ai'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { IconCheck, IconCopy } from '@/components/ui/icons'
|
||||
import {
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconEdit,
|
||||
IconRefresh,
|
||||
IconTrash
|
||||
} from '@/components/ui/icons'
|
||||
import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { MessageActionType } from '@/lib/types'
|
||||
|
||||
interface ChatMessageActionsProps extends React.ComponentProps<'div'> {
|
||||
message: Message
|
||||
handleMessageAction: (messageId: string, action: MessageActionType) => void
|
||||
}
|
||||
|
||||
export function ChatMessageActions({
|
||||
message,
|
||||
className,
|
||||
handleMessageAction,
|
||||
...props
|
||||
}: ChatMessageActionsProps) {
|
||||
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 })
|
||||
|
|
@ -26,11 +35,38 @@ export function ChatMessageActions({
|
|||
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',
|
||||
'flex items-center justify-end transition-opacity group-hover:opacity-100 md:absolute md:-right-[5rem] md:-top-2 md:opacity-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{message.role === 'user' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={e => handleMessageAction(message.id, 'edit')}
|
||||
>
|
||||
<IconEdit />
|
||||
<span className="sr-only">Edit message</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={e => handleMessageAction(message.id, 'regenerate')}
|
||||
>
|
||||
<IconRefresh />
|
||||
<span className="sr-only">Regenerate message</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={e => handleMessageAction(message.id, 'delete')}
|
||||
>
|
||||
<IconTrash />
|
||||
<span className="sr-only">Delete message</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onCopy}>
|
||||
{isCopied ? <IconCheck /> : <IconCopy />}
|
||||
<span className="sr-only">Copy message</span>
|
||||
|
|
|
|||
|
|
@ -11,12 +11,18 @@ import { MemoizedReactMarkdown } from '@/components/markdown'
|
|||
import { IconUser } from '@/components/ui/icons'
|
||||
import Image from 'next/image'
|
||||
import { ChatMessageActions } from '@/components/chat-message-actions'
|
||||
import { MessageActionType } from '@/lib/types'
|
||||
|
||||
export interface ChatMessageProps {
|
||||
message: Message
|
||||
handleMessageAction: (messageId: string, action: MessageActionType) => void
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, ...props }: ChatMessageProps) {
|
||||
export function ChatMessage({
|
||||
message,
|
||||
handleMessageAction,
|
||||
...props
|
||||
}: ChatMessageProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('group relative mb-4 flex items-start md:-ml-12')}
|
||||
|
|
@ -74,7 +80,10 @@ export function ChatMessage({ message, ...props }: ChatMessageProps) {
|
|||
>
|
||||
{message.content}
|
||||
</MemoizedReactMarkdown>
|
||||
<ChatMessageActions message={message} />
|
||||
<ChatMessageActions
|
||||
message={message}
|
||||
handleMessageAction={handleMessageAction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { type UseChatHelpers } from 'ai/react'
|
||||
|
||||
import React from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { PromptForm } from '@/components/prompt-form'
|
||||
import { PromptForm, PromptFormRef } from '@/components/prompt-form'
|
||||
import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom'
|
||||
import { IconRefresh, IconStop } from '@/components/ui/icons'
|
||||
import { FooterText } from '@/components/footer'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { UseChatHelpers } from 'ai/react'
|
||||
|
||||
export interface ChatPanelProps
|
||||
extends Pick<
|
||||
|
|
@ -18,6 +19,8 @@ export interface ChatPanelProps
|
|||
| 'setInput'
|
||||
> {
|
||||
id?: string
|
||||
className?: string
|
||||
onSubmit: (content: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function ChatPanel({
|
||||
|
|
@ -28,10 +31,22 @@ export function ChatPanel({
|
|||
reload,
|
||||
input,
|
||||
setInput,
|
||||
messages
|
||||
messages,
|
||||
className,
|
||||
onSubmit
|
||||
}: ChatPanelProps) {
|
||||
const promptFormRef = React.useRef<PromptFormRef>(null)
|
||||
React.useEffect(() => {
|
||||
promptFormRef?.current?.focus()
|
||||
}, [id])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 bg-gradient-to-b from-muted/10 from-10% to-muted/30 to-50%">
|
||||
<div
|
||||
className={cn(
|
||||
'from-muted/10 to-muted/30 bg-gradient-to-b from-10% to-50%',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ButtonScrollToBottom />
|
||||
<div className="mx-auto sm:max-w-2xl sm:px-4">
|
||||
<div className="flex h-10 items-center justify-center">
|
||||
|
|
@ -57,15 +72,10 @@ export function ChatPanel({
|
|||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4 border-t bg-background px-4 py-2 shadow-lg sm:rounded-t-xl sm:border md:py-4">
|
||||
<div className="bg-background space-y-4 border-t px-4 py-2 shadow-lg sm:rounded-t-xl sm:border md:py-4">
|
||||
<PromptForm
|
||||
onSubmit={async value => {
|
||||
await append({
|
||||
id,
|
||||
content: value,
|
||||
role: 'user'
|
||||
})
|
||||
}}
|
||||
ref={promptFormRef}
|
||||
onSubmit={onSubmit}
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
isLoading={isLoading}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { cn, nanoid } from '@/lib/utils'
|
||||
import { useChatStore } from '@/lib/stores/chat-store'
|
||||
import {
|
||||
clearChats,
|
||||
deleteChat,
|
||||
setActiveChatId
|
||||
} from '@/lib/stores/chat-actions'
|
||||
import { IconPlus, IconTrash } from '@/components/ui/icons'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from '@/components/ui/tooltip'
|
||||
import { EditChatTitleDialog } from '@/components/edit-chat-title-dialog'
|
||||
import { useStore } from '@/lib/hooks/use-store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ListSkeleton } from '@/components/skeleton'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { ClearChatsButton } from './clear-chats-button'
|
||||
|
||||
interface ChatSessionsProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ChatSessions = ({ className }: ChatSessionsProps) => {
|
||||
const _hasHydrated = useStore(useChatStore, state => state._hasHydrated)
|
||||
const chats = useStore(useChatStore, state => state.chats)
|
||||
const activeChatId = useStore(useChatStore, state => state.activeChatId)
|
||||
|
||||
const onDeleteClick = (
|
||||
e: React.MouseEvent<HTMLButtonElement>,
|
||||
chatId: string
|
||||
) => {
|
||||
deleteChat(chatId)
|
||||
}
|
||||
|
||||
const onNewChatClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setActiveChatId(nanoid())
|
||||
}
|
||||
|
||||
const handleClearChats = () => {
|
||||
clearChats()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn(className)}>
|
||||
<div className="bg-card fixed inset-y-0 left-0 flex w-[279px] flex-col gap-2 overflow-hidden px-3 pt-16">
|
||||
<div className="shrink-0 pb-0 pt-2">
|
||||
<Button
|
||||
className="h-12 w-full justify-start"
|
||||
variant="ghost"
|
||||
onClick={onNewChatClick}
|
||||
>
|
||||
<IconPlus />
|
||||
<span className="ml-2">New Chat</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-1 flex-col gap-2 overflow-y-auto">
|
||||
{!_hasHydrated ? (
|
||||
<ListSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{chats?.map(chat => {
|
||||
const isActive = activeChatId === chat.id
|
||||
return (
|
||||
<div
|
||||
key={chat.id}
|
||||
onClick={e => setActiveChatId(chat.id)}
|
||||
className={cn(
|
||||
'hover:bg-accent flex cursor-pointer items-center justify-between gap-3 rounded-lg px-3 py-2 text-zinc-900 transition-all hover:text-zinc-900 dark:text-zinc-50 hover:dark:bg-zinc-900 dark:hover:text-zinc-50',
|
||||
isActive && '!bg-zinc-200 dark:!bg-zinc-800'
|
||||
)}
|
||||
>
|
||||
<span className="truncate leading-8">
|
||||
{chat.title || '(Untitled)'}
|
||||
</span>
|
||||
{isActive && (
|
||||
<div
|
||||
className="flex items-center"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<EditChatTitleDialog
|
||||
initialValue={chat.title}
|
||||
chatId={chat.id}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={e => onDeleteClick(e, chat.id)}
|
||||
>
|
||||
<IconTrash />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Delete
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="shrink-0 pb-2">
|
||||
<ClearChatsButton
|
||||
disabled={chats?.length === 0}
|
||||
onClear={handleClearChats}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,22 +1,31 @@
|
|||
'use client'
|
||||
|
||||
import { useChat, type Message } from 'ai/react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import React from 'react'
|
||||
import { useChat } from 'ai/react'
|
||||
import { cn, nanoid, truncateText } from '@/lib/utils'
|
||||
import { ChatList } from '@/components/chat-list'
|
||||
import { ChatPanel } from '@/components/chat-panel'
|
||||
import { EmptyScreen } from '@/components/empty-screen'
|
||||
import { ChatScrollAnchor } from '@/components/chat-scroll-anchor'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { usePatchFetch } from '@/lib/hooks/use-patch-fetch'
|
||||
import { addChat, updateMessages } from '@/lib/stores/chat-actions'
|
||||
import { find, findIndex } from 'lodash-es'
|
||||
import { useStore } from '@/lib/hooks/use-store'
|
||||
import { useChatStore } from '@/lib/stores/chat-store'
|
||||
import { ListSkeleton } from '@/components/skeleton'
|
||||
import type { MessageActionType } from '@/lib/types'
|
||||
import type { Message } from 'ai/react'
|
||||
|
||||
export interface ChatProps extends React.ComponentProps<'div'> {
|
||||
initialMessages?: Message[]
|
||||
id?: string
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function Chat({ id, initialMessages, className }: ChatProps) {
|
||||
export function Chat({ id, initialMessages, loading, className }: ChatProps) {
|
||||
usePatchFetch()
|
||||
const chats = useStore(useChatStore, state => state.chats)
|
||||
|
||||
const {
|
||||
messages,
|
||||
|
|
@ -39,28 +48,118 @@ export function Chat({ id, initialMessages, className }: ChatProps) {
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
const [selectedMessageId, setSelectedMessageId] = React.useState<string>()
|
||||
|
||||
const onRegenerateResponse = (messageId: string) => {
|
||||
const messageIndex = findIndex(messages, { id: messageId })
|
||||
const prevMessage = messages?.[messageIndex - 1]
|
||||
if (prevMessage?.role === 'user') {
|
||||
setMessages(messages.slice(0, messageIndex - 1))
|
||||
append(prevMessage)
|
||||
}
|
||||
}
|
||||
|
||||
const onDeleteMessage = (messageId: string) => {
|
||||
const message = find(messages, { id: messageId })
|
||||
if (message) {
|
||||
setMessages(messages.filter(m => m.id !== messageId))
|
||||
}
|
||||
}
|
||||
|
||||
const onEditMessage = (messageId: string) => {
|
||||
const message = find(messages, { id: messageId })
|
||||
if (message) {
|
||||
setInput(message.content)
|
||||
setSelectedMessageId(messageId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMessageAction = (
|
||||
messageId: string,
|
||||
actionType: MessageActionType
|
||||
) => {
|
||||
switch (actionType) {
|
||||
case 'edit':
|
||||
onEditMessage(messageId)
|
||||
break
|
||||
case 'delete':
|
||||
onDeleteMessage(messageId)
|
||||
break
|
||||
case 'regenerate':
|
||||
onRegenerateResponse(messageId)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (value: string) => {
|
||||
if (findIndex(chats, { id }) === -1) {
|
||||
addChat(id, truncateText(value))
|
||||
} else if (selectedMessageId) {
|
||||
let messageIdx = findIndex(messages, { id: selectedMessageId })
|
||||
setMessages(messages.slice(0, messageIdx))
|
||||
setSelectedMessageId(undefined)
|
||||
}
|
||||
await append({
|
||||
id: nanoid(),
|
||||
content: value,
|
||||
role: 'user'
|
||||
})
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (id) {
|
||||
updateMessages(id, messages)
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
React.useEffect(() => {
|
||||
const scrollHeight = document.documentElement.scrollHeight
|
||||
window.scrollTo(0, scrollHeight)
|
||||
|
||||
return () => stop()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('pb-[200px] pt-4 md:pt-10', className)}>
|
||||
{messages.length ? (
|
||||
<>
|
||||
<ChatList messages={messages} />
|
||||
<ChatScrollAnchor trackVisibility={isLoading} />
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen setInput={setInput} />
|
||||
)}
|
||||
<div className="flex justify-center overflow-x-hidden">
|
||||
<div className="w-full max-w-2xl px-4">
|
||||
<div className={cn('pb-[200px] pt-4 md:pt-10', className)}>
|
||||
{loading ? (
|
||||
<div className="group relative mb-4 flex animate-pulse items-start md:-ml-12">
|
||||
<div className="shrink-0">
|
||||
<span className="block h-8 w-8 rounded-md bg-gray-200 dark:bg-gray-700"></span>
|
||||
</div>
|
||||
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
|
||||
<ListSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : messages.length ? (
|
||||
<>
|
||||
<ChatList
|
||||
messages={messages}
|
||||
handleMessageAction={handleMessageAction}
|
||||
/>
|
||||
<ChatScrollAnchor trackVisibility={isLoading} />
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen setInput={setInput} />
|
||||
)}
|
||||
</div>
|
||||
<ChatPanel
|
||||
onSubmit={handleSubmit}
|
||||
className="fixed inset-x-0 bottom-0 lg:ml-[280px]"
|
||||
id={id}
|
||||
isLoading={isLoading}
|
||||
stop={stop}
|
||||
append={append}
|
||||
reload={reload}
|
||||
messages={messages}
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
/>
|
||||
</div>
|
||||
<ChatPanel
|
||||
id={id}
|
||||
isLoading={isLoading}
|
||||
stop={stop}
|
||||
append={append}
|
||||
reload={reload}
|
||||
messages={messages}
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import React from 'react'
|
||||
import { Button, ButtonProps } from '@/components/ui/button'
|
||||
import { IconCheck, IconTrash } from '@/components/ui/icons'
|
||||
|
||||
interface ClearChatsButtonProps extends ButtonProps {
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export const ClearChatsButton = ({
|
||||
onClear,
|
||||
onClick,
|
||||
onBlur,
|
||||
...rest
|
||||
}: ClearChatsButtonProps) => {
|
||||
const [waitingConfirmation, setWaitingConfirmation] = React.useState(false)
|
||||
|
||||
const cancelConfirmation = () => {
|
||||
setWaitingConfirmation(false)
|
||||
}
|
||||
|
||||
const handleBlur: React.FocusEventHandler<HTMLButtonElement> = e => {
|
||||
if (waitingConfirmation) {
|
||||
cancelConfirmation()
|
||||
}
|
||||
onBlur?.(e)
|
||||
}
|
||||
|
||||
const handleClick: React.MouseEventHandler<HTMLButtonElement> = e => {
|
||||
if (!waitingConfirmation) {
|
||||
setWaitingConfirmation(true)
|
||||
} else {
|
||||
onClear()
|
||||
setWaitingConfirmation(false)
|
||||
}
|
||||
onClick?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="h-12 w-full justify-start"
|
||||
variant="ghost"
|
||||
{...rest}
|
||||
onClick={handleClick}
|
||||
onBlur={handleBlur}
|
||||
>
|
||||
{waitingConfirmation ? (
|
||||
<>
|
||||
<IconCheck />
|
||||
<span className="ml-2">Confirm Clear Chats</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconTrash />
|
||||
<span className="ml-2">Clear Chats</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from '@/components/ui/tooltip'
|
||||
import { IconArrowElbow, IconEdit, IconTrash } from '@/components/ui/icons'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { updateChat } from '@/lib/stores/chat-actions'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
interface EditChatTitleDialogProps {
|
||||
initialValue: string | undefined
|
||||
chatId: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const EditChatTitleDialog = ({
|
||||
children,
|
||||
initialValue,
|
||||
chatId
|
||||
}: EditChatTitleDialogProps) => {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const formRef = React.useRef<HTMLFormElement>(null)
|
||||
const [input, setInput] = React.useState(initialValue)
|
||||
|
||||
const handleSubmit: React.FormEventHandler<HTMLFormElement> = async e => {
|
||||
e.preventDefault()
|
||||
if (!input?.trim()) {
|
||||
return
|
||||
}
|
||||
updateChat(chatId, { title: input })
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
formRef.current?.requestSubmit()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={() => setOpen(true)}>
|
||||
<IconEdit />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
<DialogContent className="bg-background">
|
||||
<DialogHeader className="gap-3">
|
||||
<DialogTitle>Edit Chat Title</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<form className="relative" onSubmit={handleSubmit} ref={formRef}>
|
||||
<Input
|
||||
className="h-10 pr-12"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<div className="absolute right-2 top-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="submit" size="icon" disabled={input === ''}>
|
||||
<IconArrowElbow />
|
||||
<span className="sr-only">Send message</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit Title</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</form>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@ export function Header() {
|
|||
const newVersionAvailable = isNewVersionAvailable(version, latestRelease)
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 flex h-16 w-full shrink-0 items-center justify-between border-b bg-gradient-to-b from-background/10 via-background/50 to-background/80 px-4 backdrop-blur-xl">
|
||||
<header className="from-background/10 via-background/50 to-background/80 sticky top-0 z-50 flex h-16 w-full shrink-0 items-center justify-between border-b bg-gradient-to-b px-4 backdrop-blur-xl">
|
||||
<div className="flex items-center">
|
||||
<ThemeToggle />
|
||||
<Link href="/" className={cn(buttonVariants({ variant: 'link' }))}>
|
||||
|
|
|
|||
|
|
@ -32,12 +32,14 @@ export interface PromptProps
|
|||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function PromptForm({
|
||||
onSubmit,
|
||||
input,
|
||||
setInput,
|
||||
isLoading
|
||||
}: PromptProps) {
|
||||
export interface PromptFormRef {
|
||||
focus: () => void
|
||||
}
|
||||
|
||||
function PromptFormRenderer(
|
||||
{ onSubmit, input, setInput, isLoading }: PromptProps,
|
||||
ref: React.ForwardedRef<PromptFormRef>
|
||||
) {
|
||||
const { formRef, onKeyDown } = useEnterSubmit()
|
||||
const [queryCompletionUrl, setQueryCompletionUrl] = React.useState<
|
||||
string | null
|
||||
|
|
@ -62,6 +64,24 @@ export function PromptForm({
|
|||
}
|
||||
})
|
||||
|
||||
React.useImperativeHandle(ref, () => {
|
||||
return {
|
||||
focus: () => {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
input &&
|
||||
inputRef.current &&
|
||||
inputRef.current !== document.activeElement
|
||||
) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [input])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (nextInputSelectionRange.current?.length) {
|
||||
inputRef.current?.setSelectionRange?.(
|
||||
|
|
@ -268,6 +288,10 @@ export function PromptForm({
|
|||
)
|
||||
}
|
||||
|
||||
export const PromptForm = React.forwardRef<PromptFormRef, PromptProps>(
|
||||
PromptFormRenderer
|
||||
)
|
||||
|
||||
/**
|
||||
* Retrieves the name of the completion query from a given string@.
|
||||
* @param {string} val - The input string to search for the completion query name.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
'use client'
|
||||
|
||||
export const ListSkeleton = () => {
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
<li className="h-4 w-full rounded-full bg-gray-200 dark:bg-gray-700"></li>
|
||||
<li className="h-4 w-full rounded-full bg-gray-200 dark:bg-gray-700"></li>
|
||||
<li className="h-4 w-full rounded-full bg-gray-200 dark:bg-gray-700"></li>
|
||||
<li className="h-4 w-full rounded-full bg-gray-200 dark:bg-gray-700"></li>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import React from 'react'
|
||||
|
||||
let hydrating = true
|
||||
|
||||
export function useHydrated() {
|
||||
let [hydrated, setHydrated] = React.useState(() => !hydrating)
|
||||
|
||||
React.useEffect(function hydrate() {
|
||||
hydrating = false
|
||||
setHydrated(true)
|
||||
}, [])
|
||||
|
||||
return hydrated
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react'
|
||||
|
||||
export const useStore = <T, F>(
|
||||
store: (callback: (state: T) => unknown) => unknown,
|
||||
callback: (state: T) => F
|
||||
) => {
|
||||
const result = store(callback) as F
|
||||
const [data, setData] = React.useState<F>()
|
||||
|
||||
React.useEffect(() => {
|
||||
setData(result)
|
||||
}, [result])
|
||||
|
||||
return data
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { Message } from 'ai'
|
||||
import { nanoid } from '@/lib/utils'
|
||||
import type { Chat } from '@/lib/types'
|
||||
import { useChatStore } from './chat-store'
|
||||
|
||||
const get = useChatStore.getState
|
||||
const set = useChatStore.setState
|
||||
|
||||
export const updateHybrated = (state: boolean) => {
|
||||
set(() => ({ _hasHydrated: state }))
|
||||
}
|
||||
export const setActiveChatId = (id: string) => {
|
||||
set(() => ({ activeChatId: id }))
|
||||
}
|
||||
|
||||
export const addChat = (_id?: string, title?: string) => {
|
||||
const id = _id ?? nanoid()
|
||||
set(state => ({
|
||||
activeChatId: id,
|
||||
chats: [
|
||||
{
|
||||
id,
|
||||
title: title ?? '',
|
||||
messages: [],
|
||||
createdAt: new Date(),
|
||||
userId: '',
|
||||
path: ''
|
||||
},
|
||||
...(state.chats || [])
|
||||
]
|
||||
}))
|
||||
}
|
||||
|
||||
export const deleteChat = (id: string) => {
|
||||
set(state => {
|
||||
return {
|
||||
activeChatId: nanoid(),
|
||||
chats: state.chats?.filter(chat => chat.id !== id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const clearChats = () => {
|
||||
set(() => ({
|
||||
activeChatId: nanoid(),
|
||||
chats: []
|
||||
}))
|
||||
}
|
||||
|
||||
export const updateMessages = (id: string, messages: Message[]) => {
|
||||
set(state => ({
|
||||
chats: state.chats?.map(chat => {
|
||||
if (chat.id === id) {
|
||||
return {
|
||||
...chat,
|
||||
messages
|
||||
}
|
||||
}
|
||||
return chat
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
export const updateChat = (id: string, chat: Partial<Chat>) => {
|
||||
set(state => ({
|
||||
chats: state.chats?.map(c => {
|
||||
if (c.id === id) {
|
||||
return {
|
||||
...c,
|
||||
...chat
|
||||
}
|
||||
}
|
||||
return c
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { Chat } from '@/lib/types'
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { nanoid } from '@/lib/utils'
|
||||
|
||||
const excludeFromState = ['_hasHydrated', 'setHasHydrated']
|
||||
|
||||
export interface ChatState {
|
||||
chats: Chat[] | undefined
|
||||
activeChatId: string | undefined
|
||||
_hasHydrated: boolean
|
||||
setHasHydrated: (state: boolean) => void
|
||||
}
|
||||
|
||||
const initialState: Omit<ChatState, 'setHasHydrated' | 'deleteChat'> = {
|
||||
_hasHydrated: false,
|
||||
chats: undefined,
|
||||
activeChatId: nanoid()
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>()(
|
||||
persist(
|
||||
set => {
|
||||
return {
|
||||
...initialState,
|
||||
setHasHydrated: (state: boolean) => {
|
||||
set({
|
||||
_hasHydrated: state
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'tabby-chat-storage',
|
||||
partialize: state =>
|
||||
Object.fromEntries(
|
||||
Object.entries(state).filter(
|
||||
([key]) => !excludeFromState.includes(key)
|
||||
)
|
||||
),
|
||||
onRehydrateStorage() {
|
||||
return state => {
|
||||
if (state) {
|
||||
state.setHasHydrated(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Chat } from '@/lib/types'
|
||||
|
||||
export const getChatById = (
|
||||
chats: Chat[] | undefined,
|
||||
chatId: string | undefined
|
||||
): Chat | undefined => {
|
||||
if (!Array.isArray(chats) || !chatId) return undefined
|
||||
return chats.find(c => c.id === chatId)
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { type Message } from 'ai'
|
||||
|
||||
export interface Chat extends Record<string, any> {
|
||||
id: string
|
||||
title: string
|
||||
createdAt: Date
|
||||
userId: string
|
||||
path: string
|
||||
messages: Message[]
|
||||
sharePath?: string
|
||||
}
|
||||
|
||||
export type ISearchHit = {
|
||||
id: number
|
||||
doc?: {
|
||||
body?: string
|
||||
name?: string
|
||||
filepath?: string
|
||||
git_url?: string
|
||||
kind?: string
|
||||
language?: string
|
||||
}
|
||||
}
|
||||
export type SearchReponse = {
|
||||
hits?: Array<ISearchHit>
|
||||
num_hits?: number
|
||||
}
|
||||
|
||||
export type MessageActionType = 'edit' | 'delete' | 'regenerate'
|
||||
|
|
@ -1,15 +1,3 @@
|
|||
import { type Message } from 'ai'
|
||||
|
||||
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
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export * from './common'
|
||||
export * from './search'
|
||||
export * from './chat'
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
export type ISearchHit = {
|
||||
id: number
|
||||
doc?: {
|
||||
body?: string
|
||||
name?: string
|
||||
filepath?: string
|
||||
git_url?: string
|
||||
kind?: string
|
||||
language?: string
|
||||
}
|
||||
}
|
||||
export type SearchReponse = {
|
||||
hits?: Array<ISearchHit>
|
||||
num_hits?: number
|
||||
}
|
||||
|
|
@ -41,3 +41,30 @@ export function formatDate(input: string | number | Date): string {
|
|||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
export function truncateText(
|
||||
text: string,
|
||||
maxLength = 50,
|
||||
delimiters = /[ ,.:;\n,。:;]/
|
||||
) {
|
||||
if (!text) return ''
|
||||
if (text.length <= maxLength) {
|
||||
return text
|
||||
}
|
||||
|
||||
let truncatedText = text.slice(0, maxLength)
|
||||
|
||||
let lastDelimiterIndex = -1
|
||||
for (let i = maxLength - 1; i >= 0; i--) {
|
||||
if (delimiters.test(truncatedText[i])) {
|
||||
lastDelimiterIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (lastDelimiterIndex !== -1) {
|
||||
truncatedText = truncatedText.slice(0, lastDelimiterIndex)
|
||||
}
|
||||
|
||||
return truncatedText + '...'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@
|
|||
"react-textarea-autosize": "^8.4.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
"swr": "^2.2.4"
|
||||
"swr": "^2.2.4",
|
||||
"zustand": "^4.4.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
|
|
|
|||
|
|
@ -4430,7 +4430,7 @@ use-sidecar@^1.1.2:
|
|||
detect-node-es "^1.1.0"
|
||||
tslib "^2.0.0"
|
||||
|
||||
use-sync-external-store@^1.2.0:
|
||||
use-sync-external-store@1.2.0, use-sync-external-store@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
|
||||
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
|
||||
|
|
@ -4591,6 +4591,13 @@ zod@3.21.4:
|
|||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db"
|
||||
integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==
|
||||
|
||||
zustand@^4.4.6:
|
||||
version "4.4.6"
|
||||
resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.4.6.tgz#03c78e3e2686c47095c93714c0c600b72a6512bd"
|
||||
integrity sha512-Rb16eW55gqL4W2XZpJh0fnrATxYEG3Apl2gfHTyDSE965x/zxslTikpNch0JgNjJA9zK6gEFW8Fl6d1rTZaqgg==
|
||||
dependencies:
|
||||
use-sync-external-store "1.2.0"
|
||||
|
||||
zwitch@^2.0.0:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7"
|
||||
|
|
|
|||
Loading…
Reference in New Issue