### What problem does this PR solve? Feat: Show multiple chat boxes #3221 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.20.2
| @@ -0,0 +1,46 @@ | |||
| // https://originui.com/r/comp-23.json | |||
| 'use client'; | |||
| import { EyeIcon, EyeOffIcon } from 'lucide-react'; | |||
| import React, { useId, useState } from 'react'; | |||
| import { Input, InputProps } from '../ui/input'; | |||
| export default React.forwardRef<HTMLInputElement, InputProps>( | |||
| function PasswordInput({ ...props }, ref) { | |||
| const id = useId(); | |||
| const [isVisible, setIsVisible] = useState<boolean>(false); | |||
| const toggleVisibility = () => setIsVisible((prevState) => !prevState); | |||
| return ( | |||
| <div className="*:not-first:mt-2"> | |||
| {/* <Label htmlFor={id}>Show/hide password input</Label> */} | |||
| <div className="relative"> | |||
| <Input | |||
| id={id} | |||
| className="pe-9" | |||
| placeholder="Password" | |||
| type={isVisible ? 'text' : 'password'} | |||
| ref={ref} | |||
| {...props} | |||
| /> | |||
| <button | |||
| className="text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-md transition-[color,box-shadow] outline-none focus:z-10 focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50" | |||
| type="button" | |||
| onClick={toggleVisibility} | |||
| aria-label={isVisible ? 'Hide password' : 'Show password'} | |||
| aria-pressed={isVisible} | |||
| aria-controls="password" | |||
| > | |||
| {isVisible ? ( | |||
| <EyeOffIcon size={16} aria-hidden="true" /> | |||
| ) : ( | |||
| <EyeIcon size={16} aria-hidden="true" /> | |||
| )} | |||
| </button> | |||
| </div> | |||
| </div> | |||
| ); | |||
| }, | |||
| ); | |||
| @@ -2,7 +2,7 @@ import { PropsWithChildren } from 'react'; | |||
| export function PageHeader({ children }: PropsWithChildren) { | |||
| return ( | |||
| <header className="flex justify-between items-center border-b bg-text-title-invert p-5"> | |||
| <header className="flex justify-between items-center bg-text-title-invert p-5"> | |||
| {children} | |||
| </header> | |||
| ); | |||
| @@ -1,52 +0,0 @@ | |||
| import { Input } from '@/components/originui/input'; | |||
| import { EyeIcon, EyeOffIcon } from 'lucide-react'; | |||
| import { ChangeEvent, forwardRef, useId, useState } from 'react'; | |||
| type PropType = { | |||
| name: string; | |||
| value: string; | |||
| onBlur: () => void; | |||
| onChange: (event: ChangeEvent<HTMLInputElement>) => void; | |||
| }; | |||
| function PasswordInput(props: PropType) { | |||
| const id = useId(); | |||
| const [isVisible, setIsVisible] = useState<boolean>(false); | |||
| const toggleVisibility = () => setIsVisible((prevState) => !prevState); | |||
| return ( | |||
| <div className="*:not-first:mt-2 w-full"> | |||
| {/* <Label htmlFor={id}>Show/hide password input</Label> */} | |||
| <div className="relative"> | |||
| <Input | |||
| autoComplete="off" | |||
| inputMode="numeric" | |||
| id={id} | |||
| className="pe-9" | |||
| placeholder="" | |||
| type={isVisible ? 'text' : 'password'} | |||
| value={props.value} | |||
| onBlur={props.onBlur} | |||
| onChange={(ev) => props.onChange(ev)} | |||
| /> | |||
| <button | |||
| className="text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-md transition-[color,box-shadow] outline-none focus:z-10 focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50" | |||
| type="button" | |||
| onClick={toggleVisibility} | |||
| aria-label={isVisible ? 'Hide password' : 'Show password'} | |||
| aria-pressed={isVisible} | |||
| aria-controls="password" | |||
| > | |||
| {isVisible ? ( | |||
| <EyeOffIcon size={16} aria-hidden="true" /> | |||
| ) : ( | |||
| <EyeIcon size={16} aria-hidden="true" /> | |||
| )} | |||
| </button> | |||
| </div> | |||
| </div> | |||
| ); | |||
| } | |||
| export default forwardRef(PasswordInput); | |||
| @@ -0,0 +1,51 @@ | |||
| import { useTranslate } from '@/hooks/common-hooks'; | |||
| import { useFormContext } from 'react-hook-form'; | |||
| import PasswordInput from './originui/password-input'; | |||
| import { | |||
| FormControl, | |||
| FormDescription, | |||
| FormField, | |||
| FormItem, | |||
| FormLabel, | |||
| FormMessage, | |||
| } from './ui/form'; | |||
| interface IProps { | |||
| name?: string; | |||
| } | |||
| export function TavilyFormField({ | |||
| name = 'prompt_config.tavily_api_key', | |||
| }: IProps) { | |||
| const form = useFormContext(); | |||
| const { t } = useTranslate('chat'); | |||
| return ( | |||
| <FormField | |||
| control={form.control} | |||
| name={name} | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel tooltip={t('tavilyApiKeyTip')}>Tavily API Key</FormLabel> | |||
| <FormControl> | |||
| <PasswordInput | |||
| {...field} | |||
| placeholder={t('tavilyApiKeyMessage')} | |||
| autoComplete="new-password" | |||
| ></PasswordInput> | |||
| </FormControl> | |||
| <FormDescription> | |||
| <a | |||
| href="https://app.tavily.com/home" | |||
| target={'_blank'} | |||
| rel="noreferrer" | |||
| > | |||
| {t('tavilyApiKeyHelp')} | |||
| </a> | |||
| </FormDescription> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| ); | |||
| } | |||
| @@ -3,6 +3,7 @@ | |||
| import { FileUploader } from '@/components/file-uploader'; | |||
| import { KnowledgeBaseFormField } from '@/components/knowledge-base-item'; | |||
| import { SwitchFormField } from '@/components/switch-fom-field'; | |||
| import { TavilyFormField } from '@/components/tavily-form-field'; | |||
| import { | |||
| FormControl, | |||
| FormField, | |||
| @@ -105,6 +106,7 @@ export default function ChatBasicSetting() { | |||
| name={'prompt_config.tts'} | |||
| label={t('tts')} | |||
| ></SwitchFormField> | |||
| <TavilyFormField></TavilyFormField> | |||
| <KnowledgeBaseFormField></KnowledgeBaseFormField> | |||
| </div> | |||
| ); | |||
| @@ -68,8 +68,8 @@ export function ChatSettings({ switchSettingVisible }: ChatSettingsProps) { | |||
| }, [data, form]); | |||
| return ( | |||
| <section className="p-5 w-[440px] "> | |||
| <div className="flex justify-between items-center text-base"> | |||
| <section className="p-5 w-[440px] border-l"> | |||
| <div className="flex justify-between items-center text-base pb-2"> | |||
| Chat Settings | |||
| <X className="size-4 cursor-pointer" onClick={switchSettingVisible} /> | |||
| </div> | |||
| @@ -24,6 +24,7 @@ export function useChatSettingSchema() { | |||
| optional: z.boolean(), | |||
| }), | |||
| ), | |||
| tavily_api_key: z.string().optional(), | |||
| }); | |||
| const formSchema = z.object({ | |||
| @@ -0,0 +1,155 @@ | |||
| import { NextMessageInput } from '@/components/message-input/next'; | |||
| import MessageItem from '@/components/message-item'; | |||
| import { Button } from '@/components/ui/button'; | |||
| import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |||
| import { MessageType } from '@/constants/chat'; | |||
| import { | |||
| useFetchConversation, | |||
| useFetchDialog, | |||
| useGetChatSearchParams, | |||
| } from '@/hooks/use-chat-request'; | |||
| import { useFetchUserInfo } from '@/hooks/user-setting-hooks'; | |||
| import { buildMessageUuidWithRole } from '@/utils/chat'; | |||
| import { Trash2 } from 'lucide-react'; | |||
| import { useCallback } from 'react'; | |||
| import { | |||
| useGetSendButtonDisabled, | |||
| useSendButtonDisabled, | |||
| } from '../../hooks/use-button-disabled'; | |||
| import { useCreateConversationBeforeUploadDocument } from '../../hooks/use-create-conversation'; | |||
| import { useSendMessage } from '../../hooks/use-send-chat-message'; | |||
| import { buildMessageItemReference } from '../../utils'; | |||
| import { useAddChatBox } from '../use-add-box'; | |||
| type MultipleChatBoxProps = { | |||
| controller: AbortController; | |||
| chatBoxIds: string[]; | |||
| } & Pick<ReturnType<typeof useAddChatBox>, 'removeChatBox'>; | |||
| type ChatCardProps = { id: string } & Pick< | |||
| MultipleChatBoxProps, | |||
| 'controller' | 'removeChatBox' | |||
| >; | |||
| function ChatCard({ controller, removeChatBox, id }: ChatCardProps) { | |||
| const { | |||
| value, | |||
| // scrollRef, | |||
| messageContainerRef, | |||
| sendLoading, | |||
| derivedMessages, | |||
| handleInputChange, | |||
| handlePressEnter, | |||
| regenerateMessage, | |||
| removeMessageById, | |||
| stopOutputMessage, | |||
| } = useSendMessage(controller); | |||
| const { data: userInfo } = useFetchUserInfo(); | |||
| const { data: currentDialog } = useFetchDialog(); | |||
| const { data: conversation } = useFetchConversation(); | |||
| const handleRemoveChatBox = useCallback(() => { | |||
| removeChatBox(id); | |||
| }, [id, removeChatBox]); | |||
| return ( | |||
| <Card className="bg-transparent border flex-1"> | |||
| <CardHeader className="border-b px-5 py-3"> | |||
| <CardTitle className="flex justify-between items-center"> | |||
| <div> | |||
| <span className="text-base">Card Title</span> | |||
| <Button variant={'ghost'} className="ml-2"> | |||
| GPT-4 | |||
| </Button> | |||
| </div> | |||
| <Button variant={'ghost'} onClick={handleRemoveChatBox}> | |||
| <Trash2 /> | |||
| </Button> | |||
| </CardTitle> | |||
| </CardHeader> | |||
| <CardContent> | |||
| <div ref={messageContainerRef} className="flex-1 overflow-auto min-h-0"> | |||
| <div className="w-full"> | |||
| {derivedMessages?.map((message, i) => { | |||
| return ( | |||
| <MessageItem | |||
| loading={ | |||
| message.role === MessageType.Assistant && | |||
| sendLoading && | |||
| derivedMessages.length - 1 === i | |||
| } | |||
| key={buildMessageUuidWithRole(message)} | |||
| item={message} | |||
| nickname={userInfo.nickname} | |||
| avatar={userInfo.avatar} | |||
| avatarDialog={currentDialog.icon} | |||
| reference={buildMessageItemReference( | |||
| { | |||
| message: derivedMessages, | |||
| reference: conversation.reference, | |||
| }, | |||
| message, | |||
| )} | |||
| // clickDocumentButton={clickDocumentButton} | |||
| index={i} | |||
| removeMessageById={removeMessageById} | |||
| regenerateMessage={regenerateMessage} | |||
| sendLoading={sendLoading} | |||
| ></MessageItem> | |||
| ); | |||
| })} | |||
| </div> | |||
| {/* <div ref={scrollRef} /> */} | |||
| </div> | |||
| </CardContent> | |||
| </Card> | |||
| ); | |||
| } | |||
| export function MultipleChatBox({ | |||
| controller, | |||
| chatBoxIds, | |||
| removeChatBox, | |||
| }: MultipleChatBoxProps) { | |||
| const { | |||
| value, | |||
| sendLoading, | |||
| handleInputChange, | |||
| handlePressEnter, | |||
| stopOutputMessage, | |||
| } = useSendMessage(controller); | |||
| const { createConversationBeforeUploadDocument } = | |||
| useCreateConversationBeforeUploadDocument(); | |||
| const { conversationId } = useGetChatSearchParams(); | |||
| const disabled = useGetSendButtonDisabled(); | |||
| const sendDisabled = useSendButtonDisabled(value); | |||
| return ( | |||
| <section className="h-full flex flex-col"> | |||
| <div className="flex gap-4 flex-1 px-5 pb-12"> | |||
| {chatBoxIds.map((id) => ( | |||
| <ChatCard | |||
| key={id} | |||
| controller={controller} | |||
| id={id} | |||
| removeChatBox={removeChatBox} | |||
| ></ChatCard> | |||
| ))} | |||
| </div> | |||
| <NextMessageInput | |||
| disabled={disabled} | |||
| sendDisabled={sendDisabled} | |||
| sendLoading={sendLoading} | |||
| value={value} | |||
| onInputChange={handleInputChange} | |||
| onPressEnter={handlePressEnter} | |||
| conversationId={conversationId} | |||
| createConversationBeforeUploadDocument={ | |||
| createConversationBeforeUploadDocument | |||
| } | |||
| stopOutputMessage={stopOutputMessage} | |||
| /> | |||
| </section> | |||
| ); | |||
| } | |||
| @@ -11,16 +11,16 @@ import { buildMessageUuidWithRole } from '@/utils/chat'; | |||
| import { | |||
| useGetSendButtonDisabled, | |||
| useSendButtonDisabled, | |||
| } from '../hooks/use-button-disabled'; | |||
| import { useCreateConversationBeforeUploadDocument } from '../hooks/use-create-conversation'; | |||
| import { useSendMessage } from '../hooks/use-send-chat-message'; | |||
| import { buildMessageItemReference } from '../utils'; | |||
| } from '../../hooks/use-button-disabled'; | |||
| import { useCreateConversationBeforeUploadDocument } from '../../hooks/use-create-conversation'; | |||
| import { useSendMessage } from '../../hooks/use-send-chat-message'; | |||
| import { buildMessageItemReference } from '../../utils'; | |||
| interface IProps { | |||
| controller: AbortController; | |||
| } | |||
| export function ChatBox({ controller }: IProps) { | |||
| export function SingleChatBox({ controller }: IProps) { | |||
| const { | |||
| value, | |||
| // scrollRef, | |||
| @@ -43,7 +43,7 @@ export function ChatBox({ controller }: IProps) { | |||
| const sendDisabled = useSendButtonDisabled(value); | |||
| return ( | |||
| <section className="border-x flex flex-col p-5 flex-1 min-w-0"> | |||
| <section className="flex flex-col p-5 h-full"> | |||
| <div ref={messageContainerRef} className="flex-1 overflow-auto min-h-0"> | |||
| <div className="w-full"> | |||
| {derivedMessages?.map((message, i) => { | |||
| @@ -7,14 +7,20 @@ import { | |||
| BreadcrumbPage, | |||
| BreadcrumbSeparator, | |||
| } from '@/components/ui/breadcrumb'; | |||
| import { Button } from '@/components/ui/button'; | |||
| import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |||
| import { useSetModalState } from '@/hooks/common-hooks'; | |||
| import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks'; | |||
| import { useFetchDialog } from '@/hooks/use-chat-request'; | |||
| import { cn } from '@/lib/utils'; | |||
| import { Plus } from 'lucide-react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { useHandleClickConversationCard } from '../hooks/use-click-card'; | |||
| import { ChatSettings } from './app-settings/chat-settings'; | |||
| import { ChatBox } from './chat-box'; | |||
| import { MultipleChatBox } from './chat-box/multiple-chat-box'; | |||
| import { SingleChatBox } from './chat-box/single-chat-box'; | |||
| import { Sessions } from './sessions'; | |||
| import { useAddChatBox } from './use-add-box'; | |||
| export default function Chat() { | |||
| const { navigateToChatList } = useNavigatePage(); | |||
| @@ -24,9 +30,16 @@ export default function Chat() { | |||
| useHandleClickConversationCard(); | |||
| const { visible: settingVisible, switchVisible: switchSettingVisible } = | |||
| useSetModalState(true); | |||
| const { | |||
| removeChatBox, | |||
| addChatBox, | |||
| chatBoxIds, | |||
| hasSingleChatBox, | |||
| hasThreeChatBox, | |||
| } = useAddChatBox(); | |||
| return ( | |||
| <section className="h-full flex flex-col"> | |||
| <section className="h-full flex flex-col pr-5"> | |||
| <PageHeader> | |||
| <Breadcrumb> | |||
| <BreadcrumbList> | |||
| @@ -43,18 +56,52 @@ export default function Chat() { | |||
| </Breadcrumb> | |||
| </PageHeader> | |||
| <div className="flex flex-1 min-h-0"> | |||
| <div className="flex flex-1 min-w-0"> | |||
| <Sessions | |||
| handleConversationCardClick={handleConversationCardClick} | |||
| switchSettingVisible={switchSettingVisible} | |||
| ></Sessions> | |||
| <ChatBox controller={controller}></ChatBox> | |||
| </div> | |||
| {settingVisible && ( | |||
| <ChatSettings | |||
| switchSettingVisible={switchSettingVisible} | |||
| ></ChatSettings> | |||
| )} | |||
| <Sessions | |||
| handleConversationCardClick={handleConversationCardClick} | |||
| switchSettingVisible={switchSettingVisible} | |||
| ></Sessions> | |||
| <Card className="flex-1 min-w-0 bg-transparent border h-full"> | |||
| <CardContent className="flex p-0 h-full"> | |||
| <Card className="flex flex-col flex-1 bg-transparent"> | |||
| <CardHeader | |||
| className={cn('p-5', { 'border-b': hasSingleChatBox })} | |||
| > | |||
| <CardTitle className="flex justify-between items-center"> | |||
| <div className="text-base"> | |||
| Card Title | |||
| <Button variant={'ghost'} className="ml-2"> | |||
| GPT-4 | |||
| </Button> | |||
| </div> | |||
| <Button | |||
| variant={'ghost'} | |||
| onClick={addChatBox} | |||
| disabled={hasThreeChatBox} | |||
| > | |||
| <Plus></Plus> Multiple Models | |||
| </Button> | |||
| </CardTitle> | |||
| </CardHeader> | |||
| <CardContent className="flex-1 p-0"> | |||
| {hasSingleChatBox ? ( | |||
| <SingleChatBox controller={controller}></SingleChatBox> | |||
| ) : ( | |||
| <MultipleChatBox | |||
| chatBoxIds={chatBoxIds} | |||
| controller={controller} | |||
| removeChatBox={removeChatBox} | |||
| ></MultipleChatBox> | |||
| )} | |||
| </CardContent> | |||
| </Card> | |||
| {settingVisible && ( | |||
| <ChatSettings | |||
| switchSettingVisible={switchSettingVisible} | |||
| ></ChatSettings> | |||
| )} | |||
| </CardContent> | |||
| </Card> | |||
| </div> | |||
| </section> | |||
| ); | |||
| @@ -0,0 +1,26 @@ | |||
| import { useCallback, useState } from 'react'; | |||
| import { v4 as uuid } from 'uuid'; | |||
| export function useAddChatBox() { | |||
| const [ids, setIds] = useState<string[]>([uuid()]); | |||
| const hasSingleChatBox = ids.length === 1; | |||
| const hasThreeChatBox = ids.length === 3; | |||
| const addChatBox = useCallback(() => { | |||
| setIds((prev) => [...prev, uuid()]); | |||
| }, []); | |||
| const removeChatBox = useCallback((id: string) => { | |||
| setIds((prev) => prev.filter((x) => x !== id)); | |||
| }, []); | |||
| return { | |||
| chatBoxIds: ids, | |||
| hasSingleChatBox, | |||
| hasThreeChatBox, | |||
| addChatBox, | |||
| removeChatBox, | |||
| }; | |||
| } | |||
| @@ -1,4 +1,4 @@ | |||
| import PasswordInput from '@/components/password-input'; | |||
| import PasswordInput from '@/components/originui/password-input'; | |||
| import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; | |||
| import { Button } from '@/components/ui/button'; | |||
| import { | |||