diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 5895e23..42bb036 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,11 +1,12 @@ import { cookies } from "next/headers"; import { env } from "@/env.mjs"; -import { OpenAIStream, StreamingTextResponse } from "ai"; +import { createOpenAI } from "@ai-sdk/openai"; +import { Message, streamText } from "ai"; import { pick } from "lodash"; import { AxiomRequest, withAxiom } from "next-axiom"; -import OpenAI from "openai"; import { getAppBySlug } from "@/lib/db/apps"; +import { createNewChatMember } from "@/lib/db/chat-members"; import { createNewChat } from "@/lib/db/chats"; import { createNewMessage, @@ -19,7 +20,7 @@ export const dynamic = "force-dynamic"; export const runtime = "edge"; export const preferredRegion = "home"; -const openai = new OpenAI({ +const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY, }); @@ -43,6 +44,7 @@ export const POST = withAxiom(async (req: AxiomRequest) => { isRegenerate, regenerateMessageId, isNewChat, + enableChatAssistant = true, } = params; const user = await getCurrentUser(supabase); @@ -61,6 +63,10 @@ export const POST = withAxiom(async (req: AxiomRequest) => { app_id: currentApp.id, name: lastMessage.content, }); + await createNewChatMember(supabase, { + chat_id: chatId, + member_id: user.id, + }); } await createNewMessage(supabase, { chat_id: chatId, @@ -75,28 +81,36 @@ export const POST = withAxiom(async (req: AxiomRequest) => { } } - const response = await openai.chat.completions.create({ - model, - temperature, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - messages: messages.map((message: any) => pick(message, "content", "role")), - max_tokens: maxTokens, - top_p: topP, - frequency_penalty: frequencyPenalty, - presence_penalty: presencePenalty, - stream: true, - }); - log.debug("Create stream"); + if (!enableChatAssistant) { + return new Response(null, { + status: 200, + headers: { + "Content-Type": "application/json", + "should-redirect-to-new-chat": "true", + }, + }); + } - const stream = OpenAIStream(response, { - onCompletion: async (completion: string) => { + log.debug("Start stream text"); + const response = await streamText({ + model: openai(model), + temperature, + messages: messages.map((message: Message) => + pick(message, "content", "role") + ), + maxTokens, + topP, + frequencyPenalty, + presencePenalty, + onFinish: async ({ text }) => { await createNewMessage(supabase, { chat_id: chatId, - content: completion, + content: text, role: "assistant", }); }, }); + log.debug("End stream text"); - return new StreamingTextResponse(stream); + return response.toAIStreamResponse(); }); diff --git a/app/apps/chat/[id]/page.tsx b/app/apps/chat/[id]/page.tsx index 042cd07..4dff68d 100644 --- a/app/apps/chat/[id]/page.tsx +++ b/app/apps/chat/[id]/page.tsx @@ -1,9 +1,15 @@ import React from "react"; import { Metadata } from "next"; import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; import { Message } from "ai"; +import { + CHAT_MEMBER_SIDEBAR_LAYOUT_COOKIE, + DEFAULT_CHAT_MEMBER_SIDEBAR_LAYOUT, +} from "@/lib/contants"; import { getAppBySlug } from "@/lib/db/apps"; +import { getChatMembers } from "@/lib/db/chat-members"; import { getChatById, getChats } from "@/lib/db/chats"; import { getMessages } from "@/lib/db/message"; import { getCurrentUser } from "@/lib/session"; @@ -37,7 +43,11 @@ export default async function ChatPage({ params }: { params: { id: string } }) { const dbMessages = await getMessages(supabase, chatId); const chatDetails = await getChatById(supabase, chatId); + if (!chatDetails) { + redirect("/apps/chat"); + } const chatParams = chatDetails?.settings as ChatParams | undefined; + const isChatHost = chatDetails?.profile_id === user.id; const initialChatMessages: Message[] = dbMessages?.length ? dbMessages.map((message) => { @@ -45,6 +55,14 @@ export default async function ChatPage({ params }: { params: { id: string } }) { id: message.id, role: message.role || "system", content: message.content || "", + data: { + profile_id: message.profile_id, + chat_id: message.chat_id, + chatBubleDirection: + message.role === "user" && message.profile_id === user.id + ? "end" + : "start", + }, }; }) : []; @@ -57,12 +75,24 @@ export default async function ChatPage({ params }: { params: { id: string } }) { }); } + const chatMembers = await getChatMembers(supabase, chatId); + + const memberSidebarLayout = cookies().get(CHAT_MEMBER_SIDEBAR_LAYOUT_COOKIE); + + let defaultMemberSidebarLayout = DEFAULT_CHAT_MEMBER_SIDEBAR_LAYOUT; + if (memberSidebarLayout) { + defaultMemberSidebarLayout = JSON.parse(memberSidebarLayout.value); + } + return ( ); } diff --git a/app/apps/chat/page.tsx b/app/apps/chat/page.tsx index d195a81..02ca8d1 100644 --- a/app/apps/chat/page.tsx +++ b/app/apps/chat/page.tsx @@ -28,6 +28,13 @@ export default async function NewChatPage() { const chats = await getChats(supabase, currentApp.id); return ( - + ); } diff --git a/app/apps/layout.tsx b/app/apps/layout.tsx index d1bc18f..a553993 100644 --- a/app/apps/layout.tsx +++ b/app/apps/layout.tsx @@ -27,7 +27,7 @@ export default async function AppLayout({ children }: AppLayoutProps) {
-
+
diff --git a/app/globals.css b/app/globals.css index d1c95b5..60dfbf4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,9 +1,9 @@ +@import url('./styles/custom.css'); + @tailwind base; @tailwind components; @tailwind utilities; -@import url('./styles/custom.css'); - .drawer-toggle:checked ~ .drawer-side { backdrop-filter: blur(5px); } diff --git a/components/modules/apps/chat/ChatForm.tsx b/components/modules/apps/chat/ChatForm.tsx new file mode 100644 index 0000000..856b3fe --- /dev/null +++ b/components/modules/apps/chat/ChatForm.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { SendHorizonal } from "lucide-react"; +import { MentionsInputProps, SuggestionDataItem } from "react-mentions"; + +import { Chat, ChatMemberProfile } from "@/lib/db"; +import { useProfileStore } from "@/lib/stores/profile"; +import { useEnterSubmit } from "@/hooks/useEnterSubmit"; +import { Button } from "@/components/ui/Button"; +import { ChatInput } from "@/components/ui/chat"; + +import { MobileDrawerControl } from "./MobileDrawerControls"; + +type ChatFormProps = { + chatInput: string; + chats: Chat[] | null; + isChatStreamming: boolean; + chatMembers: ChatMemberProfile[] | null; + onSubmit: (e: React.FormEvent) => void; + onInputChange: ( + e: + | React.ChangeEvent + | React.ChangeEvent + ) => void; +}; + +export const ChatForm = ({ + chats, + isChatStreamming, + chatMembers, + chatInput, + onSubmit, + onInputChange, +}: ChatFormProps) => { + const { formRef, onKeyDown } = useEnterSubmit(); + const currentProfile = useProfileStore((state) => state.profile); + + const mentionData: SuggestionDataItem[] = React.useMemo(() => { + const mentionData = [{ id: "assistant", display: "Assistant" }]; + + if (!chatMembers) return mentionData; + + chatMembers.forEach((member) => { + if (!member.profiles) return; + mentionData.push({ + id: member.profiles.id, + display: member.profiles.username || "", + }); + }); + + return mentionData.filter((mention) => mention.id !== currentProfile?.id); + }, [chatMembers, currentProfile?.id]); + + const handleOnChange: MentionsInputProps["onChange"] = (e) => { + onInputChange({ + target: { value: e.target.value }, + } as React.ChangeEvent); + }; + + return ( +
+
+ + +
+ +
+ +
+ ); +}; diff --git a/components/modules/apps/chat/ChatHistory.tsx b/components/modules/apps/chat/ChatHistory.tsx index 7ee9adc..197d1d8 100644 --- a/components/modules/apps/chat/ChatHistory.tsx +++ b/components/modules/apps/chat/ChatHistory.tsx @@ -1,10 +1,11 @@ "use client"; import React from "react"; -import { usePathname } from "next/navigation"; import { Chat } from "@/lib/db"; +import { useChatIdFromPathName } from "@/hooks/useChatIdFromPathName"; import { Separator } from "@/components/ui/Separator"; +import { Paragraph } from "@/components/ui/typography"; import { ChatHistoryItem } from "./ChatHistoryItem"; import { NewChatButton } from "./NewChatButton"; @@ -15,8 +16,7 @@ type ChatHistoryProps = { }; export const ChatHistory = ({ data, closeDrawer }: ChatHistoryProps) => { - const pathname = usePathname(); - const chatId = pathname.split("/").pop(); + const chatId = useChatIdFromPathName(); return (
diff --git a/components/modules/apps/chat/MobileDrawerControls.tsx b/components/modules/apps/chat/MobileDrawerControls.tsx index a7df65a..e7bc106 100644 --- a/components/modules/apps/chat/MobileDrawerControls.tsx +++ b/components/modules/apps/chat/MobileDrawerControls.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Settings } from "lucide-react"; +import { PanelRight } from "lucide-react"; import { Chat } from "@/lib/db"; import { Button } from "@/components/ui/Button"; @@ -16,13 +16,13 @@ export const MobileDrawerControl = React.memo(function MobileDrawerControl({ }: MobileDrawerControlProps) { return ( <> -
+
-
+
diff --git a/components/modules/apps/chat/action.ts b/components/modules/apps/chat/action.ts index 4842a1a..1daf66a 100644 --- a/components/modules/apps/chat/action.ts +++ b/components/modules/apps/chat/action.ts @@ -2,38 +2,14 @@ import { revalidatePath } from "next/cache"; import { cookies } from "next/headers"; -import { redirect } from "next/navigation"; import { TablesUpdate } from "@/lib/db"; -import { getAppBySlug } from "@/lib/db/apps"; import { - createNewChat as createNewChatDb, deleteChat as deleteChatDb, updateChat as updateChatDb, } from "@/lib/db/chats"; -import { getCurrentUser } from "@/lib/session"; import { createClient } from "@/lib/supabase/server"; -export const createNewChat = async () => { - const cookieStore = cookies(); - const supabase = createClient(cookieStore); - const user = await getCurrentUser(supabase); - const currentApp = await getAppBySlug(supabase, "/apps/chat"); - - if (!currentApp || !user) { - throw new Error("You must be logged in to create a chat"); - } - - const newChats = await createNewChatDb(supabase, { - app_id: currentApp.id, - name: "(New Chat)", - }); - - if (newChats) { - redirect(`/apps/chat/${newChats[0].id}`); - } -}; - export const deleteChat = async (id: string) => { const cookieStore = cookies(); const supabase = createClient(cookieStore); diff --git a/components/modules/apps/chat/chat-members/AddMembersForm.tsx b/components/modules/apps/chat/chat-members/AddMembersForm.tsx new file mode 100644 index 0000000..58975db --- /dev/null +++ b/components/modules/apps/chat/chat-members/AddMembersForm.tsx @@ -0,0 +1,91 @@ +import React from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { SubmitHandler, useForm } from "react-hook-form"; +import z from "zod"; + +import { useChatIdFromPathName } from "@/hooks/useChatIdFromPathName"; +import { Button } from "@/components/ui/Button"; +import { InputField } from "@/components/ui/form/form-fields"; +import { useToast } from "@/components/ui/use-toast"; + +import { addNewMember } from "./action"; +import { addMemberSchema } from "./schema"; + +type AddMembersFormProps = { + onCloseAddMemberPopover: () => void; +}; + +type AddMembersFormParams = z.infer; + +const defaultValues: AddMembersFormParams = { + username: "", +}; + +export const AddMembersForm = ({ + onCloseAddMemberPopover, +}: AddMembersFormProps) => { + const { toast } = useToast(); + const chatId = useChatIdFromPathName(); + const [isPending, startTransition] = React.useTransition(); + + const { handleSubmit, formState, register } = useForm({ + defaultValues: defaultValues, + mode: "onChange", + resolver: zodResolver(addMemberSchema), + }); + + const fieldProps = { register, formState }; + + const onSubmit: SubmitHandler = (data) => { + handleAddMember(data.username); + }; + + const handleAddMember = async (username: string) => { + startTransition(async () => { + try { + await addNewMember(username, chatId); + toast({ + title: "Success", + description: `${username} has been added to this chat.`, + }); + } catch (error) { + toast({ + title: "Error", + description: + "Failed to add the member to this chat. Please try again.", + variant: "destructive", + }); + } finally { + onCloseAddMemberPopover(); + } + }); + }; + + return ( +
+
+

Add new members

+

+ You can add new members to this chat to discuss and share ideas. +

+
+
+ + +
+
+ ); +}; diff --git a/components/modules/apps/chat/chat-members/ChatMemberItem.tsx b/components/modules/apps/chat/chat-members/ChatMemberItem.tsx new file mode 100644 index 0000000..a6756f8 --- /dev/null +++ b/components/modules/apps/chat/chat-members/ChatMemberItem.tsx @@ -0,0 +1,48 @@ +import React from "react"; + +import { useChatIdFromPathName } from "@/hooks/useChatIdFromPathName"; +import { UserAvatar } from "@/components/ui/common/UserAvatar"; + +import { DeleteMemberAction } from "./DeleteMemberAction"; + +type ChatMemberItemProps = { + id: string; + username: string; + avatarUrl: string | null; + fullname?: string; + removeable?: boolean; + isOnline?: boolean; +}; + +export const ChatMemberItem = ({ + id, + fullname, + username, + avatarUrl, + removeable = false, + isOnline, +}: ChatMemberItemProps) => { + const chatId = useChatIdFromPathName(); + + return ( +
+ +
+

{fullname || username}

+
+ {removeable && ( + + )} +
+ ); +}; diff --git a/components/modules/apps/chat/chat-members/ChatMembers.tsx b/components/modules/apps/chat/chat-members/ChatMembers.tsx new file mode 100644 index 0000000..68b69a7 --- /dev/null +++ b/components/modules/apps/chat/chat-members/ChatMembers.tsx @@ -0,0 +1,92 @@ +"use client"; + +import React from "react"; +import { Plus } from "lucide-react"; + +import { ChatMemberProfile } from "@/lib/db"; +import { useProfileStore } from "@/lib/stores/profile"; +import { Button } from "@/components/ui/Button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/Popover"; +import { Separator } from "@/components/ui/Separator"; +import { Paragraph } from "@/components/ui/typography"; + +import { ChatPanelProps } from "../ChatPanel"; +import { AddMembersForm } from "./AddMembersForm"; +import { ChatMemberItem } from "./ChatMemberItem"; + +type ChatMembersProps = { + data: ChatMemberProfile[] | null; + isChatHost: ChatPanelProps["isChatHost"]; + closeDrawer?: () => void; +}; + +export const ChatMembers = ({ data, isChatHost }: ChatMembersProps) => { + const [addMemberPopoverOpen, setAddMemberPopoverOpen] = React.useState(false); + const currentProfile = useProfileStore((state) => state.profile); + + const handleAddMemberPopoverOpen = (isOpen: boolean) => { + setAddMemberPopoverOpen(isOpen); + }; + + const closeAddMemberPopover = () => { + setAddMemberPopoverOpen(false); + }; + + return ( + + ); +}; diff --git a/components/modules/apps/chat/chat-members/DeleteMemberAction.tsx b/components/modules/apps/chat/chat-members/DeleteMemberAction.tsx new file mode 100644 index 0000000..c62bdb2 --- /dev/null +++ b/components/modules/apps/chat/chat-members/DeleteMemberAction.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { AlertDialogProps } from "@radix-ui/react-alert-dialog"; +import { Loader, Trash2 } from "lucide-react"; + +import { Chat, Profile } from "@/lib/db"; +import { cn } from "@/lib/utils"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/AlertDialog"; +import { Button, buttonVariants } from "@/components/ui/Button"; +import { useToast } from "@/components/ui/use-toast"; + +import { deleteMember } from "./action"; + +type ChatActionProps = { + memberId: Profile["id"]; + memberUsername: Profile["username"]; + chatId: Chat["id"]; +} & AlertDialogProps; + +export const DeleteMemberAction = ({ + memberId, + memberUsername, + chatId, + ...rest +}: ChatActionProps) => { + const { toast } = useToast(); + const [isAlertOpen, setIsAlertOpen] = React.useState(false); + const [pendingDeleteMember, startDeleteMember] = React.useTransition(); + + const handleRemoveMember = ( + e: React.MouseEvent + ) => { + e.preventDefault(); + startDeleteMember(async () => { + try { + await deleteMember(memberId, chatId); + toast({ + title: "Success", + description: `${memberUsername || "The member"} has been removed to this chat.`, + }); + } catch (error) { + toast({ + title: "Error", + description: + "Failed to remove the member to this chat. Please try again.", + variant: "destructive", + }); + } finally { + setIsAlertOpen(false); + } + }); + }; + + return ( + + + + + + + + Are you sure you want to remove{" "} + "{memberUsername}" + from this chat? + + + This action cannot be undone. + + + + Cancel + + {pendingDeleteMember ? ( + + ) : ( + "Delete" + )} + + + + + ); +}; diff --git a/components/modules/apps/chat/chat-members/action.ts b/components/modules/apps/chat/chat-members/action.ts new file mode 100644 index 0000000..45fef5b --- /dev/null +++ b/components/modules/apps/chat/chat-members/action.ts @@ -0,0 +1,57 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { cookies } from "next/headers"; + +import { createNewChatMember, deleteChatMember } from "@/lib/db/chat-members"; +import { getProfileByUsername } from "@/lib/db/profile"; +import { getCurrentUser } from "@/lib/session"; +import { createClient } from "@/lib/supabase/server"; + +export const addNewMember = async (username: string, chatId: string) => { + const cookieStore = cookies(); + const supabase = createClient(cookieStore); + const user = await getCurrentUser(supabase); + + if (!user) { + throw new Error("You must be logged in to create a chat"); + } + + const profile = await getProfileByUsername(supabase, username); + + if (!profile) { + throw new Error("Profile not found"); + } + + const newMember = await createNewChatMember(supabase, { + chat_id: chatId, + member_id: profile.id, + }); + + if (!newMember) { + throw new Error("Failed to add the member to this chat"); + } + + revalidatePath(`/apps/chat/${chatId}`); + + return newMember; +}; + +export const deleteMember = async (memberId: string, chatId: string) => { + const cookieStore = cookies(); + const supabase = createClient(cookieStore); + const user = await getCurrentUser(supabase); + + if (!user) { + throw new Error("You must be logged in to delete a chat member"); + } + + try { + await deleteChatMember(supabase, memberId); + revalidatePath(`/apps/chat/${chatId}`); + } catch (error) { + throw new Error("Failed to remove the member from this chat"); + } + + return null; +}; diff --git a/components/modules/apps/chat/chat-members/index.ts b/components/modules/apps/chat/chat-members/index.ts new file mode 100644 index 0000000..4d77019 --- /dev/null +++ b/components/modules/apps/chat/chat-members/index.ts @@ -0,0 +1 @@ +export * from "./ChatMembers"; diff --git a/components/modules/apps/chat/chat-members/schema.ts b/components/modules/apps/chat/chat-members/schema.ts new file mode 100644 index 0000000..c3ea206 --- /dev/null +++ b/components/modules/apps/chat/chat-members/schema.ts @@ -0,0 +1,5 @@ +import z from "zod"; + +export const addMemberSchema = z.object({ + username: z.string(), +}); diff --git a/components/modules/apps/chat/control-side-bar/ControlSidebar.tsx b/components/modules/apps/chat/control-side-bar/ControlSidebar.tsx index 8cd485c..c8eafab 100644 --- a/components/modules/apps/chat/control-side-bar/ControlSidebar.tsx +++ b/components/modules/apps/chat/control-side-bar/ControlSidebar.tsx @@ -68,7 +68,7 @@ export const ControlSidebar = ({ Settings - {`Combining these parameters allows you to fine-tune the AI's output to suit different use cases, from creative writing to generating code snippets or answering questions.`} + {`Combining these parameters allows you to fine-tune the AI's output to suit different use cases.`} diff --git a/components/modules/apps/chat/control-side-bar/ControlSidebarSheet.tsx b/components/modules/apps/chat/control-side-bar/ControlSidebarSheet.tsx index 4ee8ebd..c3c2e24 100644 --- a/components/modules/apps/chat/control-side-bar/ControlSidebarSheet.tsx +++ b/components/modules/apps/chat/control-side-bar/ControlSidebarSheet.tsx @@ -2,8 +2,20 @@ import React from "react"; import { Message, UseChatHelpers } from "ai/react"; import { FormProvider, FormProviderProps } from "react-hook-form"; +import { + CHAT_MEMBER_SIDEBAR_LAYOUT_COOKIE, + MAX_CHAT_MEMBER_SIDEBAR_SIZE, + MIN_CHAT_MEMBER_SIDEBAR_SIZE, +} from "@/lib/contants"; +import { ChatMemberProfile } from "@/lib/db"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/Resizable"; import { SheetContent } from "@/components/ui/Sheet"; +import { ChatMembers } from "../chat-members"; import { ChatPanelProps } from "../ChatPanel"; import { ChatParams } from "../types"; import { ControlSidebar } from "./ControlSidebar"; @@ -11,18 +23,28 @@ import { ControlSidebar } from "./ControlSidebar"; type ControlSidebarSheetProps = { setMessages: UseChatHelpers["setMessages"]; messages: Message[]; + chatMembers: ChatMemberProfile[] | null; closeSidebarSheet: () => void; isNewChat: ChatPanelProps["isNewChat"]; + isChatHost: ChatPanelProps["isChatHost"]; formReturn: Omit, "children">; + defaultMemberSidebarLayout: number[]; }; export const ControlSidebarSheet = React.memo(function ControlSidebarSheet({ setMessages, messages, + chatMembers, closeSidebarSheet, isNewChat, + isChatHost, formReturn, + defaultMemberSidebarLayout, }: ControlSidebarSheetProps) { + const onLayout = (sizes: number[]) => { + document.cookie = `${CHAT_MEMBER_SIDEBAR_LAYOUT_COOKIE}=${JSON.stringify(sizes)}`; + }; + const renderControlSidebar = () => { return ( - -
{renderControlSidebar()}
+ +
+
{renderControlSidebar()}
+ {!isNewChat && ( +
+ +
+ )} +
- {renderControlSidebar()} + {isNewChat ? ( + renderControlSidebar() + ) : ( + + +
+ {renderControlSidebar()} +
+
+ + + + +
+ )}
); diff --git a/components/ui/AlertDialog.tsx b/components/ui/AlertDialog.tsx index 7c4c21c..8bfbf68 100644 --- a/components/ui/AlertDialog.tsx +++ b/components/ui/AlertDialog.tsx @@ -39,7 +39,7 @@ const AlertDialogContent = React.forwardRef< ) => ( + +); + +const ResizablePanel = ResizablePrimitive.Panel; + +const ResizableHandle = ({ + withHandle, + className, + ...props +}: React.ComponentProps & { + withHandle?: boolean; +}) => ( + div]:rotate-90", + className + )} + {...props} + > + {withHandle && ( +
+ +
+ )} +
+); + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle }; diff --git a/components/ui/TextArea.tsx b/components/ui/TextArea.tsx index 6082419..0d9978b 100644 --- a/components/ui/TextArea.tsx +++ b/components/ui/TextArea.tsx @@ -15,7 +15,7 @@ export interface TextAreaProps containerClassName?: string; } -const textAreaVariants = cva( +export const textAreaVariants = cva( "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", { variants: { diff --git a/components/ui/chat/ChatBubble.tsx b/components/ui/chat/ChatBubble.tsx index 2c99268..53d5c6e 100644 --- a/components/ui/chat/ChatBubble.tsx +++ b/components/ui/chat/ChatBubble.tsx @@ -1,18 +1,20 @@ import React from "react"; -import Image from "next/image"; import { Copy, RefreshCcw, StopCircle } from "lucide-react"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; -import { Message } from "@/lib/db"; +import { isTaggedUserPattern } from "@/lib/chat-input"; +import { AI_ASSISTANT_PROFILE } from "@/lib/contants"; +import { ChatMemberProfile, Message } from "@/lib/db"; import { cn } from "@/lib/utils"; import { badgeVariants } from "@/components/ui/Badge"; import { CodeBlock } from "@/components/modules/apps/chat/CodeBlock"; -import { Avatar, AvatarFallback, AvatarImage } from "../Avatar"; +import { UserAvatar } from "../common/UserAvatar"; +import { ChatProfileHoverCard } from "./ChatProfileHoverCard"; import { MemoizedReactMarkdown } from "./Markdown"; -type ChatBubbleProps = { +export type ChatBubbleProps = { id: Message["id"]; prevId?: Message["id"]; direction?: "start" | "end"; @@ -23,12 +25,13 @@ type ChatBubbleProps = { content: string; isLoading: boolean; isLast: boolean; + chatMemberMap?: Record; onCopy: (message: string) => void; onRegenerate: (id: Message["id"]) => void; onStopGenerating?: () => void; }; -export const ChatBubble = ({ +export const ChatBubble = React.memo(function ChatBubble({ prevId, content, name, @@ -38,10 +41,11 @@ export const ChatBubble = ({ time, isLoading, isLast, + chatMemberMap, onCopy, onRegenerate, onStopGenerating, -}: ChatBubbleProps) => { +}: ChatBubbleProps) { const chatClass = cn(`chat-${direction} chat mb-4`, { "place-items-start grid-cols-[auto_1fr]": direction === "start", "place-items-end grid-cols-[1fr_auto]": direction === "end", @@ -103,20 +107,9 @@ export const ChatBubble = ({ return (
- {avatar ? ( -
-
- avatar -
-
- ) : ( -
- - - VN - -
- )} +
+ +
{name} {time ? : null} @@ -129,6 +122,47 @@ export const ChatBubble = ({ p({ children }) { return

{children}

; }, + a({ children, node: { properties } }) { + if (!isTaggedUserPattern(properties?.href || "")) { + return ( + + {children} + + ); + } + + const userName = properties?.href?.toString()?.split(":")[1]; + + if (!userName) { + return ( + + {children} + + ); + } + + let profile = userName && chatMemberMap?.[userName]?.profiles; + const profileCreatedAt = + userName && chatMemberMap?.[userName]?.created_at; + + if (userName === "assistant") { + profile = AI_ASSISTANT_PROFILE; + } + + if (!profile) { + return {children}; + } + + return ( + + {children} + + ); + }, code({ inline, className, children, ...props }) { if (children.length) { if (children[0] == "▍") { @@ -172,4 +206,4 @@ export const ChatBubble = ({
); -}; +}); diff --git a/components/ui/chat/ChatInput.tsx b/components/ui/chat/ChatInput.tsx index 849d5f5..a5fb591 100644 --- a/components/ui/chat/ChatInput.tsx +++ b/components/ui/chat/ChatInput.tsx @@ -1,20 +1,46 @@ "use client"; import React from "react"; +import { + Mention, + MentionsInput, + MentionsInputProps, + SuggestionDataItem, +} from "react-mentions"; -import { TextArea } from "@/components/ui/TextArea"; +import { cn } from "@/lib/utils"; +import { textAreaVariants } from "@/components/ui/TextArea"; -type ChatTextAreaProps = React.ComponentPropsWithRef; +import { defaultStyle } from "./mention-input-default-style"; -export const ChatInput = (props: ChatTextAreaProps) => { +type ChatTextAreaProps = Omit & { + mentionData: SuggestionDataItem[]; +}; + +export const ChatInput = ({ mentionData, ...rest }: ChatTextAreaProps) => { return ( -