mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-14 23:21:20 +00:00
556 lines
17 KiB
TypeScript
556 lines
17 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react"
|
|
import { motion, AnimatePresence } from "motion/react"
|
|
import { useChat } from "@ai-sdk/react"
|
|
import { DefaultChatTransport } from "ai"
|
|
import NovaOrb from "@/components/nova/nova-orb"
|
|
import { Button } from "@ui/components/button"
|
|
import {
|
|
ChevronDownIcon,
|
|
HistoryIcon,
|
|
PanelRightCloseIcon,
|
|
SearchIcon,
|
|
SquarePenIcon,
|
|
} from "lucide-react"
|
|
import { cn } from "@lib/utils"
|
|
import { dmSansClassName } from "@/lib/fonts"
|
|
import ChatInput from "./input"
|
|
import ChatModelSelector from "./model-selector"
|
|
import { GradientLogo, LogoBgGradient } from "@ui/assets/Logo"
|
|
import { useProject, usePersistentChat } from "@/stores"
|
|
import type { ModelId } from "@/lib/models"
|
|
import { SuperLoader } from "../../superloader"
|
|
import { UserMessage } from "./message/user-message"
|
|
import { AgentMessage } from "./message/agent-message"
|
|
import { ChainOfThought } from "./input/chain-of-thought"
|
|
|
|
function ChatEmptyStatePlaceholder({
|
|
onSuggestionClick,
|
|
}: {
|
|
onSuggestionClick: (suggestion: string) => void
|
|
}) {
|
|
const suggestions = [
|
|
"Show me all content related to Supermemory.",
|
|
"Summarize the key ideas from My Gita.",
|
|
"Which memories connect design and AI?",
|
|
"What are the main themes across my memories?",
|
|
]
|
|
|
|
return (
|
|
<div
|
|
id="chat-empty-state"
|
|
className="flex flex-col items-center justify-center h-full"
|
|
>
|
|
<div className="relative w-32 h-32">
|
|
<GradientLogo className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-16 h-16" />
|
|
<LogoBgGradient className="w-full h-full" />
|
|
</div>
|
|
<div className="gap-3 flex flex-col items-center justify-center">
|
|
<p>Ask me anything about your memories...</p>
|
|
<div
|
|
className={cn(
|
|
dmSansClassName(),
|
|
"flex flex-col gap-2 justify-center items-center",
|
|
)}
|
|
>
|
|
{suggestions.map((suggestion) => (
|
|
<Button
|
|
key={suggestion}
|
|
variant="default"
|
|
className="rounded-full text-base gap-1 h-10! border-[#2261CA33] bg-[#041127] border w-fit py-[4px] pl-[8px] pr-[12px] hover:bg-[#0A1A3A] hover:[&_span]:text-white hover:[&_svg]:text-white transition-colors cursor-pointer"
|
|
onClick={() => onSuggestionClick(suggestion)}
|
|
>
|
|
<SearchIcon className="size-4 text-[#267BF1]" />
|
|
<span className="text-[#267BF1] text-[12px]">{suggestion}</span>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function ChatSidebar({
|
|
isChatOpen,
|
|
setIsChatOpen,
|
|
}: {
|
|
isChatOpen: boolean
|
|
setIsChatOpen: (open: boolean) => void
|
|
}) {
|
|
const [input, setInput] = useState("")
|
|
const [selectedModel, setSelectedModel] = useState<ModelId>("gemini-2.5-pro")
|
|
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
|
|
const [hoveredMessageId, setHoveredMessageId] = useState<string | null>(null)
|
|
const [messageFeedback, setMessageFeedback] = useState<
|
|
Record<string, "like" | "dislike" | null>
|
|
>({})
|
|
const [expandedMemories, setExpandedMemories] = useState<string | null>(null)
|
|
const [followUpQuestions, setFollowUpQuestions] = useState<
|
|
Record<string, string[]>
|
|
>({})
|
|
const [loadingFollowUps, setLoadingFollowUps] = useState<
|
|
Record<string, boolean>
|
|
>({})
|
|
const [isInputExpanded, setIsInputExpanded] = useState(false)
|
|
const [isScrolledToBottom, setIsScrolledToBottom] = useState(true)
|
|
const pendingFollowUpGenerations = useRef<Set<string>>(new Set())
|
|
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
|
const { selectedProject } = useProject()
|
|
const { setCurrentChatId } = usePersistentChat()
|
|
|
|
const { messages, sendMessage, status, setMessages, stop } = useChat({
|
|
transport: new DefaultChatTransport({
|
|
api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/v2`,
|
|
credentials: "include",
|
|
body: {
|
|
metadata: {
|
|
projectId: selectedProject,
|
|
model: selectedModel,
|
|
},
|
|
},
|
|
}),
|
|
onFinish: async (result) => {
|
|
if (result.message.role !== "assistant") return
|
|
|
|
// Mark this message as needing follow-up generation
|
|
// We'll generate it after the message is fully in the messages array
|
|
if (result.message.id) {
|
|
pendingFollowUpGenerations.current.add(result.message.id)
|
|
}
|
|
},
|
|
})
|
|
|
|
// Generate follow-up questions after assistant messages are complete
|
|
useEffect(() => {
|
|
const generateFollowUps = async () => {
|
|
// Find assistant messages that need follow-up generation
|
|
const messagesToProcess = messages.filter(
|
|
(msg) =>
|
|
msg.role === "assistant" &&
|
|
pendingFollowUpGenerations.current.has(msg.id) &&
|
|
!followUpQuestions[msg.id] &&
|
|
!loadingFollowUps[msg.id],
|
|
)
|
|
|
|
for (const message of messagesToProcess) {
|
|
// Get complete text from the message
|
|
const assistantText = message.parts
|
|
.filter((p) => p.type === "text")
|
|
.map((p) => p.text)
|
|
.join(" ")
|
|
.trim()
|
|
|
|
// Only generate if we have substantial text (at least 50 chars)
|
|
// This ensures the message is complete, not just the first chunk
|
|
// Also check if status is idle to ensure streaming is complete
|
|
if (
|
|
assistantText.length < 50 ||
|
|
status === "streaming" ||
|
|
status === "submitted"
|
|
) {
|
|
continue
|
|
}
|
|
|
|
// Mark as processing
|
|
pendingFollowUpGenerations.current.delete(message.id)
|
|
setLoadingFollowUps((prev) => ({
|
|
...prev,
|
|
[message.id]: true,
|
|
}))
|
|
|
|
try {
|
|
// Get recent messages for context
|
|
const recentMessages = messages.slice(-5).map((msg) => ({
|
|
role: msg.role,
|
|
content: msg.parts
|
|
.filter((p) => p.type === "text")
|
|
.map((p) => p.text)
|
|
.join(" "),
|
|
}))
|
|
|
|
const response = await fetch(
|
|
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/follow-ups`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
credentials: "include",
|
|
body: JSON.stringify({
|
|
messages: recentMessages,
|
|
assistantResponse: assistantText,
|
|
}),
|
|
},
|
|
)
|
|
|
|
if (response.ok) {
|
|
const data = await response.json()
|
|
if (data.questions && Array.isArray(data.questions)) {
|
|
setFollowUpQuestions((prev) => ({
|
|
...prev,
|
|
[message.id]: data.questions,
|
|
}))
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to generate follow-up questions:", error)
|
|
} finally {
|
|
setLoadingFollowUps((prev) => ({
|
|
...prev,
|
|
[message.id]: false,
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only generate if not currently streaming or submitted
|
|
// Small delay to ensure message is fully processed
|
|
if (status !== "streaming" && status !== "submitted") {
|
|
const timeoutId = setTimeout(() => {
|
|
generateFollowUps()
|
|
}, 300)
|
|
|
|
return () => clearTimeout(timeoutId)
|
|
}
|
|
}, [messages, followUpQuestions, loadingFollowUps, status])
|
|
|
|
const checkIfScrolledToBottom = useCallback(() => {
|
|
if (!messagesContainerRef.current) return
|
|
const container = messagesContainerRef.current
|
|
const { scrollTop, scrollHeight, clientHeight } = container
|
|
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
|
const isAtBottom = distanceFromBottom <= 20
|
|
setIsScrolledToBottom(isAtBottom)
|
|
}, [])
|
|
|
|
const scrollToBottom = useCallback(() => {
|
|
if (messagesContainerRef.current) {
|
|
messagesContainerRef.current.scrollTop =
|
|
messagesContainerRef.current.scrollHeight
|
|
setIsScrolledToBottom(true)
|
|
}
|
|
}, [])
|
|
|
|
const handleSend = () => {
|
|
if (!input.trim() || status === "submitted" || status === "streaming")
|
|
return
|
|
sendMessage({ text: input })
|
|
setInput("")
|
|
scrollToBottom()
|
|
}
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault()
|
|
handleSend()
|
|
}
|
|
}
|
|
|
|
const toggleChat = () => {
|
|
setIsChatOpen(!isChatOpen)
|
|
}
|
|
|
|
const handleCopyMessage = useCallback((messageId: string, text: string) => {
|
|
navigator.clipboard.writeText(text)
|
|
setCopiedMessageId(messageId)
|
|
setTimeout(() => setCopiedMessageId(null), 2000)
|
|
}, [])
|
|
|
|
const handleLikeMessage = useCallback((messageId: string) => {
|
|
setMessageFeedback((prev) => ({
|
|
...prev,
|
|
[messageId]: prev[messageId] === "like" ? null : "like",
|
|
}))
|
|
}, [])
|
|
|
|
const handleDislikeMessage = useCallback((messageId: string) => {
|
|
setMessageFeedback((prev) => ({
|
|
...prev,
|
|
[messageId]: prev[messageId] === "dislike" ? null : "dislike",
|
|
}))
|
|
}, [])
|
|
|
|
const handleToggleMemories = useCallback((messageId: string) => {
|
|
setExpandedMemories((prev) => (prev === messageId ? null : messageId))
|
|
}, [])
|
|
|
|
const handleNewChat = useCallback(() => {
|
|
const newId = crypto.randomUUID()
|
|
setCurrentChatId(newId)
|
|
setMessages([])
|
|
setInput("")
|
|
}, [setCurrentChatId, setMessages])
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
const activeElement = document.activeElement as HTMLElement | null
|
|
const isInEditableContext =
|
|
activeElement?.tagName === "INPUT" ||
|
|
activeElement?.tagName === "TEXTAREA" ||
|
|
activeElement?.isContentEditable ||
|
|
activeElement?.closest('[contenteditable="true"]')
|
|
|
|
if (
|
|
e.key.toLowerCase() === "t" &&
|
|
!e.metaKey &&
|
|
!e.ctrlKey &&
|
|
!e.altKey &&
|
|
isChatOpen &&
|
|
!isInEditableContext
|
|
) {
|
|
e.preventDefault()
|
|
handleNewChat()
|
|
}
|
|
}
|
|
|
|
window.addEventListener("keydown", handleKeyDown)
|
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
|
}, [isChatOpen, handleNewChat])
|
|
|
|
// Scroll to bottom when a new user message is added
|
|
useEffect(() => {
|
|
const lastMessage = messages[messages.length - 1]
|
|
if (lastMessage?.role === "user" && messagesContainerRef.current) {
|
|
messagesContainerRef.current.scrollTop =
|
|
messagesContainerRef.current.scrollHeight
|
|
setIsScrolledToBottom(true)
|
|
}
|
|
// Always check scroll position when messages change
|
|
checkIfScrolledToBottom()
|
|
}, [messages, checkIfScrolledToBottom])
|
|
|
|
// Add scroll event listener to track scroll position
|
|
useEffect(() => {
|
|
const container = messagesContainerRef.current
|
|
if (!container) return
|
|
|
|
const handleScroll = () => {
|
|
requestAnimationFrame(() => {
|
|
checkIfScrolledToBottom()
|
|
})
|
|
}
|
|
|
|
container.addEventListener("scroll", handleScroll, { passive: true })
|
|
// Initial check with a small delay to ensure DOM is ready
|
|
setTimeout(() => {
|
|
checkIfScrolledToBottom()
|
|
}, 100)
|
|
|
|
// Also observe resize to detect content height changes
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
requestAnimationFrame(() => {
|
|
checkIfScrolledToBottom()
|
|
})
|
|
})
|
|
resizeObserver.observe(container)
|
|
|
|
return () => {
|
|
container.removeEventListener("scroll", handleScroll)
|
|
resizeObserver.disconnect()
|
|
}
|
|
}, [checkIfScrolledToBottom])
|
|
|
|
return (
|
|
<AnimatePresence mode="wait">
|
|
{!isChatOpen ? (
|
|
<motion.div
|
|
key="closed"
|
|
className={cn(
|
|
"absolute top-0 right-0 flex items-start justify-start m-4",
|
|
dmSansClassName(),
|
|
)}
|
|
layoutId="chat-toggle-button"
|
|
>
|
|
<motion.button
|
|
onClick={toggleChat}
|
|
className="flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium border border-[#17181A] text-white cursor-pointer"
|
|
style={{
|
|
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
|
}}
|
|
>
|
|
<NovaOrb size={24} className="blur-[0.6px]! z-10" />
|
|
Chat with Nova
|
|
</motion.button>
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
key="open"
|
|
className={cn(
|
|
"w-[450px] h-[calc(100vh-95px)] bg-[#05070A] backdrop-blur-md flex flex-col rounded-2xl m-4 mt-2 border border-[#17181AB2] relative pt-4",
|
|
dmSansClassName(),
|
|
)}
|
|
initial={{ x: "100px", opacity: 0 }}
|
|
animate={{ x: 0, opacity: 1 }}
|
|
exit={{ x: "100px", opacity: 0 }}
|
|
transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }}
|
|
>
|
|
<div
|
|
className="absolute top-0 left-0 right-0 flex items-center justify-between pt-4 px-4 rounded-t-2xl"
|
|
style={{
|
|
background:
|
|
"linear-gradient(180deg, #0A0E14 40.49%, rgba(10, 14, 20, 0.00) 100%)",
|
|
}}
|
|
>
|
|
<ChatModelSelector
|
|
selectedModel={selectedModel}
|
|
onModelChange={setSelectedModel}
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="headers"
|
|
className="rounded-full text-base gap-2 h-10! border-[#73737333] bg-[#0D121A]"
|
|
style={{
|
|
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
|
}}
|
|
>
|
|
<HistoryIcon className="size-4 text-[#737373]" />
|
|
</Button>
|
|
<Button
|
|
variant="headers"
|
|
className="rounded-full text-base gap-3 h-10! border-[#73737333] bg-[#0D121A] cursor-pointer"
|
|
style={{
|
|
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
|
}}
|
|
onClick={handleNewChat}
|
|
title="New chat (T)"
|
|
>
|
|
<SquarePenIcon className="size-4 text-[#737373]" />
|
|
<span
|
|
className={cn(
|
|
"bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm size-4 text-[10px] flex items-center justify-center",
|
|
dmSansClassName(),
|
|
)}
|
|
>
|
|
T
|
|
</span>
|
|
</Button>
|
|
<motion.button
|
|
onClick={toggleChat}
|
|
className="flex items-center gap-2 rounded-full p-2 text-xs text-white cursor-pointer"
|
|
layoutId="chat-toggle-button"
|
|
>
|
|
<PanelRightCloseIcon className="size-4" />
|
|
</motion.button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
ref={messagesContainerRef}
|
|
className={cn(
|
|
"flex-1 overflow-y-auto px-4 scrollbar-thin",
|
|
dmSansClassName(),
|
|
)}
|
|
>
|
|
{isInputExpanded && (
|
|
<div
|
|
className="absolute inset-0 z-10! rounded-2xl pointer-events-none"
|
|
style={{ backgroundColor: "#000000E5" }}
|
|
/>
|
|
)}
|
|
{messages.length === 0 && (
|
|
<ChatEmptyStatePlaceholder
|
|
onSuggestionClick={(suggestion) => {
|
|
sendMessage({ text: suggestion })
|
|
}}
|
|
/>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
messages.length > 0
|
|
? "flex flex-col space-y-3 min-h-full justify-end pt-14"
|
|
: "",
|
|
)}
|
|
>
|
|
{messages.map((message, index) => (
|
|
// biome-ignore lint/a11y/noStaticElementInteractions: Hover detection for message actions
|
|
<div
|
|
key={message.id}
|
|
className={cn(
|
|
"flex gap-2 w-full",
|
|
message.role === "user" ? "justify-end" : "justify-start",
|
|
)}
|
|
onMouseEnter={() =>
|
|
message.role === "assistant" &&
|
|
setHoveredMessageId(message.id)
|
|
}
|
|
onMouseLeave={() =>
|
|
message.role === "assistant" && setHoveredMessageId(null)
|
|
}
|
|
>
|
|
{message.role === "user" ? (
|
|
<UserMessage
|
|
message={message}
|
|
copiedMessageId={copiedMessageId}
|
|
onCopy={handleCopyMessage}
|
|
/>
|
|
) : (
|
|
<AgentMessage
|
|
message={message}
|
|
index={index}
|
|
messagesLength={messages.length}
|
|
hoveredMessageId={hoveredMessageId}
|
|
copiedMessageId={copiedMessageId}
|
|
messageFeedback={messageFeedback}
|
|
expandedMemories={expandedMemories}
|
|
followUpQuestions={followUpQuestions[message.id] || []}
|
|
isLoadingFollowUps={loadingFollowUps[message.id] || false}
|
|
onCopy={handleCopyMessage}
|
|
onLike={handleLikeMessage}
|
|
onDislike={handleDislikeMessage}
|
|
onToggleMemories={handleToggleMemories}
|
|
onQuestionClick={(question) => {
|
|
setInput(question)
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
{(status === "submitted" || status === "streaming") &&
|
|
messages[messages.length - 1]?.role === "user" && (
|
|
<div className="flex gap-2">
|
|
<SuperLoader label="Thinking..." />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{!isScrolledToBottom && messages.length > 0 && (
|
|
<div className="absolute bottom-24 left-0 right-0 flex justify-center z-50 pointer-events-none">
|
|
<button
|
|
type="button"
|
|
className="cursor-pointer pointer-events-auto"
|
|
onClick={scrollToBottom}
|
|
>
|
|
<div className="rounded-full p-2 bg-[#0D121A] shadow-[1.5px_1.5px_4.5px_0_rgba(0,0,0,0.70)_inset] hover:bg-[#0F1620] transition-colors">
|
|
<ChevronDownIcon className="size-4 text-white" />
|
|
</div>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<ChatInput
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onSend={handleSend}
|
|
onStop={stop}
|
|
onKeyDown={handleKeyDown}
|
|
isResponding={status === "submitted" || status === "streaming"}
|
|
activeStatus={
|
|
status === "submitted"
|
|
? "Thinking..."
|
|
: status === "streaming"
|
|
? "Structuring response..."
|
|
: "Waiting for input..."
|
|
}
|
|
onExpandedChange={setIsInputExpanded}
|
|
chainOfThoughtComponent={
|
|
messages.length > 0 ? (
|
|
<ChainOfThought messages={messages} />
|
|
) : null
|
|
}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
)
|
|
}
|