better-chatbot / src /components /chat-bot.tsx
Bot
feat: implement Devin-style Agent Workstation Panel with files, logs, settings, and screenshot tabs
b973b15
Raw
History Blame Contribute Delete
18.4 kB
"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<UIMessage>;
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<HTMLDivElement>(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<typeof _addToolResult>[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 (
<AnimatePresence>
{showParticles && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 5 }}
>
<div className="absolute top-0 left-0 w-full h-full z-10">
<LightRays />
</div>
<div className="absolute top-0 left-0 w-full h-full z-10">
<Particles particleCount={400} particleBaseSize={10} />
</div>
<div className="absolute top-0 left-0 w-full h-full z-10">
<div className="w-full h-full bg-gradient-to-t from-background to-50% to-transparent z-20" />
</div>
<div className="absolute top-0 left-0 w-full h-full z-10">
<div className="w-full h-full bg-gradient-to-l from-background to-20% to-transparent z-20" />
</div>
<div className="absolute top-0 left-0 w-full h-full z-10">
<div className="w-full h-full bg-gradient-to-r from-background to-20% to-transparent z-20" />
</div>
</motion.div>
)}
</AnimatePresence>
);
}, [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}
<div className="flex h-full w-full relative z-40">
<div
className={cn(
emptyMessage && "justify-center pb-24",
"flex-1 flex flex-col min-w-0 relative h-full z-40",
)}
>
{isDragging && (
<div className="absolute inset-0 z-40 bg-background/70 backdrop-blur-sm flex items-center justify-center pointer-events-none">
<div className="rounded-2xl px-6 py-5 bg-background/80 shadow-xl border border-border flex items-center gap-3">
<div className="rounded-full bg-primary/10 p-2 text-primary">
<FilePlus className="size-6" />
</div>
<span className="text-sm text-muted-foreground">
Drop files to upload
</span>
</div>
</div>
)}
{emptyMessage ? (
<ChatGreeting />
) : (
<>
<div
className={
"flex flex-col gap-2 overflow-y-auto py-6 z-10 [scrollbar-gutter:stable_both-edges]"
}
ref={containerRef}
onScroll={handleScroll}
>
{messages.map((message, index) => {
const isLastMessage = messages.length - 1 === index;
return (
<PreviewMessage
threadId={threadId}
messageIndex={index}
prevMessage={messages[index - 1]}
key={message.id}
message={message}
status={status}
addToolResult={addToolResult}
isLoading={isLoading || isPendingToolCall}
isLastMessage={isLastMessage}
setMessages={setMessages}
sendMessage={sendMessage}
className={
isLastMessage &&
message.role != "user" &&
!space &&
message.parts.length > 1
? "min-h-[calc(55dvh-40px)]"
: ""
}
/>
);
})}
{space && (
<>
<div className="w-full mx-auto max-w-3xl px-6 relative">
<div className={space == "space" ? "opacity-0" : ""}>
<Think />
</div>
</div>
<div className="min-h-[calc(55dvh-56px)]" />
</>
)}
{error && <ErrorMessage error={error} />}
<div className="min-w-0 min-h-52" />
</div>
</>
)}
<div
className={clsx(
messages.length && "absolute bottom-14",
"w-full z-10",
)}
>
<div className="max-w-3xl mx-auto relative flex justify-center items-center -top-2">
<ScrollToBottomButton
show={!isAtBottom && messages.length > 0}
onClick={scrollToBottom}
/>
</div>
<PromptInput
input={input}
threadId={threadId}
sendMessage={sendMessage}
setInput={setInput}
isLoading={isLoading || isPendingToolCall}
onStop={stop}
onFocus={isFirstTime ? undefined : handleFocus}
/>
</div>
<DeleteThreadPopup
threadId={threadId}
onClose={() => setIsDeleteThreadPopupOpen(false)}
open={isDeleteThreadPopupOpen}
/>
</div>
{!emptyMessage && (
<AgentMonitorPanel threadId={threadId} />
)}
</div>
</>
);
}
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 (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("Chat.Thread.deleteChat")}</DialogTitle>
<DialogDescription>
{t("Chat.Thread.areYouSureYouWantToDeleteThisChatThread")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
{t("Common.cancel")}
</Button>
<Button variant="destructive" onClick={handleDelete} autoFocus>
{t("Common.delete")}
{isDeleting && <Loader className="size-3.5 ml-2 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface ScrollToBottomButtonProps {
show: boolean;
onClick: () => void;
className?: string;
}
function ScrollToBottomButton({
show,
onClick,
className,
}: ScrollToBottomButtonProps) {
return (
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className={className}
>
<Button
onClick={onClick}
className="shadow-lg backdrop-blur-sm border transition-colors"
size="icon"
variant="ghost"
>
<ArrowDown />
</Button>
</motion.div>
)}
</AnimatePresence>
);
}