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 title
release-fix-intellij-update-support-version-range
aliang 2023-11-15 04:50:58 +08:00 committed by GitHub
parent 618009373b
commit 184ccc81e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 826 additions and 88 deletions

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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 />
}

38
ee/tabby-ui/components/chat-context.tsx vendored Normal file
View File

@ -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>
)
}

View File

@ -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" />
)}

View File

@ -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>

View File

@ -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>
)

View File

@ -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}

125
ee/tabby-ui/components/chat-sessions.tsx vendored Normal file
View File

@ -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>
</>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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' }))}>

View File

@ -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.

12
ee/tabby-ui/components/skeleton.tsx vendored Normal file
View File

@ -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>
)
}

14
ee/tabby-ui/lib/hooks/use-hydration.ts vendored Normal file
View File

@ -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
}

15
ee/tabby-ui/lib/hooks/use-store.ts vendored Normal file
View File

@ -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
}

76
ee/tabby-ui/lib/stores/chat-actions.ts vendored Normal file
View File

@ -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
})
}))
}

50
ee/tabby-ui/lib/stores/chat-store.ts vendored Normal file
View File

@ -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)
}
}
}
}
)
)

9
ee/tabby-ui/lib/stores/utils.ts vendored Normal file
View File

@ -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)
}

29
ee/tabby-ui/lib/types/chat.ts vendored Normal file
View File

@ -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'

View File

@ -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
| {

View File

@ -1,2 +1,2 @@
export * from './common'
export * from './search'
export * from './chat'

View File

@ -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
}

View File

@ -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 + '...'
}

View File

@ -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",

View File

@ -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"