"use client"; import { useChat } from "@ai-sdk/react"; import { toast } from "sonner"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import PromptInput from "./prompt-input"; import clsx from "clsx"; import { appStore } from "@/app/store"; import { cn, createDebounce, generateUUID, truncateString } from "lib/utils"; import { ErrorMessage, PreviewMessage } from "./message"; import { ChatGreeting } from "./chat-greeting"; import AgentMonitorPanel from "./agent-monitor"; import { useShallow } from "zustand/shallow"; import { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls, TextUIPart, UIMessage, } from "ai"; import { safe } from "ts-safe"; import { mutate } from "swr"; import { ChatApiSchemaRequestBody, ChatAttachment, ChatModel, } from "app-types/chat"; import { useToRef } from "@/hooks/use-latest"; import { isShortcutEvent, Shortcuts } from "lib/keyboard-shortcuts"; import { Button } from "ui/button"; import { deleteThreadAction } from "@/app/api/chat/actions"; import { useRouter } from "next/navigation"; import { ArrowDown, Loader, FilePlus } from "lucide-react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "ui/dialog"; import { useTranslations } from "next-intl"; import { Think } from "ui/think"; import { useGenerateThreadTitle } from "@/hooks/queries/use-generate-thread-title"; import dynamic from "next/dynamic"; import { useMounted } from "@/hooks/use-mounted"; import { getStorageManager } from "lib/browser-stroage"; import { AnimatePresence, motion } from "framer-motion"; import { useThreadFileUploader } from "@/hooks/use-thread-file-uploader"; import { useFileDragOverlay } from "@/hooks/use-file-drag-overlay"; type Props = { threadId: string; initialMessages: Array; selectedChatModel?: string; }; const LightRays = dynamic(() => import("ui/light-rays"), { ssr: false, }); const Particles = dynamic(() => import("ui/particles"), { ssr: false, }); const debounce = createDebounce(); const firstTimeStorage = getStorageManager("IS_FIRST"); const isFirstTime = firstTimeStorage.get() ?? true; firstTimeStorage.set(false); export default function ChatBot({ threadId, initialMessages }: Props) { const containerRef = useRef(null); const [isAtBottom, setIsAtBottom] = useState(true); const { uploadFiles } = useThreadFileUploader(threadId); const handleFileDrop = useCallback( async (files: File[]) => { if (!files.length) return; await uploadFiles(files); }, [uploadFiles], ); const { isDragging } = useFileDragOverlay({ onDropFiles: handleFileDrop, }); const [ appStoreMutate, model, toolChoice, allowedAppDefaultToolkit, allowedMcpServers, threadList, threadMentions, pendingThreadMention, threadImageToolModel, ] = appStore( useShallow((state) => [ state.mutate, state.chatModel, state.toolChoice, state.allowedAppDefaultToolkit, state.allowedMcpServers, state.threadList, state.threadMentions, state.pendingThreadMention, state.threadImageToolModel, ]), ); const generateTitle = useGenerateThreadTitle({ threadId, }); const [showParticles, setShowParticles] = useState(isFirstTime); const onFinish = useCallback(() => { const messages = latestRef.current.messages; const prevThread = latestRef.current.threadList.find( (v) => v.id === threadId, ); const isNewThread = !prevThread?.title && messages.filter((v) => v.role === "user" || v.role === "assistant") .length < 3; if (isNewThread) { const part = messages .slice(0, 2) .flatMap((m) => m.parts .filter((v) => v.type === "text") .map( (p) => `${m.role}: ${truncateString((p as TextUIPart).text, 500)}`, ), ); if (part.length > 0) { generateTitle(part.join("\n\n")); } } else if (latestRef.current.threadList[0]?.id !== threadId) { mutate("/api/thread"); } }, []); const [input, setInput] = useState(""); const { messages, status, setMessages, addToolResult: _addToolResult, error, sendMessage, stop, } = useChat({ id: threadId, sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, transport: new DefaultChatTransport({ prepareSendMessagesRequest: ({ messages, body, id }) => { if (window.location.pathname !== `/chat/${threadId}`) { console.log("replace-state"); window.history.replaceState({}, "", `/chat/${threadId}`); } const lastMessage = messages.at(-1)!; // Filter out UI-only parts (e.g., source-url) so the model doesn't receive unknown parts const attachments: ChatAttachment[] = lastMessage.parts.reduce( (acc: ChatAttachment[], part: any) => { if (part?.type === "file") { acc.push({ type: "file", url: part.url, mediaType: part.mediaType, filename: part.filename, }); } else if (part?.type === "source-url") { acc.push({ type: "source-url", url: part.url, mediaType: part.mediaType, filename: part.title, }); } return acc; }, [], ); const sanitizedLastMessage = { ...lastMessage, parts: lastMessage.parts.filter((p: any) => p?.type !== "source-url"), } as typeof lastMessage; const hasFilePart = lastMessage.parts?.some( (p) => (p as any)?.type === "file", ); const requestBody: ChatApiSchemaRequestBody = { ...body, id, chatModel: (body as { model: ChatModel })?.model ?? latestRef.current.model, toolChoice: latestRef.current.toolChoice, allowedAppDefaultToolkit: latestRef.current.mentions?.length || hasFilePart ? [] : latestRef.current.allowedAppDefaultToolkit, allowedMcpServers: latestRef.current.mentions?.length ? {} : latestRef.current.allowedMcpServers, mentions: latestRef.current.mentions, message: sanitizedLastMessage, imageTool: { model: latestRef.current.threadImageToolModel[threadId], }, attachments, }; return { body: requestBody }; }, }), messages: initialMessages, generateId: generateUUID, experimental_throttle: 100, onFinish, }); const [isDeleteThreadPopupOpen, setIsDeleteThreadPopupOpen] = useState(false); const addToolResult = useCallback( async (result: Parameters[0]) => { await _addToolResult(result); // sendMessage(); }, [_addToolResult], ); const mounted = useMounted(); const latestRef = useToRef({ toolChoice, model, allowedAppDefaultToolkit, allowedMcpServers, messages, threadList, threadId, mentions: threadMentions[threadId], threadImageToolModel, }); const isLoading = useMemo( () => status === "streaming" || status === "submitted", [status], ); const emptyMessage = useMemo( () => messages.length === 0 && !error, [messages.length, error], ); const isInitialThreadEntry = useMemo( () => initialMessages.length > 0 && initialMessages.at(-1)?.id === messages.at(-1)?.id, [messages], ); const isPendingToolCall = useMemo(() => { if (status != "ready") return false; const lastMessage = messages.at(-1); if (lastMessage?.role != "assistant") return false; const lastPart = lastMessage.parts.at(-1); if (!lastPart) return false; if (!isToolUIPart(lastPart)) return false; if (lastPart.state.startsWith("output")) return false; return true; }, [status, messages]); const space = useMemo(() => { if (!isLoading || error) return false; const lastMessage = messages.at(-1); if (lastMessage?.role == "user") return "think"; const lastPart = lastMessage?.parts.at(-1); if (!lastPart) return "think"; const secondPart = lastMessage?.parts[1]; if (secondPart?.type == "text" && secondPart.text.length == 0) return "think"; if (lastPart?.type == "step-start") { return lastMessage?.parts.length == 1 ? "think" : "space"; } return false; }, [isLoading, messages.at(-1)]); const particle = useMemo(() => { return ( {showParticles && (
)} ); }, [showParticles]); const handleFocus = useCallback(() => { setShowParticles(false); debounce(() => setShowParticles(true), 60000); }, []); const handleScroll = useCallback(() => { const container = containerRef.current; if (!container) return; const { scrollTop, scrollHeight, clientHeight } = container; const isScrollAtBottom = scrollHeight - scrollTop - clientHeight < 50; setIsAtBottom(isScrollAtBottom); handleFocus(); }, [handleFocus]); const scrollToBottom = useCallback(() => { containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight, behavior: "smooth", }); }, []); useEffect(() => { appStoreMutate({ currentThreadId: threadId }); return () => { appStoreMutate({ currentThreadId: null }); }; }, [threadId]); useEffect(() => { if (pendingThreadMention && threadId) { appStoreMutate((prev) => ({ threadMentions: { ...prev.threadMentions, [threadId]: [pendingThreadMention], }, pendingThreadMention: undefined, })); } }, [pendingThreadMention, threadId, appStoreMutate]); useEffect(() => { if (isInitialThreadEntry) containerRef.current?.scrollTo({ top: containerRef.current?.scrollHeight, behavior: "instant", }); }, [isInitialThreadEntry]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const messages = latestRef.current.messages; if (messages.length === 0) return; const isLastMessageCopy = isShortcutEvent(e, Shortcuts.lastMessageCopy); const isDeleteThread = isShortcutEvent(e, Shortcuts.deleteThread); if (!isDeleteThread && !isLastMessageCopy) return; e.preventDefault(); e.stopPropagation(); if (isLastMessageCopy) { const lastMessage = messages.at(-1); const lastMessageText = lastMessage!.parts .filter((part): part is TextUIPart => part.type == "text") ?.at(-1)?.text; if (!lastMessageText) return; navigator.clipboard.writeText(lastMessageText); toast.success("Last message copied to clipboard"); } if (isDeleteThread) { setIsDeleteThreadPopupOpen(true); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, []); useEffect(() => { if (mounted) { handleFocus(); } }, [input]); return ( <> {particle}
{isDragging && (
Drop files to upload
)} {emptyMessage ? ( ) : ( <>
{messages.map((message, index) => { const isLastMessage = messages.length - 1 === index; return ( 1 ? "min-h-[calc(55dvh-40px)]" : "" } /> ); })} {space && ( <>
)} {error && }
)}
0} onClick={scrollToBottom} />
setIsDeleteThreadPopupOpen(false)} open={isDeleteThreadPopupOpen} />
{!emptyMessage && ( )}
); } function DeleteThreadPopup({ threadId, onClose, open, }: { threadId: string; onClose: () => void; open: boolean }) { const t = useTranslations(); const [isDeleting, setIsDeleting] = useState(false); const router = useRouter(); const handleDelete = useCallback(() => { setIsDeleting(true); safe(() => deleteThreadAction(threadId)) .watch(() => setIsDeleting(false)) .ifOk(() => { toast.success(t("Chat.Thread.threadDeleted")); router.push("/"); }) .ifFail(() => toast.error(t("Chat.Thread.failedToDeleteThread"))) .watch(() => onClose()); }, [threadId, router]); return ( {t("Chat.Thread.deleteChat")} {t("Chat.Thread.areYouSureYouWantToDeleteThisChatThread")} ); } interface ScrollToBottomButtonProps { show: boolean; onClick: () => void; className?: string; } function ScrollToBottomButton({ show, onClick, className, }: ScrollToBottomButtonProps) { return ( {show && ( )} ); }